fix(webchat): suppress stale active session rows (#87962)

This commit is contained in:
Mukunda Rao Katta
2026-05-31 06:35:50 -07:00
committed by GitHub
parent 9a1b95c1e6
commit e452d16cea
4 changed files with 274 additions and 1 deletions

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import { isSessionRunActive } from "../session-run-state.ts";
import type { SessionsListResult } from "../types.ts";
import {
reconcileChatRunFromCurrentSessionRow,
reconcileChatRunLifecycle,
STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS,
} from "./run-lifecycle.ts";
type ReconcileHost = Parameters<typeof reconcileChatRunFromCurrentSessionRow>[0];
type TestRow = { key: string; hasActiveRun?: boolean; status?: string; startedAt?: number };
function makeSessionsResult(rows: TestRow[]): SessionsListResult {
return { sessions: rows } as unknown as SessionsListResult;
}
function makeHost(over: Partial<ReconcileHost> = {}): ReconcileHost {
return {
sessionKey: "s1",
chatRunId: null,
chatStream: null,
sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: true, status: "running" }]),
requestUpdate: () => {},
...over,
};
}
function rowActive(host: ReconcileHost): boolean {
const row = host.sessionsResult?.sessions.find((r) => r.key === host.sessionKey);
return Boolean(row && isSessionRunActive(row));
}
describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875)", () => {
it("suppresses a stale active row after a recent local completion", () => {
const host = makeHost({
lastLocalTerminalReconcile: {
sessionKey: "s1",
runId: "r1",
phase: "done",
sessionStatus: "done",
occurredAt: Date.now(),
},
});
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
expect(rowActive(host)).toBe(false);
expect(host.lastLocalTerminalReconcile?.runId).toBe("r1");
});
it("does NOT clear a genuinely recovered active run with no recent local completion", () => {
const host = makeHost({ lastLocalTerminalReconcile: null });
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
expect(rowActive(host)).toBe(true);
});
it("ignores and clears a local terminal reconcile older than the window", () => {
const host = makeHost({
lastLocalTerminalReconcile: {
sessionKey: "s1",
runId: "r1",
phase: "done",
sessionStatus: "done",
occurredAt: Date.now() - STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS - 1_000,
},
});
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
expect(rowActive(host)).toBe(true);
expect(host.lastLocalTerminalReconcile).toBeNull();
});
it("does not suppress when the recent completion was for a different session", () => {
const host = makeHost({
sessionKey: "s2",
sessionsResult: makeSessionsResult([{ key: "s2", hasActiveRun: true, status: "running" }]),
lastLocalTerminalReconcile: {
sessionKey: "s1",
runId: "r1",
phase: "done",
sessionStatus: "done",
occurredAt: Date.now(),
},
});
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
expect(rowActive(host)).toBe(true);
});
it("clears the flag once the server poll reports a non-active row", () => {
const host = makeHost({
sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: false, status: "done" }]),
lastLocalTerminalReconcile: {
sessionKey: "s1",
runId: "r1",
phase: "done",
sessionStatus: "done",
occurredAt: Date.now(),
},
});
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
expect(host.lastLocalTerminalReconcile).toBeNull();
});
it("does not arm stale-row suppression from generic lifecycle cleanup", () => {
const host = makeHost({
chatRunId: "orphaned-run",
chatStream: "stale stream",
});
reconcileChatRunLifecycle(host, {
outcome: "interrupted",
sessionStatus: "killed",
runId: "orphaned-run",
sessionKey: "s1",
clearLocalRun: true,
clearChatStream: true,
publishRunStatus: false,
});
expect(host.lastLocalTerminalReconcile ?? null).toBeNull();
host.sessionsResult = makeSessionsResult([
{ key: "s1", hasActiveRun: true, status: "running" },
]);
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
expect(rowActive(host)).toBe(true);
});
it("does not suppress a newer active row after a follow-up run starts", () => {
const terminalAt = Date.now();
const host = makeHost({
sessionsResult: makeSessionsResult([
{
key: "s1",
hasActiveRun: true,
status: "running",
startedAt: terminalAt + 1,
},
]),
lastLocalTerminalReconcile: {
sessionKey: "s1",
runId: "r1",
phase: "done",
sessionStatus: "done",
occurredAt: terminalAt,
},
});
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
expect(rowActive(host)).toBe(true);
expect(host.lastLocalTerminalReconcile).toBeNull();
});
it("arms suppression on a completed turn, then suppresses the racing refresh", () => {
const host = makeHost({
chatRunId: "r1",
chatStream: "partial...",
sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: true, status: "running" }]),
});
reconcileChatRunLifecycle(host, {
outcome: "done",
sessionStatus: "done",
runId: "r1",
sessionKey: "s1",
clearLocalRun: true,
clearChatStream: true,
publishRunStatus: false,
armLocalTerminalReconcile: true,
});
expect(host.lastLocalTerminalReconcile?.sessionKey).toBe("s1");
expect(host.chatRunId ?? null).toBeNull();
// A racing sessions.list refresh re-introduces a stale active row.
host.sessionsResult = makeSessionsResult([
{ key: "s1", hasActiveRun: true, status: "running" },
]);
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
expect(rowActive(host)).toBe(false);
expect(host.lastLocalTerminalReconcile?.runId).toBe("r1");
});
it("keeps suppressing multiple stale active refreshes within the window", () => {
const terminalAt = Date.now();
const host = makeHost({
lastLocalTerminalReconcile: {
sessionKey: "s1",
runId: "r1",
phase: "done",
sessionStatus: "done",
occurredAt: terminalAt,
},
});
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
host.sessionsResult = makeSessionsResult([
{ key: "s1", hasActiveRun: true, status: "running", startedAt: terminalAt - 1 },
]);
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
expect(rowActive(host)).toBe(false);
});
});

View File

@@ -11,6 +11,20 @@ export type ChatRunUiStatus = {
occurredAt: number;
};
export type LocalTerminalReconcile = {
sessionKey: string;
runId: string | null;
phase: ChatRunUiStatus["phase"];
sessionStatus: SessionRunStatus;
occurredAt: number;
};
// A terminal chat event clears local run state before the periodic
// sessions.list poll catches up. Within this window a stale "active" row for
// the just-completed selected session is treated as poll lag and reconciled
// back to terminal, so the composer does not snap back to in-progress. (#87875)
export const STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS = 10_000;
type TimerHandle = ReturnType<typeof globalThis.setTimeout>;
type RunLifecycleHost = Omit<Partial<Parameters<typeof resetToolStream>[0]>, "hello"> & {
@@ -26,6 +40,7 @@ type RunLifecycleHost = Omit<Partial<Parameters<typeof resetToolStream>[0]>, "he
chatRunStatus?: ChatRunUiStatus | null;
chatRunStatusClearTimer?: TimerHandle | number | null;
sessionsResult?: SessionsListResult | null;
lastLocalTerminalReconcile?: LocalTerminalReconcile | null;
requestUpdate?: () => void;
};
@@ -42,6 +57,7 @@ type ReconcileOptions = {
clearSideResultTerminalRuns?: boolean;
clearRunStatus?: boolean;
publishRunStatus?: boolean;
armLocalTerminalReconcile?: boolean;
};
function toSessionKey(value: string | null | undefined): string | null {
@@ -184,6 +200,15 @@ export function reconcileChatRunLifecycle(host: RunLifecycleHost, options: Recon
occurredAt,
};
reconcileSessionRows(host, options, occurredAt);
if (options.armLocalTerminalReconcile) {
host.lastLocalTerminalReconcile = {
sessionKey,
runId,
phase: options.outcome,
sessionStatus: options.sessionStatus ?? (options.outcome === "done" ? "done" : "killed"),
occurredAt,
};
}
if (options.publishRunStatus !== false) {
host.chatRunStatus = status;
scheduleRunStatusClear(host, status);
@@ -198,12 +223,48 @@ function currentSessionRow(host: RunLifecycleHost) {
return host.sessionsResult?.sessions.find((row) => row.key === host.sessionKey);
}
// After a terminal chat event clears local run state, a racing sessions.list
// refresh can still carry a stale "active" row for the session we just
// finished, which would drive the composer back to in-progress. Re-apply
// terminal to that row — but only while we hold a recent LOCAL terminal
// reconcile for the currently selected session, so a genuinely recovered
// active run (e.g. opening WebChat to a session already running elsewhere) is
// never cleared. (#87875)
function reconcileStaleSelectedSessionRunAfterLocalCompletion(host: RunLifecycleHost): boolean {
const recent = host.lastLocalTerminalReconcile;
if (!recent || recent.sessionKey !== host.sessionKey) {
return false;
}
if (Date.now() - recent.occurredAt > STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS) {
host.lastLocalTerminalReconcile = null;
return false;
}
const row = currentSessionRow(host);
if (!row || !isSessionRunActive(row)) {
// No row, or the server already reflects a non-active state — the poll has
// caught up, so stop suppressing.
host.lastLocalTerminalReconcile = null;
return false;
}
if (typeof row.startedAt === "number" && row.startedAt > recent.occurredAt) {
host.lastLocalTerminalReconcile = null;
return false;
}
reconcileSessionRows(
host,
{ outcome: recent.phase, sessionStatus: recent.sessionStatus, sessionKey: recent.sessionKey },
Date.now(),
);
host.requestUpdate?.();
return true;
}
export function reconcileChatRunFromCurrentSessionRow(
host: RunLifecycleHost,
options: { publishRunStatus?: boolean } = {},
): boolean {
if (!host.chatRunId && host.chatStream == null) {
return false;
return reconcileStaleSelectedSessionRunAfterLocalCompletion(host);
}
const row = currentSessionRow(host);
if (!row) {

View File

@@ -114,6 +114,24 @@ describe("handleChatEvent", () => {
expect(handleChatEvent(state, payload)).toBe(null);
});
it("does not arm stale active-row suppression for an unowned selected-session final", () => {
const state = createState({ sessionKey: "main" }) as ChatState & {
lastLocalTerminalReconcile?: unknown;
};
const payload: ChatEventPayload = {
runId: "observed-run",
sessionKey: "main",
state: "final",
message: {
role: "assistant",
content: [{ type: "text", text: "Observed reply" }],
},
};
expect(handleChatEvent(state, payload)).toBe("final");
expect(state.lastLocalTerminalReconcile).toBeUndefined();
});
it("ignores selected-agent global events for another agent", () => {
const state = createState({
sessionKey: "global",

View File

@@ -927,6 +927,7 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
sessionKeys: sessionMatches ? [state.sessionKey, payload.sessionKey] : [],
clearLocalRun: true,
clearChatStream: true,
armLocalTerminalReconcile: hadActiveRunBeforeEvent && activeRunMatches,
});
if (payload.state === "delta") {