diff --git a/ui/src/ui/chat/run-lifecycle.test.ts b/ui/src/ui/chat/run-lifecycle.test.ts new file mode 100644 index 00000000000..1129a2e0f27 --- /dev/null +++ b/ui/src/ui/chat/run-lifecycle.test.ts @@ -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[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 { + 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); + }); +}); diff --git a/ui/src/ui/chat/run-lifecycle.ts b/ui/src/ui/chat/run-lifecycle.ts index 7e35b8a9eac..a94664594b5 100644 --- a/ui/src/ui/chat/run-lifecycle.ts +++ b/ui/src/ui/chat/run-lifecycle.ts @@ -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; type RunLifecycleHost = Omit[0]>, "hello"> & { @@ -26,6 +40,7 @@ type RunLifecycleHost = Omit[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) { diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 6c6b6c5c3ab..78c64b2d725 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -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", diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 06e93279cdb..f9655f838ec 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -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") {