feat(gateway): Wave 2 pre-gateway parity — structured note, sub_tasks, channels

Three Wave 2 gaps from the 2026-05-11 pre-gateway parity design:

G4 — note() decision/reflect now require structured fields at the gateway
(pre-gateway `Field(...)` parity). Returns `incomplete_input` envelope
with field-by-field hints when any required field is missing.
  - decision: context (str), options (list[{name,pros,cons}] min len 2),
    chosen (str), rationale (str). `consequences` and `next_steps` are
    now list[str] (was str). Renderer emits each option as a "### Name
    + Pros / Cons" block instead of a bullet — matches the pre-gateway
    DecisionOption sub-shape exposed in `roboco/mcp/schemas/__init__.py`
    at `254cc93`.
  - reflect: what_done, what_learned, what_struggled (each non-empty
    str). next_steps stays optional.
  - Bumped tests/unit/gateway/test_content_actions.py with explicit
    pass-with-N-options coverage (≥2 floor; 3-option case green).

G5 — i_will_plan now persists sub_tasks alongside approach / risks /
open_questions / technical_considerations. The Plan tab's Sub-Tasks
section was empty because the verb didn't accept the field. Choreographer
server-assigns id + order to each sub_task (pre-gateway build_plan_data
parity) and normalizes every list entry to the EXACT shape
`panel/src/types/index.ts::TaskPlan` consumes:
  - SubTask: {id, title, description, completed:false, order,
    estimated_hours:null, notes:null}
  - Risk: {description, mitigation, severity:null} — accepts the
    {risk, mitigation} pre-gateway shape too
  - OpenQuestion: {question, answer:null, answered_by:null,
    answered_at:null} — accepts a bare string fallback
The normalization lives in three small module-level helpers
(_normalize_sub_task / _normalize_risk / _normalize_open_question)
called from _build_panel_shaped_plan, keeping i_will_plan's branch
count under PLR0912.

G6 — new `channels()` verb returns the agent's readable + writable
channel slugs from foundation.policy.communications. Stops invented
slugs ("backend-dev", "backend") that we kept seeing in smoke runs.
Added to every role's manifest including auditor (read-only access).

Wired through:
- roboco/api/schemas/v2/do.py — list-typed consequences/next_steps,
  dict-typed options, ChannelsRequest
- roboco/api/schemas/v2/flow.py — IWillPlanRequest.sub_tasks
- roboco/api/routes/v2/do.py — /channels endpoint
- roboco/api/routes/v2/flow_*.py — pass sub_tasks through
- roboco/services/gateway/content_actions.py — channels() method;
  _check_scope_required_fields enforces decision/reflect structure;
  _render_option_block emits per-option markdown blocks
- roboco/services/gateway/choreographer/_impl.py — _build_panel_shaped_plan
  helper used by i_will_plan
- roboco/services/gateway/role_config.py — _CHANNEL_DISCOVERY tuple
  on every role
- roboco/mcp/do_server.py — channels() tool + note() signature with
  options as list[dict[str,str]]
- roboco/mcp/flow_server.py — i_will_plan signature with sub_tasks

Frontend: no code change. panel/src/types/index.ts already declares
the exact shape we now write; panel/src/components/tasks/task-detail/
{tab-plan,tab-progress,tab-sessions,tab-notes}.tsx already reads it.
The empty panels we observed were a backend write-side problem, not
a frontend read-side problem — Wave 1 + Wave 2 close it.

Quality: ruff + mypy clean. 505 unit tests pass (added 2 new tests on
decision-scope requirements, updated 3 existing tests to fit the
pre-gateway-parity contract).

Spec ref: docs/superpowers/specs/2026-05-11-pre-gateway-parity-design.md
This commit is contained in:
Renn F
2026-05-11 05:57:57 +02:00
parent 8408d761ca
commit 72e01a7f13
12 changed files with 423 additions and 41 deletions
+12
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Header, Request
from roboco.api.deps import get_content_actions
from roboco.api.routes.v2._role_dep import envelope_to_response
from roboco.api.schemas.v2.do import (
ChannelsRequest,
CommitRequest,
DmRequest,
EvidenceRequest,
@@ -231,3 +232,14 @@ async def do_notify_ack(
notification_id=body.notification_id,
)
return envelope_to_response(env, request)
@router.post("/channels")
async def do_channels(
request: Request,
_body: ChannelsRequest,
x_agent_id: _AgentIdHeader,
actions: _ContentActionsDep,
) -> dict:
env = await actions.channels(agent_id=x_agent_id)
return envelope_to_response(env, request)
+1
View File
@@ -57,6 +57,7 @@ async def i_will_plan(
body.plan,
rich_plan={
"approach": body.approach,
"sub_tasks": body.sub_tasks,
"technical_considerations": body.technical_considerations,
"risks": body.risks,
"open_questions": body.open_questions,
+1
View File
@@ -57,6 +57,7 @@ async def i_will_plan(
body.plan,
rich_plan={
"approach": body.approach,
"sub_tasks": body.sub_tasks,
"technical_considerations": body.technical_considerations,
"risks": body.risks,
"open_questions": body.open_questions,
+9 -5
View File
@@ -30,17 +30,17 @@ class NoteRequest(BaseModel):
scope: str = "note"
task_id: UUID | None = None
title: str | None = None
# decision scope
# decision scope (all required at gateway when scope='decision')
context: str | None = None
options: list[str] | None = None
options: list[dict[str, str]] | None = None # [{name, pros, cons}, ...]
chosen: str | None = None
rationale: str | None = None
consequences: str | None = None
# reflect scope
consequences: list[str] | None = None
# reflect scope (what_done/learned/struggled required when scope='reflect')
what_done: str | None = None
what_learned: str | None = None
what_struggled: str | None = None
next_steps: str | None = None
next_steps: list[str] | None = None
class SayRequest(BaseModel):
@@ -121,3 +121,7 @@ class NotifyGetRequest(BaseModel):
class NotifyAckRequest(BaseModel):
notification_id: UUID
class ChannelsRequest(BaseModel):
"""No params — caller's identity comes from X-Agent-ID header."""
+4
View File
@@ -96,6 +96,10 @@ class IWillPlanRequest(BaseModel):
# shows Approach / Sub-Tasks / Technical Considerations / Risks /
# Open Questions instead of an empty pane. Pre-gateway parity.
approach: str = ""
sub_tasks: list[dict[str, str]] = Field(
default_factory=list,
description="List of {title, description} — server assigns id + order",
)
technical_considerations: list[str] = Field(default_factory=list)
risks: list[dict[str, str]] = Field(default_factory=list)
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
+21 -9
View File
@@ -92,26 +92,27 @@ def note(
task_id: str | None = None,
title: str | None = None,
context: str | None = None,
options: list[str] | None = None,
options: list[dict[str, str]] | None = None,
chosen: str | None = None,
rationale: str | None = None,
consequences: str | None = None,
consequences: list[str] | None = None,
what_done: str | None = None,
what_learned: str | None = None,
what_struggled: str | None = None,
next_steps: str | None = None,
next_steps: list[str] | None = None,
) -> dict[str, Any]:
"""Write a journal entry. scope in note|decision|reflect|learning|struggle.
``text`` is always the short summary (one paragraph max). For ``decision``
and ``reflect`` scopes, fill the scope-specific structured fields so the
panel renders them as named sections — pre-gateway parity:
and ``reflect`` scopes the structured fields are REQUIRED — pre-gateway
parity. The gateway returns ``incomplete_input`` if any is missing.
- decision: ``context`` (the situation), ``options`` (list of strings,
one per alternative considered), ``chosen`` (the alternative you took),
``rationale`` (why), ``consequences`` (what this commits us to)
- decision: ``context`` (situation), ``options`` (list of ≥2 dicts
``{name, pros, cons}``), ``chosen`` (which option), ``rationale``
(why), ``consequences`` (list of strings — what this commits us to)
- reflect: ``what_done`` (literal output), ``what_learned`` (new info),
``what_struggled`` (where you got stuck), ``next_steps`` (follow-ups)
``what_struggled`` (where you got stuck), ``next_steps`` (list of
follow-up strings)
Other scopes (note / learning / struggle) just need ``text``.
"""
@@ -321,6 +322,16 @@ def notify_ack(notification_id: str) -> dict[str, Any]:
)
def channels() -> dict[str, Any]:
"""List the channel slugs you can read / write.
Use this BEFORE ``say(channel=...)`` if you're unsure of the slug —
inventing slugs returns ``Channel not found``. Returns
``{writable: [...], readable: [...]}``.
"""
return _post("/api/v2/do/channels", {})
# ---------- Tool registry ----------
#
# Maps the tool name an agent calls (matches manifest entries and the
@@ -339,6 +350,7 @@ _TOOLS: dict[str, Any] = {
"notify_list": notify_list,
"notify_get": notify_get,
"notify_ack": notify_ack,
"channels": channels,
}
+6
View File
@@ -329,6 +329,7 @@ def i_will_plan(
task_id: str,
plan: str,
approach: str = "",
sub_tasks: list[dict[str, str]] | None = None,
technical_considerations: list[str] | None = None,
risks: list[dict[str, str]] | None = None,
open_questions: list[dict[str, str | bool]] | None = None,
@@ -341,6 +342,10 @@ def i_will_plan(
approach: 2-4 sentences describing the high-level approach for the
Plan tab. Required for non-trivial tasks; empty string is allowed
but produces an unpopulated Plan view.
sub_tasks: Decomposition of this task into sub-units, each
``{"title": "...", "description": "..."}``. The gateway assigns
stable ids + order server-side. Populates the Plan tab's
Sub-Tasks section. Pre-gateway parity.
technical_considerations: Bullet list of architectural / library /
constraint notes. Each item is a single string.
risks: List of {"risk": "...", "mitigation": "..."} entries.
@@ -352,6 +357,7 @@ def i_will_plan(
"task_id": task_id,
"plan": plan,
"approach": approach,
"sub_tasks": sub_tasks or [],
"technical_considerations": technical_considerations or [],
"risks": risks or [],
"open_questions": open_questions or [],
+88 -10
View File
@@ -46,6 +46,93 @@ from roboco.services.gateway.remediation import (
logger = structlog.get_logger()
def _normalize_sub_task(st: dict[str, Any], order: int) -> dict[str, Any]:
"""Shape a sub_task entry to panel/src/types/index.ts::SubTask."""
from uuid import uuid4 as _uuid4
return {
"id": str(st.get("id") or _uuid4()),
"title": str(st.get("title", "")),
"description": st.get("description") or None,
"completed": bool(st.get("completed", False)),
"order": order,
"estimated_hours": st.get("estimated_hours"),
"notes": st.get("notes"),
}
def _normalize_risk(r: dict[str, Any]) -> dict[str, Any]:
"""Shape a risk entry to panel/src/types/index.ts (description/mitigation/severity).
Accepts either {description, mitigation, severity?} (panel shape) or
{risk, mitigation} (pre-gateway agent shape).
"""
description = r.get("description") or r.get("risk") or ""
return {
"description": str(description),
"mitigation": str(r.get("mitigation", "")),
"severity": r.get("severity"),
}
def _normalize_open_question(q: Any) -> dict[str, Any] | None:
"""Shape an open_question entry to panel shape.
Returns None for entries we can't interpret (e.g., None or a number).
Accepts a bare string (the agent's short-form question), {question,
answered, answer} (pre-gateway shape), or {question, answer,
answered_by, answered_at} (panel shape).
"""
if isinstance(q, str):
return {
"question": q,
"answer": None,
"answered_by": None,
"answered_at": None,
}
if not isinstance(q, dict):
return None
return {
"question": str(q.get("question", "")),
"answer": q.get("answer"),
"answered_by": q.get("answered_by"),
"answered_at": q.get("answered_at"),
}
def _build_panel_shaped_plan(
plan_text: str, rich_plan: dict[str, Any]
) -> dict[str, Any]:
"""Build the Task.plan dict in the exact shape the panel UI consumes.
Panel reference: panel/src/types/index.ts::TaskPlan. Each list entry is
normalized so the panel renders without optional-field JS errors.
"""
sub_tasks = [
_normalize_sub_task(st, i)
for i, st in enumerate(rich_plan.get("sub_tasks") or [])
if isinstance(st, dict)
]
risks = [
_normalize_risk(r)
for r in (rich_plan.get("risks") or [])
if isinstance(r, dict)
]
open_questions = [
normalized
for q in (rich_plan.get("open_questions") or [])
if (normalized := _normalize_open_question(q)) is not None
]
return {
"text": plan_text,
"approach": rich_plan.get("approach", ""),
"sub_tasks": sub_tasks,
"technical_considerations": rich_plan.get("technical_considerations", []),
"risks": risks,
"open_questions": open_questions,
}
def _extract_original_developer(task: Any) -> str | None:
"""Pull the original_developer slug out of a task's quick_context, if any.
@@ -1825,16 +1912,7 @@ class Choreographer:
"open_questions",
)
):
effective_plan = {
"text": plan,
"approach": rich_plan.get("approach", ""),
"sub_tasks": rich_plan.get("sub_tasks", []),
"technical_considerations": rich_plan.get(
"technical_considerations", []
),
"risks": rich_plan.get("risks", []),
"open_questions": rich_plan.get("open_questions", []),
}
effective_plan = _build_panel_shaped_plan(plan, rich_plan)
else:
effective_plan = plan
ctx = _ClaimPlanStartContext(
+124 -4
View File
@@ -64,13 +64,29 @@ _REFLECT_SECTIONS: tuple[tuple[str, str], ...] = (
)
def _render_option_block(option: dict[str, str] | str) -> str:
"""Render one decision option. Accepts dict or legacy string."""
if isinstance(option, str):
return f"- {option}"
name = option.get("name", "").strip() or "(unnamed)"
pros = option.get("pros", "").strip()
cons = option.get("cons", "").strip()
block = [f"### {name}"]
if pros:
block.append(f"- Pros: {pros}")
if cons:
block.append(f"- Cons: {cons}")
return "\n".join(block)
def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -> str:
"""Build the journal entry body. Pre-gateway parity for decision/reflect.
For scopes that have a structured shape (``decision``, ``reflect``), append
a markdown section for each populated field. Other scopes return ``text``
unchanged. The original ``text`` always lands first so consumers that
only render flat content still see the summary line.
a markdown section for each populated field. ``decision.options`` is
rendered as named blocks with Pros/Cons (pre-gateway DecisionOption shape).
Other scopes return ``text`` unchanged. The original ``text`` always lands
first so flat-content consumers still see the summary line.
"""
sections = (
_DECISION_SECTIONS
@@ -86,7 +102,11 @@ def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -
value = structured.get(key)
if value is None:
continue
if isinstance(value, list):
if key == "options" and isinstance(value, list):
if not value:
continue
rendered = "\n\n".join(_render_option_block(o) for o in value)
elif isinstance(value, list):
if not value:
continue
rendered = "\n".join(f"- {item}" for item in value)
@@ -98,6 +118,60 @@ def _render_journal_content(scope: str, text: str, structured: dict[str, Any]) -
return "\n\n".join(body_parts) if body_parts else text
# Pre-gateway `DecisionLogInput.options` enforced `min_length=2`. Same here.
_MIN_DECISION_OPTIONS = 2
def _check_scope_required_fields(
scope: str, structured: dict[str, Any]
) -> tuple[list[str], dict[str, str]]:
"""Pre-gateway parity: decision/reflect scopes required structured fields.
Returns (missing_field_names, field_hints) both empty when satisfied.
"""
if scope == "decision":
decision_required: tuple[tuple[str, str], ...] = (
("context", "What situation led to this decision"),
("options", "At least 2 alternatives considered as list[{name,pros,cons}]"),
("chosen", "Which option you took"),
("rationale", "Why this option (cite trade-offs)"),
)
missing: list[str] = []
hints: dict[str, str] = {}
for field, hint in decision_required:
value = structured.get(field)
if field == "options":
if (
not isinstance(value, list)
or len(value) < _MIN_DECISION_OPTIONS
):
missing.append(field)
hints[field] = (
"options must be a list of at least 2 dicts with "
"shape {name: str, pros: str, cons: str}"
)
continue
if not value or not str(value).strip():
missing.append(field)
hints[field] = hint
return missing, hints
if scope == "reflect":
reflect_required: tuple[tuple[str, str], ...] = (
("what_done", "Literal output: what shipped, where (file:line / commit)"),
("what_learned", "New info you didn't have before"),
("what_struggled", "Where you got stuck (even briefly)"),
)
missing = []
hints = {}
for field, hint in reflect_required:
value = structured.get(field)
if not value or not str(value).strip():
missing.append(field)
hints[field] = hint
return missing, hints
return [], {}
def _ownership_violation(task_id: UUID) -> Envelope:
"""Standard envelope for Gate Set D ownership violations.
@@ -279,6 +353,19 @@ class ContentActions:
if t is not None:
task_id = t.id
s = structured or {}
# Pre-gateway parity: decision and reflect scopes had required fields.
missing, hints = _check_scope_required_fields(scope, s)
if missing:
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."
),
context_briefing={},
)
title = (s.get("title") or text.split("\n", 1)[0])[:200]
content = _render_journal_content(scope, text, s)
await self.journal.write_entry(
@@ -761,6 +848,39 @@ class ContentActions:
context_briefing={},
)
async def channels(self, *, agent_id: UUID) -> Envelope:
"""Return the channels this agent can read / write.
Pre-gateway parity for ``roboco_channel_list``. Stops the
invented-channel-slug pattern (e.g. ``backend-dev``, ``backend``)
observed on smoke runs the LLM sees the closed set in the
response and can pattern-match valid slugs from it.
"""
from roboco.enforcement.channel_access import get_agent_channels
agent = await self.task.agent_for(agent_id)
slug = getattr(agent, "slug", "") or ""
if not slug:
return Envelope.not_found(
message=f"agent {agent_id} not in registry",
)
readable = sorted(get_agent_channels(slug, action="read"))
writable = sorted(get_agent_channels(slug, action="write"))
return Envelope.ok(
status="ok",
task_id=None,
next="continue",
evidence={
"writable": writable,
"readable": readable,
"note": (
"Use the slug verbatim (no leading '#'). Inventing slugs "
"returns 'Channel not found'."
),
},
context_briefing={},
)
async def notify_ack(
self,
*,
+17 -8
View File
@@ -34,28 +34,37 @@ class RoleConfig:
# Wave 1 receivers — every role with inbox access gets notify_list/get/ack
# so `i_am_idle()` doesn't soft-block forever on unread notifications.
_NOTIFY_RECEIVER = ("notify_list", "notify_get", "notify_ack")
# Wave 2 — channel discovery. Every role gets `channels()` so the LLM stops
# inventing slugs ("backend-dev", "backend") that don't exist.
_CHANNEL_DISCOVERY = ("channels",)
_DEV_FLOW = spec.intents_for_role(spec.Role.DEVELOPER)
_DEV_DO = ("commit", "note", "say", "dm", "evidence", "progress", *_NOTIFY_RECEIVER)
_DEV_DO = (
"commit", "note", "say", "dm", "evidence", "progress",
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
)
_QA_FLOW = spec.intents_for_role(spec.Role.QA)
_QA_DO = ("note", "say", "dm", "evidence", *_NOTIFY_RECEIVER)
_QA_DO = ("note", "say", "dm", "evidence", *_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY)
_DOC_FLOW = spec.intents_for_role(spec.Role.DOCUMENTER)
_DOC_DO = ("commit", "note", "say", "dm", "evidence", "progress", *_NOTIFY_RECEIVER)
_DOC_DO = (
"commit", "note", "say", "dm", "evidence", "progress",
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
)
_CELL_PM_FLOW = spec.intents_for_role(spec.Role.CELL_PM)
_CELL_PM_DO = (
"note", "say", "dm", "notify", "evidence",
"open_session", "link_session",
*_NOTIFY_RECEIVER,
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
)
_MAIN_PM_FLOW = spec.intents_for_role(spec.Role.MAIN_PM)
_MAIN_PM_DO = (
"note", "say", "dm", "notify", "evidence",
"open_session", "link_session",
*_NOTIFY_RECEIVER,
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
)
_PRODUCT_OWNER_FLOW = spec.intents_for_role(spec.Role.PRODUCT_OWNER)
@@ -63,13 +72,13 @@ _HEAD_MARKETING_FLOW = spec.intents_for_role(spec.Role.HEAD_MARKETING)
_BOARD_DO = (
"note", "say", "dm", "notify", "evidence",
"open_session", # Board can open strategic sessions but not link arbitrary
*_NOTIFY_RECEIVER,
*_NOTIFY_RECEIVER, *_CHANNEL_DISCOVERY,
)
_AUDITOR_FLOW = spec.intents_for_role(spec.Role.AUDITOR)
# Auditor reads, does not chat or escalate. notify_list/get for inbox visibility;
# no ack (silent observer — wouldn't ack notifications).
_AUDITOR_DO = ("note", "evidence", "notify_list", "notify_get")
# no ack (silent observer — wouldn't ack notifications). channels for read map.
_AUDITOR_DO = ("note", "evidence", "notify_list", "notify_get", *_CHANNEL_DISCOVERY)
ROLE_CONFIGS: dict[str, RoleConfig] = {
+133 -1
View File
@@ -221,7 +221,12 @@ async def test_commit_allows_documenter_role() -> None:
@pytest.mark.asyncio
async def test_note_reflect_scope_succeeds() -> None:
"""scope='reflect' is valid; journal.write_entry is called."""
"""scope='reflect' is valid; journal.write_entry is called.
Pre-gateway parity: reflect requires what_done / what_learned /
what_struggled (each a non-empty string). The gateway returns
`incomplete_input` if any is missing.
"""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
@@ -240,6 +245,11 @@ async def test_note_reflect_scope_succeeds() -> None:
text="Reflected on approach: went with async generator pattern.",
scope="reflect",
task_id=task_id,
structured={
"what_done": "Shipped the async generator pattern in service.py:120-180",
"what_learned": "asyncio.shield wraps cancellation correctly here",
"what_struggled": "Initially missed the cleanup race; commits 3a4f1 fix it",
},
)
body = env.as_dict()
@@ -250,6 +260,119 @@ async def test_note_reflect_scope_succeeds() -> None:
assert call_kwargs["scope"] == "reflect"
@pytest.mark.asyncio
async def test_note_reflect_missing_required_fields_returns_incomplete_input() -> None:
"""Pre-gateway parity: reflect without structured fields fails fast."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
task_svc.get.return_value = MagicMock(
id=task_id, assigned_to=agent_id, status="in_progress"
)
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
env = await ca.note(
agent_id=agent_id,
text="bare reflect with no structured fields",
scope="reflect",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
assert {"what_done", "what_learned", "what_struggled"}.issubset(set(body["missing"]))
journal_svc.write_entry.assert_not_awaited()
@pytest.mark.asyncio
async def test_note_decision_requires_options_and_more() -> None:
"""Pre-gateway parity: decision requires context/options(>=2)/chosen/rationale."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get_active_task_for_agent.return_value = None
task_svc.get.return_value = MagicMock(
id=task_id, assigned_to=agent_id, status="in_progress"
)
journal_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
# Missing everything structured → incomplete_input listing all required.
env = await ca.note(
agent_id=agent_id,
text="bare decision",
scope="decision",
task_id=task_id,
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
assert {"context", "options", "chosen", "rationale"}.issubset(set(body["missing"]))
# Single option still fails (min 2).
env = await ca.note(
agent_id=agent_id,
text="decision with one option",
scope="decision",
task_id=task_id,
structured={
"context": "needed a queue",
"options": [{"name": "redis", "pros": "fast", "cons": "ephemeral"}],
"chosen": "redis",
"rationale": "speed beats durability for this case",
},
)
body = env.as_dict()
assert body["error"] == "incomplete_input"
assert "options" in body["missing"]
# Two options + all required → success.
env = await ca.note(
agent_id=agent_id,
text="real decision",
scope="decision",
task_id=task_id,
structured={
"context": "needed a queue",
"options": [
{"name": "redis", "pros": "fast", "cons": "ephemeral"},
{"name": "postgres", "pros": "durable", "cons": "slower writes"},
],
"chosen": "redis",
"rationale": "speed beats durability for ephemeral work",
},
)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "noted"
# Three+ options also pass — 2 is the floor, not the ceiling.
env = await ca.note(
agent_id=agent_id,
text="three-way decision",
scope="decision",
task_id=task_id,
structured={
"context": "queue tech choice",
"options": [
{"name": "redis", "pros": "fast", "cons": "ephemeral"},
{"name": "postgres", "pros": "durable", "cons": "slower"},
{"name": "rabbitmq", "pros": "ordered", "cons": "ops overhead"},
],
"chosen": "rabbitmq",
"rationale": "ordering matters more than raw speed here",
},
)
body = env.as_dict()
assert body["error"] is None
assert body["status"] == "noted"
@pytest.mark.asyncio
async def test_note_invalid_scope_returns_invalid_state() -> None:
"""Unknown scope yields invalid_state with valid-scope hint."""
@@ -283,6 +406,15 @@ async def test_note_auto_fills_task_id_from_active_task() -> None:
agent_id=agent_id,
text="Decided to use UUIDs instead of integer PKs for portability.",
scope="decision",
structured={
"context": "Choosing primary-key strategy for the new tables",
"options": [
{"name": "int", "pros": "compact", "cons": "leaks volume"},
{"name": "uuid", "pros": "portable", "cons": "wider rows"},
],
"chosen": "uuid",
"rationale": "portability matters more than 8 bytes/row",
},
)
body = env.as_dict()
@@ -133,10 +133,13 @@ async def test_note_with_task_id_allows_when_assignee() -> None:
deps = _make_deps(task=task_svc, journal=journal_svc)
ca = ContentActions(deps)
# Uses scope='note' (no structured-field requirement) — the test
# exercises the ownership gate, not the journal-shape gate (which is
# covered separately in test_content_actions.py).
env = await ca.note(
agent_id=agent_id,
text="Reflecting on my own task",
scope="reflect",
text="Working on my own task",
scope="note",
task_id=task_id,
)
assert env.error is None
@@ -155,8 +158,8 @@ async def test_note_without_task_id_skips_ownership_check() -> None:
env = await ca.note(
agent_id=agent_id,
text="A general reflection note with enough length",
scope="reflect",
text="A general note with enough length",
scope="note",
)
assert env.error is None
journal_svc.write_entry.assert_awaited_once()