mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user