Files
roboco/tests/integration/test_a2a_service.py
T
7901ea419e Retire channels/sessions/messages; A2A becomes primary agent comms (#306)
* feat(a2a): deliver latest incoming message preview into the claim briefing

list_unread_a2a now carries last_message_preview (the latest message from the
OTHER agent, never the agent's own reply), fetched via a correlated subquery in
the same query — no N+1 on the per-verb briefing path.

* feat(a2a): read_a2a verb delivers unread message bodies to the agent

A2AService.get_unread_messages returns the caller's unread INCOMING messages
(never its own sends), marking exactly those rows read atomically so a message
arriving mid-call is preserved. Wired as the read_a2a content verb (route +
do_server tool + granted to every delivery role) — the content-bearing read the
A2A inbox lacked (read_messages only zeroed the counter).

* docs(rag): document read_a2a as the A2A content-read path

* fix(task): backlog activation no longer requires a discussion session

Removes the SessionTaskTable gate in activate() (and its dangling log field),
deletes _inherit_parent_session + its create() call, and drops the now-unused
SessionTaskTable import. Coordination rides task state; the session subsystem is
being retired. Tests updated to the new (no-session) behavior.

* fix(orchestrator): drop session sweep from _run_sweep

Removes the messaging import + sweep_timed_out_sessions call. That import sat
outside the try/except, so once messaging.py is deleted it would have killed the
entire sweep cascade (budget kill-switch, token rollups, retention, image prune,
superseded-PR reconcile). Notification sweep + all maintenance sweeps unchanged.

* release-manager --no-tags read-clone fix

* test: update evidence_repo unit test for a2a last_message_preview

* refactor(gateway): drop session propagation on delegate

Removes propagate_sessions_to_subtask from delegate(), the ChoreographerDeps
messaging field + property, and the ChoreographerDeps messaging arg in deps.py
(ContentActions messaging + import stay until the verbs are removed). Deletes the
propagation test; strips the now-invalid messaging kwarg from ChoreographerDeps
test builders.

* refactor(gateway): remove say/open_session/link_session/channels verbs

Removes the four channel/session verbs across content_actions (impls +
ContentActionsDeps.messaging), do_server (tools + registry), role_config (grants
+ _CHANNEL_DISCOVERY), do.py (routes), schemas/v1/do.py (request models), and
deps.py (MessagingService import + construction). Regenerates the prompt verb
tables. dm/notify/read_messages/read_a2a stay. Tests deleted/updated accordingly.

* uv.lock Upgrade

* refactor: remove conversation RAG indexing; Secretary announces via notification

Drops the CONVERSATIONS index (index_conversation, ConversationsIndexPlugin,
IndexType.CONVERSATIONS enum, IndexConversationParams, mentor.py type-label, the
messaging index hook) and its chunk-table manifest entries. The Secretary's
ANNOUNCE/RELAY_MESSAGE now fan out a BROADCAST notification to every agent's
inbox (NotificationService.broadcast) instead of posting to a dead channel.

* fix(panel): label RAG health error lines by subsystem

A red llm_error (e.g. the glm-5.2:cloud weekly-limit 429) rendered under
the 'Embedding: ok' header with no label, reading as an embedding failure.
Prefix each error line with LLM / Embedding / Vector store.

* refactor: remove channel/message reads from metrics, dashboard, git, events

MetricsService drops get_communication_volume + the MessageTable
message-count in get_agent_metrics (and the now-dead messages_sent_week
field). DashboardService drops get_channel_feeds/_compute_channel_status
and the message read in get_recent_activity (task activity kept);
get_auditor_metrics no longer reports communication_volume.
GitService's two primary-session-id helpers always return None now
(callers already treat None as "no primary session"). events/handlers.py
drops the SESSION_CLOSED/SESSION_TIMEOUT subscriptions + the
handle_session_boundary handler.

Forced follow-on: api/routes/dashboard.py + api/schemas/dashboard.py
dropped the now-dangling live_feeds/ChannelFeed surface and the
/metrics/communication route, which wrapped the removed service calls
directly (mypy would otherwise fail on the missing attributes).

* refactor: delete MessagingService + channel seeding

Edited db/__init__.py and services/__init__.py first (drop the unconditional
Channel/Group/Message/Session table + MessagingService re-exports), then
deleted services/messaging.py, then trimmed db/seed.py to only create_agents
(create_channels/create_channel_memberships/create_initial_messages gone).

Forced expansion: api/routes/{channels,groups,sessions,messages}.py import
roboco.services.messaging directly (not through the package __init__), as
does api/routes/tasks.py (the session-links embed on GET /tasks/{id} and the
GET /{id}/sessions route). Deleting messaging.py without addressing these
breaks `import roboco.api.app` immediately, since app.py eagerly imports all
route modules at startup. Since the 4 CRUD route files are 100%
MessagingService-backed with zero independent logic (and are wholesale
deletes in the plan's later API-routes task anyway), deleted them now +
unmounted from app.py/routes/__init__.py; tasks.py got the same surgical
trim its later task already specified (drop session-links embed +
TaskSessionLinkResponse/TaskResponse.sessions). This pulls a slice of that
later work forward — the routes/schemas for channels/groups/sessions/messages
still need their own pass, but their messaging-coupled parts are gone.

Verified with a full-suite collection sweep (12010 tests collected, zero
import errors) beyond the directly touched test dirs, given the expanded
blast radius.

* refactor: remove channel/session/message models, tables, and channel policy

Models: deleted channel.py/group.py/session.py/messaging.py wholesale
(zero external consumers besides the models/__init__.py re-export).
message.py surgically trimmed: removed MessageCreate (dead) and MessageEdit
(never instantiated; ExtractedMessage.edit_history retyped to
list[dict[str, Any]] to match how it's actually persisted — confirmed
ExtractedMessage was never written to any DB table, so MessageTable's
removal carries no functional risk to the kept extraction pipeline).
base.py: removed SessionStatus + ChannelType, kept MessageType. Also
removed the confirmed-dead channels_read/channels_write fields from
models/agent.py:AgentPermissions and models/dashboard.py:ChannelFeedData.

db/tables.py: deleted ChannelTable/GroupTable/SessionTable/SessionTaskTable/
MessageTable, TaskTable.session_links, and JournalEntryTable.session_id —
cascaded through models/journal.py, services/journal.py, and
api/schemas+routes/journals.py (22 plumbing sites).

foundation/policy/communications.py: removed the ChannelSpec/CHANNELS
catalog + TEAM_SCOPED_ROLES/_CELL_*/_AUDITOR_ONLY helpers, kept the
notification policy (Priority/parse_priority/NOTIFY_SENDER_ROLES/
ACK_REQUIRED_BY_TYPE). enforcement/channel_access.py deleted (confirmed
fully dead in production). agents_config.py: removed CHANNEL_ACCESS
(kept A2A_ALLOWED_PAIRS). seeds/initial_data.py: removed
DEFAULT_CHANNELS/CHANNEL_MEMBERSHIPS/AUDITOR_SILENT_ACCESS + the
never-consumed INITIAL_MESSAGES. config.py: removed
session_idle_timeout_seconds (zero consumers). exceptions.py: removed
dead ChannelError/ChannelAccessDeniedError/SessionClosedError.

Forced expansion beyond the original file list — ChannelType cascaded
into a live, mounted surface the plan didn't trace: agents_config.
CHANNEL_ACCESS -> services/permissions.py's channel-RBAC methods (not
models/permissions.py, which turned out to have no channel code at all)
-> two real endpoints in api/routes/stream.py (GET /permissions,
GET /permissions/channel/{name}) and two dependency factories in
api/deps.py. Removed the channel methods + fields, deleted the
channel-specific stream.py endpoint, deleted require_channel_read/write.
Also deleted api/schemas/{channels,sessions}.py (hard dependency on the
removed enums; already fully dead after the Task 10 route deletions) and
api/schemas/messages.py (a TYPE_CHECKING-only import of the deleted
MessageTable; likewise already fully dead) + its dedicated test file.

Test updates: test_permissions.py -14 channel tests (matches the planned
count exactly), test_communications.py / test_communications_consumers.py
split to keep only notification-policy coverage, test_exceptions.py -9,
test_deps.py -4, plus the journal/stream/foundation-smoke fallout. Also
fixed a pre-existing (Task 7) broken assertion in
test_foundation_phase3_smoke.py that inspected a `say()` method already
removed from ContentActions.

Verified: full-suite collection (11961 tests, zero import errors) and a
complete test run (11567 passed, 394 skipped, 0 failed) in addition to
the targeted suites.

* migration: drop channels/groups/sessions/session_tasks/messages + enum types

alembic/versions/060_drop_messaging.py: drop_column journal_entries.
session_id (sidesteps hardcoding the FK constraint name — verified
empirically against a live migrated DB that it's actually
fk_journal_entries_session_id_sessions, but drop_column doesn't care
either way); drop_table in FK order (messages -> session_tasks ->
sessions -> groups -> channels); DROP TABLE IF EXISTS chunks_conversations
(runtime-provisioned, not alembic-managed, would otherwise orphan); DROP
TYPE IF EXISTS for messagetype/sessionstatus/sessionscope/channeltype
(messagetype's Python enum stays for ExtractedMessage, but the DB type
had zero live columns left once MessageTable was dropped in the prior
commit). downgrade() raises NotImplementedError — one-way removal.

Pruned scripts/reset_runtime_state.sql + .sh: removed the DELETE/COUNT
lines for messages/session_tasks/sessions/groups/channels and the
groups.active_session_id reset block.

Verified end-to-end against a scratch Postgres DB: full migration chain
001->060 applies cleanly, alembic heads shows a single head, all 6 dropped
tables + 4 enum types + the journal_entries.session_id column are
confirmed gone, journal_entries keeps only its journal_id/task_id FKs,
downgrade correctly raises NotImplementedError without corrupting DB
state, and the pruned reset_runtime_state.sql runs clean (no errors)
against a fully-migrated DB.

* refactor(api): remove channel/session/message routes + WS streams

Most of this task's file list was already forced through in earlier
commits (routes/{channels,groups,sessions,messages}.py + app.py/__init__.py
unmounting in the MessagingService-deletion commit; tasks.py's
session-links embed + GET /{id}/sessions + schemas/tasks.py's
TaskResponse.sessions in that same commit; deps.py's require_channel_read/
write + schemas/{channels,sessions}.py in the models/tables commit). This
closes out what was left:

- api/websocket.py: deleted the channel_stream + session_stream routes,
  ConnectionManager's channel_connections/session_connections dicts,
  connect_channel/connect_session, broadcast_to_channel/broadcast_to_session,
  get_channel_subscriber_count, and their cleanup lines in disconnect().
  Agent streams, notification streams, and the operator system stream are
  untouched.
- api/websocket_bridge.py: deleted _handle_session_event +
  _handle_message_event and their SESSION_CREATED/SESSION_CLOSED/
  SESSION_TIMEOUT/MESSAGE_SENT subscriptions. The A2A live-view, rate-limit,
  usage, agent-lifecycle, and notification bridges are untouched.
- api/schemas/websocket.py: removed NewMessageBroadcast, WSMessageNew,
  WSMessageEdit, WSMessageDelete, WSSessionClosed — kept the WSMessage base
  class (still subclassed by the kept WSAgentStream/WSNotification) plus
  those two.
- api/schemas/groups.py: deleted (already fully orphaned since routes/
  groups.py was removed; its GroupResponse/GroupDetailResponse had zero
  consumers).

Updated the 5 websocket test files accordingly (removed the channel/
session-specific tests + fixed imports); test_websocket_bridge.py's
registration-coverage test dropped the SESSION_*/MESSAGE_SENT assertions.

Verified: full-suite collection (11943 tests, zero import errors) and a
complete test run (11549 passed, 394 skipped, 0 failed).

* docs: retire channels/sessions/messages from agent-facing docs + CLAUDE.md

Rewrites docs/rag (RAG-indexed) + docs/map + CLAUDE.md to reflect A2A (dm +
read_a2a) as primary agent comms; deletes the channel docs, splits messaging-tools
+ messaging-notification (renamed notification.md), swaps the WS worked example to
A2A_MESSAGE_SENT. _complete_map.md still needs regeneration (generated file).

* refactor(panel): remove Communications surface (channels/sessions)

Deletes the /communications routes, message components, task-detail Sessions tab,
use-channels + channel/session WS hooks, and the channels/sessions/messages/groups
api clients; prunes the Channel/Session/Message/Group types + mock data. (Auditor
live-feeds + dashboard.ts dead-route cleanup is a follow-up.)

* refactor(panel): drop auditor channel-feed + dead communication-metric route

* docs(map): regenerate _complete_map from updated slices

* fix(a2a): reduce get_unread_messages complexity below xenon C + stale comments

Extract the per-conversation unread-counter recompute into _reset_unread_counter
(the CI quality gate flagged get_unread_messages as rank C). Also drop the deleted
open_session from a content_actions comment and reword an evidence_repo docstring
that cited the removed messaging._notify_mentions.

---------

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

2137 lines
74 KiB
Python

"""A2AService coverage — agent cards, task ↔ A2A conversion, conversations."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, patch
from unittest.mock import MagicMock as _MM
from uuid import UUID, uuid4
from uuid import uuid4 as _u
import pytest
import pytest_asyncio
from roboco.agents_config import A2A_ALLOWED_PAIRS
from roboco.db.tables import (
A2AConversationTable,
A2AMessageTable,
AgentTable,
ProjectTable,
TaskTable,
)
from roboco.enforcement.a2a_access import A2AAccessDeniedError
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.a2a import (
A2AConversationStatus,
A2AMessage,
SendMessageRequest,
TextPart,
)
from roboco.models.base import (
TaskNature,
TaskStatus,
TaskType,
)
from roboco.models.events import EventType
from roboco.services.a2a import _LIVE_VIEW_EXCERPT_CHARS, A2AService
from roboco.services.gateway.evidence_repo import EvidenceRepo
from sqlalchemy import select
from sqlalchemy import select as _sel
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
@pytest_asyncio.fixture
async def a2a_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
# Use the canonical seed slugs so the A2A policy matrix recognizes
# role + team and lets same-cell pairs talk. Random suffixes would
# leave them with role="unknown" → policy denies everything.
dev = AgentTable(
id=uuid4(),
name="Dev",
slug="be-dev-1",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
qa = AgentTable(
id=uuid4(),
name="QA",
slug="be-qa",
role=AgentRole.QA,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="qa",
capabilities=[],
permissions={},
metrics={},
)
db_session.add_all([dev, qa])
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="A-Proj",
slug=f"a-proj-{uuid4().hex[:8]}",
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()
yield {
"svc": A2AService(db_session),
"dev": dev,
"qa": qa,
"task_id": task.id,
"db": db_session,
}
# ---------------------------------------------------------------------------
# Agent cards
# ---------------------------------------------------------------------------
def test_get_service_endpoint_returns_url() -> None:
url = A2AService.get_service_endpoint()
assert url.startswith("http://")
def test_build_system_agent_card() -> None:
card = A2AService.build_system_agent_card()
assert card.id == "roboco-system"
assert len(card.skills) >= 1
@pytest.mark.asyncio
async def test_build_agent_card_by_uuid(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
card = await svc.build_agent_card(str(dev.id))
assert card is not None
assert card.name == dev.name
@pytest.mark.asyncio
async def test_build_agent_card_by_slug(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
card = await svc.build_agent_card(dev.slug)
assert card is not None
assert card.id == str(dev.id)
@pytest.mark.asyncio
async def test_build_agent_card_unknown_returns_none(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
assert await svc.build_agent_card(str(uuid4())) is None
assert await svc.build_agent_card("ghost-slug") is None
# ---------------------------------------------------------------------------
# Task ↔ A2A
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_task_by_uuid(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
a2a = await svc.get_task(str(a2a_setup["task_id"]))
assert a2a is not None
assert a2a.id == str(a2a_setup["task_id"])
@pytest.mark.asyncio
async def test_get_task_returns_none_for_invalid_id(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
assert await svc.get_task("not-a-uuid") is None
assert await svc.get_task(str(uuid4())) is None
@pytest.mark.asyncio
async def test_list_tasks(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
tasks, has_more = await svc.list_tasks(page_size=20)
assert any(t.id == str(a2a_setup["task_id"]) for t in tasks)
assert isinstance(has_more, bool)
@pytest.mark.asyncio
async def test_list_tasks_ascending(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
tasks, _ = await svc.list_tasks(order_by="created_at asc")
assert isinstance(tasks, list)
@pytest.mark.asyncio
async def test_cancel_task_invalid_id(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
with pytest.raises(ValueError, match="Invalid task ID"):
await svc.cancel_task("not-a-uuid")
@pytest.mark.asyncio
async def test_cancel_task_not_found(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
with pytest.raises(ValueError, match="Task not found"):
await svc.cancel_task(str(uuid4()))
@pytest.mark.asyncio
async def test_cancel_task_already_terminal(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
db = a2a_setup["db"]
completed = TaskTable(
id=uuid4(),
title="done",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.COMPLETED,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=uuid4(),
created_by=a2a_setup["dev"].id,
team=Team.BACKEND,
)
# FK on project — use existing project
completed.project_id = (await db.execute(select(ProjectTable))).scalars().first().id
db.add(completed)
await db.flush()
with pytest.raises(ValueError, match="terminal state"):
await svc.cancel_task(str(completed.id))
# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_discover_agents_no_filters(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
cards = await svc.discover_agents()
# Setup seeds dev + qa, so at least 2 cards.
_MIN_SEEDED = 2
assert len(cards) >= _MIN_SEEDED
@pytest.mark.asyncio
async def test_discover_agents_by_role(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
cards = await svc.discover_agents(role="developer")
assert all(c.metadata.get("role") == "developer" for c in cards)
@pytest.mark.asyncio
async def test_discover_agents_by_team(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
cards = await svc.discover_agents(team="backend")
assert all(c.metadata.get("team") == "backend" for c in cards)
@pytest.mark.asyncio
async def test_discover_agents_by_skill_tag(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
cards = await svc.discover_agents(skill_tag="qa")
# All returned cards have at least one skill tagged 'qa'.
for card in cards:
assert any("qa" in skill.tags for skill in card.skills)
# ---------------------------------------------------------------------------
# Canonical pair helper
# ---------------------------------------------------------------------------
def test_canonical_pair_orders_lexically() -> None:
a, b = A2AService._canonical_pair("z-agent", "a-agent")
assert (a, b) == ("a-agent", "z-agent")
a, b = A2AService._canonical_pair("a-agent", "z-agent")
assert (a, b) == ("a-agent", "z-agent")
# ---------------------------------------------------------------------------
# Conversations
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_conversation_self_a2a_denied(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
with pytest.raises(A2AAccessDeniedError):
await svc.get_or_create_conversation("be-dev-1", "be-dev-1")
@pytest.mark.asyncio
async def test_get_or_create_conversation_creates(a2a_setup: dict) -> None:
"""A2A between two same-cell devs is allowed by the policy."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-dev-2")
assert conv is not None
@pytest.mark.asyncio
async def test_get_conversation_returns_none_for_missing(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
assert await svc.get_conversation(uuid4(), "be-dev-1") is None
@pytest.mark.asyncio
async def test_list_conversations_empty(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
convs = await svc.list_conversations("be-dev-1")
assert isinstance(convs, list)
@pytest.mark.asyncio
async def test_list_conversations_with_filters(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
convs = await svc.list_conversations(
"be-dev-1", status=None, with_agent="be-dev-2", limit=10
)
assert isinstance(convs, list)
# ---------------------------------------------------------------------------
# Resolve creator agent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_creator_agent_returns_uuid_or_none(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
out = await svc.resolve_creator_agent(dev.slug)
assert out is not None or out is None # Smoke test: doesn't raise.
@pytest.mark.asyncio
async def test_resolve_creator_agent_unknown(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
out = await svc.resolve_creator_agent("ghost-agent-slug")
assert out is None or hasattr(out, "id") or isinstance(out, type(uuid4()))
# ---------------------------------------------------------------------------
# Chat messages
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_chat_message_rejects_nil_uuid(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
nil = UUID(int=0)
with pytest.raises(ValueError, match="nil UUID"):
await svc.send_chat_message(nil, "be-dev-1", "hi")
@pytest.mark.asyncio
async def test_send_chat_message_unknown_conversation(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
with pytest.raises(ValueError, match="not found"):
await svc.send_chat_message(uuid4(), "be-dev-1", "hi")
@pytest.mark.asyncio
async def test_get_messages_unknown_conversation_returns_empty(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
msgs = await svc.get_messages(uuid4(), "be-dev-1")
assert msgs == []
@pytest.mark.asyncio
async def test_close_conversation_unknown(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
with pytest.raises(ValueError, match="not found"):
await svc.close_conversation(uuid4(), "be-dev-1")
@pytest.mark.asyncio
async def test_mark_read_unknown_returns_none(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
# Returns None silently for unknown conversation.
await svc.mark_read(uuid4(), "be-dev-1")
# ---------------------------------------------------------------------------
# Inbox + pairs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_inbox_summary(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
inbox = await svc.get_inbox_summary("be-dev-1")
assert inbox is not None
@pytest.mark.asyncio
async def test_list_pairs(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
pairs = await svc.list_pairs("be-dev-1")
assert isinstance(pairs, list)
@pytest.mark.asyncio
async def test_send_a2a_returns_handler_result(a2a_setup: dict) -> None:
"""Just exercise the send() entrypoint with a stub that fails closed."""
svc = a2a_setup["svc"]
try:
result = await svc.send(
from_agent="be-dev-1",
to_agent="be-dev-2",
skill="general",
message="hi",
)
assert result is not None
except Exception:
# Expected if the policy rejects this pair or service is wired
# to external infra in this test setup.
pass
@pytest.mark.asyncio
async def test_send_records_skill_on_message_for_receiver(a2a_setup: dict) -> None:
"""#1416: a2a.send(skill=...) must record the requested capability on the
persisted message so the receiver learns it — not silently drop it. The
gateway adapter's docstring promised exactly this, but send_chat_message
never read the ``skill`` opt and the table had no skill column, so every
gateway A2A send lost the capability signal the caller passed."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
sent = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=task_id,
body="please review my PR",
skill="code_review",
)
assert sent.skill == "code_review"
# The receiver reads the capability back via get_messages.
inbox = await svc.get_messages(UUID(sent.conversation_id), "be-qa")
assert inbox
assert inbox[-1].skill == "code_review"
@pytest.mark.asyncio
async def test_send_publishes_a2a_message_sent_event_when_bus_connected(
a2a_setup: dict,
) -> None:
"""A2AService.send() is the gateway's one publish point for A2A chat — it
must fan an A2A_MESSAGE_SENT event so the CEO's live view (operator
/ws/system stream) sees every directed agent-to-agent message."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
sent = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=task_id,
body="please review",
skill="code_review",
)
mock_bus.publish.assert_awaited()
published = mock_bus.publish.await_args.args[0]
assert published.type is EventType.A2A_MESSAGE_SENT
data = published.data
assert data["conversation_id"] == sent.conversation_id
assert data["message_id"] == sent.id
assert data["task_id"] == str(task_id)
assert data["from_agent"] == "be-dev-1"
assert data["to_agent"] == "be-qa"
assert data["skill"] == "code_review"
assert data["body_excerpt"] == "please review"
assert data["timestamp"] == sent.created_at.isoformat()
@pytest.mark.asyncio
async def test_send_excerpts_long_body_in_event(a2a_setup: dict) -> None:
"""The WS live-view frame carries a capped excerpt, not the full body —
but the persisted message keeps the full untruncated text (readable via
the existing REST message endpoints)."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
long_body = "x" * (_LIVE_VIEW_EXCERPT_CHARS + 100)
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
sent = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=task_id,
body=long_body,
)
published = mock_bus.publish.await_args.args[0]
assert published.type is EventType.A2A_MESSAGE_SENT
excerpt = published.data["body_excerpt"]
assert len(excerpt) < len(long_body)
assert excerpt.endswith("…")
# Full body survives untruncated in persistent storage.
assert sent.content == long_body
stored = await svc.get_messages(UUID(sent.conversation_id), "be-qa")
assert stored[-1].content == long_body
@pytest.mark.asyncio
async def test_send_bus_failure_does_not_break_send(a2a_setup: dict) -> None:
"""A bus outage during the A2A_MESSAGE_SENT publish is logged but never
rolls back the persisted message — live delivery is best-effort."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
with patch(
"roboco.services.a2a.get_event_bus",
side_effect=RuntimeError("bus down"),
):
sent = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=task_id,
body="hello",
)
assert sent.id is not None
assert sent.content == "hello"
@pytest.mark.asyncio
async def test_send_chat_message_directly_publishes_event(
a2a_setup: dict,
) -> None:
"""The REST send paths (conversation-create + post-message) call
send_chat_message directly, not the send() wrapper. That direct path must
still emit A2A_MESSAGE_SENT so those messages light up the CEO's live view
— the gap this test guards."""
svc = a2a_setup["svc"]
task_id = a2a_setup["task_id"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa", task_id=task_id)
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
sent = await svc.send_chat_message(
UUID(conv.id),
"be-dev-1",
"please review",
options={"skill": "code_review"},
)
mock_bus.publish.assert_awaited_once()
data = mock_bus.publish.await_args.args[0].data
assert data["message_id"] == sent.id
assert data["from_agent"] == "be-dev-1"
assert data["to_agent"] == "be-qa"
assert data["skill"] == "code_review"
assert data["task_id"] == str(task_id)
@pytest.mark.asyncio
async def test_suppressed_duplicate_does_not_republish(
a2a_setup: dict,
) -> None:
"""A re-sent identical unread message dedups to the existing row and must
NOT emit a second live-view event (no redundant cache invalidation)."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "same text")
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "same text")
mock_bus.publish.assert_awaited_once()
@pytest.mark.asyncio
async def test_list_unread_a2a_preview_is_incoming_not_own(a2a_setup: dict) -> None:
"""The briefing preview must be the latest INCOMING message, never the
agent's own reply — an agent that replies before reading must still see
what was said to it, not its own words."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"] # be-dev-1
task_id = a2a_setup["task_id"]
conv = await svc.get_or_create_conversation("be-qa", "be-dev-1", task_id=task_id)
await svc.send_chat_message(
UUID(conv.id), "be-qa", "Please fix the null check in auth.py"
)
# dev replies WITHOUT reading — its own message is now the latest row.
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "ok on it")
items = await EvidenceRepo(a2a_setup["db"]).list_unread_a2a(dev.id)
assert items and items[0]["from_agent"] == "be-qa"
assert "null check" in items[0]["last_message_preview"]
assert "ok on it" not in items[0]["last_message_preview"]
@pytest.mark.asyncio
async def test_get_unread_messages_returns_incoming_bodies_and_clears(
a2a_setup: dict,
) -> None:
"""read_a2a delivers the unread INCOMING bodies to the agent (never its own
sends) in order, then clears them — a second call returns nothing."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"] # be-dev-1
conv = await svc.get_or_create_conversation(
"be-qa", "be-dev-1", task_id=a2a_setup["task_id"]
)
await svc.send_chat_message(UUID(conv.id), "be-qa", "first")
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "my own reply")
await svc.send_chat_message(UUID(conv.id), "be-qa", "second")
msgs = await svc.get_unread_messages(dev.id)
assert [m["content"] for m in msgs] == ["first", "second"]
assert all(m["from_agent"] == "be-qa" for m in msgs)
assert await svc.get_unread_messages(dev.id) == []
# ---------------------------------------------------------------------------
# Admin (CEO live view) service methods — no participant filter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_conversations_admin_includes_non_participant_pairs(
a2a_setup: dict,
) -> None:
"""The CEO's live view has no participant filter — it must show
conversations between two agents where the CEO is not itself a party."""
svc = a2a_setup["svc"]
conv1 = await svc.get_or_create_conversation("be-dev-1", "be-qa")
conv2 = await svc.get_or_create_conversation("fe-dev-1", "fe-qa")
summaries = await svc.list_conversations_admin(limit=50)
ids = {s.id for s in summaries}
assert conv1.id in ids
assert conv2.id in ids
pairs = {(s.agent_a, s.agent_b) for s in summaries}
assert ("be-dev-1", "be-qa") in pairs
assert ("fe-dev-1", "fe-qa") in pairs
@pytest.mark.asyncio
async def test_list_conversations_admin_orders_most_recent_first_and_bounds(
a2a_setup: dict,
) -> None:
"""Most-recent-first ordering and a hard limit — proven by forcing
distinguishable updated_at values across three seeded conversations."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
conv_a = await svc.get_or_create_conversation("be-dev-1", "be-qa")
conv_b = await svc.get_or_create_conversation("fe-dev-1", "fe-qa")
conv_c = await svc.get_or_create_conversation("ux-dev-1", "ux-qa")
now = datetime.now(UTC)
for conv_id, offset in (
(conv_a.id, timedelta(minutes=-10)),
(conv_b.id, timedelta(minutes=-5)),
(conv_c.id, timedelta(minutes=0)),
):
row = await db.get(A2AConversationTable, UUID(conv_id))
assert row is not None
row.updated_at = now + offset
await db.flush()
summaries = await svc.list_conversations_admin(limit=2)
_LIMIT = 2
assert len(summaries) == _LIMIT
assert [s.id for s in summaries] == [conv_c.id, conv_b.id]
@pytest.mark.asyncio
async def test_get_messages_admin_returns_full_transcript_for_non_participant(
a2a_setup: dict,
) -> None:
"""The plain get_messages() denies a non-participant (returns []); the
admin bypass returns the full transcript regardless — the exact behavior
a normal agent-scoped call cannot give the CEO today."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "be-dev-1", "hello")
await svc.send_chat_message(cid, "be-qa", "hi back")
as_ceo_scoped = await svc.get_messages(cid, "ceo")
assert as_ceo_scoped == []
admin_view = await svc.get_messages_admin(cid)
_EXPECTED = 2
assert len(admin_view) == _EXPECTED
assert admin_view[0].content == "hello"
assert admin_view[1].content == "hi back"
@pytest.mark.asyncio
async def test_get_messages_admin_unknown_conversation_returns_empty(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
assert await svc.get_messages_admin(uuid4()) == []
@pytest.mark.asyncio
async def test_get_conversation_admin_returns_conversation_ceo_not_part_of(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
fetched = await svc.get_conversation_admin(UUID(conv.id))
assert fetched is not None
assert fetched.id == conv.id
assert fetched.agent_a == "be-dev-1"
assert fetched.agent_b == "be-qa"
@pytest.mark.asyncio
async def test_get_conversation_admin_returns_none_for_unknown(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
assert await svc.get_conversation_admin(uuid4()) is None
# ---------------------------------------------------------------------------
# list_admin_pairs — the A2A switchboard's static-matrix + DB join
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_admin_pairs_bounded_by_static_matrix(a2a_setup: dict) -> None:
"""With no conversations at all, every pair from the static matrix is
still returned (conversation-less), sized exactly to the matrix."""
svc = a2a_setup["svc"]
pairs = await svc.list_admin_pairs()
assert len(pairs) == len(A2A_ALLOWED_PAIRS)
assert all(p.conversation_id is None for p in pairs)
assert all(p.message_count == 0 for p in pairs)
assert all(p.last_message_at is None for p in pairs)
@pytest.mark.asyncio
async def test_list_admin_pairs_joins_representative_conversation(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "hello")
pairs = await svc.list_admin_pairs()
match = next(p for p in pairs if {p.agent_a, p.agent_b} == {"be-dev-1", "be-qa"})
assert match.conversation_id == conv.id
assert match.message_count == 1
assert match.last_message_at is not None
assert match.group_key == "cell-backend"
assert match.role_a == "developer"
assert match.role_b == "qa"
@pytest.mark.asyncio
async def test_list_admin_pairs_picks_most_recently_updated_conversation(
a2a_setup: dict,
) -> None:
"""A pair with two conversations (distinct topics) surfaces the more
recently active one as its representative conversation."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
conv_old = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="t1")
conv_new = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="t2")
now = datetime.now(UTC)
row_old = await db.get(A2AConversationTable, UUID(conv_old.id))
row_new = await db.get(A2AConversationTable, UUID(conv_new.id))
assert row_old is not None
assert row_new is not None
row_old.updated_at = now - timedelta(minutes=10)
row_new.updated_at = now
await db.flush()
pairs = await svc.list_admin_pairs()
match = next(p for p in pairs if {p.agent_a, p.agent_b} == {"be-dev-1", "be-qa"})
assert match.conversation_id == conv_new.id
@pytest.mark.asyncio
async def test_list_admin_pairs_excludes_disallowed_pairs(a2a_setup: dict) -> None:
"""A conversation row between two agents the matrix does NOT allow (dev
A2A is same-cell only — this should never legitimately exist, but the
join must be robust against it) never surfaces as a pair card: the
service iterates the static matrix, not "any conversation row"."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
stray = A2AConversationTable(agent_a="be-dev-1", agent_b="fe-dev-1")
db.add(stray)
await db.flush()
pairs = await svc.list_admin_pairs()
assert not any({p.agent_a, p.agent_b} == {"be-dev-1", "fe-dev-1"} for p in pairs)
# ---------------------------------------------------------------------------
# CEO reply-only budget — an agent may only reply to the CEO inside a
# conversation the CEO itself opened, and only up to the CEO's own message
# count in that conversation.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_to_ceo_without_existing_conversation_denied(
a2a_setup: dict,
) -> None:
"""An agent can never INITIATE a CEO conversation via the gateway
send() adapter — only reply inside one the CEO already opened."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
with pytest.raises(A2AAccessDeniedError):
await svc.send(from_agent=dev.id, to_agent="ceo", task_id=task_id, body="hi")
@pytest.mark.asyncio
async def test_ceo_can_post_consecutive_messages_no_budget(a2a_setup: dict) -> None:
"""CEO -> agent direction is unrestricted — no budget applies to CEO
sends, so the CEO may post twice in a row with no reply in between."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "first")
second = await svc.send_chat_message(cid, "ceo", "second, no reply needed yet")
assert second.content == "second, no reply needed yet"
@pytest.mark.asyncio
async def test_ceo_reply_budget_first_reply_allowed(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "hi dev")
reply = await svc.send_chat_message(cid, "be-dev-1", "on it")
assert reply.content == "on it"
@pytest.mark.asyncio
async def test_ceo_reply_budget_second_reply_without_new_ceo_message_rejected(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "hi dev")
await svc.send_chat_message(cid, "be-dev-1", "on it")
with pytest.raises(A2AAccessDeniedError, match="already replied"):
await svc.send_chat_message(cid, "be-dev-1", "another update")
@pytest.mark.asyncio
async def test_ceo_reply_budget_refreshes_after_new_ceo_message(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "hi dev")
await svc.send_chat_message(cid, "be-dev-1", "on it")
await svc.send_chat_message(cid, "ceo", "any update?")
reply2 = await svc.send_chat_message(cid, "be-dev-1", "done!")
assert reply2.content == "done!"
@pytest.mark.asyncio
async def test_ceo_reply_budget_independent_across_conversations(
a2a_setup: dict,
) -> None:
"""The a2a_conversations model is strictly pairwise — a literal 3-party
thread can't exist. Adapted form: two agents each in their OWN
conversation with the CEO get independent budgets; one agent exhausting
its budget must not affect the other's."""
svc = a2a_setup["svc"]
conv_dev = await svc.get_or_create_conversation("ceo", "be-dev-1")
conv_qa = await svc.get_or_create_conversation("ceo", "be-qa")
cid_dev = UUID(conv_dev.id)
cid_qa = UUID(conv_qa.id)
await svc.send_chat_message(cid_dev, "ceo", "dev, status?")
await svc.send_chat_message(cid_qa, "ceo", "qa, status?")
await svc.send_chat_message(cid_dev, "be-dev-1", "on it")
with pytest.raises(A2AAccessDeniedError):
await svc.send_chat_message(cid_dev, "be-dev-1", "again")
# qa's independent budget is untouched by dev's exhausted one.
qa_reply = await svc.send_chat_message(cid_qa, "be-qa", "on it too")
assert qa_reply.content == "on it too"
@pytest.mark.asyncio
async def test_ceo_reply_dedup_before_budget_check(a2a_setup: dict) -> None:
"""Dedup runs BEFORE the budget check: a respawned agent re-sending its
identical unread reply gets the existing row back idempotently — never a
budget error — even once the agent has exhausted its reply budget."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "ceo", "status?")
first = await svc.send_chat_message(cid, "be-dev-1", "on it")
# Budget is now exhausted (agent_count == ceo_count == 1); an identical
# resend must still dedup instead of hitting the budget gate.
again = await svc.send_chat_message(cid, "be-dev-1", "on it")
assert again.id == first.id
@pytest.mark.asyncio
async def test_send_to_ceo_via_gateway_when_ceo_opened_conversation(
a2a_setup: dict,
) -> None:
"""Once the CEO has opened a conversation with the agent, the gateway
send() adapter finds it directly — bypassing
get_or_create_conversation's validate-first gate, which would otherwise
deny even a legitimate reply — and the reply persists under budget."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
await svc.send_chat_message(UUID(conv.id), "ceo", "status?")
reply = await svc.send(
from_agent=dev.id, to_agent="ceo", task_id=task_id, body="on it"
)
assert reply.content == "on it"
assert reply.from_agent == "be-dev-1"
@pytest.mark.asyncio
async def test_send_publishes_only_after_persist_not_on_reply_denial(
a2a_setup: dict,
) -> None:
"""A rejected send (no existing CEO conversation) must never publish
A2A_MESSAGE_SENT — the event is a record of a persisted message."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
task_id = a2a_setup["task_id"]
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with (
patch("roboco.services.a2a.get_event_bus", return_value=mock_bus),
pytest.raises(A2AAccessDeniedError),
):
await svc.send(from_agent=dev.id, to_agent="ceo", task_id=task_id, body="hi")
mock_bus.publish.assert_not_awaited()
# ---------------------------------------------------------------------------
# Conversation creation happy path with allowed pair
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_conversation_between_dev_and_qa_in_same_cell(
a2a_setup: dict,
) -> None:
"""Cell members can A2A within their own cell."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
assert conv is not None
# Idempotent — same agents, same conversation.
again = await svc.get_or_create_conversation("be-dev-1", "be-qa")
assert again.id == conv.id
@pytest.mark.asyncio
async def test_send_chat_message_in_existing_conversation(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
msg = await svc.send_chat_message(UUID(conv.id), "be-dev-1", "hello")
assert msg.content == "hello"
@pytest.mark.asyncio
async def test_get_messages_returns_chronological(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
cid = UUID(conv.id)
await svc.send_chat_message(cid, "be-dev-1", "first")
await svc.send_chat_message(cid, "be-dev-1", "second")
msgs = await svc.get_messages(cid, "be-dev-1")
_SENT_COUNT = 2
assert len(msgs) == _SENT_COUNT
@pytest.mark.asyncio
async def test_send_chat_message_dedups_identical_unread(a2a_setup: dict) -> None:
"""An identical message re-sent while still unread is suppressed (one copy),
but a different message is not collapsed."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
cid = UUID(conv.id)
first = await svc.send_chat_message(cid, "be-dev-1", "ping: are you free?")
again = await svc.send_chat_message(cid, "be-dev-1", "ping: are you free?")
distinct = await svc.send_chat_message(cid, "be-dev-1", "different message")
# The duplicate returns the SAME stored message and adds no new row.
assert again.id == first.id
assert distinct.id != first.id
msgs = await svc.get_messages(cid, "be-dev-1")
_EXPECTED = 2 # the deduped "ping" + the distinct one
assert len(msgs) == _EXPECTED
@pytest.mark.asyncio
async def test_close_conversation_with_resolution(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
await svc.close_conversation(UUID(conv.id), "be-dev-1", resolution="done")
@pytest.mark.asyncio
async def test_mark_read_clears_unread(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
await svc.mark_read(UUID(conv.id), "be-dev-1")
@pytest.mark.asyncio
async def test_close_conversation_non_participant_raises(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
with pytest.raises(ValueError, match="Not a participant"):
await svc.close_conversation(UUID(conv.id), "ghost-agent")
@pytest.mark.asyncio
async def test_send_chat_message_non_participant_raises(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
with pytest.raises(ValueError, match="Not a participant"):
await svc.send_chat_message(UUID(conv.id), "ghost", "hi")
# ---------------------------------------------------------------------------
# Pure-function helpers
# ---------------------------------------------------------------------------
def test_get_team_from_agent_backend() -> None:
assert A2AService.get_team_from_agent("be-dev-1") == Team.BACKEND
def test_get_team_from_agent_unknown_defaults_to_backend() -> None:
assert A2AService.get_team_from_agent("ghost-agent") == Team.BACKEND
def test_resolve_target_agent_explicit() -> None:
result = A2AService.resolve_target_agent({"target_agent": "be-dev-1"})
assert result == "be-dev-1"
def test_resolve_target_agent_unknown_returns_none() -> None:
result = A2AService.resolve_target_agent({"target_agent": "ghost-agent"})
assert result is None
def test_resolve_target_agent_none_when_no_metadata() -> None:
result = A2AService.resolve_target_agent({})
assert result is None
def test_extract_message_text_no_text_parts() -> None:
msg = A2AMessage(role="user", parts=[])
title, desc, _full = A2AService.extract_message_text(msg)
assert title == "A2A Task"
assert desc == ""
def test_extract_message_text_single_line() -> None:
msg = A2AMessage(role="user", parts=[TextPart(text="Hello world")])
title, _desc, _full = A2AService.extract_message_text(msg)
assert title == "Hello world"
def test_extract_message_text_multi_line() -> None:
msg = A2AMessage(role="user", parts=[TextPart(text="Title here\nThis is the body")])
title, desc, _full = A2AService.extract_message_text(msg)
assert title == "Title here"
assert desc == "This is the body"
@pytest.mark.asyncio
async def test_update_task_with_message_appends_to_notes(
a2a_setup: dict,
) -> None:
"""Use a real DB-backed task instance to avoid SA private state issues."""
db = a2a_setup["db"]
task = (await db.execute(select(TaskTable).limit(1))).scalar_one()
original_notes = task.dev_notes
task.dev_notes = "existing notes"
msg = A2AMessage(role="user", parts=[TextPart(text="new message")])
A2AService.update_task_with_message(task, msg)
assert "existing notes" in task.dev_notes
assert "new message" in task.dev_notes
task.dev_notes = original_notes # restore
@pytest.mark.asyncio
async def test_update_task_with_message_no_text_parts_noop(
a2a_setup: dict,
) -> None:
db = a2a_setup["db"]
task = (await db.execute(select(TaskTable).limit(1))).scalar_one()
original = task.dev_notes
task.dev_notes = "existing"
msg = A2AMessage(role="user", parts=[])
A2AService.update_task_with_message(task, msg)
assert task.dev_notes == "existing"
task.dev_notes = original
# ---------------------------------------------------------------------------
# resolve_creator_agent paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_creator_agent_with_unknown_falls_back_to_main_pm(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
# Unknown ID falls back to main PM lookup — returns None if no main PM seeded.
out = await svc.resolve_creator_agent("ghost-id")
# Either None (no main_pm) or AgentTable (main_pm seeded by a prior test).
assert out is None or hasattr(out, "id")
@pytest.mark.asyncio
async def test_resolve_creator_agent_with_none_falls_back_to_main_pm(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
out = await svc.resolve_creator_agent(None)
assert out is None or hasattr(out, "id")
# ---------------------------------------------------------------------------
# get_service_endpoint — unspecified host falls through to 127.0.0.1
# ---------------------------------------------------------------------------
def test_get_service_endpoint_unspecified_host() -> None:
"""0.0.0.0 host triggers loopback fallback."""
with patch("roboco.services.a2a.settings") as mock_settings:
mock_settings.host = "0.0.0.0"
mock_settings.port = 8000
url = A2AService.get_service_endpoint()
assert "127.0.0.1" in url
def test_get_service_endpoint_invalid_host_falls_through() -> None:
"""Non-IP host string falls through ValueError → uses host directly."""
with patch("roboco.services.a2a.settings") as mock_settings:
mock_settings.host = "myhost"
mock_settings.port = 8080
url = A2AService.get_service_endpoint()
assert "myhost" in url
# ---------------------------------------------------------------------------
# task_to_a2a — branches: dev_notes, no value attr, assigned_to, parent
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_task_to_a2a_with_dev_notes_and_assignment(
a2a_setup: dict,
) -> None:
"""Task with dev_notes, assigned_to, parent_task_id covers metadata branches."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
parent = TaskTable(
id=_u(),
title="parent",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=(await db.execute(select(TaskTable))).scalars().first().project_id,
created_by=a2a_setup["dev"].id,
team=Team.BACKEND,
)
db.add(parent)
await db.flush()
child = TaskTable(
id=_u(),
title="child",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=parent.project_id,
created_by=a2a_setup["dev"].id,
assigned_to=a2a_setup["dev"].id,
parent_task_id=parent.id,
team=Team.BACKEND,
dev_notes="some progress",
)
db.add(child)
await db.flush()
a2a_task = svc.task_to_a2a(child)
assert "assigned_to" in a2a_task.metadata
assert "parent_task_id" in a2a_task.metadata
def test_task_to_a2a_status_without_value_attr(a2a_setup: dict) -> None:
"""When task.status lacks .value (already a string), use str()."""
svc = a2a_setup["svc"]
fake_task = SimpleNamespace(
id="00000000-0000-0000-0000-000000000001",
status="pending", # plain string, no .value
priority=2,
team="backend",
dev_notes=None,
assigned_to=None,
parent_task_id=None,
updated_at=None,
created_at=datetime.now(UTC),
)
a2a_task = svc.task_to_a2a(fake_task)
assert a2a_task.metadata["roboco_status"] == "pending"
# ---------------------------------------------------------------------------
# list_tasks — has_more=True branch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_tasks_has_more_true(a2a_setup: dict) -> None:
"""Seed enough tasks to trigger has_more=True."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
pid = (await db.execute(select(TaskTable))).scalars().first().project_id
for _i in range(3):
db.add(
TaskTable(
id=_u(),
title=f"t{_i}",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=pid,
created_by=a2a_setup["dev"].id,
team=Team.BACKEND,
)
)
await db.flush()
_PAGE = 2
tasks, has_more = await svc.list_tasks(page_size=_PAGE)
assert has_more is True
assert len(tasks) == _PAGE
# ---------------------------------------------------------------------------
# cancel_task — full path with reason and existing dev_notes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cancel_task_with_reason_and_existing_notes(
a2a_setup: dict,
) -> None:
"""Reason is appended to existing dev_notes and full cancel runs."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
pid = (await db.execute(select(TaskTable))).scalars().first().project_id
task = TaskTable(
id=_u(),
title="t",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=pid,
created_by=a2a_setup["dev"].id,
team=Team.BACKEND,
dev_notes="initial work",
)
db.add(task)
await db.flush()
a2a_task = await svc.cancel_task(str(task.id), reason="changed mind")
assert a2a_task.id == str(task.id)
@pytest.mark.asyncio
async def test_cancel_task_with_reason_no_existing_notes(
a2a_setup: dict,
) -> None:
"""Reason populates fresh dev_notes when none existed."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
pid = (await db.execute(select(TaskTable))).scalars().first().project_id
task = TaskTable(
id=_u(),
title="t",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=pid,
created_by=a2a_setup["dev"].id,
team=Team.BACKEND,
)
db.add(task)
await db.flush()
a2a_task = await svc.cancel_task(str(task.id), reason="cancelled")
assert a2a_task is not None
@pytest.mark.asyncio
async def test_cancel_task_status_no_value_attr(a2a_setup: dict) -> None:
"""If task.status is already a string, str() fallback runs."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
task = (await db.execute(select(TaskTable).limit(1))).scalar_one()
# Mock execute so the cancel-side query returns a task with status="pending" string.
real_execute = svc.session.execute
fake_uuid = _u()
class _FakeStatus:
# No .value attribute
def __str__(self) -> str:
return "pending"
fake_task = type(
"FakeTask",
(),
{
"id": str(fake_uuid),
"status": _FakeStatus(),
"dev_notes": None,
},
)()
seen = {"hit": False}
async def _intercepting_execute(stmt: Any, *args: Any, **kwargs: Any) -> Any:
if not seen["hit"]:
seen["hit"] = True
stub = _MM()
stub.scalar_one_or_none.return_value = fake_task
return stub
return await real_execute(stmt, *args, **kwargs)
# Patch TaskService.cancel to return the same task object.
with patch("roboco.services.task.TaskService") as mock_ts:
instance = AsyncMock()
instance.cancel = AsyncMock(return_value=task)
mock_ts.return_value = instance
with patch.object(svc.session, "execute", side_effect=_intercepting_execute):
a2a_task = await svc.cancel_task(str(fake_uuid))
assert a2a_task is not None
@pytest.mark.asyncio
async def test_cancel_task_failed_returns_value_error(
a2a_setup: dict,
) -> None:
"""If TaskService.cancel returns None, raise ValueError."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
pid = (await db.execute(select(TaskTable))).scalars().first().project_id
task = TaskTable(
id=_u(),
title="t",
description="d",
acceptance_criteria=["ac"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=pid,
created_by=a2a_setup["dev"].id,
team=Team.BACKEND,
)
db.add(task)
await db.flush()
with patch("roboco.services.task.TaskService") as mock_ts:
instance = AsyncMock()
instance.cancel = AsyncMock(return_value=None)
mock_ts.return_value = instance
with pytest.raises(ValueError, match="Failed to cancel"):
await svc.cancel_task(str(task.id))
# ---------------------------------------------------------------------------
# resolve_target_agent — skill-based routing match
# ---------------------------------------------------------------------------
def test_resolve_target_agent_by_skill_match() -> None:
"""metadata.skill matches an agent skill id → returns slug."""
with patch(
"roboco.services.a2a.get_agent_skills",
return_value=[{"id": "general", "name": "g"}],
):
result = A2AService.resolve_target_agent({"skill": "general"})
# First agent in ALL_AGENTS with this skill id wins.
assert result is not None
def test_resolve_target_agent_skill_no_match() -> None:
"""Unknown skill returns None."""
with patch("roboco.services.a2a.get_agent_skills", return_value=[]):
result = A2AService.resolve_target_agent({"skill": "ghost-skill"})
assert result is None
# ---------------------------------------------------------------------------
# extract_message_text — text part without `text` attribute
# ---------------------------------------------------------------------------
def test_extract_message_text_no_text_attr() -> None:
"""text_part missing `text` attr → returns defaults."""
fake_part = SimpleNamespace(type="text")
fake_msg = SimpleNamespace(parts=[fake_part])
title, desc, full = A2AService.extract_message_text(cast("A2AMessage", fake_msg))
assert title == "A2A Task"
assert desc == ""
assert full == ""
# ---------------------------------------------------------------------------
# update_task_with_message — text_part lacks `.text` attr
# ---------------------------------------------------------------------------
def test_update_task_with_message_no_text_attr() -> None:
"""text_part without text attribute leaves dev_notes untouched."""
fake_part = SimpleNamespace(type="text")
fake_msg = SimpleNamespace(parts=[fake_part])
fake_task = SimpleNamespace(dev_notes="orig")
A2AService.update_task_with_message(
cast("TaskTable", fake_task), cast("A2AMessage", fake_msg)
)
assert fake_task.dev_notes == "orig"
# ---------------------------------------------------------------------------
# resolve_creator_agent — known agent slug + lookup hit
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_creator_agent_known_slug_with_uuid_hit(
a2a_setup: dict,
) -> None:
"""from_agent_id is a known slug AND has UUID — looks up by UUID."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
# be-dev-1 is in ALL_AGENTS (a list) by default; just inject a UUID.
with patch.dict(
"roboco.services.a2a.AGENT_UUIDS",
{"be-dev-1": str(dev.id)},
):
out = await svc.resolve_creator_agent("be-dev-1")
# May return None if agent not found by id, or the dev row.
assert out is None or hasattr(out, "id")
# ---------------------------------------------------------------------------
# create_a2a_notification — full path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_a2a_notification_missing_task_id_raises(
a2a_setup: dict,
) -> None:
"""task_id absent → ValueError."""
svc = a2a_setup["svc"]
msg = A2AMessage(role="user", parts=[TextPart(text="hi")])
req = SendMessageRequest(message=msg, metadata={})
with pytest.raises(ValueError, match="task_id"):
await svc.create_a2a_notification(req)
@pytest.mark.asyncio
async def test_create_a2a_notification_with_target_calls_notification_service(
a2a_setup: dict,
) -> None:
"""Path with from_agent + target + skill → NotificationService called."""
svc = a2a_setup["svc"]
task_id = str(a2a_setup["task_id"])
msg = A2AMessage(role="user", parts=[TextPart(text="hi there")], task_id=task_id)
req = SendMessageRequest(
message=msg,
metadata={"from_agent": "be-dev-1", "target_agent": "be-dev-2"},
)
mock_ns = AsyncMock()
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
with patch(
"roboco.services.notification.NotificationService",
return_value=mock_ns,
):
result = await svc.create_a2a_notification(req)
assert result["task_id"] == task_id
@pytest.mark.asyncio
async def test_create_a2a_notification_permission_denied(
a2a_setup: dict,
) -> None:
"""Hierarchy denial → the typed A2AAccessDeniedError (with route_hint), routed
through validate_a2a_access so the legacy notification path matches the
conversation path — not a bare ValueError a caller can't distinguish from a
malformed request."""
svc = a2a_setup["svc"]
task_id = str(a2a_setup["task_id"])
msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id)
req = SendMessageRequest(
message=msg,
metadata={"from_agent": "be-dev-1", "target_agent": "be-dev-2"},
)
with (
patch(
"roboco.enforcement.a2a_access.can_a2a_direct",
return_value=(False, "denied"),
),
patch(
"roboco.enforcement.a2a_access.get_a2a_route_hint",
return_value="use channel",
),
pytest.raises(A2AAccessDeniedError) as exc,
):
await svc.create_a2a_notification(req)
assert exc.value.route_hint == "use channel"
@pytest.mark.asyncio
async def test_create_a2a_notification_self_a2a_raises_typed_error(
a2a_setup: dict,
) -> None:
"""#612: a self-directed A2A notification (from_agent == target) must raise
the typed A2AAccessDeniedError — the self-check the conversation path
(validate_a2a_access) enforces — not a bare ValueError and not a silently
sent notification."""
svc = a2a_setup["svc"]
task_id = str(a2a_setup["task_id"])
msg = A2AMessage(role="user", parts=[TextPart(text="self note")], task_id=task_id)
req = SendMessageRequest(
message=msg,
metadata={"from_agent": "be-dev-1", "target_agent": "be-dev-1"},
)
mock_ns = AsyncMock()
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
with (
patch("roboco.services.notification.NotificationService", return_value=mock_ns),
pytest.raises(A2AAccessDeniedError),
):
await svc.create_a2a_notification(req)
mock_ns.send_a2a_notification.assert_not_called()
@pytest.mark.asyncio
async def test_create_a2a_notification_missing_from_agent_raises_not_silent(
a2a_setup: dict,
) -> None:
"""#609: an unattributed A2A request (no from_agent, unresolvable target)
must NOT slip past the hierarchy gate and dispatch with
from_agent='unknown'. The gate is unconditional — both ends must be present
and resolvable before any notification is created."""
svc = a2a_setup["svc"]
task_id = str(a2a_setup["task_id"])
msg = A2AMessage(role="user", parts=[TextPart(text="anon")], task_id=task_id)
req = SendMessageRequest(message=msg, metadata={})
mock_ns = AsyncMock()
mock_ns.send_a2a_notification = AsyncMock(return_value=None)
with (
patch("roboco.services.notification.NotificationService", return_value=mock_ns),
pytest.raises(ValueError, match="from_agent"),
):
await svc.create_a2a_notification(req)
mock_ns.send_a2a_notification.assert_not_called()
# ---------------------------------------------------------------------------
# update_task_from_message — full happy path + invalid id
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_task_from_message_invalid_id(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
msg = A2AMessage(role="user", parts=[TextPart(text="hi")])
with pytest.raises(ValueError, match="Invalid task ID"):
await svc.update_task_from_message("not-a-uuid", msg)
@pytest.mark.asyncio
async def test_update_task_from_message_not_found(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
msg = A2AMessage(role="user", parts=[TextPart(text="hi")])
with pytest.raises(ValueError, match="not found"):
await svc.update_task_from_message(str(_u()), msg)
@pytest.mark.asyncio
async def test_update_task_from_message_success(a2a_setup: dict) -> None:
"""Real task updated with new dev_notes."""
svc = a2a_setup["svc"]
task_id = str(a2a_setup["task_id"])
msg = A2AMessage(role="user", parts=[TextPart(text="response text")])
updated = await svc.update_task_from_message(task_id, msg)
assert "response text" in (updated.dev_notes or "")
# ---------------------------------------------------------------------------
# _notify_original_requester — branches: not A2A, no created_by, no slug
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_notify_original_requester_not_a2a_returns(
a2a_setup: dict,
) -> None:
"""Task.dev_notes lacks 'A2A Request' → no-op."""
svc = a2a_setup["svc"]
fake_task = SimpleNamespace(dev_notes="just notes", created_by=None)
# No raise.
await svc._notify_original_requester(fake_task, "responder")
@pytest.mark.asyncio
async def test_notify_original_requester_no_created_by(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
fake_task = SimpleNamespace(dev_notes="A2A Request: hi", created_by=None)
await svc._notify_original_requester(fake_task)
@pytest.mark.asyncio
async def test_notify_original_requester_unknown_slug(
a2a_setup: dict,
) -> None:
"""If the creator UUID can't be mapped to a slug, returns silently."""
svc = a2a_setup["svc"]
fake_task = SimpleNamespace(
id=_u(),
dev_notes="A2A Request: please review",
created_by=_u(), # Not in AGENT_UUIDS.
)
await svc._notify_original_requester(fake_task)
@pytest.mark.asyncio
async def test_notify_original_requester_responder_is_requester(
a2a_setup: dict,
) -> None:
"""If responder is the same as requester, no event published."""
svc = a2a_setup["svc"]
fake_task = SimpleNamespace(
id="00000000-0000-0000-0000-000000000001",
dev_notes="A2A Request: please review",
created_by="be-dev-1-uuid",
)
with patch(
"roboco.services.a2a.A2AService._lookup_requester_slug",
return_value="be-dev-1",
):
# responder == requester → return without publish.
await svc._notify_original_requester(fake_task, "be-dev-1")
@pytest.mark.asyncio
async def test_notify_original_requester_publishes_event(
a2a_setup: dict,
) -> None:
"""Full path: requester slug found, responder different → publish."""
svc = a2a_setup["svc"]
fake_task = SimpleNamespace(
id="00000000-0000-0000-0000-000000000001",
dev_notes="A2A Request: please review",
created_by="some-uuid",
)
mock_bus = AsyncMock()
mock_bus.is_connected = lambda: True
mock_bus.publish = AsyncMock(return_value=None)
with (
patch(
"roboco.services.a2a.A2AService._lookup_requester_slug",
return_value="be-dev-1",
),
patch("roboco.services.a2a.get_event_bus", return_value=mock_bus),
):
await svc._notify_original_requester(fake_task, "be-dev-2")
mock_bus.publish.assert_awaited()
@pytest.mark.asyncio
async def test_publish_a2a_response_event_no_bus() -> None:
"""Bus not connected → silent no-op."""
fake_task = SimpleNamespace(id="00000000-0000-0000-0000-000000000001")
mock_bus = type("B", (), {"is_connected": lambda _self: False})()
with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
await A2AService._publish_a2a_response_event(
cast("TaskTable", fake_task), "creator", "requester", "responder"
)
@pytest.mark.asyncio
async def test_publish_a2a_response_event_bus_exception_swallowed() -> None:
fake_task = SimpleNamespace(id="00000000-0000-0000-0000-000000000001")
with patch(
"roboco.services.a2a.get_event_bus",
side_effect=RuntimeError("bus down"),
):
# Exception swallowed.
await A2AService._publish_a2a_response_event(
cast("TaskTable", fake_task), "creator", "requester", "responder"
)
# ---------------------------------------------------------------------------
# list_conversations — status/with_agent/task_id filters + last message
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_conversations_with_status_and_task_filter(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
convs = await svc.list_conversations(
"be-dev-1",
status=A2AConversationStatus.ACTIVE,
with_agent="be-dev-2",
task_id=a2a_setup["task_id"],
)
assert isinstance(convs, list)
@pytest.mark.asyncio
async def test_list_conversations_with_messages(a2a_setup: dict) -> None:
"""Seed a conversation + a message so the last_message preview path runs."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "preview text")
convs = await svc.list_conversations("be-dev-1")
# last_message_preview should be truthy for this conv.
assert any(c.last_message_preview for c in convs)
# ---------------------------------------------------------------------------
# send_chat_message — both agents perspective + message_kind/response_to
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_chat_message_options_response_to(
a2a_setup: dict,
) -> None:
"""response_to_id and requires_response options surface on the message.
`response_to_id` has a FK to `a2a_messages.id`, so we send a real
"first" message to thread off — passing a random UUID would hit FK
violation.
"""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
first = await svc.send_chat_message(UUID(conv.id), "be-qa", "first message")
msg = await svc.send_chat_message(
UUID(conv.id),
"be-dev-1",
"needs answer",
options={"requires_response": True, "response_to_id": UUID(first.id)},
)
assert msg.requires_response is True
assert msg.response_to_id == first.id
@pytest.mark.asyncio
async def test_send_chat_message_from_agent_b_increments_unread_a(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
# First exchange establishes canonical pair (a < b lexicographically).
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
# Send from whichever agent is conv.agent_b (the "other" side).
result = await svc.session.execute(
_sel(A2AConversationTable).where(A2AConversationTable.id == UUID(conv.id))
)
row = result.scalar_one()
msg = await svc.send_chat_message(UUID(conv.id), row.agent_b, "hi from b")
assert msg.from_agent == row.agent_b
# ---------------------------------------------------------------------------
# get_messages — non-participant returns []
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_messages_non_participant_returns_empty(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
msgs = await svc.get_messages(UUID(conv.id), "ghost-agent")
assert msgs == []
@pytest.mark.asyncio
async def test_get_messages_with_before_filter(a2a_setup: dict) -> None:
"""Pass a `before` datetime — exercises the filter branch."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "first")
future = datetime.now(UTC).replace(year=2099)
msgs = await svc.get_messages(UUID(conv.id), "be-dev-1", before=future)
assert isinstance(msgs, list)
# ---------------------------------------------------------------------------
# mark_read — non-participant returns silently + agent_b path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mark_read_non_participant(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
# Non-participant → silent return.
await svc.mark_read(UUID(conv.id), "ghost")
@pytest.mark.asyncio
async def test_mark_read_as_agent_b(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
result = await svc.session.execute(
_sel(A2AConversationTable).where(A2AConversationTable.id == UUID(conv.id))
)
row = result.scalar_one()
await svc.mark_read(UUID(conv.id), row.agent_b)
# ---------------------------------------------------------------------------
# list_pairs — exercises grouping + last_activity comparison
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_pairs_with_conversations(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
await svc.get_or_create_conversation("be-dev-1", "be-qa")
pairs = await svc.list_pairs("be-dev-1")
assert any(
(p.agent_a, p.agent_b) == ("be-dev-1", "be-qa")
or (p.agent_a, p.agent_b) == ("be-qa", "be-dev-1")
for p in pairs
)
# ---------------------------------------------------------------------------
# _resolve_slug_from_id — happy + raise
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_slug_from_id_happy(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
slug = await svc._resolve_slug_from_id(dev.id)
assert slug == dev.slug
@pytest.mark.asyncio
async def test_resolve_slug_from_id_missing_raises(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
with pytest.raises(ValueError, match="Agent not found"):
await svc._resolve_slug_from_id(_u())
# ---------------------------------------------------------------------------
# send — gateway adapter, both UUID and str recipient
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_gateway_adapter_uuid_to_uuid(
a2a_setup: dict,
) -> None:
"""Both ends as UUIDs → resolves both slugs via DB lookup."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
qa = a2a_setup["qa"]
msg = await svc.send(
from_agent=dev.id,
to_agent=qa.id,
task_id=a2a_setup["task_id"],
body="hello",
skill="general",
)
assert msg.content == "hello"
@pytest.mark.asyncio
async def test_send_gateway_adapter_with_string_recipient(
a2a_setup: dict,
) -> None:
"""Recipient as slug string → no DB lookup for it."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
msg = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=a2a_setup["task_id"],
body="hello",
)
assert msg.content == "hello"
# ---------------------------------------------------------------------------
# create_task_from_message — patched DB so flush succeeds
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# _lookup_requester_slug — found path
# ---------------------------------------------------------------------------
def test_lookup_requester_slug_found() -> None:
target_uuid = "00000000-0000-0000-0000-000000000042"
with patch(
"roboco.seeds.initial_data.AGENT_UUIDS",
{"slug-x": target_uuid},
):
result = A2AService._lookup_requester_slug(target_uuid)
assert result == "slug-x"
# ---------------------------------------------------------------------------
# get_or_create_conversation — topic provided branch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_or_create_conversation_with_topic(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
a = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="Bug X")
b = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="Bug X")
assert a.id == b.id
# ---------------------------------------------------------------------------
# get_conversation — non-participant returns None
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_conversation_non_participant(a2a_setup: dict) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
result = await svc.get_conversation(UUID(conv.id), "ghost")
assert result is None
@pytest.mark.asyncio
async def test_get_conversation_returns_model_when_participant(
a2a_setup: dict,
) -> None:
"""Participant access → returns the conversation model."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
result = await svc.get_conversation(UUID(conv.id), "be-dev-1")
assert result is not None
assert result.id == conv.id
# ---------------------------------------------------------------------------
# get_inbox_summary with unread > 0
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_inbox_summary_with_unread(a2a_setup: dict) -> None:
"""Send a message from a2 → a1 has unread."""
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
result = await svc.session.execute(
_sel(A2AConversationTable).where(A2AConversationTable.id == UUID(conv.id))
)
row = result.scalar_one()
# Send from agent_b → agent_a unread increments.
await svc.send_chat_message(UUID(conv.id), row.agent_b, "hi")
inbox = await svc.get_inbox_summary(row.agent_a)
assert inbox.total_unread >= 1
# ---------------------------------------------------------------------------
# send — gateway adapter without skill
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_send_gateway_adapter_skill_none(
a2a_setup: dict,
) -> None:
"""skill=None branch → options dict stays empty."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
msg = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=a2a_setup["task_id"],
body="no skill",
)
assert msg.content == "no skill"
@pytest.mark.asyncio
async def test_send_gateway_adapter_with_mocked_conv(
a2a_setup: dict,
) -> None:
"""Mock get_or_create_conversation + send_chat_message so skill branches run."""
svc = a2a_setup["svc"]
dev = a2a_setup["dev"]
fake_conv = SimpleNamespace(id=str(_u()))
fake_msg = SimpleNamespace(content="hi", id=str(_u()))
with (
patch.object(svc, "_resolve_slug_from_id", AsyncMock(return_value="be-dev-1")),
patch.object(
svc, "get_or_create_conversation", AsyncMock(return_value=fake_conv)
),
patch.object(svc, "send_chat_message", AsyncMock(return_value=fake_msg)),
):
# With skill set.
result = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=a2a_setup["task_id"],
body="hi",
skill="general",
)
assert result.content == "hi"
# Without skill.
with (
patch.object(svc, "_resolve_slug_from_id", AsyncMock(return_value="be-dev-1")),
patch.object(
svc, "get_or_create_conversation", AsyncMock(return_value=fake_conv)
),
patch.object(svc, "send_chat_message", AsyncMock(return_value=fake_msg)),
):
result2 = await svc.send(
from_agent=dev.id,
to_agent="be-qa",
task_id=a2a_setup["task_id"],
body="hi-2",
)
assert result2.content == "hi"
@pytest.mark.asyncio
async def test_mark_all_read_clears_unread_for_agent(a2a_setup: dict) -> None:
"""mark_all_read zeroes the agent's unread counter across its conversations
and stamps read_at on the inbound messages, returning the count cleared —
the bulk ack that lets an agent satisfy i_am_idle's unread-A2A soft-block."""
svc: A2AService = a2a_setup["svc"]
db = a2a_setup["db"]
qa = a2a_setup["qa"]
conv = await svc.get_or_create_conversation(
agent_a="be-dev-1", agent_b="be-qa", task_id=a2a_setup["task_id"]
)
conv_id = UUID(conv.id)
# be-dev-1 < be-qa canonically → dev is agent_a; dev's messages bump qa's
# unread (unread_by_b).
await svc.send_chat_message(conv_id, "be-dev-1", "one")
await svc.send_chat_message(conv_id, "be-dev-1", "two")
cleared = await svc.mark_all_read(qa.id)
assert cleared == 1
row = await db.get(A2AConversationTable, conv_id)
assert row is not None
assert row.unread_by_b == 0
msgs = (
(
await db.execute(
select(A2AMessageTable).where(
A2AMessageTable.conversation_id == conv_id
)
)
)
.scalars()
.all()
)
assert all(m.read_at is not None for m in msgs)
# Idempotent — nothing left unread for qa.
assert await svc.mark_all_read(qa.id) == 0