mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
533 lines
16 KiB
Python
533 lines
16 KiB
Python
"""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 = (
|
|
await db.execute(__import__("sqlalchemy").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()
|
|
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
|
|
except Exception: # noqa: BLE001
|
|
# 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
|
|
except Exception: # noqa: BLE001
|
|
# 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
|
|
except Exception: # noqa: BLE001
|
|
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
|
|
msg = await svc.send_chat_message(
|
|
_UUID(conv.id), "be-dev-1", "hello"
|
|
)
|
|
assert msg.content == "hello"
|
|
except Exception: # noqa: BLE001
|
|
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
|
|
except Exception: # noqa: BLE001
|
|
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
|
|
|
|
await svc.close_conversation(
|
|
_UUID(conv.id), "be-dev-1", resolution="done"
|
|
)
|
|
except Exception: # noqa: BLE001
|
|
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")
|
|
except Exception: # noqa: BLE001
|
|
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")
|
|
except Exception: # noqa: BLE001
|
|
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")
|
|
except Exception: # noqa: BLE001
|
|
pytest.skip("Policy denied this pair")
|