Dogfood round 1: propose_work_items gate, team-tie survives turn saves, board wakes render as cards

Leads lose propose_plan (trait-derived exclusion — plan mode is meaningless without execution tools) and gain propose_work_items: mode-independent decomposition whose approval creates the items.
Team field moves off the turn-save upsert to a dedicated setter (workers detached from their lead after one turn); board deliveries carry a MessageSource sidecar; test-worker prefers project-local tool installs.
This commit is contained in:
Rohit C Prasad
2026-08-16 15:43:06 -07:00
committed by Rohit P
parent 13f9c0b6c0
commit 844a6510a2
17 changed files with 508 additions and 25 deletions
+13 -6
View File
@@ -214,6 +214,7 @@ def build_engine(
question_asker: Optional[Any] = None, question_asker: Optional[Any] = None,
tool_requester: Optional[Any] = None, tool_requester: Optional[Any] = None,
team_approver: Optional[Any] = None, team_approver: Optional[Any] = None,
items_approver: Optional[Any] = None,
subscription_store: Optional[Any] = None, subscription_store: Optional[Any] = None,
channel_buffer: Optional[Any] = None, channel_buffer: Optional[Any] = None,
routing_targets: Optional[list[str]] = None, routing_targets: Optional[list[str]] = None,
@@ -424,16 +425,21 @@ def build_engine(
roots=root_list or None, roots=root_list or None,
risk_overrides=risk_overrides, risk_overrides=risk_overrides,
) )
# The plan-mode exit door. Always registered (surfaces can flip a live session into # The plan-mode exit door — mutually exclusive with the board's decomposition
# plan mode via set_mode, and the registry is fixed at build); the engine rejects the # gate, DERIVED from the team trait (owner call 2026-08-16): a lead never
# call whenever the session isn't actually in plan mode. # implements, so plan mode is meaningless for it, and shipping both tools made
# the lead pick the wrong one (dogfood-hit: propose_plan denied outside plan
# mode). Solo/worker personas keep propose_plan as always (mode can flip
# mid-session; the engine rejects the call outside plan mode).
if agent.team != "lead":
registry.register(propose_plan_tool()) registry.register(propose_plan_tool())
# The staffing gate — leads only. The engine intercepts it (out-of-band approval); # The lead's gates: propose_work_items (decomposition → items on approval, any
# approval pre-spawns the worker sessions and returns actor ids to assign to. # mode) and propose_team (staffing → pre-spawn on approval).
if agent.team == "lead": if agent.team == "lead":
from .teams.tools import propose_team_tool from .teams.tools import propose_team_tool, propose_work_items_tool
registry.register(propose_work_items_tool())
registry.register(propose_team_tool()) registry.register(propose_team_tool())
# Per-turn ephemeral context, appended to the latest user message since mid-thread system # Per-turn ephemeral context, appended to the latest user message since mid-thread system
@@ -511,6 +517,7 @@ def build_engine(
question_asker=question_asker, question_asker=question_asker,
tool_requester=tool_requester, tool_requester=tool_requester,
team_approver=team_approver, team_approver=team_approver,
items_approver=items_approver,
) )
engine.executor = executor # type: ignore[attr-defined] engine.executor = executor # type: ignore[attr-defined]
engine.todo = todo # type: ignore[attr-defined] engine.todo = todo # type: ignore[attr-defined]
+12 -1
View File
@@ -200,7 +200,6 @@ class ConversationStore:
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent, title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots, n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
grants = excluded.grants, compaction = excluded.compaction, grants = excluded.grants, compaction = excluded.compaction,
team = excluded.team,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
""", """,
( (
@@ -258,6 +257,18 @@ class ConversationStore:
team=_load_grants(row["team"] if "team" in row.keys() else None), team=_load_grants(row["team"] if "team" in row.keys() else None),
) )
def set_team(self, session_id: str, team: dict) -> None:
"""Persist the session's team tie independent of the turn-save path. The
upsert deliberately never touches `team` — a per-turn save rebuilds the
record without it, and letting the rebuild win detached workers from their
lead's sidebar entry the moment they ran a turn (owner-hit 2026-08-16)."""
with self._lock:
self._conn.execute(
"UPDATE sessions SET team = ? WHERE session_id = ?",
(json.dumps(team or {}), session_id),
)
self._conn.commit()
def set_extra_roots(self, session_id: str, extra_roots: list[dict]) -> None: def set_extra_roots(self, session_id: str, extra_roots: list[dict]) -> None:
"""Persist just the session's added folders, independent of its message log — used when """Persist just the session's added folders, independent of its message log — used when
the user adds/removes a folder (which may happen with no active engine).""" the user adds/removes a folder (which may happen with no active engine)."""
+65
View File
@@ -92,6 +92,9 @@ class TurnEngine:
team_approver: Optional[ team_approver: Optional[
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"] Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
] = None, ] = None,
items_approver: Optional[
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
] = None,
# Called (thread-safe, best-effort) when the user stops the turn — e.g. the # Called (thread-safe, best-effort) when the user stops the turn — e.g. the
# executor's kill for a running shell command. # executor's kill for a running shell command.
interrupt_hooks: Optional[list[Callable[[], None]]] = None, interrupt_hooks: Optional[list[Callable[[], None]]] = None,
@@ -126,6 +129,12 @@ class TurnEngine:
# for the user's decision; approval pre-spawns the worker sessions and the result # for the user's decision; approval pre-spawns the worker sessions and the result
# carries the roster (actor ids). None on surfaces that can't prompt. # carries the roster (actor ids). None on surfaces that can't prompt.
self.team_approver = team_approver self.team_approver = team_approver
# Handles `propose_work_items` (the decomposition gate): emits ITEMS_PROPOSED,
# waits; approval creates the items on the board. Mode-independent by design —
# unlike propose_plan it carries no permission-mode semantics: propose_plan is
# an IMPLEMENTATION plan (steps/files, plan-mode exit); this is a team
# decomposition onto the board.
self.items_approver = items_approver
# Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer # Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces # (answerable inline in a live session or from the Inbox when unattended). None on surfaces
# that can't ask (the tool then no-ops). # that can't ask (the tool then no-ops).
@@ -655,6 +664,10 @@ class TurnEngine:
async for event in self._handle_team_proposal(tool_call): async for event in self._handle_team_proposal(tool_call):
yield event yield event
continue continue
if tool_call.name == "propose_work_items":
async for event in self._handle_items_proposal(tool_call):
yield event
continue
if tool_call.name == "ask_user": if tool_call.name == "ask_user":
async for event in self._handle_ask_user(tool_call): async for event in self._handle_ask_user(tool_call):
yield event yield event
@@ -930,6 +943,58 @@ class TurnEngine:
except Exception: except Exception:
pass pass
async def _handle_items_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]:
"""The decomposition gate: emit the proposed items, await the user's decision.
Approval creates them on the board (server-side, inside the approver) and the
result carries their ids; rejection returns feedback for a revised split."""
args = tool_call.arguments or {}
items = args.get("items") or []
valid = [
i
for i in items
if isinstance(i, dict)
and str(i.get("title", "")).strip()
and str(i.get("criteria", "")).strip()
]
if not valid or len(valid) != len(items):
result: dict[str, Any] = {
"approved": False,
"error": "every proposed item needs a title and acceptance criteria",
}
elif self.items_approver is None:
result = {
"approved": False,
"error": "item proposals aren't available in this surface",
}
else:
yield Event(
EventType.ITEMS_PROPOSED,
{"items": valid, "note": str(args.get("note", ""))},
)
self._audit(tool_call, stage="items_proposed")
result = await self._interruptible(
self.items_approver(dict(args), tool_call.id),
interrupted={"approved": False, "error": "interrupted by user"},
) or {"approved": False, "error": "no response"}
status = "ok" if result.get("approved") else "denied"
self.messages.append(_tool_result_message(tool_call, result))
self._audit(
tool_call,
stage="finished",
status=status,
result=result,
result_preview=_preview(result),
)
yield Event(
EventType.TOOL_FINISHED,
{
"name": tool_call.name,
"status": status,
"result_preview": _preview(result),
},
)
async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]: async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]:
"""The staffing gate: emit the proposed roster, await the user's out-of-band """The staffing gate: emit the proposed roster, await the user's out-of-band
decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the
+4
View File
@@ -29,6 +29,10 @@ class EventType(str, Enum):
TEAM_PROPOSED = ( TEAM_PROPOSED = (
"team_proposed" # a lead proposes a worker roster (the staffing gate) "team_proposed" # a lead proposes a worker roster (the staffing gate)
) )
ITEMS_PROPOSED = (
"items_proposed" # a lead proposes work items (the decomposition gate);
# unlike propose_plan this is mode-independent — approval creates the items
)
TOOL_STARTED = "tool_started" TOOL_STARTED = "tool_started"
TOOL_FINISHED = "tool_finished" TOOL_FINISHED = "tool_finished"
ITERATION_END = "iteration_end" ITERATION_END = "iteration_end"
@@ -20,8 +20,10 @@ How you run a piece of work:
1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly. 1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly.
2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a 2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a
verifier can actually check. Acceptance criteria are the single biggest quality lever verifier can actually check. Acceptance criteria are the single biggest quality lever
you own; vague criteria produce vague work. Present the decomposition to the user with you own; vague criteria produce vague work. Present the decomposition with
propose_plan and revise until approved. Only then create the items (create_item). propose_work_items (works in any mode; approval creates the items on the board and
returns their ids) and revise until the user approves. Use create_item only for
one-off additions after the plan is approved.
3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per 3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per
member). Approval creates their sessions and returns actor ids. Only team-capable member). Approval creates their sessions and returns actor ids. Only team-capable
worker coworkers can be staffed. worker coworkers can be staffed.
@@ -20,6 +20,10 @@ How you verify:
- Start from the item under verification: its criteria are your checklist, one by one. - Start from the item under verification: its criteria are your checklist, one by one.
Test the actual behavior — run the app, run the tests, exercise the change — never Test the actual behavior — run the app, run the tests, exercise the change — never
judge by reading the diff alone. judge by reading the diff alone.
- Missing a test tool? Prefer a PROJECT-LOCAL install first (`npm i -D playwright`,
`pip install pytest` — inside the workspace, like any developer would). Use
request_tool only for system-level binaries the project can't carry; if neither
works, verify what you can and say exactly which checks you couldn't run.
- Verification is media-heavy on purpose: take screenshots, capture outputs, diff - Verification is media-heavy on purpose: take screenshots, capture outputs, diff
renders. That cost lands in YOUR context so the builder's stays for building. Save renders. That cost lands in YOUR context so the builder's stays for building. Save
captures as files in the workspace and reference them by path — never describe pixels captures as files in the workspace and reference them by path — never describe pixels
+33 -1
View File
@@ -1872,6 +1872,37 @@ def create_app(manager: SessionManager) -> FastAPI:
enable_chat=bool(_args.get("enable_chat", False)), enable_chat=bool(_args.get("enable_chat", False)),
) )
async def items_approver(_args: dict, tool_call_id=None) -> dict:
# The decomposition gate. TEAMS-flavored sibling of plan_approver:
# park a durable Inbox item, wait, and on approval create the items.
items = _args.get("items") or []
body = "\n".join(
f"- {i.get('title', '?')} — Done when: {i.get('criteria', '?')}"
for i in items
if isinstance(i, dict)
)
item = manager.inbox.add_plan(
session_id,
"Approve the proposed work items?",
body=body,
inbox=_route(),
visibility=_visibility(),
tool_call_id=tool_call_id,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
resp = _parse_json(await manager.inbox.wait(item.id))
if not resp.get("approved"):
return {
"approved": False,
"feedback": resp.get("feedback") or "the user declined the split",
}
return manager.board_create_items(
session_id, [i for i in items if isinstance(i, dict)]
)
async def _apply_model(model: Optional[str]) -> None: async def _apply_model(model: Optional[str]) -> None:
# Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04 # Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04
# lock): history is canonical and providers convert per call. A real switch # lock): history is canonical and providers convert per call. A real switch
@@ -1912,6 +1943,7 @@ def create_app(manager: SessionManager) -> FastAPI:
question_asker=question_asker, question_asker=question_asker,
tool_requester=tool_requester, tool_requester=tool_requester,
team_approver=team_approver, team_approver=team_approver,
items_approver=items_approver,
) )
if engine is None: if engine is None:
await ws.send_json( await ws.send_json(
@@ -2062,7 +2094,7 @@ def create_app(manager: SessionManager) -> FastAPI:
} }
) )
) )
elif kind == "team_response": elif kind in ("team_response", "items_response"):
_resolve_pending( _resolve_pending(
json.dumps( json.dumps(
{ {
+87 -6
View File
@@ -484,6 +484,7 @@ class SessionManager:
question_asker: Optional[Any] = None, question_asker: Optional[Any] = None,
tool_requester: Optional[Any] = None, tool_requester: Optional[Any] = None,
team_approver: Optional[Any] = None, team_approver: Optional[Any] = None,
items_approver: Optional[Any] = None,
) -> Optional[TurnEngine]: ) -> Optional[TurnEngine]:
engine = self._engines.get(session_id) engine = self._engines.get(session_id)
if engine is not None: if engine is not None:
@@ -499,6 +500,8 @@ class SessionManager:
engine.tool_requester = tool_requester engine.tool_requester = tool_requester
if team_approver is not None: if team_approver is not None:
engine.team_approver = team_approver engine.team_approver = team_approver
if items_approver is not None:
engine.items_approver = items_approver
return engine return engine
record = self.session_store.load(session_id) record = self.session_store.load(session_id)
@@ -576,6 +579,7 @@ class SessionManager:
or self.inbox_question_asker(session_id, agent), or self.inbox_question_asker(session_id, agent),
tool_requester=tool_requester, tool_requester=tool_requester,
team_approver=team_approver, team_approver=team_approver,
items_approver=items_approver,
subscription_store=self.subscriptions, subscription_store=self.subscriptions,
channel_buffer=self.channel_buffer, channel_buffer=self.channel_buffer,
routing_targets=self._routing_targets(session_id, agent), routing_targets=self._routing_targets(session_id, agent),
@@ -1422,6 +1426,47 @@ class SessionManager:
except (TeamsBoardError, ValueError) as error: except (TeamsBoardError, ValueError) as error:
return {"error": str(error)} return {"error": str(error)}
def board_create_items(
self, session_id: str, items: list[dict[str, Any]]
) -> dict[str, Any]:
"""The decomposition gate's approved action: create the proposed items as
the LEAD (its identity is the creator; the user's approval is the gate that
let this run). Validates everything up front so a bad batch creates nothing."""
record = self.session_store.load(session_id)
if record is None or not record.workspace:
return {"approved": False, "error": "the session has no workspace"}
space = space_for_workspace(record.workspace)
actor = TeamActor(
id=f"{record.agent}:{session_id[:8]}",
role=TeamRole.LEAD,
persona=record.agent,
session_id=session_id,
)
for entry in items:
if not str((entry or {}).get("title", "")).strip() or not str(
(entry or {}).get("criteria", "")
).strip():
return {
"approved": False,
"error": "every item needs a title and acceptance criteria",
}
created = []
for entry in items:
item = self.team_store.create_item(
space,
actor,
title=str(entry["title"]),
criteria=str(entry["criteria"]),
description=str(entry.get("description", "")),
case=str(entry.get("case", "")) or None,
)
created.append({"id": item["id"], "title": item["title"]})
return {
"approved": True,
"items": created,
"note": "items created on the board — staff and assign to start work",
}
def journal_overview(self) -> list[dict[str, Any]]: def journal_overview(self) -> list[dict[str, Any]]:
return self.journal_store.overview(self._user_actor()) return self.journal_store.overview(self._user_actor())
@@ -1585,14 +1630,20 @@ class SessionManager:
mode=record.mode, mode=record.mode,
messages=[], messages=[],
agent=pid, agent=pid,
team={ )
)
# Written via the dedicated setter: the turn-save upsert never touches
# `team`, so a worker's first turn can't detach it from its lead.
self.session_store.set_team(
worker_sid,
{
"team_id": "", # patched below once the team exists
"role": "worker", "role": "worker",
"actor": actor, "actor": actor,
"lead_session": session_id, "lead_session": session_id,
"space": space, "space": space,
}, },
) )
)
workers.append( workers.append(
TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model) TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model)
) )
@@ -1604,14 +1655,26 @@ class SessionManager:
chat_enabled=enable_chat, chat_enabled=enable_chat,
) )
for worker in workers: for worker in workers:
self.session_store.set_team(
worker.session_id,
{
"team_id": team.team_id,
"role": "worker",
"actor": worker.actor,
"lead_session": session_id,
"space": space,
},
)
self._emit_session_created(worker.session_id, worker.persona) self._emit_session_created(worker.session_id, worker.persona)
record.team = { self.session_store.set_team(
session_id,
{
"team_id": team.team_id, "team_id": team.team_id,
"role": "lead", "role": "lead",
"actor": team.lead_actor, "actor": team.lead_actor,
"space": space, "space": space,
} },
self.session_store.save(record) )
return { return {
"approved": True, "approved": True,
"team_id": team.team_id, "team_id": team.team_id,
@@ -1665,10 +1728,11 @@ class SessionManager:
return 0 return 0
message = self._team_digest(team, directs, subs, is_lead=is_lead) message = self._team_digest(team, directs, subs, is_lead=is_lead)
self._team_inflight.add(session_id) self._team_inflight.add(session_id)
source = self._board_source(team, message)
async def _deliver() -> None: async def _deliver() -> None:
try: try:
await self.deliver_to_session(session_id, message) await self.deliver_to_session(session_id, message, source=source)
# Consume only after the turn dispatched: a crash before this replays # Consume only after the turn dispatched: a crash before this replays
# the batch next tick (at-least-once, never silently lost). # the batch next tick (at-least-once, never silently lost).
if directs: if directs:
@@ -1734,6 +1798,23 @@ class SessionManager:
" Journal evidence as you go." " Journal evidence as you go."
) )
@staticmethod
def _board_source(team, message: str) -> dict[str, Any]:
"""Display-only MessageSource sidecar for board deliveries — the same
mechanism connector messages use, so the GUI renders a structured card
instead of a fake user bubble (owner ask 2026-08-16). The framed message
stays the model-facing text; this only shapes presentation."""
return {
"connector": "board",
"kind": "channel",
"channel_id": team.space,
"channel_name": "Team board",
"sender_id": "board",
"sender_name": "Board",
"ts": time.time(),
"text": message,
}
def team_staleness_digest(self, session_id: str) -> str: def team_staleness_digest(self, session_id: str) -> str:
"""Attached to a lead's TIMER wakes: pure code over the board — a """Attached to a lead's TIMER wakes: pure code over the board — a
nothing's-wrong wake is one cheap glance, never a re-survey. Scoped by nothing's-wrong wake is one cheap glance, never a re-survey. Scoped by
+62
View File
@@ -256,6 +256,68 @@ _PROPOSE_TEAM_SCHEMA = {
} }
# The decomposition gate's schema carrier — the board-flavored sibling of
# propose_plan, usable in ANY permission mode (proposing costs nothing; the board
# only ever holds accepted work). The engine intercepts it; approval creates the
# items and returns their ids.
_PROPOSE_ITEMS_SCHEMA = {
"type": "function",
"function": {
"name": "propose_work_items",
"description": (
"Present your decomposition to the user as proposed WORK ITEMS for the"
" team board. Approval creates them on the board (ids come back in the"
" result); rejection returns feedback to revise. Each item needs a"
" title and acceptance criteria — what gets verified before it can be"
" done. This is not propose_plan: it carries no implementation steps"
" and works in any mode — it is how a lead plans and coordinates via"
" the board."
),
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"criteria": {"type": "string"},
"description": {"type": "string"},
"case": {"type": "string"},
},
"required": ["title", "criteria"],
},
},
"note": {"type": "string"},
},
"required": ["items"],
},
},
}
def propose_work_items_tool() -> object:
def propose_work_items(items: Optional[list] = None, note: str = "") -> dict:
"""Present proposed work items ({title, criteria, description?, case?})
for the user's approval; approval creates them on the board."""
return {
"approved": False,
"error": "item proposals aren't available in this surface",
}
wrapped = ai.tool(
propose_work_items,
metadata=ai.ToolMetadata(
category="team",
risk_level="low",
capabilities=["team"],
),
)
wrapped.__coworker_schema__ = _PROPOSE_ITEMS_SCHEMA
return wrapped
def propose_team_tool() -> object: def propose_team_tool() -> object:
def propose_team( def propose_team(
members: Optional[list] = None, enable_chat: bool = False, note: str = "" members: Optional[list] = None, enable_chat: bool = False, note: str = ""
+24
View File
@@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) {
}); });
return; // suspended on the approval return; // suspended on the approval
} }
// Agent teams: the decomposition gate — the lead proposes work items and
// SUSPENDS until the items_response verdict arrives (approval creates them).
if (/propose the split/i.test(msg.text)) {
send("items_proposed", {
items: [
{ title: "Statement API endpoint", criteria: "returns opening/closing balances; 8 endpoint tests green" },
{ title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" },
{ title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" },
{ title: "Verification pass", criteria: "tester confirms page renders with live API data" },
],
note: "Shared journal case: statements.",
});
return; // suspended on the items decision
}
// Agent teams (OPE-97): the staffing gate — the lead proposes a roster and // Agent teams (OPE-97): the staffing gate — the lead proposes a roster and
// SUSPENDS until the team_response verdict arrives. // SUSPENDS until the team_response verdict arrives.
if (/staff the team/i.test(msg.text)) { if (/staff the team/i.test(msg.text)) {
@@ -841,6 +855,16 @@ export async function mockApi(page: import("@playwright/test").Page) {
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` }); send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
} }
send("turn_done"); send("turn_done");
} else if (msg.type === "items_response") {
if (msg.approved) {
seedBoard(); // "created on the board" — the board fetch now shows them
send("assistant_message", {
text: "Items created on the board — staffing next.",
});
} else {
send("assistant_message", { text: "Understood — reworking the split." });
}
send("turn_done");
} else if (msg.type === "team_response") { } else if (msg.type === "team_response") {
if (msg.approved) { if (msg.approved) {
// Server-side create_team pre-spawned the workers; surface them in the // Server-side create_team pre-spawned the workers; surface them in the
+29
View File
@@ -12,6 +12,35 @@ async function proposeTeam(page: import("@playwright/test").Page) {
await expect(page.getByTestId("teamreq-card")).toBeVisible(); await expect(page.getByTestId("teamreq-card")).toBeVisible();
} }
test("the decomposition gate shows items with criteria; approval lands them on the board", async ({
page,
}) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
await page.getByRole("button", { name: "Send" }).click();
const card = page.getByTestId("itemsreq-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Proposed work items — 4");
await expect(card).toContainText("Done when:");
// 3 visible + expander with the true remainder
await expect(card.getByText("Verification pass")).toHaveCount(0);
await card.getByRole("button", { name: /1 more item/ }).click();
await expect(card.getByText("Verification pass")).toBeVisible();
await page.getByTestId("itemsreq-approve").click();
await expect(page.getByText(/Items created on the board/)).toBeVisible();
await expect(page.getByTestId("board-rail")).toBeVisible();
});
test("declining the split returns feedback to the lead", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
await page.getByRole("button", { name: "Send" }).click();
await page.getByTestId("itemsreq-card").waitFor();
await page.getByRole("button", { name: "Not now" }).click();
await expect(page.getByText(/reworking the split/)).toBeVisible();
});
test("the staffing gate shows the roster and the grant sentence", async ({ page }) => { test("the staffing gate shows the roster and the grant sentence", async ({ page }) => {
await proposeTeam(page); await proposeTeam(page);
const card = page.getByTestId("teamreq-card"); const card = page.getByTestId("teamreq-card");
+34
View File
@@ -77,6 +77,7 @@ import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
import { PlanCard } from "./components/PlanCard"; import { PlanCard } from "./components/PlanCard";
import { BoardOverlay } from "./components/BoardPanel"; import { BoardOverlay } from "./components/BoardPanel";
import { TeamRequestCard } from "./components/TeamRequestCard"; import { TeamRequestCard } from "./components/TeamRequestCard";
import { WorkItemsCard } from "./components/WorkItemsCard";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
const newId = () => const newId = () =>
@@ -770,6 +771,18 @@ export function App() {
}, },
]); ]);
break; break;
case "items_proposed":
// The decomposition gate — approval creates the items on the board.
if (unattendedRef.current) break;
setItems((p) => [
...p,
{
kind: "itemsreq",
items: Array.isArray(d.items) ? d.items : [],
note: d.note || "",
},
]);
break;
case "question_requested": case "question_requested":
// ask_user in an attended session — answered inline (not routed to the Inbox). // ask_user in an attended session — answered inline (not routed to the Inbox).
setItems((p) => [ setItems((p) => [
@@ -1035,6 +1048,12 @@ export function App() {
dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item
sessionRef.current?.respondTeam(approved, feedback); sessionRef.current?.respondTeam(approved, feedback);
}; };
const respondItemsReq = (approved: boolean, feedback?: string) => {
setItems((p) => resolveLastItemsReq(p, approved ? "approved" : "rejected"));
dropSessionInbox("plan");
sessionRef.current?.respondItems(approved, feedback);
if (approved) setTimeout(refreshBoard, 400); // the items just landed
};
const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => { const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => {
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied")); setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
dropSessionInbox("directory"); dropSessionInbox("directory");
@@ -1409,6 +1428,7 @@ export function App() {
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved); const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved); const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved);
const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved); const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved);
const pendingItemsReq = [...items].reverse().find((i) => i.kind === "itemsreq" && !i.resolved);
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved); const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the // Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
// workspace folder for project-scoped sessions). Renders only once the session has history; // workspace folder for project-scoped sessions). Renders only once the session has history;
@@ -1924,6 +1944,8 @@ export function App() {
// parked in the Inbox and surfaced via the answer-in-context card below. // parked in the Inbox and surfaced via the answer-in-context card below.
!unattended && pendingPlan?.kind === "planreq" ? ( !unattended && pendingPlan?.kind === "planreq" ? (
<PlanCard item={pendingPlan} onRespond={respondPlan} /> <PlanCard item={pendingPlan} onRespond={respondPlan} />
) : !unattended && pendingItemsReq?.kind === "itemsreq" ? (
<WorkItemsCard item={pendingItemsReq} onRespond={respondItemsReq} />
) : !unattended && pendingTeam?.kind === "teamreq" ? ( ) : !unattended && pendingTeam?.kind === "teamreq" ? (
<TeamRequestCard item={pendingTeam} onRespond={respondTeam} /> <TeamRequestCard item={pendingTeam} onRespond={respondTeam} />
) : !unattended && pendingToolReq?.kind === "toolreq" ? ( ) : !unattended && pendingToolReq?.kind === "toolreq" ? (
@@ -2141,6 +2163,18 @@ function resolveLastTeam(items: Item[], resolved: "approved" | "rejected"): Item
return copy; return copy;
} }
function resolveLastItemsReq(items: Item[], resolved: "approved" | "rejected"): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
const it = copy[i];
if (it.kind === "itemsreq" && !it.resolved) {
copy[i] = { ...it, resolved };
break;
}
}
return copy;
}
function resolveLastQuestion(items: Item[], answer: string): Item[] { function resolveLastQuestion(items: Item[], answer: string): Item[] {
const copy = [...items]; const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) { for (let i = copy.length - 1; i >= 0; i--) {
+8
View File
@@ -2197,6 +2197,14 @@ export class Session {
}); });
} }
respondItems(approved: boolean, feedback?: string) {
this.send({
type: "items_response",
approved,
...(feedback ? { feedback } : {}),
});
}
// Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox). // Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox).
respondQuestion(answer: string) { respondQuestion(answer: string) {
this.send({ type: "question_response", answer }); this.send({ type: "question_response", answer });
@@ -0,0 +1,63 @@
// The decomposition gate (agent teams): a lead proposes work items; approval
// creates them on the board. The board-flavored sibling of PlanCard — items with
// acceptance criteria as the primary text, 3 visible + expander with the true
// count in the header, no in-card reply surface (editing happens by replying).
import { useState } from "react";
import type { Item } from "../types";
import { Icon } from "./Icon";
export function WorkItemsCard({
item,
onRespond,
}: {
item: Extract<Item, { kind: "itemsreq" }>;
onRespond: (approved: boolean, feedback?: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const visible = expanded ? item.items : item.items.slice(0, 3);
const hidden = item.items.length - visible.length;
return (
<div className="dirreq-card itemsreq-card" data-testid="itemsreq-card">
<div className="itemsreq-head">
<Icon name="table" size={15} />
<span className="itemsreq-title">
Proposed work items {item.items.length}
</span>
</div>
{item.note && <div className="itemsreq-note">{item.note}</div>}
{visible.map((entry, i) => (
<div className="itemsreq-item" key={i}>
<span className="itemsreq-num">{i + 1}.</span>
<span className="itemsreq-body">
<span className="itemsreq-item-title">{entry.title}</span>
<span className="itemsreq-ac">
<b>Done when:</b> {entry.criteria}
</span>
</span>
</div>
))}
{hidden > 0 && (
<button className="itemsreq-more" onClick={() => setExpanded(true)}>
{hidden} more item{hidden === 1 ? "" : "s"}
<Icon name="chevronDown" size={12} />
</button>
)}
<div className="dirreq-actions">
<span className="itemsreq-grant">
Reply to edit the split; approval creates these on the board.
</span>
<span className="spacer" />
<button className="btn" onClick={() => onRespond(false)}>
Not now
</button>
<button
className="btn primary"
data-testid="itemsreq-approve"
onClick={() => onRespond(true)}
>
Approve items
</button>
</div>
</div>
);
}
+14
View File
@@ -1734,3 +1734,17 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.team-dot.in_progress { background: var(--ok-dot); } .team-dot.in_progress { background: var(--ok-dot); }
.team-dot.blocked { background: var(--danger); } .team-dot.blocked { background: var(--danger); }
.team-dot.review { background: var(--warn-ink); } .team-dot.review { background: var(--warn-ink); }
/* Decomposition gate (proposed work items) */
.itemsreq-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.itemsreq-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.itemsreq-title { font-weight: 600; font-size: 13px; color: var(--ink); }
.itemsreq-note { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.itemsreq-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); }
.itemsreq-num { color: var(--faint); font-size: 12px; padding-top: 1px; }
.itemsreq-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.itemsreq-item-title { font-size: 12.5px; color: var(--ink); }
.itemsreq-ac { font-size: 11.5px; color: var(--muted); }
.itemsreq-ac b { font-weight: 600; }
.itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; }
.itemsreq-grant { font-size: 11.5px; color: var(--faint); }
+8
View File
@@ -12,6 +12,7 @@ export type EventType =
| "question_requested" | "question_requested"
| "plan_proposed" | "plan_proposed"
| "team_proposed" | "team_proposed"
| "items_proposed"
| "tool_started" | "tool_started"
| "tool_finished" | "tool_finished"
| "iteration_end" | "iteration_end"
@@ -164,6 +165,13 @@ export type Item =
note?: string; note?: string;
resolved?: "approved" | "rejected"; resolved?: "approved" | "rejected";
} }
| {
// The decomposition gate: a lead proposes work items; approval creates them.
kind: "itemsreq";
items: { title: string; criteria: string; description?: string }[];
note?: string;
resolved?: "approved" | "rejected";
}
| { | {
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox). // A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
kind: "question"; kind: "question";
+35
View File
@@ -231,3 +231,38 @@ def test_team_options_lists_only_enabled_workers(manager):
assert {"swe-worker", "design-worker", "test-worker"} <= workers assert {"swe-worker", "design-worker", "test-worker"} <= workers
assert "swe-lead" not in workers # leads staff, they aren't staffed assert "swe-lead" not in workers # leads staff, they aren't staffed
assert "security" not in workers # solo coworkers are not team-eligible assert "security" not in workers # solo coworkers are not team-eligible
def test_turn_saves_never_detach_a_worker_from_its_team(manager, monkeypatch):
from coworker.agents.base import Agent
from coworker.sessions import SessionRecord
worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker")
monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent)
manager.session_store.save(
SessionRecord(
session_id="lead-sid",
workspace=manager.default_workspace,
model="m",
mode="interactive",
messages=[],
agent="swe-lead",
)
)
result = manager.create_team("lead-sid", [{"persona": "swe-worker"}])
wid = result["workers"][0]["session_id"]
# A per-turn save rebuilds the record WITHOUT the team field (the engine doesn't
# carry it) — owner-hit 2026-08-16: this detached workers from the lead's entry.
record = manager.session_store.load(wid)
manager.session_store.save(
SessionRecord(
session_id=wid,
workspace=record.workspace,
model=record.model,
mode=record.mode,
messages=[{"role": "user", "content": "hi"}],
agent=record.agent,
)
)
assert manager.session_store.load(wid).team["lead_session"] == "lead-sid"
assert manager.session_store.load("lead-sid").team["role"] == "lead"