mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -13,6 +13,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.api.websocket_bridge import (
|
||||
_handle_a2a_message_event,
|
||||
_handle_agent_event,
|
||||
_handle_message_event,
|
||||
_handle_notification_sent,
|
||||
@@ -429,6 +430,44 @@ async def test_handle_usage_snapshot_broadcasts_to_system() -> None:
|
||||
assert len(msg["by_agent"]) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _handle_a2a_message_event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_a2a_message_event_broadcasts_to_system() -> None:
|
||||
"""An A2A_MESSAGE_SENT event is forwarded to /ws/system as an
|
||||
`a2a.message` frame — the CEO's live view of every agent-to-agent chat."""
|
||||
event = _evt(
|
||||
EventType.A2A_MESSAGE_SENT,
|
||||
{
|
||||
"conversation_id": "conv-1",
|
||||
"message_id": "msg-1",
|
||||
"task_id": "task-1",
|
||||
"from_agent": "be-dev-1",
|
||||
"to_agent": "be-qa",
|
||||
"skill": "code_review",
|
||||
"body_excerpt": "please review",
|
||||
"timestamp": "2026-07-02T00:00:00+00:00",
|
||||
},
|
||||
)
|
||||
with patch("roboco.api.websocket_bridge.manager") as mgr:
|
||||
mgr.broadcast_system = AsyncMock()
|
||||
await _handle_a2a_message_event(event)
|
||||
mgr.broadcast_system.assert_awaited_once()
|
||||
msg = mgr.broadcast_system.await_args.args[0]
|
||||
assert msg["type"] == "a2a.message"
|
||||
assert msg["conversation_id"] == "conv-1"
|
||||
assert msg["message_id"] == "msg-1"
|
||||
assert msg["task_id"] == "task-1"
|
||||
assert msg["from_agent"] == "be-dev-1"
|
||||
assert msg["to_agent"] == "be-qa"
|
||||
assert msg["skill"] == "code_review"
|
||||
assert msg["body_excerpt"] == "please review"
|
||||
assert msg["timestamp"] == "2026-07-02T00:00:00+00:00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration + start
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -465,6 +504,8 @@ def test_register_websocket_bridge_handlers_subscribes_all_event_types() -> None
|
||||
assert EventType.USAGE_SNAPSHOT in types
|
||||
# Message delivery forwarded to /ws/channels + /ws/sessions.
|
||||
assert EventType.MESSAGE_SENT in types
|
||||
# A2A live chat forwarded to /ws/system (CEO live view).
|
||||
assert EventType.A2A_MESSAGE_SENT in types
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from roboco.agents_config import can_a2a_direct
|
||||
from roboco.enforcement.a2a_access import (
|
||||
A2AAccessDeniedError,
|
||||
get_a2a_allowed_targets,
|
||||
@@ -50,3 +51,46 @@ def test_get_a2a_allowed_targets_excludes_self() -> None:
|
||||
targets = get_a2a_allowed_targets("be-dev-1", ["be-dev-1", "be-qa"])
|
||||
# Self should be filtered.
|
||||
assert "be-dev-1" not in targets or "be-qa" in targets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CEO-initiated A2A — the one asymmetric rule: CEO may send, nobody may
|
||||
# target CEO (the block above must still hold).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_a2a_access_ceo_to_agent_allowed() -> None:
|
||||
result = validate_a2a_access("ceo", "be-dev-1")
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_can_a2a_direct_ceo_to_main_pm() -> None:
|
||||
assert can_a2a_direct("ceo", "main-pm") == (True, None)
|
||||
|
||||
|
||||
def test_can_a2a_direct_ceo_to_board_member() -> None:
|
||||
"""Board members are normally unreachable via direct A2A for everyone
|
||||
else (routed through main-pm) — CEO is exempt from that restriction."""
|
||||
assert can_a2a_direct("ceo", "product-owner") == (True, None)
|
||||
|
||||
|
||||
def test_validate_a2a_to_ceo_still_denied_with_ceo_send_rule() -> None:
|
||||
"""Regression: allowing CEO-initiated A2A must not loosen the inbound
|
||||
block — nobody may target the CEO."""
|
||||
with pytest.raises(A2AAccessDeniedError):
|
||||
validate_a2a_access("be-dev-1", "ceo")
|
||||
|
||||
|
||||
def test_get_a2a_allowed_targets_ceo_includes_all_roles() -> None:
|
||||
targets = get_a2a_allowed_targets("ceo", ["be-dev-1", "be-qa", "main-pm"])
|
||||
assert set(targets) == {"be-dev-1", "be-qa", "main-pm"}
|
||||
|
||||
|
||||
def test_can_a2a_direct_to_ceo_message_explains_reply_only() -> None:
|
||||
"""An agent can never INITIATE with the CEO (only reply inside a
|
||||
conversation the CEO opened) — the matrix denial message must say so,
|
||||
not point at the old blanket 'use notify()' framing."""
|
||||
allowed, reason = can_a2a_direct("be-dev-1", "ceo")
|
||||
assert allowed is False
|
||||
assert reason is not None
|
||||
assert "reply" in reason.lower()
|
||||
|
||||
@@ -316,3 +316,166 @@ async def test_propose_draft_reports_relay_failure(
|
||||
msg = await intake_server.propose_draft({"title": "X"})
|
||||
assert "Could not submit the draft" in msg
|
||||
assert "http_503" in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_past_tasks — the intake's mid-conversation "have we done this before?"
|
||||
# tool (grok-CLI path).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_past_tasks_success_sends_q_and_limit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("ROBOCO_API_URL", "http://orch:8000")
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["params"] = dict(request.url.params)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[
|
||||
{
|
||||
"id": "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
"title": "Fix login bug",
|
||||
"status": "completed",
|
||||
"team": "backend",
|
||||
"date": "2026-01-01",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.query_past_tasks(
|
||||
"sess-1", "login", limit=5, client=client
|
||||
)
|
||||
|
||||
assert seen["url"].startswith(
|
||||
"http://orch:8000/api/prompter/live/sess-1/search-tasks"
|
||||
)
|
||||
assert seen["params"]["q"] == "login"
|
||||
assert seen["params"]["limit"] == "5"
|
||||
assert result["results"][0]["title"] == "Fix login bug"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_past_tasks_too_short_query_never_calls_http() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError("must not call the relay for a too-short query")
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.query_past_tasks("sess-1", "a", client=client)
|
||||
|
||||
assert result == {"error": "query_too_short", "results": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_past_tasks_http_error_shape() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(503)
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.query_past_tasks("sess-1", "login", client=client)
|
||||
|
||||
assert result["error"] == "http_503"
|
||||
assert result["results"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_past_tasks_request_failure() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ConnectError("boom")
|
||||
|
||||
async with _client(handler) as client:
|
||||
result = await intake_server.query_past_tasks("sess-1", "login", client=client)
|
||||
|
||||
assert result["error"] == "request_failed"
|
||||
assert "boom" in result["detail"]
|
||||
assert result["results"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_past_tasks_clamps_limit_above_max() -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["limit"] = dict(request.url.params)["limit"]
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
async with _client(handler) as client:
|
||||
await intake_server.query_past_tasks(
|
||||
"sess-1", "login", limit=999, client=client
|
||||
)
|
||||
|
||||
assert seen["limit"] == "10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_past_tasks_clamps_limit_below_min() -> None:
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["limit"] = dict(request.url.params)["limit"]
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
async with _client(handler) as client:
|
||||
await intake_server.query_past_tasks("sess-1", "login", limit=0, client=client)
|
||||
|
||||
assert seen["limit"] == "1"
|
||||
|
||||
|
||||
def test_format_search_results_error_dict() -> None:
|
||||
msg = intake_server.format_search_results({"error": "http_503", "results": []})
|
||||
assert "Could not search past tasks" in msg
|
||||
assert "http_503" in msg
|
||||
|
||||
|
||||
def test_format_search_results_empty_list() -> None:
|
||||
msg = intake_server.format_search_results({"results": []})
|
||||
assert "No past tasks matched" in msg
|
||||
|
||||
|
||||
def test_format_search_results_renders_lines() -> None:
|
||||
result = {
|
||||
"results": [
|
||||
{
|
||||
"id": "abcdef1234567890",
|
||||
"title": "Fix login bug",
|
||||
"status": "completed",
|
||||
"team": "backend",
|
||||
"date": "2026-01-01",
|
||||
}
|
||||
]
|
||||
}
|
||||
msg = intake_server.format_search_results(result)
|
||||
assert "`abcdef12`" in msg # short id truncated to 8 chars
|
||||
assert "Fix login bug" in msg
|
||||
assert "completed" in msg
|
||||
assert "backend" in msg
|
||||
assert "2026-01-01" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_past_tasks_requires_a_live_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ROBOCO_PROMPTER_SESSION_ID", raising=False)
|
||||
msg = await intake_server.search_past_tasks("login")
|
||||
assert "No live session id" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_past_tasks_success_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ROBOCO_PROMPTER_SESSION_ID", "sess-1")
|
||||
stub_result = {
|
||||
"results": [{"id": "x", "title": "T", "status": "s", "team": "t", "date": "d"}]
|
||||
}
|
||||
|
||||
async def _stub(_session_id: str, _query: str, **_kwargs: Any) -> dict[str, Any]:
|
||||
return stub_result
|
||||
|
||||
monkeypatch.setattr(intake_server, "query_past_tasks", _stub)
|
||||
msg = await intake_server.search_past_tasks("login")
|
||||
assert msg == intake_server.format_search_results(stub_result)
|
||||
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import (
|
||||
@@ -263,6 +263,118 @@ class TestIntakeScopeSlugs:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_history_digest_projects — the prompter-memory ambient's project scope
|
||||
# (covers all three intake scopes, unlike the conventions ambient resolver).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveHistoryDigestProjects:
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_slug_branch_resolves_single_project(self) -> None:
|
||||
class _FakeProjectSvc:
|
||||
async def get_by_slug(self, slug: str) -> Any:
|
||||
return SimpleNamespace(slug=slug, id=uuid4())
|
||||
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
||||
object(), project_slug="roboco", product_id=None, project_ids=None
|
||||
)
|
||||
assert [p.slug for p in projects] == ["roboco"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_slug_missing_returns_empty(self) -> None:
|
||||
class _FakeProjectSvc:
|
||||
async def get_by_slug(self, _slug: str) -> Any:
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
||||
object(), project_slug="ghost", product_id=None, project_ids=None
|
||||
)
|
||||
assert projects == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_product_id_branch_delegates_to_ambient_product_projects(
|
||||
self,
|
||||
) -> None:
|
||||
sentinel = [SimpleNamespace(slug="p1", id=uuid4())]
|
||||
|
||||
async def _fake_product_projects(_db: Any, product_id: str) -> list[Any]:
|
||||
assert product_id == "prod-1"
|
||||
return sentinel
|
||||
|
||||
with patch.object(
|
||||
AgentOrchestrator, "_ambient_product_projects", _fake_product_projects
|
||||
):
|
||||
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
||||
object(), project_slug=None, product_id="prod-1", project_ids=None
|
||||
)
|
||||
assert projects is sentinel
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_ids_branch_preserves_order_and_skips_missing(
|
||||
self,
|
||||
) -> None:
|
||||
good1 = "11111111-1111-1111-1111-111111111111"
|
||||
missing = "22222222-2222-2222-2222-222222222222"
|
||||
good2 = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
class _FakeProjectSvc:
|
||||
async def get(self, pid: Any) -> Any:
|
||||
if str(pid) == missing:
|
||||
return None
|
||||
return SimpleNamespace(slug=f"proj-{str(pid)[0]}", id=pid)
|
||||
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
||||
object(),
|
||||
project_slug=None,
|
||||
product_id=None,
|
||||
project_ids=[good1, missing, good2],
|
||||
)
|
||||
# Order preserved; the unresolvable id is skipped, not raised — this is
|
||||
# a best-effort ambient resolver, not the hard clone-scope resolver.
|
||||
assert [p.slug for p in projects] == ["proj-1", "proj-3"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_scope_given_returns_empty(self) -> None:
|
||||
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
||||
object(), project_slug=None, product_id=None, project_ids=None
|
||||
)
|
||||
assert projects == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_history_digest_ambient — best-effort: any failure returns None.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveHistoryDigestAmbient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_returns_none_not_raises(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
|
||||
def _boom() -> Any:
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
|
||||
|
||||
result = await orch._resolve_history_digest_ambient("roboco")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn_intake_session / reap_intake_session — orchestration (docker mocked).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -389,6 +501,63 @@ class TestSpawnIntakeSession:
|
||||
await orch.spawn_intake_session("sess-2", project_slug="roboco")
|
||||
assert stopped == [INTAKE_AGENT_ID] # the old one was reaped first
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_merges_conventions_and_history_ambient(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The composed prompt's ambient is the conventions + history-digest
|
||||
blocks joined with compose_prompt's own layer separator."""
|
||||
orch = _make_minimal_orchestrator()
|
||||
run_calls: list[list[str]] = []
|
||||
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
||||
|
||||
async def _conventions(*_a: Any, **_k: Any) -> str | None:
|
||||
return "CONVENTIONS BLOCK"
|
||||
|
||||
async def _history(*_a: Any, **_k: Any) -> str | None:
|
||||
return "HISTORY BLOCK"
|
||||
|
||||
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _conventions)
|
||||
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _history)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _spy_prompt(*_args: Any, **kwargs: Any) -> Path:
|
||||
captured["ambient"] = kwargs.get("ambient")
|
||||
return Path("/tmp/intake-1-prompt.md")
|
||||
|
||||
monkeypatch.setattr(orch, "_generate_composed_prompt", _spy_prompt)
|
||||
|
||||
await orch.spawn_intake_session("sess-merge", project_slug="roboco")
|
||||
|
||||
assert captured["ambient"] == "CONVENTIONS BLOCK\n\n---\n\nHISTORY BLOCK"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_ambient_none_when_both_resolvers_empty(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
run_calls: list[list[str]] = []
|
||||
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
||||
|
||||
async def _none(*_a: Any, **_k: Any) -> str | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _none)
|
||||
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _none)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _spy_prompt(*_args: Any, **kwargs: Any) -> Path:
|
||||
captured["ambient"] = kwargs.get("ambient")
|
||||
return Path("/tmp/intake-1-prompt.md")
|
||||
|
||||
monkeypatch.setattr(orch, "_generate_composed_prompt", _spy_prompt)
|
||||
|
||||
await orch.spawn_intake_session("sess-no-ambient", project_slug="roboco")
|
||||
|
||||
assert captured["ambient"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_message_is_scheduled(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -8,10 +8,15 @@ conftest fixtures.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from roboco.db.tables import (
|
||||
AgentTable,
|
||||
ProductTable,
|
||||
@@ -28,15 +33,23 @@ from roboco.models.base import (
|
||||
Team,
|
||||
)
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services import prompter as prompter_module
|
||||
from roboco.services.base import ServiceError, ValidationError
|
||||
from roboco.services.prompter import (
|
||||
_HISTORY_DIGEST_PER_PROJECT_LIMIT,
|
||||
_HISTORY_TITLE_EXCERPT_CAP,
|
||||
PrompterService,
|
||||
_cell_teams,
|
||||
_clean_list,
|
||||
_draft_cell_map,
|
||||
_task_activity_date,
|
||||
_title_excerpt,
|
||||
build_history_digest,
|
||||
compact_task_rows,
|
||||
compose_description,
|
||||
derive_scale,
|
||||
get_prompter_service,
|
||||
history_digest_layer,
|
||||
parse_readiness,
|
||||
)
|
||||
|
||||
@@ -1052,3 +1065,216 @@ async def test_create_task_from_draft_does_not_mutate_caller_draft(
|
||||
assert draft["the_work"][0]["items"] == original_items
|
||||
# ...and the top-level acceptance_criteria was NOT replaced.
|
||||
assert draft["acceptance_criteria"] == ["done"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Prompter memory v1 — history digest + compact search rows (pure, no DB)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _task(title: str, **overrides: Any) -> TaskTable:
|
||||
"""An unattached TaskTable instance — plain attribute assignment, no session.
|
||||
|
||||
Defaults to a completed backend task with no dates; pass ``completed_at`` /
|
||||
``updated_at`` / ``created_at`` / ``status`` / ``team`` to override.
|
||||
"""
|
||||
fields: dict[str, Any] = {
|
||||
"id": uuid4(),
|
||||
"title": title,
|
||||
"status": TaskStatus.COMPLETED,
|
||||
"team": Team.BACKEND,
|
||||
"completed_at": None,
|
||||
"updated_at": None,
|
||||
"created_at": None,
|
||||
}
|
||||
fields.update(overrides)
|
||||
return TaskTable(**fields)
|
||||
|
||||
|
||||
def test_task_activity_date_prefers_completed_at() -> None:
|
||||
now = datetime.now(UTC)
|
||||
task = _task(
|
||||
"t",
|
||||
completed_at=now,
|
||||
updated_at=now - timedelta(days=1),
|
||||
created_at=now - timedelta(days=2),
|
||||
)
|
||||
assert _task_activity_date(task) == now
|
||||
|
||||
|
||||
def test_task_activity_date_falls_back_to_updated_at() -> None:
|
||||
now = datetime.now(UTC)
|
||||
task = _task(
|
||||
"t", completed_at=None, updated_at=now, created_at=now - timedelta(days=1)
|
||||
)
|
||||
assert _task_activity_date(task) == now
|
||||
|
||||
|
||||
def test_task_activity_date_falls_back_to_created_at() -> None:
|
||||
now = datetime.now(UTC)
|
||||
task = _task("t", completed_at=None, updated_at=None, created_at=now)
|
||||
assert _task_activity_date(task) == now
|
||||
|
||||
|
||||
def test_title_excerpt_leaves_short_titles_untouched() -> None:
|
||||
assert _title_excerpt("Fix login bug") == "Fix login bug"
|
||||
|
||||
|
||||
def test_title_excerpt_truncates_long_titles_with_ellipsis() -> None:
|
||||
long_title = "A" * 100
|
||||
excerpt = _title_excerpt(long_title)
|
||||
assert len(excerpt) == _HISTORY_TITLE_EXCERPT_CAP
|
||||
assert excerpt.endswith("…")
|
||||
|
||||
|
||||
def test_build_history_digest_empty_is_blank() -> None:
|
||||
assert build_history_digest([]) == ""
|
||||
|
||||
|
||||
def test_build_history_digest_caps_at_limit_keeps_most_recent() -> None:
|
||||
now = datetime.now(UTC)
|
||||
# t0 oldest ... t19 newest.
|
||||
ascending = [
|
||||
_task(f"t{i}", created_at=now + timedelta(days=i), updated_at=None)
|
||||
for i in range(20)
|
||||
]
|
||||
# Mimic the DB's most-recent-first ordering.
|
||||
most_recent_first = list(reversed(ascending))
|
||||
|
||||
digest = build_history_digest(most_recent_first)
|
||||
|
||||
lines = digest.splitlines()
|
||||
assert len(lines) == _HISTORY_DIGEST_PER_PROJECT_LIMIT
|
||||
for i in range(5): # the 5 oldest are excluded
|
||||
assert f"`{str(ascending[i].id)[:8]}`" not in digest
|
||||
for i in range(5, 20): # the 15 most recent are present
|
||||
assert f"`{str(ascending[i].id)[:8]}`" in digest
|
||||
|
||||
|
||||
def test_build_history_digest_renders_oldest_first() -> None:
|
||||
now = datetime.now(UTC)
|
||||
a = _task("Task A", created_at=now - timedelta(days=2), updated_at=None)
|
||||
b = _task("Task B", created_at=now - timedelta(days=1), updated_at=None)
|
||||
c = _task("Task C", created_at=now, updated_at=None)
|
||||
|
||||
# DB order is most-recent-first: C, B, A.
|
||||
digest = build_history_digest([c, b, a])
|
||||
|
||||
idx_a = digest.index("Task A")
|
||||
idx_b = digest.index("Task B")
|
||||
idx_c = digest.index("Task C")
|
||||
assert idx_a < idx_b < idx_c
|
||||
|
||||
|
||||
def test_compact_task_rows_shape() -> None:
|
||||
now = datetime.now(UTC)
|
||||
task = _task(
|
||||
"Fix login bug",
|
||||
status=TaskStatus.COMPLETED,
|
||||
team=Team.BACKEND,
|
||||
completed_at=now,
|
||||
)
|
||||
rows = compact_task_rows([task])
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert set(row.keys()) == {"id", "title", "status", "team", "date"}
|
||||
assert row["id"] == str(task.id)
|
||||
assert row["title"] == "Fix login bug"
|
||||
assert row["status"] == "completed"
|
||||
assert row["team"] == "backend"
|
||||
assert row["date"] == now.date().isoformat()
|
||||
|
||||
|
||||
def test_compact_task_rows_preserves_none_team() -> None:
|
||||
task = _task("No team", team=None, created_at=datetime.now(UTC))
|
||||
rows = compact_task_rows([task])
|
||||
assert rows[0]["team"] is None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# history_digest_layer — ambient-block assembly (project_history_digest stubbed)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_digest_layer_empty_projects_returns_none() -> None:
|
||||
assert await history_digest_layer(cast("AsyncSession", object()), []) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_digest_layer_single_project_has_no_header(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake(_session: Any, _project: Any, *, _limit: int = 15) -> str | None:
|
||||
return "- `abc12345` Some task (completed, 2026-01-01)"
|
||||
|
||||
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
|
||||
project = SimpleNamespace(slug="roboco", id=uuid4())
|
||||
|
||||
text = await history_digest_layer(cast("AsyncSession", object()), [project])
|
||||
|
||||
assert text is not None
|
||||
assert text.startswith("## Task History\n\n### Recent tasks\n")
|
||||
assert "### Recent tasks —" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_digest_layer_multi_project_headers_by_slug(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
projects = [
|
||||
SimpleNamespace(slug="backend-svc", id=uuid4()),
|
||||
SimpleNamespace(slug="frontend-app", id=uuid4()),
|
||||
]
|
||||
|
||||
async def _fake(_session: Any, project: Any, *, _limit: int = 15) -> str | None:
|
||||
return f"- `deadbeef` Task for {project.slug} (completed, 2026-01-01)"
|
||||
|
||||
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
|
||||
|
||||
text = await history_digest_layer(cast("AsyncSession", object()), projects)
|
||||
|
||||
assert text is not None
|
||||
assert "### Recent tasks — `backend-svc`" in text
|
||||
assert "### Recent tasks — `frontend-app`" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_digest_layer_skips_projects_with_no_tasks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
has_tasks = SimpleNamespace(slug="has-tasks", id=uuid4())
|
||||
no_tasks = SimpleNamespace(slug="empty-proj", id=uuid4())
|
||||
|
||||
async def _fake(_session: Any, project: Any, *, _limit: int = 15) -> str | None:
|
||||
return (
|
||||
"- `deadbeef` A task (completed, 2026-01-01)"
|
||||
if project is has_tasks
|
||||
else None
|
||||
)
|
||||
|
||||
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
|
||||
|
||||
text = await history_digest_layer(
|
||||
cast("AsyncSession", object()), [has_tasks, no_tasks]
|
||||
)
|
||||
|
||||
assert text is not None
|
||||
assert "has-tasks" in text
|
||||
assert "empty-proj" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_digest_layer_all_empty_returns_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def _fake(_session: Any, _project: Any, *, _limit: int = 15) -> str | None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(prompter_module, "project_history_digest", _fake)
|
||||
projects = [
|
||||
SimpleNamespace(slug="a", id=uuid4()),
|
||||
SimpleNamespace(slug="b", id=uuid4()),
|
||||
]
|
||||
|
||||
assert await history_digest_layer(cast("AsyncSession", object()), projects) is None
|
||||
|
||||
Reference in New Issue
Block a user