Files
roboco/tests/integration/test_a2a_routes.py
T
312ec990dd fix: prod triage 2026-07-08 — MCP auth residue, gateway envelopes, verb-loop cap, A2A interjection, manual spawn UX (#334)
* fix(auth): pass agent UUID to CLI-arg MCP servers (optimal/docs/search)

The container token is HMAC-signed over the agent UUID (#314), but the
optimal/docs/search MCP servers received the slug as their CLI arg and
sent X-Agent-ID=<slug>, so every research/RAG/docs call 401ed with
signature mismatch under enforced auth. Pass the already-computed
agent_uuid in the three args lists instead.

* fix(gateway): include remediate in gateway.rejected audit details

Conventions-gate rejections carry the offending file:line listing only
in the envelope's remediate field, which the audit row dropped -- ops
logs showed just the violation count with no way to see what blocked.

* fix(gateway): return envelope on do/commit git failure

A GitError from the commit verb propagated to the generic middleware
handler, so agents got a raw error blob with no remediate/next. Catch
it and return an error envelope; 'no changes added to commit' with an
explicit files list now names the mismatch and the omit-files fallback.

* fix(agent-sdk): absolute rejection cap breaks slow-drip verb loops

The verb circuit breaker only counted rejections inside a 60s sliding
window, so an agent retrying i_am_done every 3-4 minutes looped for 30+
minutes without tripping it. Add a session-scoped cumulative per-(verb,
task) cap at 3x the windowed limit that trips regardless of pacing.

* feat(a2a): CEO chime-in interjects into the viewed conversation

Previously reply_as_ceo re-homed the message into a canonical CEO<->target
conversation with no panel surface, so a chime-in reported success but was
invisible and only opportunistically delivered. interject_as_ceo now inserts
the message into the conversation being viewed (from_agent=ceo, directed via
an @target content prefix), bumps that conversation's counters with the
unread ping keyed to the addressed participant, and both participants see it
in transcript and read_a2a.

* feat(panel): manual spawn carries task + message, surfaces refusals

The agent detail page spawned with no request body (task/message impossible),
the spawn button could double-fire (2.5ms double-POST seen live), and refusal
reasons never reached the UI: readiness refusals were generic 500s and the
already-running no-op looked like success. Detail page now uses
SpawnAgentDialog, a synchronous ref guard blocks re-entry, AgentReadinessError
maps to 409 with its reason shown, already_running is signalled and toasted,
and a task_id builds a task-aware prompt instructing the claim (task_id alone
never did), with the CEO's message appended as a note.

* test(panel): align a2a page test with the interjection footer copy

The chime-in rebuild changed the composer footer; the page-level test
asserting the old copy was outside the rebuild's scoped vitest run.

* fix(api): commit the request DB session before the response is sent

FastAPI unwinds yield-dependencies after the response bytes go out, so
get_db's post-yield commit raced the client's next request -- a verb
could return ok while its claim/status write was still uncommitted (the
e2e ok-without-effect flake family), and a failed commit was silently
lost behind an already-sent 200. DbCommitMiddleware (innermost, pure
ASGI) commits the session stashed by get_db_committed before forwarding
http.response.start; commit failure now surfaces as a 5xx. get_db is
untouched for its direct non-request callers.

* fix(db): invalidate, not rollback, the session on request cancellation

With the commit moved into the send path, the flow-verb timeout can
cancel mid-commit; rolling back then issues another command over an
asyncpg connection stranded mid-wire-protocol, and the poisoned
connection segfaults uvloop/asyncpg when a later checkout recycles it
(3/3 identical CI faulthandler dumps). On CancelledError discard the
connection via session.invalidate() -- SQLAlchemy's documented handling
for a timeout during commit -- and keep rollback for plain exceptions.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-08 10:41:02 +02:00

1762 lines
62 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, cast
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, 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 A2AAdminPairSummary, A2ATask, A2ATaskState, A2ATaskStatus
from roboco.models.base import (
TaskNature,
TaskStatus,
TaskType,
)
from roboco.models.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_PAGE_TOKEN_OFFSET = 20
_MIN_STREAM_CHUNKS = 2
_EXPECTED_PAIR_LIST_TOTAL = 2
_EXPECTED_PAIR_MESSAGE_COUNT = 4
@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
async def _override_agent_context() -> AgentContext:
# The authenticated caller is the seeded developer by default. Tests
# that need a different role (e.g. the PM-gated cancel route) swap this
# override on the yielded app before posting.
return AgentContext(
agent_id=cast("UUID", dev.id),
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
slug=dev.slug,
)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_current_agent_slug] = _override_agent_slug
app.dependency_overrides[get_agent_context] = _override_agent_context
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "dev": dev, "task": task, "app": app}
app.dependency_overrides.clear()
_HDR = {"X-Agent-ID": "be-dev-1", "X-Agent-Role": "developer"}
def _set_pm_context(app: FastAPI, dev: AgentTable) -> None:
"""Override the agent context to a cell PM so the PM-gated cancel route
admits the call (the default fixture context is a developer)."""
async def _pm() -> AgentContext:
return AgentContext(
agent_id=cast("UUID", dev.id),
role=AgentRole.CELL_PM,
team=Team.BACKEND,
slug=dev.slug,
)
app.dependency_overrides[get_agent_context] = _pm
# ---------------------------------------------------------------------------
# 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"]
_set_pm_context(a2a_route_client["app"], a2a_route_client["dev"])
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={
"name": f"tasks/{a2a_route_client['task'].id}",
"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"]
_set_pm_context(a2a_route_client["app"], a2a_route_client["dev"])
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"]
_set_pm_context(a2a_route_client["app"], a2a_route_client["dev"])
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
# ---------------------------------------------------------------------------
# #423: the cancel route must be authenticated + PM/management-gated, and pass
# the authenticated actor + role into the service (it cascades cancel to all
# non-terminal descendants — lifecycle rule: Any -> cancelled: PM roles only).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cancel_task_developer_role_forbidden(a2a_route_client: dict) -> None:
"""A developer (default fixture context) must NOT be able to cancel a task
via A2A — the route was previously unauthenticated with no role gate, so any
caller could cancel any task tree (#423). Now PM/management-only."""
client = a2a_route_client["client"]
# default fixture context = developer
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.cancel_task = AsyncMock()
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.FORBIDDEN
instance.cancel_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_cancel_task_no_auth_header_rejected(a2a_route_client: dict) -> None:
"""A request with no agent headers at all is rejected — the route must not
be reachable unauthenticated (#423)."""
client = a2a_route_client["client"]
response = await client.post(
f"/api/a2a/tasks/{a2a_route_client['task'].id}/cancel",
)
assert response.status_code in (
HTTPStatus.UNAUTHORIZED,
HTTPStatus.FORBIDDEN,
HTTPStatus.UNPROCESSABLE_ENTITY,
)
@pytest.mark.asyncio
async def test_cancel_task_pm_passes_actor_and_role_to_service(
a2a_route_client: dict,
) -> None:
"""A PM cancel threads the authenticated role (for the cascade role gate)
and the actor slug (for the cancellation-note attribution) into the service
— previously the service was called with no actor and a hardcoded
cell_pm role, so the audit trail recorded no real caller (#423)."""
client = a2a_route_client["client"]
_set_pm_context(a2a_route_client["app"], a2a_route_client["dev"])
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.cancel_task = AsyncMock(
return_value=A2ATask.model_validate(
{
"id": str(a2a_route_client["task"].id),
"contextId": str(uuid4()),
"status": A2ATaskStatus(state=A2ATaskState.CANCELLED).model_dump(
mode="json"
),
}
)
)
mock_service_cls.return_value = instance
# No body → request=None → the handler runs (a body without the A2A
# ``name`` field 422s at request validation before the handler). The
# invariant under test is the role/slug threading, not the reason.
response = await client.post(
f"/api/a2a/tasks/{a2a_route_client['task'].id}/cancel",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# The authenticated PM role + slug reach the service.
_kwargs = instance.cancel_task.await_args.kwargs
assert _kwargs.get("agent_role") == "cell_pm"
assert _kwargs.get("actor_slug") == a2a_route_client["dev"].slug
# ---------------------------------------------------------------------------
# #116: send_message must record the AUTHENTICATED identity as the responder,
# not a client-supplied metadata.from_agent (spoof).
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_message_uses_authenticated_identity_not_client_from_agent(
a2a_route_client: dict,
) -> None:
"""is_response=True must stamp the authenticated caller's slug as the
responder, ignoring a spoofed metadata.from_agent — previously the route
took from_agent verbatim from the request body, so any agent could
impersonate anyone (e.g. from_agent='ceo') in the task's notes and in the
spawn/notification routed back to the original requester (#116)."""
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": "ceo"},
},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
# The authenticated dev slug is the responder — NOT the spoofed 'ceo'.
_kwargs = instance.update_task_from_message.await_args.kwargs
assert _kwargs.get("responder_agent") == a2a_route_client["dev"].slug
assert _kwargs.get("responder_agent") != "ceo"
@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_send_chat_message_over_budget_returns_403(
a2a_route_client: dict,
) -> None:
"""An over-budget reply to the CEO raises A2AAccessDeniedError from the
service — the route must surface 403, not crash into a 500 or fall
through to the ValueError->404 branch."""
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=A2AAccessDeniedError(
from_agent="be-dev-1",
to_agent="ceo",
reason=(
"you have already replied to the CEO's last message — "
"wait for the CEO to respond before sending again"
),
)
)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/conversations/{uuid4()}/messages",
json={"content": "another update"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@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
# ---------------------------------------------------------------------------
# Admin / live-view endpoints (CEO-only) — GET all conversations, GET any
# conversation's messages, POST a reply as the CEO.
# ---------------------------------------------------------------------------
def _set_ceo_context(app: FastAPI, dev: AgentTable) -> None:
"""Override the agent context to the CEO so the admin live-view routes
admit the call (the default fixture context is a developer)."""
async def _ceo() -> AgentContext:
return AgentContext(
agent_id=cast("UUID", dev.id),
role=AgentRole.CEO,
team=None,
slug="ceo",
)
app.dependency_overrides[get_agent_context] = _ceo
def _admin_conv_obj(
*,
conv_id: UUID,
agent_a: str = "be-dev-1",
agent_b: str = "fe-dev-1",
task_id: UUID | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
id=str(conv_id),
agent_a=agent_a,
agent_b=agent_b,
topic=None,
task_id=str(task_id) if task_id else None,
status="active",
resolution=None,
message_count=2,
unread_by_a=0,
unread_by_b=0,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
last_message_at=datetime.now(UTC),
last_message_preview="hi there",
)
@pytest.mark.asyncio
async def test_admin_list_conversations_forbidden_for_non_ceo(
a2a_route_client: dict,
) -> None:
client = a2a_route_client["client"]
response = await client.get("/api/a2a/chat/admin/conversations", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_get_messages_forbidden_for_non_ceo(
a2a_route_client: dict,
) -> None:
client = a2a_route_client["client"]
response = await client.get(
f"/api/a2a/chat/admin/conversations/{uuid4()}/messages", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_reply_forbidden_for_non_ceo(a2a_route_client: dict) -> None:
client = a2a_route_client["client"]
response = await client.post(
f"/api/a2a/chat/admin/conversations/{uuid4()}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_list_conversations_as_ceo(a2a_route_client: dict) -> None:
"""CEO sees conversations it is not itself a participant in."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv = _admin_conv_obj(conv_id=uuid4())
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.list_conversations_admin = AsyncMock(return_value=[conv])
mock_service_cls.return_value = instance
response = await client.get(
"/api/a2a/chat/admin/conversations?limit=10", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
instance.list_conversations_admin.assert_awaited_once_with(10)
body = response.json()
assert body["total"] == 1
assert body["items"][0]["agent_a"] == "be-dev-1"
assert body["items"][0]["agent_b"] == "fe-dev-1"
@pytest.mark.asyncio
async def test_admin_list_pairs_forbidden_for_non_ceo(a2a_route_client: dict) -> None:
client = a2a_route_client["client"]
response = await client.get("/api/a2a/chat/admin/pairs", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_list_pairs_as_ceo(a2a_route_client: dict) -> None:
"""CEO gets the switchboard's pair cards — the static matrix joined with
each pair's representative conversation stats."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
pair_with_history = A2AAdminPairSummary(
agent_a="be-dev-1",
role_a="developer",
team_a="backend",
agent_b="be-qa",
role_b="qa",
team_b="backend",
group_key="cell-backend",
conversation_id=str(conv_id),
last_message_at=datetime.now(UTC),
message_count=4,
)
pair_never_talked = A2AAdminPairSummary(
agent_a="auditor",
role_a="auditor",
team_a="board",
agent_b="product-owner",
role_b="product_owner",
team_b="board",
group_key="board",
conversation_id=None,
last_message_at=None,
message_count=0,
)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.list_admin_pairs = AsyncMock(
return_value=[pair_with_history, pair_never_talked]
)
mock_service_cls.return_value = instance
response = await client.get("/api/a2a/chat/admin/pairs", headers=_HDR)
assert response.status_code == HTTPStatus.OK
instance.list_admin_pairs.assert_awaited_once_with()
body = response.json()
assert body["total"] == _EXPECTED_PAIR_LIST_TOTAL
first = body["items"][0]
assert first["agent_a"] == "be-dev-1"
assert first["agent_b"] == "be-qa"
assert first["group_key"] == "cell-backend"
assert first["conversation_id"] == str(conv_id)
assert first["message_count"] == _EXPECTED_PAIR_MESSAGE_COUNT
second = body["items"][1]
assert second["group_key"] == "board"
assert second["conversation_id"] is None
assert second["message_count"] == 0
@pytest.mark.asyncio
async def test_admin_get_messages_as_ceo_returns_full_transcript(
a2a_route_client: dict,
) -> None:
"""The route uses get_messages_admin — the participant-bypassing
accessor — not the ordinary get_messages()."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
msg = SimpleNamespace(
id=uuid4(),
conversation_id=conv_id,
from_agent="be-dev-1",
content="hello",
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.get_messages_admin = AsyncMock(return_value=[msg, msg])
mock_service_cls.return_value = instance
response = await client.get(
f"/api/a2a/chat/admin/conversations/{conv_id}/messages", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
instance.get_messages_admin.assert_awaited_once()
body = response.json()
_EXPECTED_MESSAGES = 2
assert body["total"] == _EXPECTED_MESSAGES
assert not body["has_more"]
@pytest.mark.asyncio
async def test_admin_reply_unknown_conversation_404(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=None)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{uuid4()}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_admin_reply_non_participant_target_400(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "ghost-agent", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_admin_reply_no_task_id_400(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=None)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_admin_reply_success(a2a_route_client: dict) -> None:
"""The route posts into the VIEWED conversation via interject_as_ceo —
not a re-homed CEO<->target DM (the prior, rejected behavior)."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
sent_msg = SimpleNamespace(
id=uuid4(),
conversation_id=conv_id,
from_agent="ceo",
content="@be-dev-1: chiming in",
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.get_conversation_admin = AsyncMock(return_value=conv)
instance.interject_as_ceo = AsyncMock(return_value=sent_msg)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "chiming in"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
instance.interject_as_ceo.assert_awaited_once()
call_kwargs = instance.interject_as_ceo.await_args.kwargs
assert call_kwargs["conversation_id"] == conv_id
assert call_kwargs["to_agent"] == "be-dev-1"
assert call_kwargs["content"] == "chiming in"
body = response.json()
assert body["content"] == "@be-dev-1: chiming in"
# ---------------------------------------------------------------------------
# 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"]
_set_pm_context(a2a_route_client["app"], a2a_route_client["dev"])
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