From 6550d69b75afed0c1314a292b19b9e7d22ddd80a Mon Sep 17 00:00:00 2001 From: Renn F Date: Tue, 12 May 2026 04:35:13 +0200 Subject: [PATCH] fix(gateway): B4 decision/reflect remediate includes literal call example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke run 3 showed Main PM taking 7 attempts to satisfy the decision-note required-fields contract — the remediate listed which fields were missing but didn't show what a fully-formed call looks like. The LLM pattern-matches examples better than field-list prose; each retry it dropped a different field. Added a literal note(scope='decision', ...) / note(scope='reflect', ...) call template to the rejection remediate so the agent sees the canonical shape with named-keyword args and example values. The missing-fields list stays — both pieces of information are useful, but the example is what actually drives convergence. Spec ref: docs/superpowers/specs/2026-05-12-post-smoke-3-fixes-design.md section B4. --- roboco/services/gateway/content_actions.py | 37 +++++++++++++-- tests/unit/gateway/test_content_actions.py | 53 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index d4a11c4a..83075da5 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -360,14 +360,41 @@ class ContentActions: # Pre-gateway parity: decision and reflect scopes had required fields. missing, hints = _check_scope_required_fields(scope, s) if missing: + if scope == "decision": + remediate = ( + "re-issue note(scope='decision', ...) with these fields filled: " + f"{', '.join(missing)}.\n\nExample:\n" + "note(\n" + " scope='decision',\n" + " text='',\n" + " context='',\n" + " options=[\n" + " {'name': 'optionA', 'pros': '', 'cons': ''},\n" + " {'name': 'optionB', 'pros': '', 'cons': ''},\n" + " ],\n" + " chosen='',\n" + " rationale='',\n" + ")\n\n" + "Pre-gateway parity — these populate the panel's Decisions view." + ) + else: + remediate = ( + "re-issue note(scope='reflect', ...) with these fields filled: " + f"{', '.join(missing)}.\n\nExample:\n" + "note(\n" + " scope='reflect',\n" + " text='',\n" + " what_done='',\n" + " what_learned='',\n" + " what_struggled='',\n" + " next_steps=['', ''],\n" + ")\n\n" + "Pre-gateway parity — these populate the panel's Reflections view." + ) return Envelope.incomplete_input( missing=missing, field_hints=hints, - remediate=( - f"re-issue note(scope={scope!r}, ...) with these fields " - f"filled: {', '.join(missing)}. Pre-gateway parity — these " - f"populate the panel's {scope.capitalize()}s view." - ), + remediate=remediate, context_briefing={}, ) title = (s.get("title") or text.split("\n", 1)[0])[:200] diff --git a/tests/unit/gateway/test_content_actions.py b/tests/unit/gateway/test_content_actions.py index ca38fdd8..f4439549 100644 --- a/tests/unit/gateway/test_content_actions.py +++ b/tests/unit/gateway/test_content_actions.py @@ -646,3 +646,56 @@ async def test_verify_explicit_task_ownership_returns_not_found() -> None: assert env is not None body = env.as_dict() assert body["error"] == "not_found" + + +# --------------------------------------------------------------------------- +# B4 — decision/reflect remediate includes a literal call example +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_decision_incomplete_input_includes_call_example() -> None: + """Decision rejection includes a literal note(scope='decision', ...) template.""" + task = AsyncMock() + task.get_active_task_for_agent.return_value = None + task.agent_for.return_value = MagicMock(role="cell_pm") + deps = _make_deps(task=task) + ca = ContentActions(deps) + + env = await ca.note( + agent_id=uuid4(), + text="bare decision", + scope="decision", + ) + body = env.as_dict() + assert body["error"] == "incomplete_input" + remediate = body.get("remediate", "") + # Must include a literal call template, not just a field list + assert "note(scope='decision'" in remediate, remediate + assert "context=" in remediate, remediate + assert "options=[" in remediate, remediate + assert "chosen=" in remediate, remediate + assert "rationale=" in remediate, remediate + + +@pytest.mark.asyncio +async def test_reflect_incomplete_input_includes_call_example() -> None: + """Reflect rejection includes a literal note(scope='reflect', ...) call template.""" + task = AsyncMock() + task.get_active_task_for_agent.return_value = None + task.agent_for.return_value = MagicMock(role="developer") + deps = _make_deps(task=task) + ca = ContentActions(deps) + + env = await ca.note( + agent_id=uuid4(), + text="bare reflect", + scope="reflect", + ) + body = env.as_dict() + assert body["error"] == "incomplete_input" + remediate = body.get("remediate", "") + assert "note(scope='reflect'" in remediate, remediate + assert "what_done=" in remediate, remediate + assert "what_learned=" in remediate, remediate + assert "what_struggled=" in remediate, remediate