mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
1762 lines
62 KiB
Python
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
|