mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 [],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user