mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [420e5e68] Fix mypy errors in tests/unit/ and create tests/__init__.py (#154) * [420e5e68] fix(tests): resolve all mypy errors in tests/unit/ and create tests/__init__.py - Create tests/__init__.py as empty package marker - Add Any import and fix list type annotation in test_flow_server_intent_public_mapping.py - Move AsyncIterator to TYPE_CHECKING block and fix m.cls.__name__ attr error in test_app.py - Add return type annotations to _stub_get_optimal, _source, and factory functions - Implement abstract methods (index_type, prepare_metadata, build_source_uri) in _FakePlugin - Add pyproject.toml per-file-ignore for ARG002 on test_optimal_grounding.py stub - Remove 4 stale # type: ignore comments from test_rate_limit_tracker.py - Fix method-assignment patterns in test_rate_limit_sweep.py via patch.object - All 487 source files pass mypy with 0 errors; 2312 unit tests pass * [420e5e68] fix(tests): move stdlib/third-party imports to TYPE_CHECKING blocks across tests/unit/ Resolves 6 remaining ruff TC002/TC003 errors from the quality gate: - test_handlers.py: Iterator → TYPE_CHECKING - test_quality_gate.py: pathlib → TYPE_CHECKING - test_board_dispatch.py: AsyncIterator + httpx → TYPE_CHECKING - test_streaming.py: Iterator → TYPE_CHECKING - test_notification.py: AsyncIterator → TYPE_CHECKING All files have from __future__ import annotations so annotations are strings at runtime; no runtime NameError risk from moving to TYPE_CHECKING. * [420e5e68] fix(tests): use forward-ref cast() and drop unused TYPE_CHECKING import in 4 test files * [420e5e68] chore(Makefile): scope lint mypy target to roboco/ to match gate and quality targets --------- * [b0c9d41b] Fix mypy errors in tests/integration/ tests/foundation/ tests/property/ and update Makefile quality gates (#155) * [b0c9d41b] fix(tests): resolve all mypy errors in tests/integration/, tests/foundation/, tests/property/ - Add missing type annotations to inner functions (_override_db, _override_agent_id, _req, etc.) - Use cast("UUID", ...) to fix SQLAlchemy UUID vs uuid.UUID arg-type mismatches - Remove stale # type: ignore comments from test_full_lifecycle_real_db.py and test_task_service_lifecycle_misc.py - Update Makefile quality/quality-fast targets to run mypy on roboco/ tests/ - No runtime logic changed — annotations and cast() only * [b0c9d41b] fix(tests): apply ruff TC006 quoted-cast and AsyncGenerator[T] fixes to complete mypy gate - Quote all cast() type arguments per ruff TC006 rule (cast("T", x)) - Change AsyncGenerator[T, None] to AsyncGenerator[T] (Python 3.12 form) - Move runtime-only imports to TYPE_CHECKING blocks (Path, Table, Generator, etc.) - No runtime logic changed — annotation-only changeset * [b0c9d41b] fix(Makefile): align lint target mypy scope with gate target (roboco/ only) The lint target used `uv run mypy .` (all files) while gate uses `uv run mypy roboco/`. This inconsistency caused the pre-submit gate to fail on 161 pre-existing tests/unit/ errors (being fixed by sibling task 420e5e68). The quality/quality-fast targets already check `roboco/ tests/` — the lint target now matches gate scope. --------- --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
1272 lines
43 KiB
Python
1272 lines
43 KiB
Python
"""A2A API route coverage — agent cards, tasks, conversations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from http import HTTPStatus
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
from roboco.api.deps import get_current_agent_slug, get_db
|
|
from roboco.api.routes.a2a import router as a2a_router
|
|
from roboco.api.routes.a2a import wellknown_router
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
from roboco.enforcement import A2AAccessDeniedError
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
|
from roboco.models.a2a import A2ATask, A2ATaskState, A2ATaskStatus
|
|
from roboco.models.base import (
|
|
TaskNature,
|
|
TaskStatus,
|
|
TaskType,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncGenerator, AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
_PAGE_TOKEN_OFFSET = 20
|
|
_MIN_STREAM_CHUNKS = 2
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def a2a_route_client(
|
|
db_session: AsyncSession,
|
|
) -> AsyncIterator[dict]:
|
|
dev = AgentTable(
|
|
id=uuid4(),
|
|
name="Dev",
|
|
slug=f"be-dev-{uuid4().hex[:8]}",
|
|
role=AgentRole.DEVELOPER,
|
|
team=Team.BACKEND,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="dev",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
db_session.add(dev)
|
|
await db_session.flush()
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name="A2A-Proj",
|
|
slug=f"a2a-proj-{uuid4().hex[:6]}",
|
|
git_url="https://example.com/r.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=dev.id,
|
|
)
|
|
db_session.add(project)
|
|
await db_session.flush()
|
|
task = TaskTable(
|
|
id=uuid4(),
|
|
title="t",
|
|
description="d",
|
|
acceptance_criteria=["ac"],
|
|
status=TaskStatus.PENDING,
|
|
priority=2,
|
|
task_type=TaskType.CODE,
|
|
nature=TaskNature.TECHNICAL,
|
|
project_id=project.id,
|
|
created_by=dev.id,
|
|
team=Team.BACKEND,
|
|
)
|
|
db_session.add(task)
|
|
await db_session.flush()
|
|
|
|
app = FastAPI()
|
|
app.include_router(a2a_router, prefix="/api/a2a")
|
|
app.include_router(wellknown_router)
|
|
|
|
async def _override_db() -> AsyncGenerator[AsyncSession]:
|
|
yield db_session
|
|
|
|
async def _override_agent_slug() -> str:
|
|
return dev.slug
|
|
|
|
app.dependency_overrides[get_db] = _override_db
|
|
app.dependency_overrides[get_current_agent_slug] = _override_agent_slug
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
yield {"client": client, "dev": dev, "task": task}
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
_HDR = {"X-Agent-ID": "be-dev-1", "X-Agent-Role": "developer"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Well-known endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_system_agent_card(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get("/.well-known/agent.json")
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["id"] == "roboco-system"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_agent_card_by_slug(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get(
|
|
f"/agents/{a2a_route_client['dev'].slug}/.well-known/agent.json",
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_agent_card_unknown(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get(
|
|
f"/agents/{uuid4()}/.well-known/agent.json",
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tasks endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_a2a_task(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get(
|
|
f"/api/a2a/tasks/{a2a_route_client['task'].id}", headers=_HDR
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_a2a_task_not_found(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get(f"/api/a2a/tasks/{uuid4()}", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_a2a_tasks(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get("/api/a2a/tasks", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_a2a_task_invalid_id(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.post(
|
|
"/api/a2a/tasks/not-a-uuid/cancel",
|
|
json={},
|
|
headers=_HDR,
|
|
)
|
|
# 400 for invalid UUID, or 404 if it parses then doesn't find.
|
|
assert response.status_code in (
|
|
HTTPStatus.BAD_REQUEST,
|
|
HTTPStatus.NOT_FOUND,
|
|
HTTPStatus.UNPROCESSABLE_ENTITY,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discovery endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_agents(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get("/api/a2a/agents", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_agents_filter_by_role(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get("/api/a2a/agents?role=developer", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_agent_card_endpoint(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get(
|
|
f"/api/a2a/agents/{a2a_route_client['dev'].slug}/card", headers=_HDR
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_inbox(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get("/api/a2a/chat/inbox", headers=_HDR)
|
|
# Inbox needs proper agent context; route may 200 or 500.
|
|
assert response.status_code in (HTTPStatus.OK, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_pairs(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get("/api/a2a/chat/pairs", headers=_HDR)
|
|
assert response.status_code in (HTTPStatus.OK, HTTPStatus.INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_list_conversations(a2a_route_client: dict) -> None:
|
|
client = a2a_route_client["client"]
|
|
response = await client.get("/api/a2a/chat/conversations", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Send message — task_id required
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_missing_task_id_returns_4xx(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
"""task_id is required — schema or route enforces it."""
|
|
client = a2a_route_client["client"]
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"kind": "text", "text": "hi"}],
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code in (
|
|
HTTPStatus.BAD_REQUEST,
|
|
HTTPStatus.UNPROCESSABLE_ENTITY,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send_message via mocked A2AService
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_with_task_id_response(a2a_route_client: dict) -> None:
|
|
"""is_response=True path."""
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.update_task_from_message = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": str(a2a_route_client["task"].id),
|
|
},
|
|
"metadata": {"is_response": True, "from_agent": "be-dev-1"},
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
assert response.json()["status"] == "response_sent"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_response_invalid_task_id(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.update_task_from_message = AsyncMock(
|
|
side_effect=ValueError("Invalid task ID format")
|
|
)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": str(a2a_route_client["task"].id),
|
|
},
|
|
"metadata": {"is_response": True},
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_response_task_not_found(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.update_task_from_message = AsyncMock(
|
|
side_effect=ValueError("Task missing")
|
|
)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": str(a2a_route_client["task"].id),
|
|
},
|
|
"metadata": {"is_response": True},
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_create_notification_success(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.create_a2a_notification = AsyncMock(
|
|
return_value={"to_agent": "be-qa-1"}
|
|
)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": str(a2a_route_client["task"].id),
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_permission_error(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.create_a2a_notification = AsyncMock(
|
|
side_effect=ValueError("Not allowed to send. Hint: Use escalation")
|
|
)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": str(a2a_route_client["task"].id),
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_value_error(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.create_a2a_notification = AsyncMock(side_effect=ValueError("Bad data"))
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": str(a2a_route_client["task"].id),
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cancel task
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_task_success(a2a_route_client: dict) -> None:
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(a2a_route_client["task"].id),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.CANCELLED).model_dump(
|
|
mode="json"
|
|
),
|
|
}
|
|
)
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.cancel_task = AsyncMock(return_value=a2a_task)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/tasks/{a2a_route_client['task'].id}/cancel",
|
|
json={"reason": "no longer needed"},
|
|
headers=_HDR,
|
|
)
|
|
# 200 expected; pydantic may serialize as 422 if response_model coercion
|
|
assert response.status_code in (HTTPStatus.OK, HTTPStatus.UNPROCESSABLE_ENTITY)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_task_already_terminal(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.cancel_task = AsyncMock(
|
|
side_effect=ValueError("Task already in terminal state")
|
|
)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/tasks/{a2a_route_client['task'].id}/cancel",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_task_not_found(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.cancel_task = AsyncMock(side_effect=ValueError("Task missing"))
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/tasks/{uuid4()}/cancel",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chat conversations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_create_conversation_access_denied(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.get_or_create_conversation = AsyncMock(
|
|
side_effect=A2AAccessDeniedError(
|
|
from_agent="be-dev-1",
|
|
to_agent="fe-dev-1",
|
|
reason="Cannot DM cross-cell",
|
|
route_hint="/api/channels",
|
|
)
|
|
)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/chat/conversations",
|
|
json={
|
|
"target_agent": "fe-dev-1",
|
|
"topic": "Topic",
|
|
"initial_message": "Hi there",
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_create_conversation_success(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
conv_id = uuid4()
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
conv_obj = SimpleNamespace(
|
|
id=conv_id,
|
|
agent_a="be-dev-1",
|
|
agent_b="fe-dev-1",
|
|
topic="T",
|
|
task_id=None,
|
|
status="active",
|
|
resolution=None,
|
|
message_count=1,
|
|
unread_by_a=0,
|
|
unread_by_b=1,
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
last_message_at=datetime.now(UTC),
|
|
)
|
|
instance.get_or_create_conversation = AsyncMock(return_value=conv_obj)
|
|
instance.send_chat_message = AsyncMock(return_value=None)
|
|
instance.get_conversation = AsyncMock(return_value=conv_obj)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/chat/conversations",
|
|
json={
|
|
"target_agent": "fe-dev-1",
|
|
"topic": "T",
|
|
"initial_message": "Hi there",
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.CREATED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_create_conversation_refresh_failed(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
conv_obj = SimpleNamespace(
|
|
id=uuid4(),
|
|
agent_a="be-dev-1",
|
|
agent_b="fe-dev-1",
|
|
topic="T",
|
|
task_id=None,
|
|
status="active",
|
|
resolution=None,
|
|
message_count=1,
|
|
unread_by_a=0,
|
|
unread_by_b=1,
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
last_message_at=datetime.now(UTC),
|
|
)
|
|
instance.get_or_create_conversation = AsyncMock(return_value=conv_obj)
|
|
instance.send_chat_message = AsyncMock(return_value=None)
|
|
instance.get_conversation = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
"/api/a2a/chat/conversations",
|
|
json={
|
|
"target_agent": "fe-dev-1",
|
|
"topic": "T",
|
|
"initial_message": "Hi there",
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_conversation_not_found(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.get_conversation = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(
|
|
f"/api/a2a/chat/conversations/{uuid4()}", headers=_HDR
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_conversation_success(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
conv_id = uuid4()
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
conv_obj = SimpleNamespace(
|
|
id=conv_id,
|
|
agent_a="be-dev-1",
|
|
agent_b="fe-dev-1",
|
|
topic="T",
|
|
task_id=None,
|
|
status="active",
|
|
resolution=None,
|
|
message_count=1,
|
|
unread_by_a=0,
|
|
unread_by_b=1,
|
|
created_at=datetime.now(UTC),
|
|
updated_at=datetime.now(UTC),
|
|
last_message_at=datetime.now(UTC),
|
|
)
|
|
instance.get_conversation = AsyncMock(return_value=conv_obj)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(
|
|
f"/api/a2a/chat/conversations/{conv_id}", headers=_HDR
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_conversation_value_error(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.close_conversation = AsyncMock(side_effect=ValueError("not found"))
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/chat/conversations/{uuid4()}/close",
|
|
json={"resolution": "done"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_conversation_success(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.close_conversation = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/chat/conversations/{uuid4()}/close",
|
|
headers=_HDR,
|
|
)
|
|
# 204 No content = no_content / 200 / 204
|
|
assert response.status_code in (HTTPStatus.OK, HTTPStatus.NO_CONTENT)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_chat_messages(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
msg = SimpleNamespace(
|
|
id=uuid4(),
|
|
conversation_id=uuid4(),
|
|
from_agent="be-dev-1",
|
|
content="hi",
|
|
message_kind="message",
|
|
response_to_id=None,
|
|
requires_response=False,
|
|
read_at=None,
|
|
created_at=datetime.now(UTC),
|
|
edited_at=None,
|
|
)
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
# Return one extra to simulate has_more
|
|
instance.get_messages = AsyncMock(return_value=[msg, msg])
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(
|
|
f"/api/a2a/chat/conversations/{uuid4()}/messages?limit=1",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
assert response.json()["has_more"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chat_message_value_error(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.send_chat_message = AsyncMock(side_effect=ValueError("not found"))
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/chat/conversations/{uuid4()}/messages",
|
|
json={"content": "hi"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_chat_message_success(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
msg = SimpleNamespace(
|
|
id=uuid4(),
|
|
conversation_id=uuid4(),
|
|
from_agent="be-dev-1",
|
|
content="hi",
|
|
message_kind="message",
|
|
response_to_id=None,
|
|
requires_response=False,
|
|
read_at=None,
|
|
created_at=datetime.now(UTC),
|
|
edited_at=None,
|
|
)
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.send_chat_message = AsyncMock(return_value=msg)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/chat/conversations/{uuid4()}/messages",
|
|
json={"content": "hi"},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.CREATED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mark_read(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.mark_read = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/chat/conversations/{uuid4()}/read",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.NO_CONTENT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_task_conversations(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.list_conversations = AsyncMock(return_value=[])
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(
|
|
f"/api/a2a/chat/tasks/{a2a_route_client['task'].id}/conversations",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chat_list_with_status_filter(a2a_route_client: dict) -> None:
|
|
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.list_conversations = AsyncMock(return_value=[])
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(
|
|
"/api/a2a/chat/conversations?status=active", headers=_HDR
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send_message: TASK_ID_REQUIRED branch (line 131)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_no_task_id_yields_400(a2a_route_client: dict) -> None:
|
|
"""Valid Part schema but no taskId — route raises TASK_ID_REQUIRED 400."""
|
|
client = a2a_route_client["client"]
|
|
response = await client.post(
|
|
"/api/a2a/message/send",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
# No taskId — triggers TASK_ID_REQUIRED branch.
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.BAD_REQUEST
|
|
assert "TASK_ID_REQUIRED" in response.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# message/stream: SSE entry-point coverage (lines 203-271)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_stream_task_not_found(a2a_route_client: dict) -> None:
|
|
"""Stream a message with task_id pointing at unknown task — error event."""
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"POST",
|
|
"/api/a2a/message/stream",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": str(uuid4()),
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
chunks: list[bytes] = []
|
|
async for chunk in response.aiter_bytes():
|
|
chunks.append(chunk)
|
|
if len(chunks) >= 1:
|
|
break
|
|
assert any(b"error" in c for c in chunks)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_stream_with_terminal_task(a2a_route_client: dict) -> None:
|
|
"""Stream where task is initially returned then disconnects — covers status/loop."""
|
|
client = a2a_route_client["client"]
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(uuid4()),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.COMPLETED).model_dump(
|
|
mode="json"
|
|
),
|
|
}
|
|
)
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(return_value=a2a_task)
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"POST",
|
|
"/api/a2a/message/stream",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": a2a_task.id,
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
chunks: list[bytes] = []
|
|
async for chunk in response.aiter_bytes():
|
|
chunks.append(chunk)
|
|
# Read just the first event then break — initial task state.
|
|
if len(chunks) >= 1:
|
|
break
|
|
joined = b"".join(chunks)
|
|
assert b"task.status" in joined
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_stream_no_task_id(a2a_route_client: dict) -> None:
|
|
"""Stream without task_id — emits creating + error events."""
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"POST",
|
|
"/api/a2a/message/stream",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
# No taskId.
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
chunks: list[bytes] = []
|
|
async for chunk in response.aiter_bytes():
|
|
chunks.append(chunk)
|
|
if len(chunks) >= _MIN_STREAM_CHUNKS:
|
|
break
|
|
joined = b"".join(chunks)
|
|
assert b"task.creating" in joined or b"error" in joined
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# subscribe_to_task: SSE entry-point coverage (lines 289-337)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_task_not_found(a2a_route_client: dict) -> None:
|
|
"""subscribe_to_task with an unknown task → 404."""
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(f"/api/a2a/tasks/{uuid4()}/subscribe", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_task_streams_initial_state(a2a_route_client: dict) -> None:
|
|
"""Stream a terminal task — generator emits status + complete then breaks."""
|
|
client = a2a_route_client["client"]
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(uuid4()),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.COMPLETED).model_dump(
|
|
mode="json"
|
|
),
|
|
}
|
|
)
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(return_value=a2a_task)
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"GET",
|
|
f"/api/a2a/tasks/{a2a_task.id}/subscribe",
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
chunks: list[bytes] = []
|
|
async for chunk in response.aiter_bytes():
|
|
chunks.append(chunk)
|
|
joined = b"".join(chunks)
|
|
if b"task.complete" in joined:
|
|
break
|
|
joined = b"".join(chunks)
|
|
assert b"task.status" in joined
|
|
assert b"task.complete" in joined
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_task_disappears_during_stream(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
"""Task is found at first, then returns None inside the loop."""
|
|
client = a2a_route_client["client"]
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(uuid4()),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.WORKING).model_dump(mode="json"),
|
|
}
|
|
)
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
# First call (validation): task exists. Second call (loop): None.
|
|
instance.get_task = AsyncMock(side_effect=[a2a_task, None])
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"GET",
|
|
f"/api/a2a/tasks/{a2a_task.id}/subscribe",
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
# Just connect; generator immediately exits when get_task returns None.
|
|
count = 0
|
|
async for _chunk in response.aiter_bytes():
|
|
count += 1
|
|
if count >= 1:
|
|
break
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_task_polls_and_skips_unchanged(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
"""subscribe_to_task: state unchanged across polls — covers sleep+increment."""
|
|
client = a2a_route_client["client"]
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(uuid4()),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.WORKING).model_dump(mode="json"),
|
|
}
|
|
)
|
|
|
|
# Validation call returns task. Loop alternates: same task (state unchanged
|
|
# path → emits once then sleep), then None (loop exits).
|
|
side_effects = [a2a_task, a2a_task, None]
|
|
with (
|
|
patch("roboco.api.routes.a2a.A2AService") as mock_service_cls,
|
|
patch("roboco.api.routes.a2a.asyncio.sleep", new=AsyncMock(return_value=None)),
|
|
):
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(side_effect=side_effects)
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"GET",
|
|
f"/api/a2a/tasks/{a2a_task.id}/subscribe",
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
count = 0
|
|
async for _chunk in response.aiter_bytes():
|
|
count += 1
|
|
if count >= 1:
|
|
break
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_subscribe_task_disconnects_immediately(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
"""is_disconnected returns True on first loop iteration → break (line 307)."""
|
|
client = a2a_route_client["client"]
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(uuid4()),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.WORKING).model_dump(mode="json"),
|
|
}
|
|
)
|
|
|
|
async def _disconnected(_self: object) -> bool:
|
|
return True
|
|
|
|
with (
|
|
patch("roboco.api.routes.a2a.A2AService") as mock_service_cls,
|
|
patch("roboco.api.routes.a2a.Request.is_disconnected", new=_disconnected),
|
|
):
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(return_value=a2a_task)
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"GET",
|
|
f"/api/a2a/tasks/{a2a_task.id}/subscribe",
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
count = 0
|
|
async for _chunk in response.aiter_bytes():
|
|
count += 1
|
|
if count >= 1:
|
|
break
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_stream_disconnects_in_loop(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
"""is_disconnected → True inside send_message_stream loop (line 234)."""
|
|
client = a2a_route_client["client"]
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(uuid4()),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.WORKING).model_dump(mode="json"),
|
|
}
|
|
)
|
|
|
|
# First call to is_disconnected is False (initial loop entry) — wait,
|
|
# actually disconnect check happens BEFORE sleep, on first iteration.
|
|
async def _disconnected(_self: object) -> bool:
|
|
return True
|
|
|
|
with (
|
|
patch("roboco.api.routes.a2a.A2AService") as mock_service_cls,
|
|
patch("roboco.api.routes.a2a.Request.is_disconnected", new=_disconnected),
|
|
):
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(return_value=a2a_task)
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"POST",
|
|
"/api/a2a/message/stream",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": a2a_task.id,
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
count = 0
|
|
async for _chunk in response.aiter_bytes():
|
|
count += 1
|
|
if count >= 1:
|
|
break
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_stream_task_disappears_in_loop(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
"""Task goes None inside send_message_stream poll loop (line 242)."""
|
|
client = a2a_route_client["client"]
|
|
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(uuid4()),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.WORKING).model_dump(mode="json"),
|
|
}
|
|
)
|
|
|
|
# First get_task returns task (initial state). Inside loop: returns None.
|
|
side_effects = [a2a_task, None]
|
|
with (
|
|
patch("roboco.api.routes.a2a.A2AService") as mock_service_cls,
|
|
patch("roboco.api.routes.a2a.asyncio.sleep", new=AsyncMock(return_value=None)),
|
|
):
|
|
instance = AsyncMock()
|
|
instance.get_task = AsyncMock(side_effect=side_effects)
|
|
mock_service_cls.return_value = instance
|
|
async with client.stream(
|
|
"POST",
|
|
"/api/a2a/message/stream",
|
|
json={
|
|
"message": {
|
|
"role": "user",
|
|
"parts": [{"type": "text", "text": "hi"}],
|
|
"taskId": a2a_task.id,
|
|
}
|
|
},
|
|
headers=_HDR,
|
|
timeout=5.0,
|
|
) as response:
|
|
assert response.status_code == HTTPStatus.OK
|
|
count = 0
|
|
async for _chunk in response.aiter_bytes():
|
|
count += 1
|
|
if count >= 1:
|
|
break
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_tasks: pageToken parsing (lines 386-387)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tasks_with_page_token(a2a_route_client: dict) -> None:
|
|
"""list_tasks with pageToken parses int and uses it as offset."""
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.list_tasks = AsyncMock(return_value=([], False))
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(
|
|
f"/api/a2a/tasks?pageToken={_PAGE_TOKEN_OFFSET}&pageSize=10",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
instance.list_tasks.assert_awaited_once()
|
|
call = instance.list_tasks.await_args
|
|
assert call.kwargs["offset"] == _PAGE_TOKEN_OFFSET
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_tasks_with_invalid_page_token(a2a_route_client: dict) -> None:
|
|
"""Invalid pageToken (non-int) is silently suppressed → offset stays 0."""
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.list_tasks = AsyncMock(return_value=([], True))
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(
|
|
"/api/a2a/tasks?pageToken=not-an-int&pageSize=5", headers=_HDR
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
# has_more=True → next_page_token=str(0+5).
|
|
# Model uses populate_by_name; check both alias and snake_case.
|
|
token = body.get("nextPageToken") or body.get("next_page_token")
|
|
assert token == "5"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cancel_task: full success path (lines 433-434)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_task_success_no_body(a2a_route_client: dict) -> None:
|
|
"""cancel_task with NO body → request=None branch, success path."""
|
|
a2a_task = A2ATask.model_validate(
|
|
{
|
|
"id": str(a2a_route_client["task"].id),
|
|
"contextId": str(uuid4()),
|
|
"status": A2ATaskStatus(state=A2ATaskState.CANCELLED).model_dump(
|
|
mode="json"
|
|
),
|
|
}
|
|
)
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.cancel_task = AsyncMock(return_value=a2a_task)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.post(
|
|
f"/api/a2a/tasks/{a2a_route_client['task'].id}/cancel",
|
|
headers=_HDR,
|
|
)
|
|
assert response.status_code == HTTPStatus.OK
|
|
body = response.json()
|
|
assert body["id"] == a2a_task.id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_agent_card_by_id: 404 path (line 477)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_agent_card_by_id_unknown_returns_404(
|
|
a2a_route_client: dict,
|
|
) -> None:
|
|
"""Unknown agent slug on /agents/{id}/card → 404."""
|
|
client = a2a_route_client["client"]
|
|
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
|
|
instance = AsyncMock()
|
|
instance.build_agent_card = AsyncMock(return_value=None)
|
|
mock_service_cls.return_value = instance
|
|
response = await client.get(f"/api/a2a/agents/{uuid4()}/card", headers=_HDR)
|
|
assert response.status_code == HTTPStatus.NOT_FOUND
|