diff --git a/roboco/api/routes/v2/do.py b/roboco/api/routes/v2/do.py index e2b9da7a..e6c43cf6 100644 --- a/roboco/api/routes/v2/do.py +++ b/roboco/api/routes/v2/do.py @@ -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) diff --git a/roboco/api/routes/v2/flow_cell_pm.py b/roboco/api/routes/v2/flow_cell_pm.py index 52acdd5f..371a0e6d 100644 --- a/roboco/api/routes/v2/flow_cell_pm.py +++ b/roboco/api/routes/v2/flow_cell_pm.py @@ -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, diff --git a/roboco/api/routes/v2/flow_main_pm.py b/roboco/api/routes/v2/flow_main_pm.py index e877199d..289ed8e0 100644 --- a/roboco/api/routes/v2/flow_main_pm.py +++ b/roboco/api/routes/v2/flow_main_pm.py @@ -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, diff --git a/roboco/api/schemas/v2/do.py b/roboco/api/schemas/v2/do.py index 525f5113..da70cd50 100644 --- a/roboco/api/schemas/v2/do.py +++ b/roboco/api/schemas/v2/do.py @@ -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.""" diff --git a/roboco/api/schemas/v2/flow.py b/roboco/api/schemas/v2/flow.py index 6fa650bb..74fc722e 100644 --- a/roboco/api/schemas/v2/flow.py +++ b/roboco/api/schemas/v2/flow.py @@ -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) diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index 9e795a1b..1cb192f9 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -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, } diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index 5c67d5c2..cf52fe4e 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -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 [], diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 8255ac06..4f7d6a48 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -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( diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index 3182db12..a6b5ad0c 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -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, *, diff --git a/roboco/services/gateway/role_config.py b/roboco/services/gateway/role_config.py index 44596799..f195005d 100644 --- a/roboco/services/gateway/role_config.py +++ b/roboco/services/gateway/role_config.py @@ -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] = { diff --git a/tests/unit/gateway/test_content_actions.py b/tests/unit/gateway/test_content_actions.py index 74860ef5..59c53f79 100644 --- a/tests/unit/gateway/test_content_actions.py +++ b/tests/unit/gateway/test_content_actions.py @@ -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() diff --git a/tests/unit/gateway/test_content_actions_ownership.py b/tests/unit/gateway/test_content_actions_ownership.py index cc0e8fcf..fd1c2a06 100644 --- a/tests/unit/gateway/test_content_actions_ownership.py +++ b/tests/unit/gateway/test_content_actions_ownership.py @@ -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()