mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 18:26:51 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user