fix(ui): ignore detached btw terminal teardown

This commit is contained in:
Nimrod Gutman
2026-04-10 15:34:06 +03:00
parent 9e2adb3ea8
commit b3a9c95dde
2 changed files with 97 additions and 21 deletions

View File

@@ -98,7 +98,14 @@ vi.mock("./controllers/chat.ts", async () => {
};
});
function createHost() {
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
chatSideResult: unknown;
chatSideResultTerminalRuns: Set<string>;
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<typeof connectGateway>[0] & {
chatSideResult: unknown;
chatSideResultTerminalRuns: Set<string>;
};
} 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();

View File

@@ -115,6 +115,12 @@ type GatewayHostWithSideResults = GatewayHost & {
chatSideResultTerminalRuns?: Set<string>;
};
function isTerminalChatState(
state: ChatEventPayload["state"] | ReturnType<typeof handleChatEvent> | 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<typeof resetToolStream>[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<typeof handleChatEvent>,
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);
}
}