Wave 2 features: A2A live view (CEO chime-in + reply budget) and prompter memory (#297)

* feat(a2a): live view — watch fleet conversations, CEO chime-in, reply budget

A2A_MESSAGE_SENT published from A2AService.send (excerpt-capped) and
fanned through the existing /ws/system bridge; CEO-only admin REST for
conversations/messages + a reply route on the publish-bearing send path;
panel /a2a page with live transcript and a composer gated on task-linked
conversations. The matrix gains its one asymmetric rule: CEO may message
anyone, nobody may target the CEO — and agent replies inside a
CEO-opened conversation are hard-budgeted to one per CEO message
(per conversation, per agent), rejected with wait-don't-retry guidance.
Built subagent-driven (Sonnet 5), reviewed; v1 seams documented in the
map delta.

* feat(prompter): intake remembers the task history

Intake spawns now carry a per-project chronological digest of recent
tasks (capped: 15 lines/project, 4000 chars total — ~300-1000 tokens)
merged into the ambient layer, and the interviewer gets a bounded
search_past_tasks tool (one shared implementation behind the grok MCP
tool and the Claude SDK in-process tool) to check precedent
mid-conversation. Informational memory only — the sequencing analyzer
keeps ownership of ordering. Built subagent-driven (Sonnet 5), reviewed;
pre-existing conventions-ambient MegaTask-scope gap flagged, untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 00:07:55 +02:00
committed by GitHub
co-authored by Renn F
parent 48f2944086
commit da563487b8
39 changed files with 3808 additions and 27 deletions
+304
View File
@@ -893,6 +893,35 @@ async def test_send_chat_message_success(a2a_route_client: dict) -> None:
assert response.status_code == HTTPStatus.CREATED
@pytest.mark.asyncio
async def test_send_chat_message_over_budget_returns_403(
a2a_route_client: dict,
) -> None:
"""An over-budget reply to the CEO raises A2AAccessDeniedError from the
service — the route must surface 403, not crash into a 500 or fall
through to the ValueError->404 branch."""
client = a2a_route_client["client"]
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.send_chat_message = AsyncMock(
side_effect=A2AAccessDeniedError(
from_agent="be-dev-1",
to_agent="ceo",
reason=(
"you have already replied to the CEO's last message — "
"wait for the CEO to respond before sending again"
),
)
)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/conversations/{uuid4()}/messages",
json={"content": "another update"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_mark_read(a2a_route_client: dict) -> None:
@@ -937,6 +966,281 @@ async def test_chat_list_with_status_filter(a2a_route_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# Admin / live-view endpoints (CEO-only) — GET all conversations, GET any
# conversation's messages, POST a reply as the CEO.
# ---------------------------------------------------------------------------
def _set_ceo_context(app: FastAPI, dev: AgentTable) -> None:
"""Override the agent context to the CEO so the admin live-view routes
admit the call (the default fixture context is a developer)."""
async def _ceo() -> AgentContext:
return AgentContext(
agent_id=cast("UUID", dev.id),
role=AgentRole.CEO,
team=None,
slug="ceo",
)
app.dependency_overrides[get_agent_context] = _ceo
def _admin_conv_obj(
*,
conv_id: UUID,
agent_a: str = "be-dev-1",
agent_b: str = "fe-dev-1",
task_id: UUID | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
id=str(conv_id),
agent_a=agent_a,
agent_b=agent_b,
topic=None,
task_id=str(task_id) if task_id else None,
status="active",
resolution=None,
message_count=2,
unread_by_a=0,
unread_by_b=0,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
last_message_at=datetime.now(UTC),
last_message_preview="hi there",
)
@pytest.mark.asyncio
async def test_admin_list_conversations_forbidden_for_non_ceo(
a2a_route_client: dict,
) -> None:
client = a2a_route_client["client"]
response = await client.get("/api/a2a/chat/admin/conversations", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_get_messages_forbidden_for_non_ceo(
a2a_route_client: dict,
) -> None:
client = a2a_route_client["client"]
response = await client.get(
f"/api/a2a/chat/admin/conversations/{uuid4()}/messages", headers=_HDR
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_reply_forbidden_for_non_ceo(a2a_route_client: dict) -> None:
client = a2a_route_client["client"]
response = await client.post(
f"/api/a2a/chat/admin/conversations/{uuid4()}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_list_conversations_as_ceo(a2a_route_client: dict) -> None:
"""CEO sees conversations it is not itself a participant in."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv = _admin_conv_obj(conv_id=uuid4())
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.list_conversations_admin = AsyncMock(return_value=[conv])
mock_service_cls.return_value = instance
response = await client.get(
"/api/a2a/chat/admin/conversations?limit=10", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
instance.list_conversations_admin.assert_awaited_once_with(10)
body = response.json()
assert body["total"] == 1
assert body["items"][0]["agent_a"] == "be-dev-1"
assert body["items"][0]["agent_b"] == "fe-dev-1"
@pytest.mark.asyncio
async def test_admin_get_messages_as_ceo_returns_full_transcript(
a2a_route_client: dict,
) -> None:
"""The route uses get_messages_admin — the participant-bypassing
accessor — not the ordinary get_messages()."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
msg = SimpleNamespace(
id=uuid4(),
conversation_id=conv_id,
from_agent="be-dev-1",
content="hello",
message_kind="message",
response_to_id=None,
requires_response=False,
read_at=None,
created_at=datetime.now(UTC),
edited_at=None,
)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_messages_admin = AsyncMock(return_value=[msg, msg])
mock_service_cls.return_value = instance
response = await client.get(
f"/api/a2a/chat/admin/conversations/{conv_id}/messages", headers=_HDR
)
assert response.status_code == HTTPStatus.OK
instance.get_messages_admin.assert_awaited_once()
body = response.json()
_EXPECTED_MESSAGES = 2
assert body["total"] == _EXPECTED_MESSAGES
assert not body["has_more"]
@pytest.mark.asyncio
async def test_admin_reply_unknown_conversation_404(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=None)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{uuid4()}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_admin_reply_non_participant_target_400(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "ghost-agent", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_admin_reply_no_task_id_400(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=None)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_admin_reply_success(a2a_route_client: dict) -> None:
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
sent_msg = SimpleNamespace(
id=uuid4(),
conversation_id=conv_id,
from_agent="ceo",
content="chiming in",
message_kind="message",
response_to_id=None,
requires_response=False,
read_at=None,
created_at=datetime.now(UTC),
edited_at=None,
)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
instance.send = AsyncMock(return_value=sent_msg)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "chiming in"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.CREATED
instance.send.assert_awaited_once()
call_kwargs = instance.send.await_args.kwargs
assert call_kwargs["to_agent"] == "be-dev-1"
assert call_kwargs["task_id"] == task_id
assert call_kwargs["body"] == "chiming in"
body = response.json()
assert body["content"] == "chiming in"
@pytest.mark.asyncio
async def test_admin_reply_access_denied_maps_to_403(a2a_route_client: dict) -> None:
"""Defensive: if send() ever rejects a CEO-authored A2A, surface 403
rather than crash — mirrors create_conversation's handling."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
task_id = uuid4()
conv = _admin_conv_obj(conv_id=conv_id, task_id=task_id)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.get_conversation_admin = AsyncMock(return_value=conv)
instance.send = AsyncMock(
side_effect=A2AAccessDeniedError(
from_agent="ceo",
to_agent="be-dev-1",
reason="denied",
)
)
mock_service_cls.return_value = instance
response = await client.post(
f"/api/a2a/chat/admin/conversations/{conv_id}/reply",
json={"to_agent": "be-dev-1", "content": "hi"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
# ---------------------------------------------------------------------------
# send_message: TASK_ID_REQUIRED branch (line 131)
# ---------------------------------------------------------------------------
+350 -2
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, patch
@@ -32,7 +32,8 @@ from roboco.models.base import (
TaskStatus,
TaskType,
)
from roboco.services.a2a import A2AService
from roboco.models.events import EventType
from roboco.services.a2a import _LIVE_VIEW_EXCERPT_CHARS, A2AService
from sqlalchemy import select
from sqlalchemy import select as _sel
@@ -441,6 +442,353 @@ async def test_send_records_skill_on_message_for_receiver(a2a_setup: dict) -> No
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"
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
+66 -1
View File
@@ -8,10 +8,11 @@ start/stop routes against a fake orchestrator.
from __future__ import annotations
from datetime import UTC, datetime
from http import HTTPStatus
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
@@ -460,3 +461,67 @@ async def test_preview_batch_returns_waves_and_does_not_reap(
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"waves": [[0, 1]], "warnings": []}
assert orch.reaped == [] # preview creates nothing and leaves the chat alive
# ---------------------------------------------------------------------------
# search-tasks — the intake's mid-conversation "have we done this before?" tool.
# ---------------------------------------------------------------------------
def _row(title: str = "Fix login bug") -> MagicMock:
row = MagicMock()
row.id = uuid4()
row.title = title
row.status = "completed"
row.team = "backend"
row.completed_at = datetime.now(UTC)
row.updated_at = None
row.created_at = datetime.now(UTC)
return row
@pytest.mark.asyncio
async def test_search_tasks_returns_compact_rows_for_alive_session(
live_client: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
client, registry = live_client["client"], live_client["registry"]
registry.open("s1", "intake-1")
task_svc = MagicMock()
task_svc.search_tasks = AsyncMock(return_value=[_row()])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _db: task_svc)
resp = await client.get("/api/prompter/live/s1/search-tasks", params={"q": "login"})
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert len(body) == 1
assert set(body[0].keys()) == {"id", "title", "status", "team", "date"}
assert body[0]["title"] == "Fix login bug"
task_svc.search_tasks.assert_awaited_once()
@pytest.mark.asyncio
async def test_search_tasks_unknown_session_404(live_client: dict) -> None:
resp = await live_client["client"].get(
"/api/prompter/live/nope/search-tasks", params={"q": "login"}
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_search_tasks_query_too_short_422(live_client: dict) -> None:
live_client["registry"].open("s1", "intake-1")
resp = await live_client["client"].get(
"/api/prompter/live/s1/search-tasks", params={"q": "a"}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_search_tasks_limit_above_max_422(live_client: dict) -> None:
live_client["registry"].open("s1", "intake-1")
resp = await live_client["client"].get(
"/api/prompter/live/s1/search-tasks", params={"q": "login", "limit": 11}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
+85 -2
View File
@@ -8,8 +8,9 @@ existing v1-flow integration tests.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
@@ -237,6 +238,88 @@ async def test_get_active_count_for_agent(task_setup: dict) -> None:
assert isinstance(count, int)
# ---------------------------------------------------------------------------
# list_recent_for_project — the prompter's history digest source
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_recent_for_project_orders_most_recent_activity_first(
task_setup: dict,
) -> None:
svc = task_setup["svc"]
db = task_setup["db"]
now = datetime.now(UTC)
oldest = await svc.create(_req(task_setup, title="oldest"))
middle = await svc.create(_req(task_setup, title="middle"))
newest = await svc.create(_req(task_setup, title="newest"))
# Distinct activity dates: oldest only has created_at far in the past;
# middle was touched (updated_at) more recently; newest actually completed
# (completed_at wins over updated_at/created_at).
oldest.created_at = now - timedelta(days=10)
oldest.updated_at = None
middle.created_at = now - timedelta(days=9)
middle.updated_at = now - timedelta(days=5)
newest.created_at = now - timedelta(days=8)
newest.updated_at = now - timedelta(days=7)
newest.completed_at = now - timedelta(days=1)
await db.flush()
rows = await svc.list_recent_for_project(task_setup["project_id"])
ids = [t.id for t in rows]
assert ids.index(newest.id) < ids.index(middle.id) < ids.index(oldest.id)
@pytest.mark.asyncio
async def test_list_recent_for_project_respects_limit(task_setup: dict) -> None:
svc = task_setup["svc"]
db = task_setup["db"]
now = datetime.now(UTC)
tasks = [await svc.create(_req(task_setup, title=f"t{i}")) for i in range(3)]
for i, t in enumerate(tasks):
t.created_at = now - timedelta(days=10 - i) # t0 oldest, t2 newest
t.updated_at = None
await db.flush()
query_limit = 2
rows = await svc.list_recent_for_project(
task_setup["project_id"], limit=query_limit
)
assert len(rows) == query_limit
ids = [t.id for t in rows]
assert ids == [tasks[2].id, tasks[1].id]
@pytest.mark.asyncio
async def test_list_recent_for_project_scoped_to_project(
task_setup: dict, db_session: AsyncSession
) -> None:
svc = task_setup["svc"]
in_scope = await svc.create(_req(task_setup, title="in-scope"))
other_project = ProjectTable(
id=uuid4(),
name="Other-Proj",
slug=f"other-proj-{uuid4().hex[:8]}",
git_url="https://example.com/other.git",
assigned_cell=Team.BACKEND,
created_by=task_setup["agent_id"],
)
db_session.add(other_project)
await db_session.flush()
other_req = _req(task_setup, title="other-project-task")
other_req.project_id = cast("UUID", other_project.id)
out_of_scope = await svc.create(other_req)
rows = await svc.list_recent_for_project(task_setup["project_id"])
ids = {t.id for t in rows}
assert in_scope.id in ids
assert out_of_scope.id not in ids
# ---------------------------------------------------------------------------
# Subtask hierarchy
# ---------------------------------------------------------------------------