From b3a9c95dde6ee34d72bcd7113b977bb7ee26f6ac Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 10 Apr 2026 15:34:06 +0300 Subject: [PATCH] fix(ui): ignore detached btw terminal teardown --- ui/src/ui/app-gateway.node.test.ts | 88 ++++++++++++++++++++++++++++-- ui/src/ui/app-gateway.ts | 30 +++++----- 2 files changed, 97 insertions(+), 21 deletions(-) diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 4f5fd74a3df..46421d704b2 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -98,7 +98,14 @@ vi.mock("./controllers/chat.ts", async () => { }; }); -function createHost() { +type TestGatewayHost = Parameters[0] & { + chatSideResult: unknown; + chatSideResultTerminalRuns: Set; + chatStream: string | null; + toolStreamOrder: string[]; +}; + +function createHost(): TestGatewayHost { return { settings: { gatewayUrl: "ws://127.0.0.1:18789", @@ -155,10 +162,7 @@ function createHost() { execApprovalQueue: [], execApprovalError: null, updateAvailable: null, - } as unknown as Parameters[0] & { - chatSideResult: unknown; - chatSideResultTerminalRuns: Set; - }; + } as unknown as TestGatewayHost; } function connectHostGateway() { @@ -594,9 +598,12 @@ describe("connectGateway", () => { expect(host.chatSideResultTerminalRuns.has("btw-run-1")).toBe(true); }); - it("does not reload chat history for BTW terminal finals, even after tool events", () => { + it("ignores tracked BTW terminal finals without tearing down the active run", () => { const { host, client } = connectHostGateway(); + host.chatRunId = "main-run-1"; emitToolResultEvent(client); + host.chatStream = "still streaming"; + expect(host.toolStreamOrder).toHaveLength(1); client.emitEvent({ event: "chat.side_result", @@ -619,9 +626,78 @@ describe("connectGateway", () => { }); expect(loadChatHistoryMock).not.toHaveBeenCalled(); + expect(host.chatRunId).toBe("main-run-1"); + expect(host.chatStream).toBe("still streaming"); + expect(host.toolStreamOrder).toHaveLength(1); expect(host.chatSideResultTerminalRuns.has("btw-run-2")).toBe(false); }); + it.each(["aborted", "error"] as const)( + "cleans up tracked BTW %s events without touching the active run", + (terminalState) => { + const { host, client } = connectHostGateway(); + host.chatRunId = "main-run-2"; + emitToolResultEvent(client); + host.chatStream = "stream in progress"; + + client.emitEvent({ + event: "chat.side_result", + payload: { + kind: "btw", + runId: `btw-run-${terminalState}`, + sessionKey: "main", + question: "what changed?", + text: "Detached BTW response", + ts: 789, + }, + }); + client.emitEvent({ + event: "chat", + payload: { + runId: `btw-run-${terminalState}`, + sessionKey: "main", + state: terminalState, + errorMessage: terminalState === "error" ? "btw failed" : undefined, + }, + }); + + expect(host.chatSideResultTerminalRuns.has(`btw-run-${terminalState}`)).toBe(false); + expect(host.chatRunId).toBe("main-run-2"); + expect(host.chatStream).toBe("stream in progress"); + expect(host.toolStreamOrder).toHaveLength(1); + expect(host.lastError).toBeNull(); + }, + ); + + it("clears tracked BTW terminal runs after reconnect hello", () => { + const host = createHost(); + + connectGateway(host); + const firstClient = gatewayClientInstances[0]; + expect(firstClient).toBeDefined(); + + firstClient.emitEvent({ + event: "chat.side_result", + payload: { + kind: "btw", + runId: "btw-run-reconnect", + sessionKey: "main", + question: "what changed?", + text: "Temporary BTW state", + ts: 987, + }, + }); + expect(host.chatSideResultTerminalRuns.has("btw-run-reconnect")).toBe(true); + + connectGateway(host); + const reconnectClient = gatewayClientInstances[1]; + expect(reconnectClient).toBeDefined(); + + reconnectClient.emitHello(); + + expect(host.chatSideResultTerminalRuns.size).toBe(0); + }); + it("ignores BTW side results for other sessions", () => { const { host, client } = connectHostGateway(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 0e5ef389375..39e56618292 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -115,6 +115,12 @@ type GatewayHostWithSideResults = GatewayHost & { chatSideResultTerminalRuns?: Set; }; +function isTerminalChatState( + state: ChatEventPayload["state"] | ReturnType | null | undefined, +): state is "final" | "aborted" | "error" { + return state === "final" || state === "aborted" || state === "error"; +} + type ConnectGatewayOptions = { reason?: "initial" | "seq-gap"; }; @@ -251,6 +257,7 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption host.chatRunId = null; (host as unknown as { chatStream: string | null }).chatStream = null; (host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null; + (host as GatewayHostWithSideResults).chatSideResultTerminalRuns?.clear(); resetToolStream(host as unknown as Parameters[0]); if (shutdownHost.resumeChatQueueAfterReconnect) { // The interrupted run will never emit its terminal event now that the @@ -328,7 +335,6 @@ function handleTerminalChatEvent( host: GatewayHost, payload: ChatEventPayload | undefined, state: ReturnType, - opts?: { skipHistoryReload?: boolean }, ): boolean { if (state !== "final" && state !== "error" && state !== "aborted") { return false; @@ -353,7 +359,7 @@ function handleTerminalChatEvent( } // Reload history when tools were used so the persisted tool results // replace the now-cleared streaming state. - if (hadToolEvents && state === "final" && !opts?.skipHistoryReload) { + if (hadToolEvents && state === "final") { void loadChatHistory(host as unknown as ChatState); return true; } @@ -367,24 +373,18 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u payload.sessionKey, ); } - const state = handleChatEvent(host as unknown as ChatState, payload); const sideResultHost = host as GatewayHostWithSideResults; - const skipHistoryReloadForSideResult = - state === "final" && + const isTrackedSideResultTerminalEvent = + isTerminalChatState(payload?.state) && typeof payload?.runId === "string" && sideResultHost.chatSideResultTerminalRuns?.has(payload.runId) === true; - if (skipHistoryReloadForSideResult && payload?.runId) { + if (isTrackedSideResultTerminalEvent && payload?.runId) { sideResultHost.chatSideResultTerminalRuns?.delete(payload.runId); + return; } - const historyReloaded = handleTerminalChatEvent(host, payload, state, { - skipHistoryReload: skipHistoryReloadForSideResult, - }); - if ( - state === "final" && - !skipHistoryReloadForSideResult && - !historyReloaded && - shouldReloadHistoryForFinalEvent(payload) - ) { + const state = handleChatEvent(host as unknown as ChatState, payload); + const historyReloaded = handleTerminalChatEvent(host, payload, state); + if (state === "final" && !historyReloaded && shouldReloadHistoryForFinalEvent(payload)) { void loadChatHistory(host as unknown as ChatState); } }