From d778d72252c049337405bd4eca8f2ca983d16267 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 20:45:36 +0545 Subject: [PATCH 1/8] Add tests for answering a parked tool call from the thread and the drawer --- e2e/browser/approval.test.ts | 147 +++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 e2e/browser/approval.test.ts diff --git a/e2e/browser/approval.test.ts b/e2e/browser/approval.test.ts new file mode 100644 index 000000000..273b792c4 --- /dev/null +++ b/e2e/browser/approval.test.ts @@ -0,0 +1,147 @@ +// A gated tool call parks the worker on an approval. The ask surfaces in the +// thread and on the bench pill without any navigation, either the thread card +// or the drawer's "Needs you" row answers it, and the worker's run resumes +// with the tool result. +import { expect, test } from "bun:test"; +import type { Page } from "puppeteer-core"; +import { bootAimock, MOCK_REPLY, type Fixture } from "../lib/aimock"; +import { bootBrowserApp, browserGate } from "../lib/browser"; +import { clickText, STEP_TIMEOUT, waitForText } from "../lib/first-run"; + +const describeBrowser = browserGate(import.meta.path); + +const THREAD_ASK = "THREAD-SHELL"; +// Longer than the drawer's argument preview, so the thread card is shown to +// carry the whole call rather than a truncated summary. +const THREAD_COMMAND = `echo ${"thread-approval-".repeat(10)}end`; +// The same turn parks a second time once the first call is answered. +const THREAD_SECOND_COMMAND = "echo second-thread-approval"; +const THREAD_DONE = "Both thread-approved commands ran."; + +const DRAWER_ASK = "DRAWER-SHELL"; +const DRAWER_COMMAND = "echo drawer-approval"; +const DRAWER_DONE = "The drawer-approved command ran."; + +type ChatRequest = Parameters>[0]; + +// Tool results the model has seen since the latest message carrying `ask`. +function toolResultsSince(req: ChatRequest, ask: string): number | null { + const asked = req.messages.findLastIndex( + (message) => + message.role === "user" && + typeof message.content === "string" && + message.content.includes(ask), + ); + if (asked === -1) return null; + return req.messages.slice(asked).filter((message) => message.role === "tool").length; +} + +// One run_shell call per command, each parking the run on an approval, then +// `done` once every call has its result. +function shellFixtures(ask: string, commands: readonly string[], done: string): Fixture[] { + return [ + ...commands.map((command, index) => ({ + match: { predicate: (req: ChatRequest) => toolResultsSince(req, ask) === index }, + response: { toolCalls: [{ name: "run_shell", arguments: JSON.stringify({ command }) }] }, + })), + { + match: { predicate: (req: ChatRequest) => toolResultsSince(req, ask) === commands.length }, + response: { content: done }, + }, + ]; +} + +async function startBenchWithWorker(page: Page, origin: string, aimockURL: string): Promise { + // Pages share one browser, so a previous test's session would skip signup. + const cdp = await page.createCDPSession(); + await cdp.send("Network.clearBrowserCookies"); + await page.goto(origin, { waitUntil: "networkidle0" }); + await clickText(page, "button", "Create an account"); + await waitForText(page, "Create your account"); + await page.type("input[type=email]", `alice+${String(Date.now())}@example.com`); + await page.type("input[type=password]", "correct-horse-battery-staple"); + await page.click("button[type=submit]"); + + // Only the Custom option takes a base URL. + await waitForText(page, "Choose your AI model"); + await clickText(page, "label", "Custom"); + await page.waitForSelector("input[type=password]"); + await page.type("form input:not([type=radio]):not([type=password])", `${aimockURL}/v1`); + await page.type("input[type=password]", "mock"); + const fields = await page.$$("form input:not([type=radio]):not([type=password])"); + await fields[1]?.type("mock-model"); + await clickText(page, "button", "Connect"); + + // The composer renders disabled until the account's tenant resolves. + await page.waitForSelector("textarea:not([disabled])", { timeout: STEP_TIMEOUT }); + await page.type("textarea", "Say hello"); + await page.click("button[aria-label='Start this workbench']"); + await page.waitForFunction(`location.pathname.startsWith("/w/")`, { timeout: STEP_TIMEOUT }); + await waitForText(page, MOCK_REPLY); +} + +async function send(page: Page, text: string): Promise { + await page.waitForSelector("textarea:not([disabled])", { timeout: STEP_TIMEOUT }); + await page.type("textarea", text); + await page.keyboard.press("Enter"); +} + +function threadIncludes(text: string): string { + return `document.querySelector(".workbench-main")?.innerText.includes(${JSON.stringify(text)})`; +} + +describeBrowser("approving a parked tool call", () => { + const aimock = bootAimock([ + ...shellFixtures(THREAD_ASK, [THREAD_COMMAND, THREAD_SECOND_COMMAND], THREAD_DONE), + ...shellFixtures(DRAWER_ASK, [DRAWER_COMMAND], DRAWER_DONE), + ]); + const app = bootBrowserApp(); + + async function run(label: string, body: (page: Page) => Promise): Promise { + const { page, errors } = await app().newPage(); + try { + await startBenchWithWorker(page, app().origin, aimock().url); + await body(page); + } catch (cause) { + const text = await page.evaluate("document.body.innerText"); + process.stderr.write( + `${label} failed at ${page.url()}:\n${String(text)}\nconsole: ${errors.join(" | ")}\njournal: ${JSON.stringify(aimock().journal()).slice(-3000)}\n`, + ); + throw cause; + } + await page.close(); + } + + test("each ask in a turn lands in the thread and Allow once resumes the worker", async () => { + await run("thread approval", async (page) => { + await send(page, THREAD_ASK); + + await page.waitForFunction(threadIncludes(THREAD_COMMAND), { timeout: STEP_TIMEOUT }); + await page.waitForFunction( + `document.querySelector(".bench-pill")?.innerText.includes("needs you")`, + { timeout: STEP_TIMEOUT }, + ); + expect(await page.evaluate(threadIncludes(THREAD_DONE))).toBe(false); + + await clickText(page, ".workbench-main button", "Allow once"); + await page.waitForFunction(threadIncludes(THREAD_SECOND_COMMAND), { timeout: STEP_TIMEOUT }); + await clickText(page, ".workbench-main button", "Allow once"); + await page.waitForFunction(threadIncludes(THREAD_DONE), { timeout: STEP_TIMEOUT }); + await page.waitForFunction(`!${threadIncludes("Allow once")}`, { timeout: STEP_TIMEOUT }); + }); + }, 300_000); + + test("the drawer's Needs you row answers the same ask", async () => { + await run("drawer approval", async (page) => { + await send(page, DRAWER_ASK); + + await page.click("button[aria-controls='bench-drawer']"); + await clickText(page, "[role=tab]", "Information"); + await waitForText(page, DRAWER_COMMAND); + + await clickText(page, "#bench-drawer button", "Approve"); + await page.waitForFunction(threadIncludes(DRAWER_DONE), { timeout: STEP_TIMEOUT }); + await waitForText(page, "Nothing is waiting on you."); + }); + }, 300_000); +}); From eb69b222c17bb5aa1fa63bce7c5cc2341f225bd8 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 20:45:36 +0545 Subject: [PATCH 2/8] Bench drawer: pending approvals can be answered again The Needs you section listed a parked ask with a static Waiting pill and no way to resolve it, so the only remaining approval surface could not unpark the run. The row now shows the call it is gating and Approve / Deny, and a 409 says the ask can no longer be answered instead of echoing the status code. --- apps/web/src/app.css | 6 --- apps/web/src/{chat => bench}/approval-row.tsx | 51 +++++++++++-------- apps/web/src/bench/drawer.css | 37 +++++++------- apps/web/src/bench/information-tab.tsx | 17 +------ 4 files changed, 50 insertions(+), 61 deletions(-) rename apps/web/src/{chat => bench}/approval-row.tsx (56%) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 37a6dd404..1375f8afa 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1733,7 +1733,6 @@ tr.insights-row-clickable:hover { justify-content: flex-end; gap: 0.4rem; } -.workbench-info-approval-list, .workbench-info-schedule-list { display: flex; flex-direction: column; @@ -1747,11 +1746,6 @@ tr.insights-row-clickable:hover { color: var(--muted-foreground); text-decoration: line-through; } -.workbench-info-approval-row { - display: flex; - flex-direction: column; - gap: 0.4rem; -} /* A message's attachments: a package gets a Deploy card, anything else a quiet file list. */ diff --git a/apps/web/src/chat/approval-row.tsx b/apps/web/src/bench/approval-row.tsx similarity index 56% rename from apps/web/src/chat/approval-row.tsx rename to apps/web/src/bench/approval-row.tsx index 5562b4967..4fdbd9027 100644 --- a/apps/web/src/chat/approval-row.tsx +++ b/apps/web/src/bench/approval-row.tsx @@ -1,15 +1,22 @@ -// One pending approval with its approve/deny buttons. Shared by the -// workbench workbench's info column and the chat transcript, so a person can -// answer an agent's ask wherever they are looking when it lands. +// One pending approval in the bench drawer's "Needs you" section: who is +// asking, the exact call it is gating, and the approve/deny buttons that +// unpark the run. import { Button, formatRelativeTime } from "@corbits/react-ui"; import { toast } from "@corbits/react-ui/ui/toast"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ApiQueryError } from "@/lib/api-query"; import { approveApproval, rejectApproval } from "../api"; +import { IdentityAvatar } from "../chat/avatar"; import type { PendingApproval } from "../pending-approvals"; import { tenantKeys } from "../query-client"; +// The hub answers 409 when the ask was already resolved or the run that +// raised it has stopped (e.g. a hub restart); either way no retry helps. +const STALE_APPROVAL = + "This request can't be answered anymore: it was already resolved or its worker stopped."; + export function ApprovalRow({ item, tenantId, @@ -27,6 +34,10 @@ export function ApprovalRow({ }); }, onError: (cause, action) => { + if (cause instanceof ApiQueryError && cause.status === 409) { + toast(STALE_APPROVAL); + return; + } toast( cause instanceof Error ? cause.message @@ -37,25 +48,23 @@ export function ApprovalRow({ const pending = resolveMutation.isPending ? resolveMutation.variables : null; return ( -
  • -
    - {item.headline} -
    - {item.toolName !== undefined && ( - <> - {/* Arguments are untrusted agent output: rendered as plain text - * nodes only, never as markup. */} - - {item.toolName} - {item.argumentsSummary !== undefined ? `: ${item.argumentsSummary}` : ""} - -
    - - )} - - {item.agentName} · {formatRelativeTime(item.createdAt)} +
    +
    + + + {item.headline} + + {item.agentName} + {item.toolName === undefined ? "" : ` · ${item.toolName}`} ·{" "} + {formatRelativeTime(item.createdAt)} +
    + {/* Arguments are untrusted agent output: rendered as a plain text node + * only, never as markup. */} + {item.argumentsSummary !== undefined ? ( + {item.argumentsSummary} + ) : null}
    -
  • + ); } diff --git a/apps/web/src/bench/drawer.css b/apps/web/src/bench/drawer.css index 98c724d7f..5a508b1ec 100644 --- a/apps/web/src/bench/drawer.css +++ b/apps/web/src/bench/drawer.css @@ -114,25 +114,6 @@ box-shadow: 0 0 0 2px var(--surface); border-radius: 8px; } -.drawer-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 10px; - border-radius: 999px; - font-size: 12px; - font-weight: 700; - white-space: nowrap; - color: var(--ink-2); - background: color-mix(in srgb, var(--action) 14%, transparent); -} -.drawer-pill::before { - content: ""; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--action); -} .drawer-sec-head button { padding: 0; border: 0; @@ -208,3 +189,21 @@ .drawer-list-text > b { font-weight: 800; } + +/* A pending approval: the asker's row, the call it gates, then its answer. */ +.drawer-approval { + display: grid; + gap: 6px; + padding-bottom: 6px; +} +.drawer-approval-call { + display: block; + padding: 6px 8px; + border-radius: var(--r-md); + background: var(--hover); + color: var(--ink-2); + font-family: var(--font-mono, monospace); + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/apps/web/src/bench/information-tab.tsx b/apps/web/src/bench/information-tab.tsx index fc3b1b155..dc91af4c4 100644 --- a/apps/web/src/bench/information-tab.tsx +++ b/apps/web/src/bench/information-tab.tsx @@ -22,6 +22,7 @@ import { WorkbenchSchedulesPanel } from "../pages/workbench-schedules-panel"; import { usePendingApprovals } from "../pending-approvals"; import { benchLink } from "../shell/page-crumbs"; import type { WorkerStatus } from "../worker-status"; +import { ApprovalRow } from "./approval-row"; import { DrawerTabContext } from "./bench-drawer"; import "./description.css"; import { DESCRIPTION_MAX, useBenchDescription } from "./description"; @@ -257,21 +258,7 @@ export function InformationTab({ {pendingApprovals !== null && pendingApprovals.length > 0 ? (
    {pendingApprovals.map((item) => ( -
    - - - {item.headline} - - {item.agentName} - {item.toolName === undefined ? "" : ` · ${item.toolName}`} - - - Waiting -
    + ))}
    ) : null} From 5a9dfc4430620e08f1bda9e512c587f8cb59e370 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 20:45:36 +0545 Subject: [PATCH 3/8] Poll pending approvals while a turn is waiting A run that parks on an approval sends no mail and ticks no inbox event, so the approvals list stayed stale and the worker kept reading as working while it was blocked on the person. --- apps/web/src/pages/workbench-page.tsx | 7 ++++--- apps/web/src/worker-status.ts | 13 ++++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 74dd933fc..53f41b77d 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -228,9 +228,10 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } }); // Invalidates rather than patches: the stream carries no thread - // identity. Also invalidates approvals, since a parked ask sends no - // mail but still ticks the stream. An agent reply may have saved an - // artifact too, so the panel and library counts stay in sync. + // identity. Also invalidates approvals, since new mail can follow a + // resolved ask; a parked ask itself ticks nothing (see worker-status.ts). + // An agent reply may have saved an artifact too, so the panel and library + // counts stay in sync. useEffect( () => subscribeToInbox(workbenchTenantId, () => { diff --git a/apps/web/src/worker-status.ts b/apps/web/src/worker-status.ts index b9cf9ec76..cec8ea4ed 100644 --- a/apps/web/src/worker-status.ts +++ b/apps/web/src/worker-status.ts @@ -63,11 +63,6 @@ export function useBenchWorkerStatus( enabled: bench.domain !== "", })), }); - const approvals = useQueries({ - queries: benches.map((bench) => ({ - ...apiQueryOptions(pendingApprovalsPath(bench.id), TenantApprovalsSchema), - })), - }); const pending = useQueries({ queries: benches.map((bench) => ({ queryKey: workbenchKeys.pendingTurn(bench.id), @@ -76,6 +71,14 @@ export function useBenchWorkerStatus( gcTime: Infinity, })), }); + // A parked ask sends no mail and ticks no stream, so a pending turn polls + // for it; every approvals surface shares this query and refreshes with it. + const approvals = useQueries({ + queries: benches.map((bench, index) => ({ + ...apiQueryOptions(pendingApprovalsPath(bench.id), TenantApprovalsSchema), + refetchInterval: pending[index]?.data != null ? PENDING_POLL_MS : false, + })), + }); const timelines = useQueries({ queries: benches.map((bench, index) => ({ queryKey: workbenchKeys.timeline(bench.id), From 6405f3eb45aea2e326b80f455564216f672b6bfc Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 20:45:36 +0545 Subject: [PATCH 4/8] Workbench thread: show the live worker's pending approvals inline Approvals render in the thread where the decision happens, with Allow once, Always allow, Ask every time and Deny. Only asks raised by a live agent address are shown, since an ask whose run is gone can no longer be answered. --- apps/web/src/bench/thread-approvals.tsx | 43 +++++++++++++++++++++++++ apps/web/src/chat/strings.ts | 1 + apps/web/src/pages/workbench-page.tsx | 11 +++++-- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/bench/thread-approvals.tsx diff --git a/apps/web/src/bench/thread-approvals.tsx b/apps/web/src/bench/thread-approvals.tsx new file mode 100644 index 000000000..a5f800b3b --- /dev/null +++ b/apps/web/src/bench/thread-approvals.tsx @@ -0,0 +1,43 @@ +// The asks this workbench's live worker is parked on, rendered in the thread +// where the decision happens. An ask whose run is gone stays listed in the +// drawer, but never here: it can no longer be answered. + +import { useQueryClient } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { ApproveBlockView } from "@/chat/blocks/approve-block"; +import { CHAT_STRINGS } from "@/chat/strings"; +import { sameAddress } from "@/chat/threads-api"; +import { TenantApprovalsSchema, useAPIQuery } from "../api"; +import { createChatApprovalActions } from "../approval-actions"; +import { pendingApprovalsPath } from "../pending-approvals"; + +export function ThreadApprovals({ + workbenchTenantId, + agentAddresses, +}: { + readonly workbenchTenantId: string; + /** Live run addresses of this workbench's agents. */ + readonly agentAddresses: readonly string[]; +}) { + const queryClient = useQueryClient(); + const actions = useMemo( + () => createChatApprovalActions(workbenchTenantId, queryClient), + [workbenchTenantId, queryClient], + ); + const approvals = useAPIQuery(pendingApprovalsPath(workbenchTenantId), TenantApprovalsSchema); + if (approvals.kind !== "ready") return null; + + const asks = approvals.data.data.filter( + (row) => + row.status === "pending" && + agentAddresses.some((address) => sameAddress(address, row.agentAddress)), + ); + return asks.map((row) => ( + + )); +} diff --git a/apps/web/src/chat/strings.ts b/apps/web/src/chat/strings.ts index 8c39b2fa8..a5fd594f3 100644 --- a/apps/web/src/chat/strings.ts +++ b/apps/web/src/chat/strings.ts @@ -139,6 +139,7 @@ export const CHAT_STRINGS = { blockUnsupportedTitle: "Unsupported block", blockUnsupportedBody: (type: string) => `This "${type}" block can't be shown here yet.`, blockApproveAction: "Approve", + blockApproveNeedsYouTitle: "Needs your approval", blockDenyAction: "Deny", blockApproveAllowOnce: "Allow once", blockApproveAlways: "Always allow for this worker", diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 53f41b77d..818700230 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -55,6 +55,7 @@ import { PROVIDER_SETTINGS_PATH, ProviderSkipBanner } from "../provider-skip-ban import { useNavigate } from "../navigation"; import { isClassifiedInferenceFailureText } from "@/chat/inference-failure"; import { AgentRedeployer } from "../agent-redeployer"; +import { ThreadApprovals } from "../bench/thread-approvals"; import { useRestartAgent } from "../use-restart-agent"; import { workbenchIdFromPath } from "../workbench-path"; @@ -271,9 +272,9 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } const workerStatus = useWorkerStatus(workbenchTenantId, agents[0]?.id); const restart = useRestartAgent(); // Only a live agent can be addressed, so only one can be mentioned. - const mentionables = agents - .filter((agent) => agent.address.includes("@")) - .map((agent) => ({ id: agent.id, name: agent.name })); + const liveAgents = agents.filter((agent) => agent.address.includes("@")); + const liveAgentAddresses = liveAgents.map((agent) => agent.address); + const mentionables = liveAgents.map((agent) => ({ id: agent.id, name: agent.name })); const send = useMutation({ mutationFn: ({ content, @@ -395,6 +396,10 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } workbenchTenantId={workbenchTenantId} participants={roster} /> + {workerStatus.tone === "working" ? : null} {send.error === null ? null : ( From cd6acfba1f41645f5eab04cf67bb26f5e026f655 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 8 Oct 2026 20:45:36 +0545 Subject: [PATCH 5/8] Bench pill: say when the worker needs you --- apps/web/src/bench/bench-pill.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/bench/bench-pill.tsx b/apps/web/src/bench/bench-pill.tsx index da4982391..191032304 100644 --- a/apps/web/src/bench/bench-pill.tsx +++ b/apps/web/src/bench/bench-pill.tsx @@ -23,6 +23,7 @@ export function BenchPill({ readonly onToggle: () => void; }) { const working = status.tone === "working"; + const needsYou = status.text === "Needs you"; return (