mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 15:50:02 +00:00
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:
+14
-7
@@ -214,6 +214,7 @@ def build_engine(
|
||||
question_asker: Optional[Any] = None,
|
||||
tool_requester: Optional[Any] = None,
|
||||
team_approver: Optional[Any] = None,
|
||||
items_approver: Optional[Any] = None,
|
||||
subscription_store: Optional[Any] = None,
|
||||
channel_buffer: Optional[Any] = None,
|
||||
routing_targets: Optional[list[str]] = None,
|
||||
@@ -424,16 +425,21 @@ def build_engine(
|
||||
roots=root_list or None,
|
||||
risk_overrides=risk_overrides,
|
||||
)
|
||||
# The plan-mode exit door. Always registered (surfaces can flip a live session into
|
||||
# plan mode via set_mode, and the registry is fixed at build); the engine rejects the
|
||||
# call whenever the session isn't actually in plan mode.
|
||||
registry.register(propose_plan_tool())
|
||||
# The plan-mode exit door — mutually exclusive with the board's decomposition
|
||||
# gate, DERIVED from the team trait (owner call 2026-08-16): a lead never
|
||||
# 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())
|
||||
|
||||
# The staffing gate — leads only. The engine intercepts it (out-of-band approval);
|
||||
# approval pre-spawns the worker sessions and returns actor ids to assign to.
|
||||
# The lead's gates: propose_work_items (decomposition → items on approval, any
|
||||
# mode) and propose_team (staffing → pre-spawn on approval).
|
||||
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())
|
||||
|
||||
# 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,
|
||||
tool_requester=tool_requester,
|
||||
team_approver=team_approver,
|
||||
items_approver=items_approver,
|
||||
)
|
||||
engine.executor = executor # type: ignore[attr-defined]
|
||||
engine.todo = todo # type: ignore[attr-defined]
|
||||
|
||||
@@ -200,7 +200,6 @@ class ConversationStore:
|
||||
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
|
||||
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
|
||||
grants = excluded.grants, compaction = excluded.compaction,
|
||||
team = excluded.team,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
@@ -258,6 +257,18 @@ class ConversationStore:
|
||||
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:
|
||||
"""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)."""
|
||||
|
||||
@@ -92,6 +92,9 @@ class TurnEngine:
|
||||
team_approver: Optional[
|
||||
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
|
||||
] = 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
|
||||
# executor's kill for a running shell command.
|
||||
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
|
||||
# carries the roster (actor ids). None on surfaces that can't prompt.
|
||||
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
|
||||
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces
|
||||
# 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):
|
||||
yield event
|
||||
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":
|
||||
async for event in self._handle_ask_user(tool_call):
|
||||
yield event
|
||||
@@ -930,6 +943,58 @@ class TurnEngine:
|
||||
except Exception:
|
||||
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]:
|
||||
"""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
|
||||
|
||||
@@ -29,6 +29,10 @@ class EventType(str, Enum):
|
||||
TEAM_PROPOSED = (
|
||||
"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_FINISHED = "tool_finished"
|
||||
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.
|
||||
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
|
||||
you own; vague criteria produce vague work. Present the decomposition to the user with
|
||||
propose_plan and revise until approved. Only then create the items (create_item).
|
||||
you own; vague criteria produce vague work. Present the decomposition with
|
||||
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
|
||||
member). Approval creates their sessions and returns actor ids. Only team-capable
|
||||
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.
|
||||
Test the actual behavior — run the app, run the tests, exercise the change — never
|
||||
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
|
||||
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
|
||||
|
||||
+33
-1
@@ -1872,6 +1872,37 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
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:
|
||||
# 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
|
||||
@@ -1912,6 +1943,7 @@ def create_app(manager: SessionManager) -> FastAPI:
|
||||
question_asker=question_asker,
|
||||
tool_requester=tool_requester,
|
||||
team_approver=team_approver,
|
||||
items_approver=items_approver,
|
||||
)
|
||||
if engine is None:
|
||||
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(
|
||||
json.dumps(
|
||||
{
|
||||
|
||||
+95
-14
@@ -484,6 +484,7 @@ class SessionManager:
|
||||
question_asker: Optional[Any] = None,
|
||||
tool_requester: Optional[Any] = None,
|
||||
team_approver: Optional[Any] = None,
|
||||
items_approver: Optional[Any] = None,
|
||||
) -> Optional[TurnEngine]:
|
||||
engine = self._engines.get(session_id)
|
||||
if engine is not None:
|
||||
@@ -499,6 +500,8 @@ class SessionManager:
|
||||
engine.tool_requester = tool_requester
|
||||
if team_approver is not None:
|
||||
engine.team_approver = team_approver
|
||||
if items_approver is not None:
|
||||
engine.items_approver = items_approver
|
||||
return engine
|
||||
|
||||
record = self.session_store.load(session_id)
|
||||
@@ -576,6 +579,7 @@ class SessionManager:
|
||||
or self.inbox_question_asker(session_id, agent),
|
||||
tool_requester=tool_requester,
|
||||
team_approver=team_approver,
|
||||
items_approver=items_approver,
|
||||
subscription_store=self.subscriptions,
|
||||
channel_buffer=self.channel_buffer,
|
||||
routing_targets=self._routing_targets(session_id, agent),
|
||||
@@ -1422,6 +1426,47 @@ class SessionManager:
|
||||
except (TeamsBoardError, ValueError) as 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]]:
|
||||
return self.journal_store.overview(self._user_actor())
|
||||
|
||||
@@ -1585,14 +1630,20 @@ class SessionManager:
|
||||
mode=record.mode,
|
||||
messages=[],
|
||||
agent=pid,
|
||||
team={
|
||||
"role": "worker",
|
||||
"actor": actor,
|
||||
"lead_session": session_id,
|
||||
"space": space,
|
||||
},
|
||||
)
|
||||
)
|
||||
# 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",
|
||||
"actor": actor,
|
||||
"lead_session": session_id,
|
||||
"space": space,
|
||||
},
|
||||
)
|
||||
workers.append(
|
||||
TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model)
|
||||
)
|
||||
@@ -1604,14 +1655,26 @@ class SessionManager:
|
||||
chat_enabled=enable_chat,
|
||||
)
|
||||
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)
|
||||
record.team = {
|
||||
"team_id": team.team_id,
|
||||
"role": "lead",
|
||||
"actor": team.lead_actor,
|
||||
"space": space,
|
||||
}
|
||||
self.session_store.save(record)
|
||||
self.session_store.set_team(
|
||||
session_id,
|
||||
{
|
||||
"team_id": team.team_id,
|
||||
"role": "lead",
|
||||
"actor": team.lead_actor,
|
||||
"space": space,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"approved": True,
|
||||
"team_id": team.team_id,
|
||||
@@ -1665,10 +1728,11 @@ class SessionManager:
|
||||
return 0
|
||||
message = self._team_digest(team, directs, subs, is_lead=is_lead)
|
||||
self._team_inflight.add(session_id)
|
||||
source = self._board_source(team, message)
|
||||
|
||||
async def _deliver() -> None:
|
||||
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
|
||||
# the batch next tick (at-least-once, never silently lost).
|
||||
if directs:
|
||||
@@ -1734,6 +1798,23 @@ class SessionManager:
|
||||
" 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:
|
||||
"""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
|
||||
|
||||
@@ -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(
|
||||
members: Optional[list] = None, enable_chat: bool = False, note: str = ""
|
||||
|
||||
@@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
});
|
||||
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
|
||||
// SUSPENDS until the team_response verdict arrives.
|
||||
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("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") {
|
||||
if (msg.approved) {
|
||||
// Server-side create_team pre-spawned the workers; surface them in the
|
||||
|
||||
@@ -12,6 +12,35 @@ async function proposeTeam(page: import("@playwright/test").Page) {
|
||||
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 }) => {
|
||||
await proposeTeam(page);
|
||||
const card = page.getByTestId("teamreq-card");
|
||||
|
||||
@@ -77,6 +77,7 @@ import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
|
||||
import { PlanCard } from "./components/PlanCard";
|
||||
import { BoardOverlay } from "./components/BoardPanel";
|
||||
import { TeamRequestCard } from "./components/TeamRequestCard";
|
||||
import { WorkItemsCard } from "./components/WorkItemsCard";
|
||||
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
|
||||
|
||||
const newId = () =>
|
||||
@@ -770,6 +771,18 @@ export function App() {
|
||||
},
|
||||
]);
|
||||
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":
|
||||
// ask_user in an attended session — answered inline (not routed to the Inbox).
|
||||
setItems((p) => [
|
||||
@@ -1035,6 +1048,12 @@ export function App() {
|
||||
dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item
|
||||
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) => {
|
||||
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
|
||||
dropSessionInbox("directory");
|
||||
@@ -1409,6 +1428,7 @@ export function App() {
|
||||
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !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 pendingItemsReq = [...items].reverse().find((i) => i.kind === "itemsreq" && !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
|
||||
// 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.
|
||||
!unattended && pendingPlan?.kind === "planreq" ? (
|
||||
<PlanCard item={pendingPlan} onRespond={respondPlan} />
|
||||
) : !unattended && pendingItemsReq?.kind === "itemsreq" ? (
|
||||
<WorkItemsCard item={pendingItemsReq} onRespond={respondItemsReq} />
|
||||
) : !unattended && pendingTeam?.kind === "teamreq" ? (
|
||||
<TeamRequestCard item={pendingTeam} onRespond={respondTeam} />
|
||||
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
|
||||
@@ -2141,6 +2163,18 @@ function resolveLastTeam(items: Item[], resolved: "approved" | "rejected"): Item
|
||||
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[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -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).
|
||||
respondQuestion(answer: string) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1734,3 +1734,17 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
|
||||
.team-dot.in_progress { background: var(--ok-dot); }
|
||||
.team-dot.blocked { background: var(--danger); }
|
||||
.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); }
|
||||
|
||||
@@ -12,6 +12,7 @@ export type EventType =
|
||||
| "question_requested"
|
||||
| "plan_proposed"
|
||||
| "team_proposed"
|
||||
| "items_proposed"
|
||||
| "tool_started"
|
||||
| "tool_finished"
|
||||
| "iteration_end"
|
||||
@@ -164,6 +165,13 @@ export type Item =
|
||||
note?: string;
|
||||
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).
|
||||
kind: "question";
|
||||
|
||||
@@ -231,3 +231,38 @@ def test_team_options_lists_only_enabled_workers(manager):
|
||||
assert {"swe-worker", "design-worker", "test-worker"} <= workers
|
||||
assert "swe-lead" not in workers # leads staff, they aren't staffed
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user