From ac81c3d937b383750a8d0a6d91850e19c5f1ea19 Mon Sep 17 00:00:00 2001 From: Duola <108492647+duolahypercho@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:54:49 -0500 Subject: [PATCH] Transcript: stop rendering an answered approval twice, and name what it was for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished turn read as a wall of unanswered prompts. Every approval was on screen in two shapes at once — a compact "Approved file_write" row, and a full-size "Approve file_write?" card still asking the question, with its outcome demoted to grey text underneath. Both described the same click. The fix is in the state machine, not the CSS. `settled` now records who produced the answer. A decision this window made is already written to the transcript as a permanent row, so its card drops out of `visibleApprovals` — out of the view, not out of the map, so a redelivered `Arrived` still finds it finished and cannot resurrect an answered prompt. An always-allow clearance is the one settle that stays up, because nobody clicked that card and it has no row of its own; it now says exactly that instead of claiming the user approved it. Finished cards also stopped asking. Titles carry their own tense: "Approved file_write", "Denied file_write", "Approval for browser_downloads lapsed". And a card now says what it is about. Two `file_write` prompts were the same four words twice, and the rows they left behind were indistinguishable; `approvalTarget` pulls the path, command or url out of the arguments so both the card and the row name it. `argumentsWorthShowing` gates the JSON block, which was printing a literal "null" for argument-less tools and repeating a single-key `{ path }` directly under the target line that already said it. Found on the way: an entry that finished with nothing in flight got `finishedAtMs: null`, and `null ?? nowMs` made its age permanently zero — those cards never aged out at all. `Tick` stamps them on first sight. Around it, the transcript's own reading: one CALICODE eyebrow per speaker turn rather than one per block, shield icons for grant and refusal rows in place of the filled square that was the heaviest mark on screen, one stroke weight across the icon set, `formatDuration` on the busy clock so "331s" and "5m 31s" stop appearing together, a base gap tight enough that tool steps read as one run, and a halo on the scroll-to-latest button so it floats over content instead of landing on a card's border. Four assertions changed with the copy they pinned, deliberately: the always-allow scope now names the tool rather than "it", the deny row carries its path, the settled sibling asserts why it settled rather than a bare "Approved.", and two lapsed cards assert the new title instead of the phrase "no longer answerable" that the title now carries. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GDe8EbQXQxLdcpttjFfPWU --- .../editor/AgentPanel.approvals.test.tsx | 28 +++- client/src/components/editor/AgentPanel.tsx | 133 ++++++++++++++---- client/src/lib/approvalRouter.test.ts | 77 +++++++++- client/src/lib/approvalStore.ts | 87 ++++++++++-- client/src/lib/types.ts | 6 + 5 files changed, 282 insertions(+), 49 deletions(-) diff --git a/client/src/components/editor/AgentPanel.approvals.test.tsx b/client/src/components/editor/AgentPanel.approvals.test.tsx index 06c8f845..9868e89c 100644 --- a/client/src/components/editor/AgentPanel.approvals.test.tsx +++ b/client/src/components/editor/AgentPanel.approvals.test.tsx @@ -228,8 +228,10 @@ describe("always allow", () => { expect(sends).toHaveLength(1); // `always` rides along with an approval; core grants the exact tool name. expect(sends[0]).toMatchObject({ approved: true, always: true }); - // The user is told the scope of what they just did. - expect(await screen.findByText(/won't ask again for it this session/)).toBeTruthy(); + // The user is told the scope of what they just did — naming the tool, not + // "it", because the row now also names the path this particular grant was + // about and the two are not the same scope. + expect(await screen.findByText(/won't ask again for file_write this session/)).toBeTruthy(); }); it("never attaches `always` to a plain approval or to a denial", async () => { @@ -275,8 +277,14 @@ describe("an always-allow that covers cards already up", () => { await emit({ type: "agent.approval_resolved", requestId: "approval-2", outcome: "always-allowed" }); const sibling = document.querySelector('[data-approval="approval-2"]'); expect(sibling?.getAttribute("data-approval-state")).toBe("settled"); - expect(sibling?.textContent).toContain("Approved."); + expect(sibling?.textContent).toContain("Approved file_write"); + // And it says why it settled without being clicked. A bare "Approved." + // credits the user with a decision this card never showed them. + expect(sibling?.textContent).toContain("Covered by the permission you just granted"); expect(sibling?.textContent).not.toContain("No longer answerable"); + // The card the user actually clicked leaves its record in the transcript, + // not as a second card saying the same thing in a different shape. + expect(document.querySelector('[data-approval="approval-1"]')).toBeNull(); }); }); @@ -299,7 +307,10 @@ describe("denying with a reason", () => { const sends = approvalSends(); expect(sends).toHaveLength(1); expect(sends[0]).toMatchObject({ approved: false, reason: "not that file, edit the config" }); - expect(await screen.findByText(/Denied file_write — not that file, edit the config/)).toBeTruthy(); + // The row names what was refused, not just which tool asked. + expect( + await screen.findByText(/Denied file_write · src\/game\.ts — not that file, edit the config/), + ).toBeTruthy(); }); it("denies on Enter so a reason costs no extra reach for the mouse", async () => { @@ -411,7 +422,11 @@ describe("the governing invariant", () => { await vi.advanceTimersByTimeAsync(11_000); }); - expect(screen.getByText(/no longer answerable/i).textContent).toMatch(/stopped waiting/i); + // The card stops asking a question it can no longer take an answer to, + // and says in its own words why. + expect(screen.getByText(/Approval for file_write lapsed/)).toBeTruthy(); + expect(screen.queryByText(/Approve file_write\?/)).toBeNull(); + expect(screen.getByText(/stopped waiting/i)).toBeTruthy(); expect(deniedSends()).toEqual([]); }); }); @@ -736,7 +751,8 @@ describe("defect 5 — the queue", () => { }); expect(document.querySelector('[data-approval="approval-1"]')?.getAttribute("data-approval-state")).toBe("lapsed"); - expect(screen.getByText(/no longer answerable/i).textContent).toMatch(/no longer waiting/i); + expect(screen.getByText(/Approval for file_write lapsed/)).toBeTruthy(); + expect(screen.getByText(/no longer waiting/i)).toBeTruthy(); }); it("core announcing a resolution retires the card without a click", async () => { diff --git a/client/src/components/editor/AgentPanel.tsx b/client/src/components/editor/AgentPanel.tsx index a50bf6cf..fa3a09b5 100644 --- a/client/src/components/editor/AgentPanel.tsx +++ b/client/src/components/editor/AgentPanel.tsx @@ -64,6 +64,8 @@ import { connectEvents, rpc, type AgentEvent, type UsageTotals } from "../../lib import { classifySendFailure, route } from "../../lib/approvalRouter"; import { APPROVAL_TTL_MS, + approvalTarget, + argumentsWorthShowing, emptyStore, headApproval, lapsedExplanation, @@ -721,14 +723,23 @@ export function ToolRow({ message, onAsk }: { message: AgentMessage; onAsk?: (me expandable ? "hover:bg-surface-2 active:bg-surface-3" : "cursor-default" }`} > + {/* One stroke weight across the set. The informational row used to + fall through to a filled square, which made the least significant + row in the transcript its heaviest mark. */} {message.status === "running" ? ( - + ) : message.status === "error" ? ( - + ) : message.status === "done" ? ( - + + ) : message.decision === "approved" ? ( + + ) : message.decision === "denied" ? ( + ) : ( - + + + )} {heading ? ( @@ -777,15 +788,15 @@ function ActivityIcon({ stopped?: boolean; }) { if (running) { - return ; + return ; } if (failed) { - return ; + return ; } // A stopped turn is neither done nor failed; a ✓ beside the word "Stopped" // reads as though the work finished. if (stopped) { - return ; + return ; } if (operation === "edit" || operation === "write") { return ; @@ -799,7 +810,7 @@ function ActivityIcon({ if (operation === "read") { return ; } - return ; + return ; } export function ActivityDetailRow({ @@ -3181,20 +3192,26 @@ export function AgentPanel({ // click and not because something dropped them. const cascaded = typeof answer?.alsoApproved === "number" ? answer.alsoApproved : 0; dispatchApproval({ kind: "SendAccepted", requestId: entry.requestId }); + // The tool name alone is not an identity: four `file_write` grants in a + // turn leave four identical rows, and the transcript stops being a + // record of what was permitted. + const target = approvalTarget(entry.arguments); + const label = target ? `${entry.tool} · ${target}` : entry.tool; setMessages((current) => [ ...current, { role: "tool", content: approved ? always - ? `Approved ${entry.tool}, and won't ask again for it this session${ - cascaded > 0 ? ` — cleared ${cascaded} waiting request${cascaded === 1 ? "" : "s"}` : "" + ? `Approved ${label} — won't ask again for ${entry.tool} this session${ + cascaded > 0 ? `, cleared ${cascaded} waiting request${cascaded === 1 ? "" : "s"}` : "" }` - : `Approved ${entry.tool}` + : `Approved ${label}` : reason - ? `Denied ${entry.tool} — ${reason}` - : `Denied ${entry.tool}`, + ? `Denied ${label} — ${reason}` + : `Denied ${label}`, tool: entry.tool, + decision: approved ? "approved" : "denied", }, ]); setDenyReasons(({ [entry.requestId]: _answered, ...rest }) => rest); @@ -3346,8 +3363,13 @@ export function AgentPanel({ )} {/* Now that the conversation is the app's center column, the readable - measure is capped and centered rather than filling the panel. */} -
+ measure is capped and centered rather than filling the panel. + + The base gap is the tight one, because most rows in a long turn are + single-line tool steps: at a uniform 18px they read as unrelated + fragments rather than one run. Prose and prompts buy their own air + back with a top margin. */} +
{messages.map((message, index) => { if (message.turnId) { if (activityAnchors.get(message.turnId) !== index) return null; @@ -3377,16 +3399,27 @@ export function AgentPanel({
{message.content}
); } if (message.role === "tool") return ; + // The eyebrow names a speaker, and the speaker does not change + // between two consecutive blocks from the agent. Repeating it + // splits one answer into two that look like different turns. + const previous = messages[index - 1]; + const continuation = previous?.role === "assistant" && !previous.turnId; return ( -
-
CALICODE
+
+ {continuation ? null : ( +
CALICODE
+ )}
{message.panel ? ( @@ -3404,7 +3437,12 @@ export function AgentPanel({ {messages.some((message) => message.status === "running") ? "Working…" : "Thinking…"} - {thinkingSeconds > 0 ? {thinkingSeconds}s : null} + {/* Same formatter as the activity row's clock. Raw seconds + here put "331s" and "5m 31s" on screen together, one + duration written two ways. */} + {thinkingSeconds > 0 ? ( + {formatDuration(thinkingSeconds * 1000)} + ) : null}
)} @@ -3419,31 +3457,61 @@ export function AgentPanel({ const answering = entry.state.kind === "answering"; const settled = entry.state.kind === "settled"; const lapsed = entry.state.kind === "lapsed"; + const finished = settled || lapsed; + const target = approvalTarget(entry.arguments); + // A finished card keeps its own past tense. Leaving the question + // form up is the defect that made a transcript of completed work + // read as a wall of unanswered prompts. + const title = settled + ? entry.state.kind === "settled" && entry.state.approved + ? `Approved ${entry.tool}` + : `Denied ${entry.tool}` + : lapsed + ? `Approval for ${entry.tool} lapsed` + : `Approve ${entry.tool}?`; return (
-

- Approve {entry.tool}? +

+ {title} {entry.graphLabel ? ( for run {entry.graphLabel} ) : null}

-
-                  {JSON.stringify(entry.arguments, null, 2)}
-                
+ {target ? ( +

+ {target} +

+ ) : null} + {/* Only when there is something the target line did not + already say. `null` used to print verbatim, which reads as + a bug in the request. */} + {!finished && argumentsWorthShowing(entry.arguments, target) ? ( +
+                    {JSON.stringify(entry.arguments, null, 2)}
+                  
+ ) : null} {lapsed && entry.state.kind === "lapsed" ? ( -

- No longer answerable — {lapsedExplanation(entry.state.reason)}. +

+ {lapsedExplanation(entry.state.reason)}.

) : settled && entry.state.kind === "settled" ? ( -

- {entry.state.approved ? "Approved." : "Denied."} +

+ {/* Only an always-allow can still be on screen once + settled, and nobody clicked this card — saying + "Approved." alone would credit the user with a decision + they were never shown. */} + {entry.state.via === "always-allowed" + ? "Covered by the permission you just granted — this one was never asked." + : entry.state.approved + ? "Approved." + : "Denied."}

) : (
@@ -3505,7 +3573,10 @@ export function AgentPanel({ onClick={() => transcriptRef.current?.scrollTo({ top: transcriptRef.current.scrollHeight, behavior: "smooth" }) } - className="pointer-events-auto inline-flex h-7 w-7 items-center justify-center rounded-full border border-line-strong bg-raised text-ink-subtle shadow-md transition-colors hover:text-ink-strong active:bg-surface-2" + /* The halo is what separates a floating control from whatever it + happens to be over. Without it the button sits exactly on a + card's top border and reads as part of the card. */ + className="pointer-events-auto inline-flex h-7 w-7 items-center justify-center rounded-full border border-line-strong bg-raised text-ink-subtle shadow-[0_0_0_5px_var(--surface-0),0_2px_8px_rgba(0,0,0,0.14)] transition-colors hover:text-ink-strong active:bg-surface-2" > diff --git a/client/src/lib/approvalRouter.test.ts b/client/src/lib/approvalRouter.test.ts index f93aa9de..5c637a79 100644 --- a/client/src/lib/approvalRouter.test.ts +++ b/client/src/lib/approvalRouter.test.ts @@ -7,6 +7,8 @@ import { APPROVAL_TTL_MS, MAX_QUEUED_APPROVALS, SETTLED_LINGER_MS, + approvalTarget, + argumentsWorthShowing, emptyStore, headApproval, reduce, @@ -291,7 +293,7 @@ describe("the queue", () => { store = reduce(store, { kind: "SendAccepted", requestId: "r-2" }); expect(stateOf(store, "r-1")).toEqual({ kind: "pending" }); expect(stateOf(store, "r-3")).toEqual({ kind: "pending" }); - expect(stateOf(store, "r-2")).toEqual({ kind: "settled", approved: true }); + expect(stateOf(store, "r-2")).toEqual({ kind: "settled", approved: true, via: "this-window" }); }); // Defect 5, second half: the queue that replaced the single slot wedged @@ -343,3 +345,76 @@ describe("the queue", () => { expect(entry.graphLabel).toBe("graph-7"); }); }); + +// --------------------------------------------------------------------------- +// Defect 7: one act rendered twice. An answered card kept its "Approve X?" +// prompt up beside the transcript row the same click had just written, so a +// finished turn read as a wall of unanswered prompts. +// --------------------------------------------------------------------------- + +describe("what a finished request still shows", () => { + it("stops rendering a card this window answered, because the transcript has it", () => { + let store = reduce(emptyStore(), arrived("r-1")); + store = reduce(store, { kind: "UserAnswered", requestId: "r-1", approved: true, nowMs: 2_000 }); + store = reduce(store, { kind: "SendAccepted", requestId: "r-1" }); + + expect(visibleApprovals(store).map((entry) => entry.requestId)).toEqual([]); + // Gone from the view, not from the map: a redelivered `Arrived` must still + // find it finished rather than resurrecting an answered prompt. + expect(stateOf(reduce(store, arrived("r-1")), "r-1")).toEqual({ + kind: "settled", + approved: true, + via: "this-window", + }); + }); + + it("keeps up a card core cleared under an always-allow, which has no row of its own", () => { + let store = reduce(emptyStore(), arrived("r-1")); + store = reduce(store, { kind: "Resolved", requestId: "r-1", outcome: "always-allowed" }); + expect(visibleApprovals(store).map((entry) => entry.requestId)).toEqual(["r-1"]); + }); + + it("starts the eviction clock on a card that finished with no send in flight", () => { + // `finishedAtMs` is null here — nothing was in flight to inherit a start + // from. `null ?? nowMs` made the age permanently zero, so the card aged + // out never and sat in the transcript for the rest of the session. + let store = reduce(emptyStore(), arrived("r-1")); + store = reduce(store, { kind: "Resolved", requestId: "r-1", outcome: "always-allowed" }); + expect(store.entries.get("r-1")!.finishedAtMs).toBeNull(); + + store = reduce(store, { kind: "Tick", nowMs: 5_000 }); + expect(store.entries.get("r-1")!.finishedAtMs).toBe(5_000); + store = reduce(store, { kind: "Tick", nowMs: 5_000 + SETTLED_LINGER_MS }); + expect(stateOf(store, "r-1")).toBe("absent"); + }); +}); + +describe("what a card says it is about", () => { + it("names the file, command or address the request would touch", () => { + expect(approvalTarget({ path: "src/game.ts" })).toBe("src/game.ts"); + expect(approvalTarget({ file_path: " a.txt " })).toBe("a.txt"); + expect(approvalTarget({ command: "rm -rf build" })).toBe("rm -rf build"); + expect(approvalTarget({ url: "https://example.com" })).toBe("https://example.com"); + }); + + it("has no target rather than a wrong one", () => { + expect(approvalTarget(null)).toBeNull(); + expect(approvalTarget({ depth: 3 })).toBeNull(); + expect(approvalTarget(["a.txt"])).toBeNull(); + expect(approvalTarget({ path: " " })).toBeNull(); + }); + + it("prints nothing a request did not carry, and nothing it already said", () => { + // `JSON.stringify` renders the first three as text the user then has to + // decide is not an error. "null" was on screen under a live prompt. + expect(argumentsWorthShowing(null, null)).toBe(false); + expect(argumentsWorthShowing(undefined, null)).toBe(false); + expect(argumentsWorthShowing({}, null)).toBe(false); + expect(argumentsWorthShowing([], null)).toBe(false); + // The lone key IS the target line directly above it. + expect(argumentsWorthShowing({ path: "a.txt" }, "a.txt")).toBe(false); + expect(argumentsWorthShowing({ path: "a.txt", encoding: "utf8" }, "a.txt")).toBe(true); + // No target line, so the JSON is the only thing describing the request. + expect(argumentsWorthShowing({ depth: 3 }, null)).toBe(true); + }); +}); diff --git a/client/src/lib/approvalStore.ts b/client/src/lib/approvalStore.ts index 7015d1c1..a260d89a 100644 --- a/client/src/lib/approvalStore.ts +++ b/client/src/lib/approvalStore.ts @@ -21,10 +21,19 @@ export type LapsedReason = | "session-changed" | "panel-gone"; +/** + * Who produced the answer. Not decoration: a decision this window made is + * already written to the transcript as a permanent row, so its card is a + * duplicate the moment it settles and must stop rendering. An + * `always-allowed` settle has no row of its own — nobody clicked that card — + * so it is the one that has to stay up and say so. + */ +export type SettledVia = "this-window" | "always-allowed"; + export type RequestState = | { kind: "pending" } | { kind: "answering"; approved: boolean; startedAtMs: number } - | { kind: "settled"; approved: boolean } + | { kind: "settled"; approved: boolean; via: SettledVia } | { kind: "lapsed"; reason: LapsedReason }; export type ApprovalEntry = { @@ -194,7 +203,7 @@ export function reduce(store: ApprovalStore, event: ApprovalEvent): ApprovalStor const entries = new Map(store.entries); entries.set(event.requestId, { ...existing, - state: { kind: "settled", approved: existing.state.approved }, + state: { kind: "settled", approved: existing.state.approved, via: "this-window" }, finishedAtMs: existing.state.startedAtMs, }); return withEntries(store, entries); @@ -227,7 +236,7 @@ export function reduce(store: ApprovalStore, event: ApprovalEvent): ApprovalStor if (event.outcome === "always-allowed") { entries.set(event.requestId, { ...existing, - state: { kind: "settled", approved: true }, + state: { kind: "settled", approved: true, via: "always-allowed" }, finishedAtMs: existing.state.kind === "answering" ? existing.state.startedAtMs : null, }); @@ -241,7 +250,7 @@ export function reduce(store: ApprovalStore, event: ApprovalEvent): ApprovalStor entries.set(event.requestId, { ...existing, state: ours - ? { kind: "settled", approved: existing.state.approved } + ? { kind: "settled", approved: existing.state.approved, via: "this-window" } : { kind: "lapsed", reason: "resolved-elsewhere" }, finishedAtMs: existing.state.startedAtMs, }); @@ -271,10 +280,20 @@ export function reduce(store: ApprovalStore, event: ApprovalEvent): ApprovalStor entries.set(id, entry); continue; } + // A card can finish without a timestamp: nothing was in flight when + // core announced it, so there was no `startedAtMs` to inherit. Stamp + // it on the first tick that sees it rather than leaving it null — + // `null ?? nowMs` makes the age permanently zero, and a card that can + // never age out never leaves the transcript. + if (entry.finishedAtMs === null) { + changed = true; + entries.set(id, { ...entry, finishedAtMs: event.nowMs }); + continue; + } // Finished cards linger so the user can read the outcome, then go. // Measured from when they finished, so an early lapse and a late // expiry get the same reading window. - if (event.nowMs - (entry.finishedAtMs ?? event.nowMs) >= SETTLED_LINGER_MS) { + if (event.nowMs - entry.finishedAtMs >= SETTLED_LINGER_MS) { changed = true; continue; } @@ -305,14 +324,22 @@ export function reduce(store: ApprovalStore, event: ApprovalEvent): ApprovalStor * `answering` entries are never head: a send that hangs must not hide the rest * of the queue behind it. Three parallel nodes prompt together and stay * independently answerable in any order. + * + * A decision this window made drops out here rather than out of the map: the + * transcript row written on the same click is the durable record, so keeping + * the card up renders one act twice, in two shapes, saying two different + * things. The entry survives so a re-delivered `Arrived` still finds it + * finished and does not resurrect an answered prompt. */ export function visibleApprovals(store: ApprovalStore): ApprovalEntry[] { - return [...store.entries.values()].sort((left, right) => { - const leftBusy = left.state.kind === "answering" ? 1 : 0; - const rightBusy = right.state.kind === "answering" ? 1 : 0; - if (leftBusy !== rightBusy) return leftBusy - rightBusy; - return left.order - right.order; - }); + return [...store.entries.values()] + .filter((entry) => !(entry.state.kind === "settled" && entry.state.via === "this-window")) + .sort((left, right) => { + const leftBusy = left.state.kind === "answering" ? 1 : 0; + const rightBusy = right.state.kind === "answering" ? 1 : 0; + if (leftBusy !== rightBusy) return leftBusy - rightBusy; + return left.order - right.order; + }); } /** The card the promotion guard and keyboard focus apply to. */ @@ -320,6 +347,44 @@ export function headApproval(store: ApprovalStore): ApprovalEntry | null { return visibleApprovals(store)[0] ?? null; } +/** + * The one thing about a request a user has to see before answering it: which + * file, command or address it would touch. Without it two `file_write` prompts + * are the same four words twice, and the transcript rows they leave behind are + * indistinguishable from each other. + * + * Keys are tried in order and the first string wins; an unrecognised tool + * simply has no target, which is why the full arguments stay available below. + */ +export function approvalTarget(args: unknown): string | null { + if (!args || typeof args !== "object" || Array.isArray(args)) return null; + const record = args as Record; + for (const key of ["path", "file_path", "file", "filename", "command", "url", "query", "pattern", "name"]) { + const value = record[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +} + +/** + * Whether the full arguments say anything the target line did not. + * + * Two cases print nothing. A tool that takes no arguments arrives as `null` or + * `{}`, and `JSON.stringify` renders both as text the user then has to decide + * is not an error. A single-key `{ path }` is already the target line, and + * repeating it as JSON directly underneath is the same duplication in + * miniature. + */ +export function argumentsWorthShowing(args: unknown, target: string | null): boolean { + if (args === null || args === undefined) return false; + if (Array.isArray(args)) return args.length > 0; + if (typeof args === "object") { + const keys = Object.keys(args as object).length; + return target ? keys > 1 : keys > 0; + } + return true; +} + /** Plain-language reason a card is no longer answerable. */ export function lapsedExplanation(reason: LapsedReason): string { switch (reason) { diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 1539d132..3f238bb4 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -120,6 +120,12 @@ export interface AgentMessage { status?: "running" | "done" | "error"; /** Tool rows only: full output, shown when the row is expanded. */ detail?: string; + /** + * This row records a permission the user granted or refused, not work the + * agent did. Carried explicitly rather than sniffed from `content`, so the + * row's icon cannot be changed by rewording a sentence. + */ + decision?: "approved" | "denied"; /** Provider tool-call identity; pairing must not rely on tool names. */ toolCallId?: string; /** Client-owned Enter-level activity grouping identity. */