Files
roboco/tests/unit/gateway/test_content_actions_sentinel.py
T
a7b970a3b2 feat(board): materialize program items as Main-PM roots, make reports actionable (#711)
Two coupled gaps in the Board Program output path.

Approved items were created unowned and in BACKLOG. Nothing dispatches
BACKLOG, and once activated a cell PM claimed the parentless task as a root,
where _cell_pm_complete resolves its merge target through
resolve_parent_branch — which for a parentless task falls through to the
project head rung. The result was a cell branch merging straight into the
trunk, bypassing the Main-PM root, the root->master PR and the CEO gate
(live: PRs #703 and #704 both targeted slave directly).

All eight materializers now create a PENDING, main-pm-assigned root with
team=Team.MAIN_PM, matching what approve_and_start does for an intake draft.
The team is load-bearing, not cosmetic: _next_hint_pr_fail,
_deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer
label all key on it, and a cell-teamed root drops the 'do NOT re-submit the
root' steer that exists because of PR #138's infinite pr_fail loop. The
item's own cell survives as a delegation hint in the description, which is
what the Main PM's briefing renders.

Periscope, Sentinel and Coroner produced artifacts with no way to act on
them — three panel surfaces carried explicit 'no approve/reject UI' comments
while each item already held a machine-readable suggested action. They now
have per-item approve and dismiss, modelled on the roadmap queue: idempotent
per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check
still fires, and every decision recorded through record_decision so it
reaches the next cycle's prompt. Approving materializes through the same
corrected Main-PM-owned path.

Target project resolves to each engine's own existing anchor — RoboCo's
project for Periscope and Sentinel, the incident's project for Coroner — and
fails with a clean invalid_state naming what is unresolvable rather than
guessing at a repo.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-26 20:01:24 +02:00

482 lines
17 KiB
Python

"""roboco.services.gateway.content_actions.propose_quality_report —
Auditor-gated Sentinel quality-report authoring. Mirrors
test_content_actions_periscope.py for the validation truth table and the
complete-at-propose asymmetry (a report has no per-item CEO queue); adds the
area-vocabulary check propose_market_brief has no equivalent of."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.foundation.policy.content import markers
from roboco.models.base import TaskStatus
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``propose_quality_report`` touches."""
def __init__(
self,
*,
assigned_to: Any,
orchestration_markers: dict[str, Any] | None = None,
status: Any = TaskStatus.PENDING,
) -> None:
self.id = uuid4()
self.assigned_to = assigned_to
self.orchestration_markers = orchestration_markers
self.status = status
def _actions(role: str, *, notification_delivery: Any = None) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
notification_delivery=notification_delivery,
)
return ContentActions(deps)
def _valid_item(idx: int, *, area: str = "waivers") -> dict[str, Any]:
return {
"area": area,
"observation": f"Observation {idx} about recurring drift",
"evidence": f"Ledger row {idx} backing this observation",
"suggested_action": f"Suggested action {idx} for the CEO",
}
def _valid_items(n: int) -> list[dict[str, Any]]:
return [_valid_item(i) for i in range(n)]
def _valid_kwargs(**overrides: Any) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"headline": "Waived findings climbed sharply this week",
"items": _valid_items(1),
"overall_assessment": "Drift is concentrated in one hotspot, not systemic",
}
kwargs.update(overrides)
return kwargs
# --------------------------------------------------------------------------- #
# Role gate
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_quality_report_forbidden_for_product_owner() -> None:
env = await _actions("product_owner").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_quality_report_forbidden_for_head_marketing() -> None:
env = await _actions("head_marketing").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_quality_report_forbidden_for_developer() -> None:
env = await _actions("developer").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
# --------------------------------------------------------------------------- #
# Headline validation
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_short_headline() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(headline="short")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_oversized_headline() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(headline="x" * 201)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_soup_headline() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(headline="tbd tbd tbd")
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# Items count + shape
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_empty_items() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_too_many_items() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=_valid_items(8))
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_non_dict_item() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=["not a dict"])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_item_missing_observation() -> None:
bad = _valid_item(0)
del bad["observation"]
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
assert "observation" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_item_missing_evidence() -> None:
bad = _valid_item(0)
del bad["evidence"]
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
assert "evidence" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_item_missing_suggested_action() -> None:
bad = _valid_item(0)
del bad["suggested_action"]
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
assert "suggested_action" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_soup_observation() -> None:
bad = _valid_item(0)
bad["observation"] = "asdf"
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_oversized_observation() -> None:
bad = _valid_item(0)
bad["observation"] = "x" * 501
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_oversized_evidence() -> None:
bad = _valid_item(0)
bad["evidence"] = "x" * 501
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_oversized_suggested_action() -> None:
bad = _valid_item(0)
bad["suggested_action"] = "x" * 301
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# Area vocabulary — the check propose_market_brief has no equivalent of
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_unknown_area() -> None:
bad = _valid_item(0, area="not_a_real_area")
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
assert "area" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_missing_area() -> None:
bad = _valid_item(0)
del bad["area"]
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[bad])
)
assert env.error == "invalid_state"
@pytest.mark.parametrize(
"area", ["waivers", "findings", "conventions", "budget", "docs", "other"]
)
@pytest.mark.asyncio
async def test_propose_quality_report_accepts_every_valid_area(
area: str, monkeypatch: pytest.MonkeyPatch
) -> None:
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(items=[_valid_item(0, area=area)])
)
# No open cycle exists — this is the FIRST check past field validation,
# proving the area itself was accepted.
assert env.error == "invalid_state"
assert "area" not in (env.message or "")
assert "no open sentinel exploration" in (env.message or "")
# --------------------------------------------------------------------------- #
# overall_assessment
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_missing_overall_assessment() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(overall_assessment="")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_oversized_overall_assessment() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(overall_assessment="x" * 801)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_rejects_soup_overall_assessment() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs(overall_assessment="tbd tbd tbd")
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# Open-cycle lookup
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_quality_report_no_open_cycle_is_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_ignores_cycle_assigned_to_another_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
other_agent = uuid4()
cycle_task = _FakeTask(assigned_to=other_agent)
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_quality_report_ignores_already_authored_cycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
authored = _FakeTask(
assigned_to=agent_id,
orchestration_markers={"quality_report": {"headline": "already filed"}},
)
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[authored])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("auditor").propose_quality_report(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# Happy path — the complete-at-propose transition
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_quality_report_persists_and_completes_the_exploration_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
actions = _actions("auditor")
actions.task.session.flush = AsyncMock()
items = _valid_items(3)
env = await actions.propose_quality_report(
agent_id=agent_id,
headline="Waived findings climbed sharply this week",
items=items,
overall_assessment="Drift is concentrated in one hotspot, not systemic",
)
assert env.error is None
assert env.status == "quality_report_proposed"
assert env.task_id == str(cycle_task.id)
payload = markers.get_quality_report(cycle_task)
assert payload is not None
assert payload["headline"] == "Waived findings climbed sharply this week"
assert len(payload["items"]) == len(items)
assert payload["items"][0]["id"] == "item-0"
assert payload["items"][0]["area"] == "waivers"
# Each item still carries its own per-item CEO decision (SentinelService.
# approve_item/reject_item) even though the exploration task completes
# here.
assert payload["items"][0]["status"] == "proposed"
assert payload["items"][0]["materialized_task_id"] is None
assert (
payload["overall_assessment"]
== "Drift is concentrated in one hotspot, not systemic"
)
# The x_feature/periscope asymmetry: the exploration task completes in
# THIS call — a report has no per-item CEO decision to wait on.
assert cycle_task.status == TaskStatus.COMPLETED
actions.task.session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_propose_quality_report_sends_telegram_push_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""ONE call per cycle — a report, not N per-item queue items (unlike
propose_bug_hunt/propose_roadmap's per-item Telegram push)."""
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
notify = AsyncMock()
actions = _actions("auditor", notification_delivery=notify)
actions.task.session.flush = AsyncMock()
env = await actions.propose_quality_report(agent_id=agent_id, **_valid_kwargs())
assert env.error is None
notify.notify_ceo_of_sentinel_report.assert_awaited_once()
call = notify.notify_ceo_of_sentinel_report.await_args
assert call.kwargs["task"] is cycle_task
assert call.kwargs["task_id"] == cycle_task.id
assert call.kwargs["headline"] == "Waived findings climbed sharply this week"
@pytest.mark.asyncio
async def test_propose_quality_report_survives_telegram_push_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
notify = MagicMock()
notify.notify_ceo_of_sentinel_report = AsyncMock(side_effect=RuntimeError("boom"))
actions = _actions("auditor", notification_delivery=notify)
actions.task.session.flush = AsyncMock()
env = await actions.propose_quality_report(agent_id=agent_id, **_valid_kwargs())
assert env.error is None
assert env.status == "quality_report_proposed"
@pytest.mark.asyncio
async def test_propose_quality_report_no_notification_delivery_is_a_no_op(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_sentinel_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
actions = _actions("auditor", notification_delivery=None)
actions.task.session.flush = AsyncMock()
env = await actions.propose_quality_report(agent_id=agent_id, **_valid_kwargs())
assert env.error is None
@pytest.mark.asyncio
async def test_propose_quality_report_missing_items_is_invalid_state() -> None:
env = await _actions("auditor").propose_quality_report(
agent_id=uuid4(),
headline="A one-line summary of this cycle's biggest signal",
items=[],
overall_assessment="An overall assessment of substantive length",
)
assert env.error == "invalid_state"