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
+24
View File
@@ -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
+29
View File
@@ -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");
+34
View File
@@ -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--) {
+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).
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>
);
}
+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.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); }
+8
View File
@@ -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";