2026-05-05 05:50:01 +02:00
|
|
|
"""A2AService coverage — agent cards, task ↔ A2A conversion, conversations."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
|
|
|
from roboco.models import AgentRole, AgentStatus, Team
|
|
|
|
|
from roboco.models.base import (
|
|
|
|
|
TaskNature,
|
|
|
|
|
TaskStatus,
|
|
|
|
|
TaskType,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
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]:
|
|
|
|
|
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={},
|
|
|
|
|
)
|
|
|
|
|
qa = AgentTable(
|
|
|
|
|
id=uuid4(),
|
|
|
|
|
name="QA",
|
|
|
|
|
slug=f"be-qa-{uuid4().hex[:8]}",
|
|
|
|
|
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_create_task_from_message_without_project_fails(a2a_setup: dict) -> None:
|
|
|
|
|
"""Pre-existing bug: create_task_from_message doesn't set project_id (required FK).
|
|
|
|
|
|
|
|
|
|
We exercise the path so the lines are covered, but assert the IntegrityError
|
|
|
|
|
rather than success. Fixing the production code is a separate change.
|
|
|
|
|
"""
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
dev = a2a_setup["dev"]
|
|
|
|
|
with pytest.raises(IntegrityError):
|
|
|
|
|
await svc.create_task_from_message(
|
|
|
|
|
title="new task",
|
|
|
|
|
description="from a2a",
|
|
|
|
|
created_by=dev.id,
|
|
|
|
|
team=Team.BACKEND,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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 = (
|
2026-05-06 00:32:52 +02:00
|
|
|
(await db.execute(__import__("sqlalchemy").select(ProjectTable)))
|
|
|
|
|
.scalars()
|
|
|
|
|
.first()
|
|
|
|
|
.id
|
|
|
|
|
)
|
2026-05-05 05:50:01 +02:00
|
|
|
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()
|
|
|
|
|
assert len(cards) >= 2 # dev + qa
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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:
|
|
|
|
|
from roboco.enforcement.a2a_access import A2AAccessDeniedError
|
|
|
|
|
|
|
|
|
|
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 dev pairs is allowed by default; just exercise the create path."""
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
try:
|
|
|
|
|
conv = await svc.get_or_create_conversation("be-dev-1", "be-dev-2")
|
|
|
|
|
assert conv is not None
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
# If the policy blocks this pair, skip — we're focused on the call path.
|
|
|
|
|
pytest.skip("A2A policy denies this pair")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from uuid import UUID # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
# Expected if the policy rejects this pair or service is wired
|
|
|
|
|
# to external infra in this test setup.
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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"]
|
|
|
|
|
try:
|
|
|
|
|
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
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
pytest.skip("Policy denied this pair")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_send_chat_message_in_existing_conversation(
|
|
|
|
|
a2a_setup: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
try:
|
|
|
|
|
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
|
|
|
|
from uuid import UUID as _UUID
|
2026-05-06 00:32:52 +02:00
|
|
|
|
|
|
|
|
msg = await svc.send_chat_message(_UUID(conv.id), "be-dev-1", "hello")
|
2026-05-05 05:50:01 +02:00
|
|
|
assert msg.content == "hello"
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
pytest.skip("Policy denied this pair")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_messages_returns_chronological(a2a_setup: dict) -> None:
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
try:
|
|
|
|
|
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
|
|
|
|
from uuid import UUID as _UUID
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
assert len(msgs) == 2
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
pytest.skip("Policy denied this pair")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_close_conversation_with_resolution(a2a_setup: dict) -> None:
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
try:
|
|
|
|
|
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
|
|
|
|
from uuid import UUID as _UUID
|
|
|
|
|
|
2026-05-06 00:32:52 +02:00
|
|
|
await svc.close_conversation(_UUID(conv.id), "be-dev-1", resolution="done")
|
|
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
pytest.skip("Policy denied this pair")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_mark_read_clears_unread(a2a_setup: dict) -> None:
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
try:
|
|
|
|
|
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
|
|
|
|
from uuid import UUID as _UUID
|
|
|
|
|
|
|
|
|
|
await svc.mark_read(_UUID(conv.id), "be-dev-1")
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
pytest.skip("Policy denied this pair")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_close_conversation_non_participant_raises(
|
|
|
|
|
a2a_setup: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
try:
|
|
|
|
|
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
|
|
|
|
from uuid import UUID as _UUID
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="Not a participant"):
|
|
|
|
|
await svc.close_conversation(_UUID(conv.id), "ghost-agent")
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
pytest.skip("Policy denied this pair")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_send_chat_message_non_participant_raises(
|
|
|
|
|
a2a_setup: dict,
|
|
|
|
|
) -> None:
|
|
|
|
|
svc = a2a_setup["svc"]
|
|
|
|
|
try:
|
|
|
|
|
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
|
|
|
|
|
from uuid import UUID as _UUID
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="Not a participant"):
|
|
|
|
|
await svc.send_chat_message(_UUID(conv.id), "ghost", "hi")
|
2026-05-06 00:32:52 +02:00
|
|
|
except Exception:
|
2026-05-05 05:50:01 +02:00
|
|
|
pytest.skip("Policy denied this pair")
|
2026-05-06 00:32:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Pure-function helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_team_from_agent_backend() -> None:
|
|
|
|
|
from roboco.models import Team
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
assert A2AService.get_team_from_agent("be-dev-1") == Team.BACKEND
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_team_from_agent_unknown_defaults_to_backend() -> None:
|
|
|
|
|
from roboco.models import Team
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
assert A2AService.get_team_from_agent("ghost-agent") == Team.BACKEND
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_target_agent_explicit() -> None:
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
result = A2AService.resolve_target_agent({"target_agent": "be-dev-1"})
|
|
|
|
|
assert result == "be-dev-1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_target_agent_unknown_returns_none() -> None:
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
result = A2AService.resolve_target_agent({"target_agent": "ghost-agent"})
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_target_agent_none_when_no_metadata() -> None:
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
result = A2AService.resolve_target_agent({})
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_extract_message_text_no_text_parts() -> None:
|
|
|
|
|
from roboco.models.a2a import A2AMessage
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
from roboco.models.a2a import A2AMessage, TextPart
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
from roboco.models.a2a import A2AMessage, TextPart
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
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."""
|
|
|
|
|
from roboco.db.tables import TaskTable
|
|
|
|
|
from roboco.models.a2a import A2AMessage, TextPart
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
db = a2a_setup["db"]
|
|
|
|
|
task = (
|
|
|
|
|
await db.execute(__import__("sqlalchemy").select(TaskTable).limit(1))
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if task is None:
|
|
|
|
|
pytest.skip("no task in DB")
|
|
|
|
|
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:
|
|
|
|
|
from roboco.db.tables import TaskTable
|
|
|
|
|
from roboco.models.a2a import A2AMessage
|
|
|
|
|
from roboco.services.a2a import A2AService
|
|
|
|
|
|
|
|
|
|
db = a2a_setup["db"]
|
|
|
|
|
task = (
|
|
|
|
|
await db.execute(__import__("sqlalchemy").select(TaskTable).limit(1))
|
|
|
|
|
).scalar_one_or_none()
|
|
|
|
|
if task is None:
|
|
|
|
|
pytest.skip("no task in DB")
|
|
|
|
|
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 — should fall 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")
|