From b07985ef45efd64f8fca875921407c8db4e7b166 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 19:44:25 -0700 Subject: [PATCH] feat(ui): show waiting-for-approval run status in chat (#111445) * feat(ui): show waiting approval run status * fix(ui): hydrate waiting approval status * fix(ui): correlate approval status by run * fix(ui): harden approval status reconciliation --- ui/src/app/exec-approval.test.ts | 11 ++ ui/src/app/exec-approval.ts | 2 + ui/src/i18n/locales/en.ts | 1 + ui/src/pages/chat/chat-pane.ts | 25 +++- ui/src/pages/chat/chat-state.test.ts | 64 ++++++++++ ui/src/pages/chat/chat-state.ts | 7 ++ ui/src/pages/chat/chat-thread.test.ts | 10 ++ ui/src/pages/chat/chat-thread.ts | 7 +- ui/src/pages/chat/chat-view.ts | 3 + ui/src/pages/chat/components/chat-composer.ts | 6 +- .../chat/components/chat-message.test.ts | 16 +++ ui/src/pages/chat/components/chat-message.ts | 3 +- ui/src/pages/chat/components/chat-thread.ts | 5 + .../chat/components/chat-working-indicator.ts | 19 ++- ui/src/pages/chat/run-lifecycle.test.ts | 24 ++++ ui/src/pages/chat/run-lifecycle.ts | 12 ++ ui/src/pages/chat/tool-stream.node.test.ts | 119 ++++++++++++++++++ ui/src/pages/chat/tool-stream.ts | 118 +++++++++++++++-- 18 files changed, 430 insertions(+), 22 deletions(-) diff --git a/ui/src/app/exec-approval.test.ts b/ui/src/app/exec-approval.test.ts index 2eb393f4c3c0..97ea21d956bb 100644 --- a/ui/src/app/exec-approval.test.ts +++ b/ui/src/app/exec-approval.test.ts @@ -82,6 +82,17 @@ describe("parseExecApprovalRequested", () => { expect(result?.request.allowedDecisions).toEqual(["allow-once", "deny", "allow-always"]); }); + + it("preserves the originating engine run id", () => { + const result = parseExecApprovalRequested({ + id: "exec-1", + request: { command: "pwd", runId: "engine-run-1" }, + createdAtMs: 1000, + expiresAtMs: 2000, + }); + + expect(result?.request.runId).toBe("engine-run-1"); + }); }); describe("parsePluginApprovalRequested", () => { diff --git a/ui/src/app/exec-approval.ts b/ui/src/app/exec-approval.ts index 8b998b09e430..7fa38d01b938 100644 --- a/ui/src/app/exec-approval.ts +++ b/ui/src/app/exec-approval.ts @@ -10,6 +10,7 @@ export type ExecApprovalRequestPayload = { agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; + runId?: string | null; commandSpans?: readonly { startIndex: number; endIndex: number; @@ -138,6 +139,7 @@ function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | nul agentId: typeof request.agentId === "string" ? request.agentId : null, resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null, sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null, + runId: typeof request.runId === "string" ? request.runId : null, commandSpans: parseCommandSpans(request.commandSpans, command.length), allowedDecisions: parseAllowedDecisions(request.allowedDecisions), }, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index d45768a918e1..aa7876c796d4 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3607,6 +3607,7 @@ export const en: TranslationMap = { }, chat: { disconnected: "Disconnected from gateway.", + waitingForApproval: "Waiting for approval…", archivedSessionDisabled: "Restore this thread to send messages.", loadOlder: "Load older", sessionHeader: { diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index bd93e6a9cf86..71f6ad6c6be6 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -223,6 +223,7 @@ import { readChatSessionSnapshot, type ChatMessageCache, } from "./session-message-cache.ts"; +import { reconcileWaitingApprovalsFromSnapshot } from "./tool-stream.ts"; import { configureToolTitleFetcher } from "./tool-titles.ts"; type ChatPageContext = ApplicationContext; @@ -427,7 +428,13 @@ class ChatPane extends OpenClawLightDomElement { void new SubscriptionsController(this) .watch( () => this.context?.overlays, - (overlays, notify) => overlays.subscribe(notify), + (overlays, notify) => + overlays.subscribe((snapshot) => { + if (this.state) { + this.reconcileWaitingApprovalSnapshot(snapshot.approvalQueue); + } + notify(); + }), ) .watch( () => this.resolveBoardProvider(), @@ -879,6 +886,7 @@ class ChatPane extends OpenClawLightDomElement { previousDraftRetry, previousComposerScope, }); + this.reconcileWaitingApprovalSnapshot(); retryChatComposerMemoryFallback(state, nextSessionKey); // Route restoration is the new persistence baseline. An untouched pane // must not later erase a draft written by another split pane. Memory-only @@ -2309,11 +2317,23 @@ class ChatPane extends OpenClawLightDomElement { return; } const reconciledLocalCompletion = reconcileStaleChatRunAfterSessionStatePublication(state); + this.reconcileWaitingApprovalSnapshot(); if (!reconciledLocalCompletion) { state.requestUpdate?.(); } } + private reconcileWaitingApprovalSnapshot( + approvalQueue?: ApplicationContext["overlays"]["snapshot"]["approvalQueue"], + ): boolean { + const state = this.state; + const queue = approvalQueue ?? this.context?.overlays?.snapshot.approvalQueue; + if (!state || !queue) { + return false; + } + return reconcileWaitingApprovalsFromSnapshot(state, queue); + } + private applyApplicationConfig(config: ApplicationContext["config"]["current"]) { const state = this.state; if (!state) { @@ -2487,6 +2507,7 @@ class ChatPane extends OpenClawLightDomElement { void this.refreshTaskSuggestions(); void this.refreshSessionPullRequests(); } + this.reconcileWaitingApprovalSnapshot(); state.requestUpdate?.(); } @@ -3035,6 +3056,7 @@ class ChatPane extends OpenClawLightDomElement { sending: state.chatSending, canAbort: hasAbortableSessionRun(state), runStatus: state.chatRunStatus, + waitingApproval: state.waitingApprovalStatuses.size > 0, compactionStatus: state.compactionStatus, fallbackStatus: state.fallbackStatus, planStatus: state.planStatus, @@ -3180,6 +3202,7 @@ class ChatPane extends OpenClawLightDomElement { state.chatSideChatHidden = false; retirePendingChatSideQuestion(state); state.resetToolStream(); + this.reconcileWaitingApprovalSnapshot(); void refreshPageChat(state, { awaitHistory: true, scheduleScroll: false }); }, onChatScroll: (event) => this.handleTranscriptScroll(event), diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 9148ad11aeeb..e30df5dc6fbd 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -49,6 +49,70 @@ afterEach(() => { }); describe("ChatStateController render lifecycle", () => { + it("tracks waiting approval only for the selected session until resolution", () => { + const state = { + sessionKey: "agent:main:current", + assistantAgentId: "main", + agentsList: { defaultId: "main" }, + chatRunId: "client-run-1", + chatStream: null, + chatStreamStartedAt: 1, + chatStreamSegments: [], + chatToolMessages: [], + toolStreamById: new Map(), + toolStreamOrder: [], + toolStreamSyncTimer: null, + waitingApprovalStatuses: new Map(), + sessions: { setModelOverride: vi.fn() }, + chatStreamRenderFrame: null, + requestUpdate: vi.fn(), + } as unknown as ChatPageHost; + const lifecycleEvent = ( + phase: "waiting-approval" | "approval-resolved", + sessionKey: string, + approvalId = "approval-1", + ) => + ({ + type: "event" as const, + event: "agent", + payload: { + runId: "engine-run-1", + seq: 1, + stream: "lifecycle", + ts: Date.now(), + sessionKey, + agentId: "main", + data: { phase, approvalId, toolCallId: `tool-${approvalId}` }, + }, + }) satisfies Parameters[1]; + + handlePageGatewayEvent(state, lifecycleEvent("waiting-approval", "agent:main:other")); + expect(state.waitingApprovalStatuses.size).toBe(0); + + handlePageGatewayEvent(state, lifecycleEvent("waiting-approval", state.sessionKey)); + expect(state.waitingApprovalStatuses.get("approval-1")).toEqual({ + approvalId: "approval-1", + toolCallId: "tool-approval-1", + runId: "engine-run-1", + }); + + handlePageGatewayEvent(state, lifecycleEvent("approval-resolved", "agent:main:other")); + expect(state.waitingApprovalStatuses.has("approval-1")).toBe(true); + + handlePageGatewayEvent( + state, + lifecycleEvent("waiting-approval", state.sessionKey, "approval-2"), + ); + handlePageGatewayEvent(state, lifecycleEvent("approval-resolved", state.sessionKey)); + expect([...state.waitingApprovalStatuses.keys()]).toEqual(["approval-2"]); + + handlePageGatewayEvent( + state, + lifecycleEvent("approval-resolved", state.sessionKey, "approval-2"), + ); + expect(state.waitingApprovalStatuses.size).toBe(0); + }); + it("coalesces stream invalidations into one animation frame", () => { let nextFrame = 1; const frames = new Map(); diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index 1c389f8a2a50..4f34a7beffcf 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -154,6 +154,7 @@ import { type FallbackStatus, type PlanStatus, type ToolStreamEntry, + type WaitingApprovalStatus, } from "./tool-stream.ts"; type ChatPageElement = { @@ -232,6 +233,9 @@ export type ChatPageHost = ChatHost & compactionStatus: CompactionStatus | null; fallbackStatus: FallbackStatus | null; planStatus: PlanStatus | null; + knownAgentRunIds: Set; + waitingApprovalStatuses: Map; + waitingApprovalResolvedIds: Set; chatRunStatus: ChatProps["runStatus"]; chatNewMessagesBelow: boolean; chatMetadataRequestVersion: number; @@ -1273,6 +1277,9 @@ export function createPageState( compactionStatus: null, fallbackStatus: null, planStatus: null, + knownAgentRunIds: new Set(), + waitingApprovalStatuses: new Map(), + waitingApprovalResolvedIds: new Set(), chatAvatarUrl: null, chatAvatarStatus: null, chatAvatarReason: null, diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index ac525735a81d..3a525cbc0b87 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -710,6 +710,16 @@ describe("buildCachedChatItems working spark", () => { expect(hasReadingIndicator({ runWorking: true, toolMessages: [liveTool(false)] })).toBe(false); }); + it("keeps the approval status row beside a visible running tool", () => { + expect( + hasReadingIndicator({ + runWorking: true, + waitingApproval: true, + toolMessages: [liveTool(false)], + }), + ).toBe(true); + }); + it("returns the spark once the running tool resolves", () => { expect(hasReadingIndicator({ runWorking: true, toolMessages: [liveTool(true)] })).toBe(true); }); diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index f0e2619e041f..452b911d8b3e 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -64,6 +64,8 @@ type BuildChatItemsProps = { showToolCalls: boolean; /** True while the agent is visibly working (isChatRunWorking). */ runWorking?: boolean; + /** Keeps the status row visible while a running tool is parked for approval. */ + waitingApproval?: boolean; /** True while the current session has an abortable live run. */ runActive?: boolean; planStatus?: PlanStatus | null; @@ -1462,7 +1464,9 @@ function buildChatItems(props: BuildChatItemsProps): Array item.sendState === "sending" && shouldRenderQueuedSendInThread(item), )); @@ -1693,6 +1697,7 @@ function sameChatItemsStructuralInput( previous.queue === next.queue && previous.showToolCalls === next.showToolCalls && previous.runWorking === next.runWorking && + previous.waitingApproval === next.waitingApproval && previous.runActive === next.runActive && previous.questionPrompts === next.questionPrompts && Boolean(previous.planStatus?.steps.length) === Boolean(next.planStatus?.steps.length) && diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 895bdd547b9a..7d8dcff41119 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -84,6 +84,7 @@ export type ChatProps = { sending: boolean; canAbort?: boolean; runStatus?: ChatRunUiStatus | null; + waitingApproval?: boolean; compactionStatus?: CompactionStatus | null; fallbackStatus?: FallbackStatus | null; planStatus?: PlanStatus | null; @@ -336,6 +337,7 @@ export function renderChat(props: ChatProps) { showToolCalls: props.showToolCalls, runActive: Boolean(props.canAbort), runWorking: isChatRunWorking(props), + waitingApproval: props.waitingApproval, planStatus: props.planStatus, questionPrompts: props.gatewayQuestionPrompts, sessions: props.sessions, @@ -396,6 +398,7 @@ export function renderChat(props: ChatProps) { sending: props.sending, canAbort: props.canAbort, runStatus: props.runStatus, + waitingApproval: props.waitingApproval, compactionStatus: props.compactionStatus, fallbackStatus: props.fallbackStatus, planStatus: props.planStatus, diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index 4cf892cb6aaf..4466df5ff1aa 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -107,6 +107,7 @@ type ChatComposerProps = { sending: boolean; canAbort?: boolean; runStatus?: ChatRunUiStatus | null; + waitingApproval?: boolean; compactionStatus?: CompactionStatus | null; fallbackStatus?: FallbackStatus | null; planStatus?: PlanStatus | null; @@ -2155,8 +2156,9 @@ export function renderChatComposer(props: ChatComposerProps) { ); const composerControls = props.composerControls ?? nothing; const assistantName = props.assistantName || "OpenClaw"; - const inProgressLabel = - submittedProgress?.sendState === "waiting-model" + const inProgressLabel = props.waitingApproval + ? t("chat.waitingForApproval") + : submittedProgress?.sendState === "waiting-model" ? t("chat.composer.preparingModel") : props.stream !== null ? t("chat.composer.responding", { name: assistantName }) diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index abfe166be3ef..9c217bbd84a9 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -1115,6 +1115,22 @@ describe("grouped chat rendering", () => { expect(container.querySelector(".chat-group-footer")).toBeNull(); }); + it("relabels the working indicator while the run waits for approval", () => { + const container = document.createElement("div"); + + render( + renderStreamGroup([{ kind: "reading-indicator", key: "reading", startedAt: 1_000 }], { + waitingApproval: true, + }), + container, + ); + + expect(container.querySelector(".chat-working-indicator__status")?.textContent?.trim()).toBe( + "Waiting for approval…", + ); + expect(container.querySelector(".chat-working-indicator__elapsed")).toBeNull(); + }); + it("renders the active plan card inside the working stream group", () => { const container = document.createElement("div"); diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index 8ac1ac058fbc..9d86bf4ea08f 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -642,6 +642,7 @@ type StreamGroupOptions = { authToken?: string | null; planStatus?: PlanStatus | null; planActive?: boolean; + waitingApproval?: boolean; questionPrompts?: ReadonlyMap; }; @@ -680,7 +681,7 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt
${parts.map((part) => part.kind === "reading-indicator" - ? renderChatWorkingIndicator(part) + ? renderChatWorkingIndicator(part, opts.waitingApproval === true) : part.kind === "question" ? renderQuestionStreamPart(part, opts) : part.kind === "plan" diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 6124271a5f0e..a3ad7c9fef84 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -116,6 +116,8 @@ type ChatThreadProps = { runActive?: boolean; /** True while the agent is visibly working (isChatRunWorking); shows the working spark. */ runWorking?: boolean; + /** Re-labels the working spark while the active run is parked on an approval. */ + waitingApproval?: boolean; planStatus?: PlanStatus | null; questionPrompts?: readonly QuestionPrompt[]; sessions: SessionsListResult | null; @@ -1089,6 +1091,7 @@ function renderChatThreadContents( queue: props.queue, showToolCalls: props.showToolCalls, runWorking: Boolean(props.runWorking), + waitingApproval: Boolean(props.waitingApproval), runActive: Boolean(props.runActive), planStatus: props.planStatus, questionPrompts: props.questionPrompts, @@ -1196,6 +1199,7 @@ function renderChatThreadContents( questionPrompts, planStatus: props.planStatus, planActive: Boolean(props.runActive), + waitingApproval: props.waitingApproval, onOpenSidebar: props.onOpenSidebar, assistant: assistantIdentity, basePath: props.basePath, @@ -1272,6 +1276,7 @@ function renderChatThreadContents( props.showToolCalls, Boolean(props.runActive), Boolean(props.runWorking), + Boolean(props.waitingApproval), props.planStatus, props.questionPrompts, Boolean(props.autoExpandToolCalls), diff --git a/ui/src/pages/chat/components/chat-working-indicator.ts b/ui/src/pages/chat/components/chat-working-indicator.ts index f412932682b4..aafa08b8d945 100644 --- a/ui/src/pages/chat/components/chat-working-indicator.ts +++ b/ui/src/pages/chat/components/chat-working-indicator.ts @@ -29,7 +29,10 @@ function punchStanceClass(key: string): string { return ""; } -export function renderChatWorkingIndicator(part: Extract) { +export function renderChatWorkingIndicator( + part: Extract, + waitingApproval = false, +) { // The animated claw stays decorative; the text status exposes progress without // announcing every elapsed-time tick to screen readers. return html` @@ -41,11 +44,15 @@ export function renderChatWorkingIndicator(part: Extract - ${t("common.working")} - + ${waitingApproval + ? html`${t("chat.waitingForApproval")}` + : html` + ${t("common.working")} + + `}
`; diff --git a/ui/src/pages/chat/run-lifecycle.test.ts b/ui/src/pages/chat/run-lifecycle.test.ts index 187a84537331..8b1c395771a7 100644 --- a/ui/src/pages/chat/run-lifecycle.test.ts +++ b/ui/src/pages/chat/run-lifecycle.test.ts @@ -113,6 +113,10 @@ describe("reconcileChatRunLifecycle indicators", () => { it("clears plan status on terminal run end", () => { const host = makeHost({ chatRunId: "r1", + knownAgentRunIds: new Set(["r1", "r2"]), + waitingApprovalStatuses: new Map([ + ["approval-1", { approvalId: "approval-1", toolCallId: "tool-1", runId: "r1" }], + ]), planStatus: { steps: [{ step: "Finish the run", status: "in_progress" }], }, @@ -125,6 +129,26 @@ describe("reconcileChatRunLifecycle indicators", () => { }); expect(host.planStatus).toBeNull(); + expect(host.knownAgentRunIds).toEqual(new Set(["r2"])); + expect(host.waitingApprovalStatuses?.size).toBe(0); + }); + + it("preserves a waiting approval owned by another run", () => { + const host = makeHost({ + chatRunId: "r1", + waitingApprovalStatuses: new Map([ + ["approval-1", { approvalId: "approval-1", toolCallId: "tool-1", runId: "r1" }], + ]), + }); + + reconcileChatRunLifecycle(host, { + outcome: "done", + runId: "r2", + clearIndicators: true, + clearLocalRun: false, + }); + + expect(host.waitingApprovalStatuses?.has("approval-1")).toBe(true); }); it("preserves an owned plan when another run terminates", () => { diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index a3240b533ed8..02558d71a902 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -21,6 +21,7 @@ import { type CompactionStatus, type FallbackStatus, type PlanStatus, + type WaitingApprovalStatus, } from "./tool-stream.ts"; export const CHAT_RUN_STATUS_TOAST_DURATION_MS = 5_000; @@ -59,6 +60,7 @@ type RunLifecycleHost = Omit< fallbackStatus?: FallbackStatus | null; fallbackClearTimer?: TimerHandle | number | null; planStatus?: PlanStatus | null; + waitingApprovalStatuses?: Map; chatRunStatus?: ChatRunUiStatus | null; chatRunStatusClearTimer?: TimerHandle | number | null; sessionsResult?: SessionsListResult | null; @@ -223,6 +225,11 @@ function scheduleRunStatusClear(host: RunLifecycleHost, status: ChatRunUiStatus) } function clearRunIndicators(host: RunLifecycleHost, runId?: string | null) { + if (runId) { + host.knownAgentRunIds?.delete(runId); + } else { + host.knownAgentRunIds?.clear(); + } clearTimer(host.compactionClearTimer); host.compactionClearTimer = null; if (host.compactionStatus) { @@ -233,6 +240,11 @@ function clearRunIndicators(host: RunLifecycleHost, runId?: string | null) { if (host.fallbackStatus) { host.fallbackStatus = null; } + for (const [approvalId, waitingApproval] of host.waitingApprovalStatuses ?? []) { + if (!runId || !waitingApproval.runId || waitingApproval.runId === runId) { + host.waitingApprovalStatuses?.delete(approvalId); + } + } // Plan checklists are run-owned (unlike the transient compaction/fallback // toasts): a terminal reconcile for another run must not clear them. const planOwner = host.planStatus?.runId; diff --git a/ui/src/pages/chat/tool-stream.node.test.ts b/ui/src/pages/chat/tool-stream.node.test.ts index 7062b31a79c5..7dfc84318612 100644 --- a/ui/src/pages/chat/tool-stream.node.test.ts +++ b/ui/src/pages/chat/tool-stream.node.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite import { handleAgentEvent, handleSessionOperationEvent, + reconcileWaitingApprovalsFromSnapshot, resetToolStream, type FallbackStatus, type PlanStatus, @@ -256,6 +257,124 @@ describe("app-tool-stream plan snapshots", () => { }); }); +describe("app-tool-stream approval lifecycle", () => { + const approval = (runId: string | undefined, sessionKey = "main") => ({ + id: "approval-1", + kind: "exec" as const, + request: { command: "echo test", sessionKey, runId }, + createdAtMs: 1, + expiresAtMs: 2, + }); + + const learnRun = (host: MutableHost, runId: string) => { + handleAgentEvent(host, agentEvent(runId, 1, "lifecycle", { phase: "start" })); + }; + + it("hydrates a parked run only when the approval matches a learned engine run id", () => { + const host = createHost({ waitingApprovalStatuses: new Map() }); + learnRun(host, "run-1"); + + expect(reconcileWaitingApprovalsFromSnapshot(host, [approval("run-1")])).toBe(true); + expect(host.waitingApprovalStatuses?.get("approval-1")).toEqual({ + approvalId: "approval-1", + toolCallId: null, + runId: "run-1", + }); + }); + + it("does not hydrate absent or mismatched run ids even while a run is active", () => { + const host = createHost({ + chatRunId: "client-active-run", + waitingApprovalStatuses: new Map(), + }); + learnRun(host, "foreground-engine-run"); + + expect(reconcileWaitingApprovalsFromSnapshot(host, [approval(undefined)])).toBe(false); + expect(reconcileWaitingApprovalsFromSnapshot(host, [approval("other-engine-run")])).toBe(false); + expect(host.waitingApprovalStatuses?.size).toBe(0); + }); + + it("does not label foreground work for an unrelated heartbeat approval", () => { + const host = createHost({ + chatRunId: "foreground-client-run", + waitingApprovalStatuses: new Map(), + }); + learnRun(host, "foreground-engine-run"); + + expect(reconcileWaitingApprovalsFromSnapshot(host, [approval("heartbeat-run")])).toBe(false); + expect(host.waitingApprovalStatuses?.size).toBe(0); + }); + + it("clears only parked runs whose approvals leave the queue snapshot", () => { + const host = createHost({ + waitingApprovalStatuses: new Map([ + ["approval-1", { approvalId: "approval-1", toolCallId: "tool-1", runId: "run-1" }], + ["approval-2", { approvalId: "approval-2", toolCallId: "tool-2", runId: "run-2" }], + ]), + }); + + expect( + reconcileWaitingApprovalsFromSnapshot(host, [{ ...approval("run-2"), id: "approval-2" }]), + ).toBe(true); + expect([...host.waitingApprovalStatuses!.keys()]).toEqual(["approval-2"]); + }); + + it("clears hydrated state when the lifecycle resolution arrives", () => { + const host = createHost({ waitingApprovalStatuses: new Map() }); + learnRun(host, "run-1"); + reconcileWaitingApprovalsFromSnapshot(host, [approval("run-1")]); + + handleAgentEvent( + host, + agentEvent("run-1", 1, "lifecycle", { + phase: "approval-resolved", + approvalId: "approval-1", + }), + ); + + expect(host.waitingApprovalStatuses?.size).toBe(0); + + resetToolStream(host); + expect(reconcileWaitingApprovalsFromSnapshot(host, [approval("run-1")])).toBe(false); + expect(host.waitingApprovalStatuses?.size).toBe(0); + + reconcileWaitingApprovalsFromSnapshot(host, []); + learnRun(host, "run-1"); + expect(reconcileWaitingApprovalsFromSnapshot(host, [approval("run-1")])).toBe(true); + }); + + it("replaces hydrated state with the authoritative lifecycle payload", () => { + const host = createHost({ waitingApprovalStatuses: new Map() }); + learnRun(host, "run-1"); + reconcileWaitingApprovalsFromSnapshot(host, [approval("run-1")]); + + handleAgentEvent( + host, + agentEvent("run-1", 1, "lifecycle", { + phase: "waiting-approval", + approvalId: "approval-1", + toolCallId: "tool-1", + }), + ); + + expect(host.waitingApprovalStatuses?.get("approval-1")).toEqual({ + approvalId: "approval-1", + toolCallId: "tool-1", + runId: "run-1", + }); + }); + + it("does not synthesize waiting state after a reset without a new run event", () => { + const host = createHost({ waitingApprovalStatuses: new Map() }); + learnRun(host, "run-1"); + reconcileWaitingApprovalsFromSnapshot(host, [approval("run-1")]); + + resetToolStream(host); + expect(reconcileWaitingApprovalsFromSnapshot(host, [approval("run-1")])).toBe(false); + expect(host.waitingApprovalStatuses?.size).toBe(0); + }); +}); + describe("app-tool-stream fallback lifecycle handling", () => { beforeEach(() => { vi.useRealTimers(); diff --git a/ui/src/pages/chat/tool-stream.ts b/ui/src/pages/chat/tool-stream.ts index 213f8eab3f94..f7609629f766 100644 --- a/ui/src/pages/chat/tool-stream.ts +++ b/ui/src/pages/chat/tool-stream.ts @@ -1,5 +1,6 @@ // Control UI module implements app tool stream behavior. import { stripInlineDirectiveTagsForDelivery } from "../../../../src/utils/directive-tags.js"; +import type { ExecApprovalRequest } from "../../app/exec-approval.ts"; import type { ChatStreamSegment } from "../../lib/chat/chat-types.ts"; import { formatUnknownText, truncateText } from "../../lib/format.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; @@ -52,11 +53,7 @@ type ToolStreamHost = { sessionKey: string; assistantAgentId?: string | null; agentsList?: { defaultId?: string | null } | null; - hello?: { - snapshot?: { - sessionDefaults?: SessionDefaultsSnapshot; - }; - } | null; + hello?: { snapshot?: unknown } | null; chatRunId: string | null; chatStream: string | null; chatStreamStartedAt: number | null; @@ -66,15 +63,12 @@ type ToolStreamHost = { chatToolMessages: Record[]; toolStreamSyncTimer: number | null; planStatus?: PlanStatus | null; + knownAgentRunIds?: Set; + waitingApprovalStatuses?: Map; + waitingApprovalResolvedIds?: Set; sessions: Pick; }; -type SessionDefaultsSnapshot = { - defaultAgentId?: string; - mainKey?: string; - mainSessionKey?: string; -}; - function toTrimmedString(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -330,6 +324,10 @@ export function resetToolStream(host: ToolStreamHost) { host.chatToolMessages = []; host.chatStreamSegments = []; host.planStatus = null; + host.knownAgentRunIds?.clear(); + host.waitingApprovalStatuses?.clear(); + // Resolution can beat the overlay queue update. Keep tombstones across transient stream resets + // until snapshot reconciliation observes the approval leaving the queue. } export type CompactionStatus = { @@ -359,6 +357,70 @@ export type PlanStatus = { }>; }; +export type WaitingApprovalStatus = { + approvalId: string; + toolCallId: string | null; + runId: string; +}; + +type WaitingApprovalSnapshotHost = Pick< + ToolStreamHost, + | "sessionKey" + | "assistantAgentId" + | "agentsList" + | "hello" + | "knownAgentRunIds" + | "waitingApprovalStatuses" + | "waitingApprovalResolvedIds" +>; + +export function reconcileWaitingApprovalsFromSnapshot( + host: WaitingApprovalSnapshotHost, + queue: readonly ExecApprovalRequest[], +): boolean { + const waiting = (host.waitingApprovalStatuses ??= new Map()); + const resolvedIds = (host.waitingApprovalResolvedIds ??= new Set()); + const allQueuedIds = new Set(queue.map((approval) => approval.id)); + for (const approvalId of resolvedIds) { + if (!allQueuedIds.has(approvalId)) { + resolvedIds.delete(approvalId); + } + } + const matchingApprovals = queue.filter( + (approval) => + approval.kind === "exec" && + approval.request.sessionKey && + uiSessionEventMatches(host, approval.request.sessionKey, approval.request.agentId), + ); + const queuedIds = new Set(matchingApprovals.map((approval) => approval.id)); + let changed = false; + for (const approvalId of waiting.keys()) { + if (!queuedIds.has(approvalId)) { + waiting.delete(approvalId); + changed = true; + } + } + if (waiting.size > 0) { + return changed; + } + // On a fresh mount the inline approval card still exposes the parked request in the transcript. + // Spinner-label hydration across mounts needs an authoritative Gateway run-state contract and + // is deliberately deferred. + for (const approval of matchingApprovals) { + const runId = toTrimmedString(approval.request.runId); + if (!runId || !host.knownAgentRunIds?.has(runId) || resolvedIds.has(approval.id)) { + continue; + } + waiting.set(approval.id, { + approvalId: approval.id, + toolCallId: null, + runId, + }); + changed = true; + } + return changed; +} + type PlanHost = ToolStreamHost & { planStatus?: PlanStatus | null; requestUpdate?: () => void; @@ -616,6 +678,31 @@ function handleLifecycleFallbackEvent(host: CompactionHost, payload: AgentEventP }, FALLBACK_TOAST_DURATION_MS); } +function handleLifecycleApprovalEvent(host: ToolStreamHost, payload: AgentEventPayload): boolean { + const phase = toTrimmedString(payload.data?.phase); + if (phase !== "waiting-approval" && phase !== "approval-resolved") { + return false; + } + const approvalId = toTrimmedString(payload.data?.approvalId); + const sessionKey = toTrimmedString(payload.sessionKey); + if (!approvalId || !sessionKey) { + return true; + } + if (phase === "waiting-approval") { + const waiting = (host.waitingApprovalStatuses ??= new Map()); + host.waitingApprovalResolvedIds?.delete(approvalId); + waiting.set(approvalId, { + approvalId, + toolCallId: toTrimmedString(payload.data?.toolCallId), + runId: payload.runId, + }); + return true; + } + (host.waitingApprovalResolvedIds ??= new Set()).add(approvalId); + host.waitingApprovalStatuses?.delete(approvalId); + return true; +} + function readPreambleProgressEvent( payload: AgentEventPayload, ): { text: string; itemId?: string } | null { @@ -764,6 +851,12 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo if (sessionKey && !uiSessionEventMatches(host, sessionKey, toTrimmedString(payload.agentId))) { return; } + if (payload.stream === "lifecycle" || payload.stream === "tool") { + const runId = toTrimmedString(payload.runId); + if (runId) { + (host.knownAgentRunIds ??= new Set()).add(runId); + } + } // Handle compaction events if (payload.stream === "compaction") { @@ -772,6 +865,9 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo } if (payload.stream === "lifecycle") { + if (handleLifecycleApprovalEvent(host, payload)) { + return; + } handleLifecycleCompactionEvent(host as CompactionHost, payload); handleLifecycleFallbackEvent(host as CompactionHost, payload); return;