Files
roboco/tests/integration/test_secretary_routes.py
T
d1cf6ecbf3 Wave 1: PR-gate turn cut, task search, trace timestamps, Secretary edits + e2e scenarios 2–3 (#295)
* feat(tests): e2e scenario 2 — the PM merge chain through the PR gate

Shared arcs extracted (arcs.py: canonical-company seeding + dev/qa/doc
segments); scenario 2 seeds a root->cell->dev hierarchy mid-flight, rides
the child through the scenario-1 arc into the cell branch (real squash
via the fake GitHub), then submit_up -> claim_gate_review/pr_pass ->
dispatcher re-claim (mirrored) -> PM complete merging cell->root. This is
the exact PM->reviewer->PM turn sequence the wave-1 turn cut shortens —
the BEFORE-net. Learned seams scripted: commit-subject validator (>=20
chars), reviewer learning-note gate, pr_pass clears ownership by design.

* feat(runtime): PR-gate turn cut — assembled parents auto-submit to the reviewer

When every child of an assembled parent is terminal, the closure
dispatcher now runs the real submit_up/submit_root through the internal
API as the owning PM (_try_auto_submit) instead of spawning the PM for
that turn — the submit's substance is deterministic gate code. Any gate
refusal falls back to the classic PM closure spawn; pr_fail routing and
the PM's final merge turn are unchanged; umbrellas never auto-submit.
ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED default-on; task.auto_submitted audit
row per cut. Proven by e2e scenario 2b (real API, real gates, real git)
against scenario 2 as the before-net.

* feat(notes): structured note sections carry a written_at trace stamp

Sections are overwrite-in-place, so without a stamp there was no way to
reconstruct WHEN a dev/qa/doc/reviewer note landed (CEO reMarkable item:
trace TIMESTAMPS). apply_structured_note stamps ISO written_at beside
the model fields; the panel notes tab renders it next to each card
title (pre-stamp rows render nothing). Progress updates, commits, and
journal entries already carried timestamps — this was the one gap.

* feat(tasks): server-side task search — title, details, and id prefix

The task list's search box only matched titles client-side, and the
trimmed summary payload deliberately carries no description — so
keyword/details/id search was impossible in the browser by design.
GET /tasks/summary gains q (ILIKE over title+description, id-prefix
match, composed with team/status and the view-permission scoping);
the panel debounces the box into the summary fetch and drops the
title-only client filter that would have hidden description matches.

* feat(wave-1): trace timestamps, real task search, Secretary task edits

- apply_structured_note stamps written_at per section; the panel notes
  tab shows it (the one trace surface without a timestamp).
- GET /tasks/summary?q= searches title+description+id-prefix server-side
  (summaries carry no description by design); panel debounces into the
  fetch and drops the title-only client filter.
- Secretary control_task gains a CEO-gated edit action over the content
  allowlist, and GET /secretary/tasks?q= resolves task names to ids for
  the chat. PM-side expansion deferred per the CEO's 'not that much'.

* fix(workspace): dep-update probe scrubs the inherited venv pin

Under uv run the orchestrator's process tree carries VIRTUAL_ENV, and a
uv-based dep_update_command in the throwaway probe clone would target
that venv instead of the clone's — the same hazard _uv_subprocess_env
already guards on the install path.

* build: private per-repo uv cache — isolate from machine-wide uvx servers

Root cause of the recurring rich/pip/bandit rot, with evidence: uv cache
clean timed out on the ~/.cache/uv lock ('is another uv process
running?') — three uvx mcp-server-fetch processes (Claude Code fetch MCP,
one alive since Wednesday) share that cache and race repo syncs on it;
poisoned entries then survive venv rebuilds because rm -rf .venv never
touches the cache, and every re-link reproduces the breakage. UV_CACHE_DIR
now pins <repo>/.uv-cache (gitignored). The earlier UV_NO_SYNC
serialization stays as defense-in-depth but was not the whole story.

* feat(tests): e2e scenario 3 — pr_fail revision loop + root→CEO chain

3a: reviewer pr_fail with a concrete issue -> needs_revision ->
i_will_plan re-entry (full plan gates) -> real fix lands on the cell
branch (the unchanged-PR hard gate refuses resubmit until it does) ->
clean second pass -> merge. 3b: submit_root -> gate -> Main PM complete
escalates the root to the CEO -> the REAL approve-and-merge endpoint
squash-merges to the origin's master. Harness gains the tasks router, a
seeded CEO identity, origin_commit, and a fake GitHub whose head.sha is
recomputed live (real-GitHub semantics the unchanged gate reads). Seeds
now encode the real shape: delivery roots are team=main_pm and
planning-typed.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-02 21:05:50 +02:00

196 lines
6.2 KiB
Python

"""roboco.api.routes.secretary — role gates + directive flow (direct-call style)."""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes import secretary as sec_route
from roboco.api.schemas.secretary import DirectiveDecision, DirectiveSubmit
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services.base import ConflictError
_ROW = object()
_DIRECTIVE_DICT: dict[str, Any] = {
"id": "11111111-1111-1111-1111-111111111111",
"kind": "relay_message",
"status": "executed",
"payload": {},
"requested_by": "22222222-2222-2222-2222-222222222222",
"requested_at": None,
"decided_by": None,
"decided_at": None,
"result": "posted to #all-hands",
}
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
def _db() -> MagicMock:
db = MagicMock()
db.commit = AsyncMock()
return db
class _FakeService:
def __init__(self, *, exc: Exception | None = None) -> None:
self._exc = exc
async def submit_directive(self, _kind: Any, _payload: Any, _by: Any) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def confirm_directive(self, _directive_id: Any, _by: Any) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def reject_directive(
self, _directive_id: Any, _by: Any, _reason: Any
) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def list_directives(self, _status: Any = None) -> list[object]:
return [_ROW]
async def read_company_state(self) -> dict[str, Any]:
return {
"goals": {},
"task_counts": {},
"pending_pitches": [],
"pending_directives": [],
}
def to_dict(self, _row: object) -> dict[str, Any]:
return dict(_DIRECTIVE_DICT)
def _install(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
monkeypatch.setattr(sec_route, "get_secretary_service", lambda _db: service)
@pytest.mark.asyncio
async def test_submit_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.submit_directive(
DirectiveSubmit(kind="relay_message"), _db(), _agent(AgentRole.DEVELOPER)
)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_submit_bad_kind_422() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.submit_directive(
DirectiveSubmit(kind="bogus"), _db(), _agent(AgentRole.SECRETARY)
)
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_submit_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.submit_directive(
DirectiveSubmit(
kind="relay_message", payload={"channel": "all-hands", "text": "hi"}
),
db,
_agent(AgentRole.SECRETARY),
)
assert resp.status == "executed"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_forbidden_for_secretary() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.SECRETARY))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_confirm_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.confirm_directive(uuid4(), db, _agent(AgentRole.CEO))
assert resp.id == _DIRECTIVE_DICT["id"]
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_conflict_409(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService(exc=ConflictError("already decided")))
with pytest.raises(HTTPException) as exc:
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.CEO))
assert exc.value.status_code == HTTPStatus.CONFLICT
@pytest.mark.asyncio
async def test_list_forbidden_for_secretary() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.list_directives(_db(), _agent(AgentRole.SECRETARY))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_reject_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.reject_directive(
uuid4(), DirectiveDecision(reason="no"), db, _agent(AgentRole.CEO)
)
assert resp.id == _DIRECTIVE_DICT["id"]
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_state_allows_secretary(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService())
resp = await sec_route.read_state(_db(), _agent(AgentRole.SECRETARY))
assert resp.pending_pitches == []
@pytest.mark.asyncio
async def test_search_tasks_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.search_tasks(_db(), _agent(AgentRole.DEVELOPER), q="greeting")
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_search_tasks_returns_compact_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The CEO refers to tasks by NAME in the Secretary chat — the search
resolves names to ids so a directive can target the right task."""
row = MagicMock()
row.id = uuid4()
row.title = "Rework the greeting banner"
row.status = "pending"
row.team = "backend"
row.priority = 2
task_svc = MagicMock()
task_svc.search_tasks = AsyncMock(return_value=[row])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _db: task_svc)
out = await sec_route.search_tasks(_db(), _agent(AgentRole.SECRETARY), q="greeting")
assert out == [
{
"id": str(row.id),
"title": "Rework the greeting banner",
"status": "pending",
"team": "backend",
"priority": 2,
}
]