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
This commit is contained in:
Peter Steinberger
2026-07-19 19:44:25 -07:00
committed by GitHub
parent 301657f2f3
commit b07985ef45
18 changed files with 430 additions and 22 deletions
+11
View File
@@ -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", () => {
+2
View File
@@ -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),
},
+1
View File
@@ -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: {
+24 -1
View File
@@ -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),
+64
View File
@@ -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<typeof handlePageGatewayEvent>[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<number, FrameRequestCallback>();
+7
View File
@@ -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<string>;
waitingApprovalStatuses: Map<string, WaitingApprovalStatus>;
waitingApprovalResolvedIds: Set<string>;
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,
+10
View File
@@ -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);
});
+6 -1
View File
@@ -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<ChatItem | MessageGro
const initialHistoryLoad = props.loading === true && items.length === 0;
const hasPendingResponse =
props.stream === null &&
((props.runWorking === true && !hasVisibleRunningTool && !initialHistoryLoad) ||
((props.runWorking === true &&
(props.waitingApproval === true || !hasVisibleRunningTool) &&
!initialHistoryLoad) ||
queuedSends.some(
(item) => 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) &&
+3
View File
@@ -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,
@@ -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 })
@@ -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");
+2 -1
View File
@@ -642,6 +642,7 @@ type StreamGroupOptions = {
authToken?: string | null;
planStatus?: PlanStatus | null;
planActive?: boolean;
waitingApproval?: boolean;
questionPrompts?: ReadonlyMap<string, QuestionPrompt>;
};
@@ -680,7 +681,7 @@ export function renderStreamGroup(parts: StreamGroupPart[], opts: StreamGroupOpt
<div class="chat-group-messages">
${parts.map((part) =>
part.kind === "reading-indicator"
? renderChatWorkingIndicator(part)
? renderChatWorkingIndicator(part, opts.waitingApproval === true)
: part.kind === "question"
? renderQuestionStreamPart(part, opts)
: part.kind === "plan"
@@ -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),
@@ -29,7 +29,10 @@ function punchStanceClass(key: string): string {
return "";
}
export function renderChatWorkingIndicator(part: Extract<ChatItem, { kind: "reading-indicator" }>) {
export function renderChatWorkingIndicator(
part: Extract<ChatItem, { kind: "reading-indicator" }>,
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<ChatItem, { kind: "read
${icons.claw}
</div>
<span class="chat-working-indicator__status">
<span class="agent-chat__sr-only">${t("common.working")}</span>
<openclaw-elapsed-time
class="chat-working-indicator__elapsed"
.startMs=${part.startedAt}
></openclaw-elapsed-time>
${waitingApproval
? html`<span>${t("chat.waitingForApproval")}</span>`
: html`
<span class="agent-chat__sr-only">${t("common.working")}</span>
<openclaw-elapsed-time
class="chat-working-indicator__elapsed"
.startMs=${part.startedAt}
></openclaw-elapsed-time>
`}
</span>
</div>
`;
+24
View File
@@ -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", () => {
+12
View File
@@ -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<string, WaitingApprovalStatus>;
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;
+119
View File
@@ -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();
+107 -11
View File
@@ -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<string, unknown>[];
toolStreamSyncTimer: number | null;
planStatus?: PlanStatus | null;
knownAgentRunIds?: Set<string>;
waitingApprovalStatuses?: Map<string, WaitingApprovalStatus>;
waitingApprovalResolvedIds?: Set<string>;
sessions: Pick<SessionCapability, "setModelOverride">;
};
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;