feat(content): anti-soup guard on the flow verbs' free-text

Extends structured-content enforcement from the content tools to every
flow verb that carries agent free-text, closing the last hole where a
dev/PM could pass word soup: i_am_blocked(reason), i_am_done(notes),
submit_up/submit_root/complete(notes), escalate_up/escalate_to_ceo(reason),
pass_review(notes), fail_review/pr_fail(issues), pr_pass(notes),
i_documented(notes), delegate(title/description). Plans (i_will_plan /
i_will_work_on) keep their existing >=150-char approach + sub_task gates
and are skipped here so recovery re-entry with thin values still works.

Shared helpers on the choreographer: _free_text_soup (bare envelope, list
aware) and _soup_or_decision_env (folds the soup check into a verb's
existing spec-gate return so no verb gains a return or tips the xenon
bound). reject_trivial now also catches all-filler multi-token strings.

base.md documents the broadened rule for agents.
This commit is contained in:
Renn F
2026-06-21 05:11:20 +02:00
parent 96ca53eff2
commit 4c85cc6dfa
13 changed files with 432 additions and 59 deletions
+12 -4
View File
@@ -131,7 +131,9 @@ async def test_board_escalate_to_ceo_blocks_wrong_state() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.escalate_to_ceo(agent_id, task_id, reason="x")
env = await c.escalate_to_ceo(
agent_id, task_id, reason="escalating to the CEO for sign-off"
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "awaiting_pm_review" in body["message"]
@@ -173,7 +175,9 @@ async def test_board_escalate_to_ceo_blocks_disallowed_role() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.escalate_to_ceo(agent_id, task_id, reason="x")
env = await c.escalate_to_ceo(
agent_id, task_id, reason="escalating to the CEO for sign-off"
)
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "qa" in body["message"]
@@ -194,7 +198,9 @@ async def test_board_escalate_to_ceo_requires_journal_decision() -> None:
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_to_ceo(agent_id, task_id, reason="x")
env = await c.escalate_to_ceo(
agent_id, task_id, reason="escalating to the CEO for sign-off"
)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
@@ -210,7 +216,9 @@ async def test_board_escalate_to_ceo_returns_not_found_when_task_missing() -> No
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.escalate_to_ceo(agent_id, task_id, reason="x")
env = await c.escalate_to_ceo(
agent_id, task_id, reason="escalating to the CEO for sign-off"
)
body = env.as_dict()
assert body["error"] == "not_found"
task_svc.escalate_to_ceo.assert_not_awaited()
+1 -1
View File
@@ -585,7 +585,7 @@ async def test_i_am_done_not_assigned_returns_tracing_gap() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_done(agent_id, task_id, "x")
env = await c.i_am_done(agent_id, task_id, "completed the work")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "owns_task" in body["missing"]
+1 -1
View File
@@ -158,7 +158,7 @@ async def test_i_documented_requires_min_notes() -> None:
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.i_documented(doc_id, task_id, notes="short", files=["a.md"])
env = await c.i_documented(doc_id, task_id, notes="wrote the docs", files=["a.md"])
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "docs_notes>=min" in body["missing"]
+5 -5
View File
@@ -763,7 +763,7 @@ async def test_complete_rejects_non_pm_role() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.complete(dev_id, task_id, notes="x")
env = await c.complete(dev_id, task_id, notes="reviewed and approved")
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "cell_pm" in body["remediate"] and "main_pm" in body["remediate"]
@@ -816,7 +816,7 @@ async def test_escalate_up_returns_invalid_state_when_target_lookup_fails() -> N
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="x")
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "main-pm" in body["message"]
@@ -841,7 +841,7 @@ async def test_escalate_up_blocks_without_journal_decision() -> None:
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="x")
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
@@ -871,7 +871,7 @@ async def test_escalate_up_no_target_returns_invalid_state() -> None:
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="x")
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
body = env.as_dict()
assert body["error"] == "invalid_state"
@@ -885,5 +885,5 @@ async def test_escalate_up_task_not_found() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="x")
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
assert env.as_dict()["error"] == "not_found"
@@ -1008,7 +1008,7 @@ async def test_submit_up_short_notes_rejected() -> None:
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.submit_up(pm_id, task_id, notes="short")
env = await c.submit_up(pm_id, task_id, notes="ready for now")
body = env.as_dict()
assert body["error"] == "tracing_gap"
+165
View File
@@ -0,0 +1,165 @@
"""Anti-soup guard on the flow verbs (reason / notes / issues / title / desc).
Two layers are tested:
- the pure helpers ``_free_text_soup`` (skip empty/None, reject filler, walk
list items) and ``_soup_or_decision_env`` (soup first, then the spec
decision, else None);
- one end-to-end wiring test per emit style ``i_am_blocked`` (folds soup into
the spec-gate return) proving a soupy ``reason`` is rejected before any
state transition and a real reason passes through.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.choreographer._impl import Choreographer as _Impl
from roboco.services.gateway.envelope import Envelope
# --------------------------------------------------------------------------- #
# _free_text_soup
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("clean", ["a real substantive reason", "rate_limited"])
def test_free_text_soup_passes_substantive(clean: str) -> None:
assert _Impl._free_text_soup((("reason", clean, 8),)) is None
@pytest.mark.parametrize("skip", [None, "", " "])
def test_free_text_soup_skips_empty_and_none(skip: str | None) -> None:
# Empty / None means "not supplied" — presence is gated elsewhere.
assert _Impl._free_text_soup((("notes", skip, 8),)) is None
@pytest.mark.parametrize("soup", ["wip", "asdf", "tbd", "...", "x", "wip wip"])
def test_free_text_soup_rejects_filler(soup: str) -> None:
env = _Impl._free_text_soup((("reason", soup, 3),))
assert env is not None
assert env.error == "invalid_state"
def test_free_text_soup_walks_list_items() -> None:
# The second issue is filler — the list form must catch it.
env = _Impl._free_text_soup(
(("issues", ["a genuine actionable issue", "asdf"], 8),)
)
assert env is not None
assert env.error == "invalid_state"
assert "issues[1]" in (env.message or "")
def test_free_text_soup_clean_list_passes() -> None:
env = _Impl._free_text_soup(
(("issues", ["first real issue", "second real issue"], 8),)
)
assert env is None
# --------------------------------------------------------------------------- #
# _soup_or_decision_env
# --------------------------------------------------------------------------- #
def _allow() -> MagicMock:
return MagicMock(allowed=True)
def _deny() -> MagicMock:
return MagicMock(
allowed=False,
rejection_kind="invalid_state",
message="bad state",
remediate="do X",
)
def test_soup_or_decision_prefers_soup() -> None:
soup = Envelope.invalid_state(message="soup", remediate="fix", context_briefing={})
out = _Impl._soup_or_decision_env(soup, _deny(), {})
assert out is soup # soup wins even when the decision also rejects
def test_soup_or_decision_falls_back_to_decision() -> None:
out = _Impl._soup_or_decision_env(None, _deny(), {})
assert out is not None
assert out.error == "invalid_state"
assert out.message == "bad state"
def test_soup_or_decision_none_when_all_clean() -> None:
assert _Impl._soup_or_decision_env(None, _allow(), {}) is None
# --------------------------------------------------------------------------- #
# Wiring: i_am_blocked rejects a soupy reason before any transition
# --------------------------------------------------------------------------- #
def _make_deps(agent_id: object, task_id: object) -> ChoreographerDeps:
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=agent_id,
task_type="code",
team="backend",
dependency_ids=[],
acceptance_criteria=[],
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
id=agent_id, role="developer", team="backend", slug="be-dev-1"
)
evidence_repo = AsyncMock()
for m in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(evidence_repo, m).return_value = []
return ChoreographerDeps(
task=task_svc,
work_session=AsyncMock(),
git=AsyncMock(),
a2a=AsyncMock(),
journal=AsyncMock(),
audit=AsyncMock(),
evidence_repo=evidence_repo,
)
async def test_i_am_blocked_rejects_soup_reason() -> None:
agent_id, task_id = uuid4(), uuid4()
deps = _make_deps(agent_id, task_id)
c = Choreographer(deps)
env = await c.i_am_blocked(agent_id, task_id, "wip")
assert env.error == "invalid_state"
# The block never happened — no struggle journal, no escalate.
deps.journal.write_struggle.assert_not_awaited()
deps.task.escalate.assert_not_awaited()
async def test_i_am_blocked_accepts_real_reason() -> None:
agent_id, task_id = uuid4(), uuid4()
deps = _make_deps(agent_id, task_id)
c = Choreographer(deps)
env = await c.i_am_blocked(
agent_id, task_id, "Waiting on the upstream auth schema migration."
)
# A substantive reason clears the soup guard (then proceeds to the spec
# gate / block path — which writes the struggle journal).
assert env.error != "invalid_state" or "placeholder" not in (env.message or "")
deps.journal.write_struggle.assert_awaited_once()
+34 -10
View File
@@ -67,7 +67,11 @@ async def test_pr_update_missing_pr_number_returns_invalid_state() -> None:
ca = ContentActions(deps)
env = await ca.pr_update(
agent_id=agent_id, task_id=task.id, title="new", body=None, reviewers=None
agent_id=agent_id,
task_id=task.id,
title="updated PR title",
body=None,
reviewers=None,
)
body = env.as_dict()
@@ -216,16 +220,16 @@ async def test_pr_update_all_three_forwarded() -> None:
env = await ca.pr_update(
agent_id=agent_id,
task_id=task.id,
title="t",
body="b",
title="updated PR title",
body="a substantive PR body",
reviewers=["be-dev-2"],
)
body = env.as_dict()
assert body["error"] is None
call_kwargs = git_svc.update_pr_for_task.call_args.kwargs
assert call_kwargs["title"] == "t"
assert call_kwargs["body"] == "b"
assert call_kwargs["title"] == "updated PR title"
assert call_kwargs["body"] == "a substantive PR body"
assert call_kwargs["reviewers"] == ["be-dev-2"]
assert set(body["evidence"]["updated_fields"]) == {"title", "body", "reviewers"}
@@ -250,7 +254,11 @@ async def test_pr_update_cell_pm_on_same_team_allowed() -> None:
ca = ContentActions(deps)
env = await ca.pr_update(
agent_id=pm_id, task_id=task.id, title="t", body=None, reviewers=None
agent_id=pm_id,
task_id=task.id,
title="updated PR title",
body=None,
reviewers=None,
)
body = env.as_dict()
@@ -272,7 +280,11 @@ async def test_pr_update_cell_pm_on_other_team_rejected() -> None:
ca = ContentActions(deps)
env = await ca.pr_update(
agent_id=pm_id, task_id=task.id, title="t", body=None, reviewers=None
agent_id=pm_id,
task_id=task.id,
title="updated PR title",
body=None,
reviewers=None,
)
assert env.as_dict()["error"] == "not_authorized"
@@ -299,7 +311,11 @@ async def test_pr_update_main_pm_any_team_allowed() -> None:
ca = ContentActions(deps)
env = await ca.pr_update(
agent_id=pm_id, task_id=task.id, title="t", body=None, reviewers=None
agent_id=pm_id,
task_id=task.id,
title="updated PR title",
body=None,
reviewers=None,
)
assert env.as_dict()["error"] is None
@@ -316,7 +332,11 @@ async def test_pr_update_task_not_found_returns_not_found() -> None:
ca = ContentActions(deps)
env = await ca.pr_update(
agent_id=agent_id, task_id=uuid4(), title="t", body=None, reviewers=None
agent_id=agent_id,
task_id=uuid4(),
title="updated PR title",
body=None,
reviewers=None,
)
body = env.as_dict()
@@ -339,7 +359,11 @@ async def test_pr_update_git_error_returned_as_invalid_state() -> None:
ca = ContentActions(deps)
env = await ca.pr_update(
agent_id=agent_id, task_id=task.id, title="t", body=None, reviewers=None
agent_id=agent_id,
task_id=task.id,
title="updated PR title",
body=None,
reviewers=None,
)
body = env.as_dict()