Files
roboco/tests/integration/test_prompter_live_routes.py
T
da563487b8 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>
2026-07-03 00:07:55 +02:00

528 lines
18 KiB
Python

"""Integration tests for the live intake chat routes (start/stop + relay + msg).
The SSE stream generator itself is unit-tested at the service layer
(``test_prompter_live``); here we exercise the HTTP contracts against an
injected registry whose container deliveries hit a mocked transport, and the
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 AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api import deps
from roboco.api.deps import get_agent_context
from roboco.api.routes.prompter_live import router
from roboco.db.base import get_db
from roboco.models.base import AgentRole
from roboco.services import prompter_live
from roboco.services.base import ValidationError
from roboco.services.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@pytest_asyncio.fixture
async def live_client() -> AsyncIterator[dict[str, Any]]:
def container_handler(_req: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True})
mock_client = httpx.AsyncClient(transport=httpx.MockTransport(container_handler))
registry = prompter_live.PrompterLiveRegistry(http_client=mock_client)
prompter_live._RegistryHolder.instance = registry # inject the singleton
app = FastAPI()
app.include_router(router, prefix="/api/prompter")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "registry": registry}
prompter_live._RegistryHolder.instance = None
await mock_client.aclose()
@pytest.mark.asyncio
async def test_relay_event_pushes_to_live_session(live_client: dict) -> None:
client, registry = live_client["client"], live_client["registry"]
registry.open("s1", "intake-1")
resp = await client.post(
"/api/prompter/live/s1/events", json={"kind": "text", "text": "hi"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"pushed": True}
# The event is now on the session's queue.
assert registry.get("s1").queue.qsize() == 1
@pytest.mark.asyncio
async def test_relay_event_unknown_session_is_noop(live_client: dict) -> None:
resp = await live_client["client"].post(
"/api/prompter/live/nope/events", json={"kind": "text"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"pushed": False}
@pytest.mark.asyncio
async def test_status_reports_alive_for_open_session(live_client: dict) -> None:
client, registry = live_client["client"], live_client["registry"]
registry.open("s1", "intake-1")
resp = await client.get("/api/prompter/live/s1/status")
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"alive": True}
@pytest.mark.asyncio
async def test_status_reports_dead_for_unknown_session(live_client: dict) -> None:
resp = await live_client["client"].get("/api/prompter/live/nope/status")
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"alive": False}
@pytest.mark.asyncio
async def test_send_message_delivers_to_container(live_client: dict) -> None:
client, registry = live_client["client"], live_client["registry"]
registry.open("s1", "intake-1")
resp = await client.post(
"/api/prompter/live/s1/messages", json={"text": "hello there"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"delivered": True}
@pytest.mark.asyncio
async def test_send_message_unknown_session_404(live_client: dict) -> None:
resp = await live_client["client"].post(
"/api/prompter/live/nope/messages", json={"text": "hi"}
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_send_message_requires_text(live_client: dict) -> None:
live_client["registry"].open("s1", "intake-1")
resp = await live_client["client"].post(
"/api/prompter/live/s1/messages", json={"text": ""}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
# ---------------------------------------------------------------------------
# start / stop — spawn + reap against a fake orchestrator (no docker).
# ---------------------------------------------------------------------------
class _FakeOrchestrator:
"""Records spawn/reap calls; stands in for the real orchestrator singleton."""
def __init__(self) -> None:
self.spawned: list[dict[str, Any]] = []
self.reaped: list[str] = []
async def start_intake_session(
self,
session_id: str,
*,
project_slug: str | None = None,
product_id: str | None = None,
project_ids: list[str] | None = None,
initial_message: str | None = None,
) -> None:
# The route is non-blocking now: it calls start_intake_session (returns
# None) which opens the relay + spawns in the background.
self.spawned.append(
{
"session_id": session_id,
"project_slug": project_slug,
"product_id": product_id,
"project_ids": project_ids,
"initial_message": initial_message,
}
)
async def reap_intake_session(self, session_id: str) -> None:
self.reaped.append(session_id)
@pytest_asyncio.fixture
async def start_client(
monkeypatch: pytest.MonkeyPatch,
) -> AsyncIterator[dict[str, Any]]:
orch = _FakeOrchestrator()
# monkeypatch.setattr is untyped (no ignore for the fake) and auto-reverts.
monkeypatch.setattr(deps._ServiceHolder, "orchestrator", orch)
async def _fake_db() -> AsyncIterator[object]:
yield object() # product-scope start never touches it
app = FastAPI()
app.include_router(router, prefix="/api/prompter")
app.dependency_overrides[get_db] = _fake_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "orch": orch}
@pytest.mark.asyncio
async def test_start_product_scope_spawns_and_returns_session(
start_client: dict,
) -> None:
client, orch = start_client["client"], start_client["orch"]
product_id = str(uuid4())
resp = await client.post(
"/api/prompter/live/start",
json={"product_id": product_id, "initial_message": "build X"},
)
assert resp.status_code == HTTPStatus.CREATED
session_id = resp.json()["session_id"]
assert session_id
assert orch.spawned == [
{
"session_id": session_id,
"project_slug": None,
"product_id": product_id,
"project_ids": None,
"initial_message": "build X",
}
]
@pytest.mark.asyncio
async def test_start_megatask_scope_passes_project_ids(start_client: dict) -> None:
client, orch = start_client["client"], start_client["orch"]
ids = [str(uuid4()), str(uuid4())]
resp = await client.post(
"/api/prompter/live/start",
json={"project_ids": ids, "initial_message": "three repos"},
)
assert resp.status_code == HTTPStatus.CREATED
assert orch.spawned[0]["project_ids"] == ids
assert orch.spawned[0]["project_slug"] is None
assert orch.spawned[0]["product_id"] is None
@pytest.mark.asyncio
async def test_start_project_scope_resolves_slug(start_client: dict) -> None:
client, orch = start_client["client"], start_client["orch"]
project_id = uuid4()
fake_svc = SimpleNamespace(
get=lambda _pid: _async_return(SimpleNamespace(slug="roboco"))
)
with patch("roboco.services.project.get_project_service", lambda _db: fake_svc):
resp = await client.post(
"/api/prompter/live/start", json={"project_id": str(project_id)}
)
assert resp.status_code == HTTPStatus.CREATED
assert orch.spawned[0]["project_slug"] == "roboco"
assert orch.spawned[0]["product_id"] is None
@pytest.mark.asyncio
async def test_start_unknown_project_404(start_client: dict) -> None:
client = start_client["client"]
fake_svc = SimpleNamespace(get=lambda _pid: _async_return(None))
with patch("roboco.services.project.get_project_service", lambda _db: fake_svc):
resp = await client.post(
"/api/prompter/live/start", json={"project_id": str(uuid4())}
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_start_requires_exactly_one_scope(start_client: dict) -> None:
client = start_client["client"]
both = await client.post(
"/api/prompter/live/start",
json={"project_id": str(uuid4()), "product_id": str(uuid4())},
)
assert both.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
neither = await client.post("/api/prompter/live/start", json={})
assert neither.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_stop_reaps_session(start_client: dict) -> None:
client, orch = start_client["client"], start_client["orch"]
resp = await client.post("/api/prompter/live/sess-9/stop")
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"stopped": True}
assert orch.reaped == ["sess-9"]
def _async_return(value: Any) -> Any:
"""Wrap a value in an awaitable so a lambda can stand in for an async method."""
async def _coro() -> Any:
return value
return _coro()
# ---------------------------------------------------------------------------
# confirm — draft → task + reap-on-confirm (service mocked; route wiring only).
# ---------------------------------------------------------------------------
class _FakeDb:
async def commit(self) -> None:
return None
@pytest_asyncio.fixture
async def confirm_client(
monkeypatch: pytest.MonkeyPatch,
) -> AsyncIterator[dict[str, Any]]:
orch = _FakeOrchestrator()
monkeypatch.setattr(deps._ServiceHolder, "orchestrator", orch)
async def _fake_db() -> AsyncIterator[_FakeDb]:
yield _FakeDb()
ceo = AgentContext(agent_id=uuid4(), role=AgentRole.CEO, team=None, slug="ceo")
app = FastAPI()
app.include_router(router, prefix="/api/prompter")
app.dependency_overrides[get_db] = _fake_db
app.dependency_overrides[get_agent_context] = lambda: ceo
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield {"client": client, "orch": orch}
@pytest.mark.asyncio
async def test_confirm_creates_task_and_reaps(confirm_client: dict) -> None:
client, orch = confirm_client["client"], confirm_client["orch"]
task_id = uuid4()
class _FakeService:
async def confirm_live_draft(self, _draft: Any, _agent: Any, **_kw: Any) -> Any:
return task_id
with patch(
"roboco.api.routes.prompter_live.get_prompter_service",
lambda _db: _FakeService(),
):
resp = await client.post(
"/api/prompter/live/s1/confirm",
json={
"project_id": str(uuid4()),
"draft": {"title": "x", "acceptance_criteria": ["a"]},
},
)
assert resp.status_code == HTTPStatus.CREATED
assert resp.json() == {"task_id": str(task_id)}
assert orch.reaped == ["s1"] # reap-on-confirm
@pytest.mark.asyncio
async def test_confirm_requires_exactly_one_target(confirm_client: dict) -> None:
client = confirm_client["client"]
both = await client.post(
"/api/prompter/live/s1/confirm",
json={
"project_id": str(uuid4()),
"product_id": str(uuid4()),
"draft": {"title": "x"},
},
)
assert both.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_confirm_validation_error_is_translated_and_not_reaped(
confirm_client: dict,
) -> None:
client, orch = confirm_client["client"], confirm_client["orch"]
class _FakeService:
async def confirm_live_draft(self, _draft: Any, _agent: Any, **_kw: Any) -> Any:
raise ValidationError(message="bad draft", field="title")
with patch(
"roboco.api.routes.prompter_live.get_prompter_service",
lambda _db: _FakeService(),
):
resp = await client.post(
"/api/prompter/live/s1/confirm",
json={"project_id": str(uuid4()), "draft": {"title": "x"}},
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
assert orch.reaped == [] # a failed confirm must NOT reap the session
# ---------------------------------------------------------------------------
# MegaTask batch routes — confirm-batch (terminal, reaps) + preview-batch (pure).
# ---------------------------------------------------------------------------
def _batch_body() -> dict[str, Any]:
return {
"title": "MegaTask",
"drafts": [
{"title": "A", "acceptance_criteria": ["a"], "project_id": str(uuid4())},
{"title": "B", "acceptance_criteria": ["b"], "project_id": str(uuid4())},
],
"project_ids": [str(uuid4()), str(uuid4())],
"route": "main_pm",
}
@pytest.mark.asyncio
async def test_confirm_batch_creates_and_reaps(confirm_client: dict) -> None:
client, orch = confirm_client["client"], confirm_client["orch"]
result = {
"umbrella_task_id": str(uuid4()),
"root_subtask_ids": [str(uuid4()), str(uuid4())],
"waves": [[0], [1]],
"warnings": [],
}
class _FakeService:
async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any:
return result
with patch(
"roboco.api.routes.prompter_live.get_prompter_service",
lambda _db: _FakeService(),
):
resp = await client.post(
"/api/prompter/live/s1/confirm-batch", json=_batch_body()
)
assert resp.status_code == HTTPStatus.CREATED
assert resp.json() == result
assert orch.reaped == ["s1"] # confirm-batch is terminal → reap
@pytest.mark.asyncio
async def test_confirm_batch_validation_error_not_reaped(confirm_client: dict) -> None:
client, orch = confirm_client["client"], confirm_client["orch"]
class _FakeService:
async def confirm_live_batch(self, *_a: Any, **_kw: Any) -> Any:
raise ValidationError(message="bad batch", field="drafts")
with patch(
"roboco.api.routes.prompter_live.get_prompter_service",
lambda _db: _FakeService(),
):
resp = await client.post(
"/api/prompter/live/s1/confirm-batch", json=_batch_body()
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
assert orch.reaped == [] # a failed confirm must NOT reap
@pytest.mark.asyncio
async def test_confirm_batch_schema_rejects_too_few_projects(
confirm_client: dict,
) -> None:
client = confirm_client["client"]
body = _batch_body()
body["project_ids"] = [str(uuid4())] # < 2 → schema 422
resp = await client.post("/api/prompter/live/s1/confirm-batch", json=body)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_preview_batch_returns_waves_and_does_not_reap(
confirm_client: dict,
) -> None:
client, orch = confirm_client["client"], confirm_client["orch"]
class _FakeService:
def preview_batch(self, _drafts: Any) -> Any:
return {"waves": [[0, 1]], "warnings": []}
with patch(
"roboco.api.routes.prompter_live.get_prompter_service",
lambda _db: _FakeService(),
):
resp = await client.post(
"/api/prompter/live/s1/preview-batch",
json={"drafts": [{"title": "A"}, {"title": "B"}]},
)
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