diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 5711b22a6500..327dc1d9c58f 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -102,6 +102,7 @@ describe("agent event handler", () => { trackTrackedRunTerminalPersistence?: AgentEventHandlerOptions["trackTrackedRunTerminalPersistence"]; resolveActiveLifecycleGenerationForRun?: (runId: string) => string | undefined; updateRunToolErrorSummary?: AgentEventHandlerOptions["updateRunToolErrorSummary"]; + resolveSessionActiveRunState?: AgentEventHandlerOptions["resolveSessionActiveRunState"]; }) { const nowSpy = params?.now === undefined ? undefined : vi.spyOn(Date, "now").mockReturnValue(params.now); @@ -136,6 +137,7 @@ describe("agent event handler", () => { trackTrackedRunTerminalPersistence: params?.trackTrackedRunTerminalPersistence, resolveActiveLifecycleGenerationForRun: params?.resolveActiveLifecycleGenerationForRun, updateRunToolErrorSummary: params?.updateRunToolErrorSummary, + resolveSessionActiveRunState: params?.resolveSessionActiveRunState, }); return { @@ -2063,8 +2065,13 @@ describe("agent event handler", () => { runtimeMs: 800, abortedLastRun: false, }); + const resolveSessionActiveRunState = vi + .fn>() + .mockReturnValueOnce({ active: true, runIds: ["run-finished"] }) + .mockReturnValue({ active: false, runIds: [] }); const { broadcastToConnIds, sessionEventSubscribers, handler } = createHarness({ resolveSessionKeyForRun: () => "session-finished", + resolveSessionActiveRunState, }); sessionEventSubscribers.subscribe("conn-session"); @@ -2104,9 +2111,17 @@ describe("agent event handler", () => { ([event]) => event === "sessions.changed", ); expect(sessionsChangedCalls).toHaveLength(2); + expectPayloadFields(sessionsChangedCalls[0]?.[1], { + sessionKey: "session-finished", + phase: "start", + hasActiveRun: true, + activeRunIds: ["run-finished"], + }); expectPayloadFields(sessionsChangedCalls[1]?.[1], { sessionKey: "session-finished", phase: "end", + hasActiveRun: false, + activeRunIds: [], status: "done", startedAt: 900, endedAt: 1_700, @@ -2114,6 +2129,10 @@ describe("agent event handler", () => { updatedAt: 1_700, abortedLastRun: false, }); + expect(resolveSessionActiveRunState).toHaveBeenCalledWith({ + requestedKey: "session-finished", + canonicalKey: "session-finished", + }); const persistParams = requireRecord( persistGatewaySessionLifecycleEventMock.mock.calls .map((call) => call[0]) diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 0c5d44d23cb2..5d80e8ab3e05 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -299,6 +299,12 @@ export type AgentEventHandlerOptions = { clientRunId: string; summary: string | undefined; }) => void; + resolveSessionActiveRunState?: (params: { + requestedKey: string; + canonicalKey: string; + sessionId?: string; + agentId?: string; + }) => { active: boolean; runIds: string[] }; }; function roundedChatSendTimingMs(value: number): number { @@ -324,6 +330,7 @@ export function createAgentEventHandler({ trackTrackedRunTerminalPersistence, resolveActiveLifecycleGenerationForRun = () => undefined, updateRunToolErrorSummary, + resolveSessionActiveRunState, }: AgentEventHandlerOptions) { type TerminalLifecycleOptions = { skipChatErrorFinal?: boolean; @@ -414,6 +421,7 @@ export function createAgentEventHandler({ sessionKey: string, evt?: AgentEventPayload, agentId?: string, + includeActiveRunState = false, ) => { const row = loadGatewaySessionRowForSnapshot(sessionKey, agentId ? { agentId } : undefined); const omitUnscopedGlobalGoal = sessionKey === "global" && !agentId; @@ -437,7 +445,20 @@ export function createAgentEventHandler({ event: evt, }) : {}; - const session = row ? { ...row, ...lifecyclePatch } : undefined; + const activeRunState = includeActiveRunState + ? resolveSessionActiveRunState?.({ + requestedKey: sessionKey, + canonicalKey: row?.key ?? sessionKey, + ...(row?.sessionId ? { sessionId: row.sessionId } : {}), + ...(agentId ? { agentId } : {}), + }) + : undefined; + // Agent lifecycle broadcasts merge into cached session rows in the UI. + // Always replace run identity so a newer start cannot inherit a completed run. + const activeRunFields = activeRunState + ? { hasActiveRun: activeRunState.active, activeRunIds: activeRunState.runIds } + : {}; + const session = row ? { ...row, ...lifecyclePatch, ...activeRunFields } : undefined; if (session && omitUnscopedGlobalGoal) { delete session.goal; } @@ -490,6 +511,7 @@ export function createAgentEventHandler({ effectiveResponseUsage: row?.effectiveResponseUsage, modelProvider: row?.modelProvider, model: row?.model, + ...activeRunFields, status: snapshotSource.status, startedAt: snapshotSource.startedAt, endedAt: snapshotSource.endedAt, @@ -715,7 +737,7 @@ export function createAgentEventHandler({ runId: evt.runId, ...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}), ts: evt.ts, - ...buildSessionEventSnapshot(sessionKey, snapshotEvent, sessionAgentId), + ...buildSessionEventSnapshot(sessionKey, snapshotEvent, sessionAgentId, true), }, sessionEventConnIds, { dropIfSlow: true }, @@ -1494,7 +1516,7 @@ export function createAgentEventHandler({ runId: evt.runId, ...(eventRunId !== evt.runId ? { clientRunId: eventRunId } : {}), ts: evt.ts, - ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId), + ...buildSessionEventSnapshot(sessionKey, evt, sessionAgentId, true), }, sessionEventConnIds, { dropIfSlow: true }, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index dda91434a214..a0bc758b303b 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -227,7 +227,10 @@ import { loadOptionalServerMethodModelCatalog, startOptionalServerMethodModelCatalogLoad, } from "./optional-model-catalog.js"; -import { hasTrackedActiveSessionRun, hasVisibleActiveSessionRun } from "./session-active-runs.js"; +import { + hasTrackedActiveSessionRun, + resolveVisibleActiveSessionRunState, +} from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; import type { GatewayClient, @@ -3207,7 +3210,7 @@ async function handleChatHistoryRequest({ }); const activeRunAgentId = canonicalKey === "global" ? (selectedAgent.agentId ?? defaultAgentId) : selectedAgent.agentId; - sessionInfo.hasActiveRun = hasVisibleActiveSessionRun({ + const activeRunState = resolveVisibleActiveSessionRunState({ context, requestedKey: sessionKey, canonicalKey, @@ -3215,6 +3218,8 @@ async function handleChatHistoryRequest({ ...(activeRunAgentId ? { agentId: activeRunAgentId } : {}), defaultAgentId, }); + sessionInfo.hasActiveRun = activeRunState.active; + sessionInfo.activeRunIds = activeRunState.runIds; const defaults = getSessionDefaults(cfg, modelCatalog, { allowPluginNormalization: false }); const thinkingLevel = sessionInfo.thinkingLevel ?? sessionInfo.thinkingDefault; const verboseLevel = entry?.verboseLevel ?? cfg.agents?.defaults?.verboseDefault; diff --git a/src/gateway/server-methods/session-active-runs.test.ts b/src/gateway/server-methods/session-active-runs.test.ts index ee381bdd5663..614a0b6aa1b4 100644 --- a/src/gateway/server-methods/session-active-runs.test.ts +++ b/src/gateway/server-methods/session-active-runs.test.ts @@ -1,6 +1,9 @@ // Tests gateway active-run matching by logical session key and backing id. import { expect, it } from "vitest"; -import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; +import { + hasVisibleActiveSessionRun, + resolveVisibleActiveSessionRunState, +} from "./session-active-runs.js"; it("matches session-id-only gateway runs during archive admission", () => { const context = { @@ -25,3 +28,22 @@ it("matches session-id-only gateway runs during archive admission", () => { }), ).toBe(true); }); + +it("returns deterministic visible run ids for the selected session", () => { + const context = { + chatAbortControllers: new Map([ + ["run-z", { sessionKey: "main" }], + ["run-hidden", { sessionKey: "main", controlUiVisible: false }], + ["run-other", { sessionKey: "other" }], + ["run-a", { sessionKey: "main" }], + ]), + } as never; + + expect( + resolveVisibleActiveSessionRunState({ + context, + requestedKey: "main", + canonicalKey: "main", + }), + ).toEqual({ active: true, runIds: ["run-a", "run-z"] }); +}); diff --git a/src/gateway/server-methods/session-active-runs.ts b/src/gateway/server-methods/session-active-runs.ts index a37fad464139..52fae7f2e1ba 100644 --- a/src/gateway/server-methods/session-active-runs.ts +++ b/src/gateway/server-methods/session-active-runs.ts @@ -11,6 +11,7 @@ import type { GatewayRequestContext } from "./types.js"; * do not make a session look busy to user-facing session operations. */ type TrackedActiveSessionRun = { + runId: string; sessionKey?: string; sessionId?: string; agentId?: string; @@ -23,7 +24,7 @@ function collectTrackedActiveSessionRuns( if (!(context.chatAbortControllers instanceof Map)) { return runs; } - for (const active of context.chatAbortControllers.values()) { + for (const [runId, active] of context.chatAbortControllers) { if (active.projectSessionActive !== false && active.controlUiVisible !== false) { const sessionKey = active.sessionKey?.trim(); const sessionId = active.sessionId?.trim(); @@ -31,6 +32,7 @@ function collectTrackedActiveSessionRuns( continue; } runs.push({ + runId, ...(sessionKey ? { sessionKey } : {}), ...(sessionId ? { sessionId } : {}), agentId: typeof active.agentId === "string" ? normalizeAgentId(active.agentId) : undefined, @@ -88,6 +90,40 @@ export function hasTrackedActiveSessionRun(params: { ); } +export function resolveVisibleActiveSessionRunState(params: { + context: Partial>; + requestedKey: string; + canonicalKey: string; + sessionId?: string; + agentId?: string; + defaultAgentId?: string; +}): { active: boolean; runIds: string[] } { + const sessionId = params.sessionId?.trim(); + const runIds = collectTrackedActiveSessionRuns(params.context) + .filter( + (active) => + isTrackedActiveSessionRunForKey( + active, + params.canonicalKey, + params.agentId, + params.defaultAgentId, + ) || + isTrackedActiveSessionRunForKey( + active, + params.requestedKey, + params.agentId, + params.defaultAgentId, + ) || + (sessionId !== undefined && active.sessionId === sessionId), + ) + .map((active) => active.runId) + .toSorted(); + return { + active: runIds.length > 0 || (sessionId !== undefined && isEmbeddedAgentRunActive(sessionId)), + runIds, + }; +} + export function hasVisibleActiveSessionRun(params: { context: Partial>; requestedKey: string; @@ -96,17 +132,5 @@ export function hasVisibleActiveSessionRun(params: { agentId?: string; defaultAgentId?: string; }): boolean { - if (hasTrackedActiveSessionRun(params)) { - return true; - } - const sessionId = params.sessionId?.trim(); - if (!sessionId) { - return false; - } - if ( - collectTrackedActiveSessionRuns(params.context).some((active) => active.sessionId === sessionId) - ) { - return true; - } - return isEmbeddedAgentRunActive(sessionId); + return resolveVisibleActiveSessionRunState(params).active; } diff --git a/src/gateway/server-methods/session-change-event.ts b/src/gateway/server-methods/session-change-event.ts index 38ee2b916101..34577ea18cd7 100644 --- a/src/gateway/server-methods/session-change-event.ts +++ b/src/gateway/server-methods/session-change-event.ts @@ -2,7 +2,7 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { buildGatewaySessionEventFields } from "../session-event-payload.js"; import { loadGatewaySessionRow } from "../session-utils.js"; -import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; +import { resolveVisibleActiveSessionRunState } from "./session-active-runs.js"; import type { GatewayRequestContext } from "./types.js"; export type SessionChangedPayload = { @@ -35,6 +35,16 @@ export function emitSessionsChanged( ) : null; const defaultAgentId = resolveDefaultAgentId(context.getRuntimeConfig()); + const activeRunState = sessionRow + ? resolveVisibleActiveSessionRunState({ + context, + requestedKey: payload.sessionKey ?? sessionRow.key, + canonicalKey: sessionRow.key, + sessionId: sessionRow.sessionId, + agentId: sessionRow.key === "global" ? payload.agentId : undefined, + defaultAgentId, + }) + : null; context.broadcastToConnIds( "sessions.changed", { @@ -45,14 +55,8 @@ export function emitSessionsChanged( ...buildGatewaySessionEventFields({ sessionRow, agentId: payload.agentId, - hasActiveRun: hasVisibleActiveSessionRun({ - context, - requestedKey: payload.sessionKey ?? sessionRow.key, - canonicalKey: sessionRow.key, - sessionId: sessionRow.sessionId, - agentId: sessionRow.key === "global" ? payload.agentId : undefined, - defaultAgentId, - }), + hasActiveRun: activeRunState?.active, + activeRunIds: activeRunState?.runIds, }), effectiveFastMode: sessionRow.effectiveFastMode, effectiveFastModeSource: sessionRow.effectiveFastModeSource, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 900f85bb4761..cb722723d9ee 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -124,7 +124,11 @@ import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js"; import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js"; import { chatHandlers } from "./chat.js"; import { loadOptionalServerMethodModelCatalog } from "./optional-model-catalog.js"; -import { hasTrackedActiveSessionRun, hasVisibleActiveSessionRun } from "./session-active-runs.js"; +import { + hasTrackedActiveSessionRun, + hasVisibleActiveSessionRun, + resolveVisibleActiveSessionRunState, +} from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; import type { GatewayClient, @@ -815,18 +819,22 @@ export const sessionsHandlers: GatewayRequestHandlers = { const sessions = measureDiagnosticsTimelineSpanSync( "gateway.sessions.list.active_run_flags", () => { - return result.sessions.map((session) => - Object.assign({}, session, { - hasActiveRun: hasVisibleActiveSessionRun({ - context, - requestedKey: session.key, - canonicalKey: session.key, - sessionId: session.sessionId, - ...(session.key === "global" && p.agentId ? { agentId: p.agentId } : {}), - defaultAgentId: resolveDefaultAgentId(cfg), - }), - }), - ); + return result.sessions.map((session) => { + const activeRunState = resolveVisibleActiveSessionRunState({ + context, + requestedKey: session.key, + canonicalKey: session.key, + sessionId: session.sessionId, + ...(session.key === "global" && p.agentId ? { agentId: p.agentId } : {}), + defaultAgentId: resolveDefaultAgentId(cfg), + }); + return Object.assign({}, session, { + hasActiveRun: activeRunState.active, + ...(activeRunState.runIds.length > 0 + ? { activeRunIds: activeRunState.runIds } + : {}), + }); + }); }, { config: cfg, diff --git a/src/gateway/server-runtime-subscriptions.ts b/src/gateway/server-runtime-subscriptions.ts index 66ab1889f5b6..7c8c106202f3 100644 --- a/src/gateway/server-runtime-subscriptions.ts +++ b/src/gateway/server-runtime-subscriptions.ts @@ -1,4 +1,6 @@ // Gateway event subscription wiring for agent, heartbeat, transcript, and lifecycle broadcasts. +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { getRuntimeConfig } from "../config/io.js"; import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js"; import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import type { SubsystemLogger } from "../logging/subsystem.js"; @@ -16,6 +18,7 @@ import type { SessionMessageSubscriberRegistry, ToolEventRecipientRegistry, } from "./server-chat-state.js"; +import { resolveVisibleActiveSessionRunState } from "./server-methods/session-active-runs.js"; function dispatchEventHandler(params: { loadHandler: () => Promise<(event: TEvent) => unknown>; @@ -168,6 +171,12 @@ export function startGatewayEventSubscriptions(params: { }, resolveActiveLifecycleGenerationForRun: (runId) => params.chatAbortControllers.get(runId)?.lifecycleGeneration, + resolveSessionActiveRunState: (session) => + resolveVisibleActiveSessionRunState({ + context: params, + ...session, + defaultAgentId: resolveDefaultAgentId(getRuntimeConfig()), + }), }), ); }, @@ -203,6 +212,7 @@ export function startGatewayEventSubscriptions(params: { createLifecycleEventBroadcastHandler({ broadcastToConnIds: params.broadcastToConnIds, sessionEventSubscribers: params.sessionEventSubscribers, + chatAbortControllers: params.chatAbortControllers, }), ); return lifecycleEventHandlerPromise; diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index aecb5fadc5a3..c6fe8511a4a9 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -14,7 +14,7 @@ import type { SessionEventSubscriberRegistry, SessionMessageSubscriberRegistry, } from "./server-chat.js"; -import { hasVisibleActiveSessionRun } from "./server-methods/session-active-runs.js"; +import { resolveVisibleActiveSessionRunState } from "./server-methods/session-active-runs.js"; import { buildGatewaySessionEventFields } from "./session-event-payload.js"; import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js"; import { @@ -75,6 +75,7 @@ function buildGatewaySessionSnapshot(params: { displayName?: string; parentSessionKey?: string; hasActiveRun?: boolean; + activeRunIds?: string[]; }): Record { const { sessionRow } = params; if (!sessionRow) { @@ -89,6 +90,9 @@ function buildGatewaySessionSnapshot(params: { if (session && params.hasActiveRun !== undefined) { session.hasActiveRun = params.hasActiveRun; } + if (session && params.activeRunIds !== undefined) { + session.activeRunIds = params.activeRunIds; + } return { ...(session ? { session } : {}), ...buildGatewaySessionEventFields({ @@ -98,6 +102,7 @@ function buildGatewaySessionSnapshot(params: { displayName: params.displayName, parentSessionKey: params.parentSessionKey, hasActiveRun: params.hasActiveRun, + activeRunIds: params.activeRunIds, }), subagentRunState: sessionRow.subagentRunState, hasActiveSubagentRun: sessionRow.hasActiveSubagentRun, @@ -178,8 +183,8 @@ async function handleTranscriptUpdateBroadcast( agentId: visibleAgentId, transcriptUsageMaxBytes: 64 * 1024, }); - const hasActiveRun = sessionRow - ? hasVisibleActiveSessionRun({ + const activeRunState = sessionRow + ? resolveVisibleActiveSessionRunState({ context: params, requestedKey: sessionKey, canonicalKey: sessionRow.key, @@ -187,12 +192,13 @@ async function handleTranscriptUpdateBroadcast( ...(sessionRow.key === "global" && visibleAgentId ? { agentId: visibleAgentId } : {}), defaultAgentId: normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())), }) - : false; + : null; const sessionSnapshot = buildGatewaySessionSnapshot({ sessionRow, agentId: visibleAgentId, includeSession: true, - hasActiveRun, + hasActiveRun: activeRunState?.active, + activeRunIds: activeRunState?.runIds, }); const idempotencyKey = readMessageIdempotencyKey(update.message); const senderIsOwner = readMessageSenderIsOwner(update.message); @@ -246,12 +252,23 @@ async function handleTranscriptUpdateBroadcast( export function createLifecycleEventBroadcastHandler(params: { broadcastToConnIds: GatewayBroadcastToConnIdsFn; sessionEventSubscribers: SessionEventSubscribers; + chatAbortControllers: Map; }) { return (event: SessionLifecycleEvent): void => { const connIds = params.sessionEventSubscribers.getAll(); if (connIds.size === 0) { return; } + const sessionRow = loadGatewaySessionRow(event.sessionKey); + const activeRunState = sessionRow + ? resolveVisibleActiveSessionRunState({ + context: params, + requestedKey: event.sessionKey, + canonicalKey: sessionRow.key, + sessionId: sessionRow.sessionId, + defaultAgentId: normalizeAgentId(resolveDefaultAgentId(getRuntimeConfig())), + }) + : null; params.broadcastToConnIds( "sessions.changed", { @@ -262,10 +279,12 @@ export function createLifecycleEventBroadcastHandler(params: { displayName: event.displayName, ts: Date.now(), ...buildGatewaySessionSnapshot({ - sessionRow: loadGatewaySessionRow(event.sessionKey), + sessionRow, label: event.label, displayName: event.displayName, parentSessionKey: event.parentSessionKey, + hasActiveRun: activeRunState?.active, + activeRunIds: activeRunState?.runIds, }), }, connIds, diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index e95c2e75c196..0ca01c005565 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -320,6 +320,7 @@ async function expectListedSessionActiveRun( const payload = expectRespondPayload(respond); const session = findSession(payload, "agent:main:main"); expect(session.hasActiveRun).toBe(expected); + expect(session.activeRunIds).toEqual(expected ? ["run-1"] : undefined); } test("sessions.list keeps bulk rows lightweight and uses persisted model fields", async () => { @@ -526,6 +527,24 @@ test("sessions.list marks sessions with active abortable runs", async () => { await expectListedSessionActiveRun("req-sessions-list-active-run", {}, true); }); +test("sessions.changed publishes visible active run ids", async () => { + await writeMainSessionStore(); + const result = await invokeSessionMutation({ + method: "sessions.patch", + params: { key: "main", label: "Active main" }, + context: { + chatAbortControllers: new Map([["run-1", { sessionKey: "agent:main:main" }]]), + }, + }); + + expectChangedBroadcast(result.broadcastToConnIds, { + sessionKey: "agent:main:main", + reason: "patch", + hasActiveRun: true, + activeRunIds: ["run-1"], + }); +}); + test("sessions.list ignores terminal abortable runs kept for retry guards", async () => { await expectListedSessionActiveRun( "req-sessions-list-terminal-run", diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index 5cb51fa33545..59e8e657daa1 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -7,6 +7,7 @@ export function buildGatewaySessionEventFields(params: { displayName?: string; parentSessionKey?: string; hasActiveRun?: boolean; + activeRunIds?: string[]; }): Record { const { sessionRow } = params; const omitUnscopedGlobalGoal = sessionRow.key === "global" && !params.agentId; @@ -63,6 +64,7 @@ export function buildGatewaySessionEventFields(params: { model: sessionRow.model, status: sessionRow.status, ...(params.hasActiveRun === undefined ? {} : { hasActiveRun: params.hasActiveRun }), + ...(params.activeRunIds === undefined ? {} : { activeRunIds: params.activeRunIds }), startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 5ea89898d81f..5d9564e9fbb8 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -90,6 +90,7 @@ export type GatewaySessionRow = { estimatedCostUsd?: number; status?: SessionRunStatus; hasActiveRun?: boolean; + activeRunIds?: string[]; subagentRunState?: SubagentRunState; hasActiveSubagentRun?: boolean; startedAt?: number; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 97cf069e7c2a..0b020d5de437 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -503,6 +503,7 @@ export type GatewaySessionRow = { estimatedCostUsd?: number; status?: SessionRunStatus; hasActiveRun?: boolean; + activeRunIds?: string[]; subagentRunState?: SubagentRunState; hasActiveSubagentRun?: boolean; startedAt?: number; diff --git a/ui/src/e2e/chat-run-lifecycle.e2e.test.ts b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts new file mode 100644 index 000000000000..bc7a5dd31ab7 --- /dev/null +++ b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts @@ -0,0 +1,104 @@ +// Control UI E2E tests cover chat run lifecycle behavior through the Gateway WebSocket. +import { chromium, type Browser, type Page } from "playwright"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { CHAT_RUN_STATUS_TOAST_DURATION_MS } from "../pages/chat/run-lifecycle.ts"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let browser: Browser | undefined; +let page: Page | undefined; +let server: ControlUiE2eServer | undefined; + +describeControlUiE2e("Control UI chat run lifecycle", () => { + beforeAll(async () => { + server = await startControlUiE2eServer(); + }); + + afterEach(async () => { + await page + ?.context() + .close() + .catch(() => {}); + await browser?.close().catch(() => {}); + page = undefined; + browser = undefined; + }); + + afterAll(async () => { + await server?.close(); + }); + + it("keeps Send visible when a stale active row outlives the terminal toast", async () => { + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + const context = await browser.newContext({ viewport: { height: 800, width: 1200 } }); + const currentPage = await context.newPage(); + page = currentPage; + const gateway = await installMockGateway(currentPage); + + await currentPage.goto(`${server?.baseUrl ?? ""}chat`); + await gateway.waitForRequest("sessions.list"); + await currentPage.locator(".agent-chat__composer-combobox textarea").fill("finish this run"); + await currentPage.getByRole("button", { name: "Send message" }).click(); + const send = await gateway.waitForRequest("chat.send"); + const params = send.params as { idempotencyKey?: unknown }; + expect(typeof params.idempotencyKey).toBe("string"); + const runId = params.idempotencyKey as string; + + await currentPage.getByRole("button", { name: "Stop generating" }).waitFor(); + await gateway.emitChatFinal({ runId, text: "Run complete." }); + await currentPage.getByText("Run complete.", { exact: true }).waitFor(); + await currentPage.getByRole("button", { name: "Send message" }).waitFor(); + + await gateway.emitGatewayEvent("sessions.changed", { + activeRunIds: [runId], + hasActiveRun: true, + key: "main", + kind: "direct", + reason: "lifecycle", + startedAt: Date.now() - 1_000, + status: "running", + updatedAt: Date.now(), + }); + expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0); + + await currentPage.waitForTimeout(CHAT_RUN_STATUS_TOAST_DURATION_MS + 250); + expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0); + expect(await currentPage.getByRole("button", { name: "Send message" }).count()).toBe(1); + + await gateway.emitGatewayEvent("sessions.changed", { + key: "agent:main:another-session", + kind: "direct", + label: "Another session", + reason: "lifecycle", + updatedAt: Date.now(), + }); + expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0); + expect(await currentPage.getByRole("button", { name: "Send message" }).count()).toBe(1); + + // Re-publish after the former 10-second suppression window. The completed + // run identity stays terminal until the Gateway publishes different state. + await currentPage.waitForTimeout(CHAT_RUN_STATUS_TOAST_DURATION_MS + 250); + await gateway.emitGatewayEvent("sessions.changed", { + activeRunIds: [runId], + hasActiveRun: true, + key: "main", + kind: "direct", + reason: "lifecycle", + startedAt: Date.now() - 11_000, + status: "running", + updatedAt: Date.now(), + }); + expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0); + expect(await currentPage.getByRole("button", { name: "Send message" }).count()).toBe(1); + }); +}); diff --git a/ui/src/pages/chat/chat-page.ts b/ui/src/pages/chat/chat-page.ts index 330f4bffdca9..55c8140090f8 100644 --- a/ui/src/pages/chat/chat-page.ts +++ b/ui/src/pages/chat/chat-page.ts @@ -72,7 +72,10 @@ import { type SidebarFullMessageRequest, } from "./components/chat-sidebar.ts"; import { exportChatMarkdown } from "./export.ts"; -import { hasAbortableSessionRun } from "./run-lifecycle.ts"; +import { + hasAbortableSessionRun, + reconcileStaleChatRunAfterSessionStatePublication, +} from "./run-lifecycle.ts"; import { scheduleChatScroll } from "./scroll.ts"; import { clearChatMessagesFromCache } from "./session-message-cache.ts"; @@ -485,7 +488,10 @@ export class ChatPage extends LitElement { }); return; } - state.requestUpdate?.(); + const reconciledLocalCompletion = reconcileStaleChatRunAfterSessionStatePublication(state); + if (!reconciledLocalCompletion) { + state.requestUpdate?.(); + } } private applyApplicationConfig(config: ApplicationContext["config"]["current"]) { diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index 7d01c6ff6eb8..267a8fefa716 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -96,6 +96,7 @@ import { reconcileChatRunFromCurrentSessionRow, reconcileChatRunFromSessionRow, reconcileChatRunLifecycle, + reconcileStaleChatRunAfterSessionStatePublication, } from "./run-lifecycle.ts"; import { scheduleChatScroll, handleChatScroll, resetChatScroll } from "./scroll.ts"; import { cacheChatMessages, readChatMessagesFromCache } from "./session-message-cache.ts"; @@ -714,6 +715,7 @@ function reconcileSessionEvent(state: ChatPageHost, payload: unknown): SessionCh state.sessionsResult = state.sessions.state.result; state.sessionsResultAgentId = state.sessions.state.agentId; state.sessionsError = state.sessions.state.error; + reconcileStaleChatRunAfterSessionStatePublication(state); } return reconciled; } diff --git a/ui/src/pages/chat/run-lifecycle.test.ts b/ui/src/pages/chat/run-lifecycle.test.ts index 3df8f533f815..01f98cac110c 100644 --- a/ui/src/pages/chat/run-lifecycle.test.ts +++ b/ui/src/pages/chat/run-lifecycle.test.ts @@ -1,16 +1,23 @@ // Control UI tests cover run lifecycle behavior. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SessionsListResult } from "../../api/types.ts"; import { isSessionRunActive } from "../../lib/session-run-state.ts"; import { + CHAT_RUN_STATUS_TOAST_DURATION_MS, reconcileChatRunFromCurrentSessionRow, reconcileChatRunFromSessionRow, reconcileChatRunLifecycle, - STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS, + reconcileStaleChatRunAfterSessionStatePublication, } from "./run-lifecycle.ts"; type ReconcileHost = Parameters[0]; -type TestRow = { key: string; hasActiveRun?: boolean; status?: string; startedAt?: number }; +type TestRow = { + key: string; + hasActiveRun?: boolean; + activeRunIds?: string[]; + status?: string; + startedAt?: number; +}; function makeSessionsResult(rows: TestRow[]): SessionsListResult { return { sessions: rows } as unknown as SessionsListResult; @@ -21,7 +28,9 @@ function makeHost(over: Partial = {}): ReconcileHost { sessionKey: "s1", chatRunId: null, chatStream: null, - sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: true, status: "running" }]), + sessionsResult: makeSessionsResult([ + { key: "s1", hasActiveRun: true, activeRunIds: ["r1"], status: "running" }, + ]), requestUpdate: () => {}, ...over, }; @@ -32,6 +41,22 @@ function rowActive(host: ReconcileHost): boolean { return Boolean(row && isSessionRunActive(row)); } +function completeLocalRun(host: ReconcileHost, publishRunStatus = true) { + reconcileChatRunLifecycle(host, { + outcome: "done", + sessionStatus: "done", + runId: "r1", + sessionKey: "s1", + clearLocalRun: true, + clearChatStream: true, + armLocalTerminalReconcile: true, + publishRunStatus, + }); + if (!host.lastLocalTerminalReconcile) { + throw new Error("Expected local terminal reconciliation to be armed"); + } +} + describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875)", () => { it("keeps a local run active when the gateway registry overrides a terminal snapshot", () => { const host = makeHost({ @@ -59,7 +84,6 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 runId: "r1", phase: "done", sessionStatus: "done", - occurredAt: Date.now(), }, }); expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true); @@ -73,19 +97,45 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 expect(rowActive(host)).toBe(true); }); - it("ignores and clears a local terminal reconcile older than the window", () => { + it("retains the completed run identity while the session row is unavailable", () => { + const host = makeHost({ + sessionsResult: null, + lastLocalTerminalReconcile: { + sessionKey: "s1", + runId: "r1", + phase: "done", + sessionStatus: "done", + }, + }); + + expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false); + expect(host.lastLocalTerminalReconcile?.runId).toBe("r1"); + + host.sessionsResult = makeSessionsResult([ + { key: "s1", hasActiveRun: true, activeRunIds: ["r1"], status: "running" }, + ]); + expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true); + expect(rowActive(host)).toBe(false); + }); + + it("keeps suppressing the exact completed run without a time limit", () => { + vi.useFakeTimers(); 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(); + try { + vi.advanceTimersByTime(60_000); + expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true); + expect(rowActive(host)).toBe(false); + expect(host.lastLocalTerminalReconcile?.runId).toBe("r1"); + } finally { + vi.useRealTimers(); + } }); it("does not suppress when the recent completion was for a different session", () => { @@ -97,7 +147,6 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 runId: "r1", phase: "done", sessionStatus: "done", - occurredAt: Date.now(), }, }); expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false); @@ -112,7 +161,6 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 runId: "r1", phase: "done", sessionStatus: "done", - occurredAt: Date.now(), }, }); expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false); @@ -135,21 +183,21 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 }); expect(host.lastLocalTerminalReconcile ?? null).toBeNull(); host.sessionsResult = makeSessionsResult([ - { key: "s1", hasActiveRun: true, status: "running" }, + { key: "s1", hasActiveRun: true, activeRunIds: ["r1"], 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(); + it("does not suppress a different active run id", () => { const host = makeHost({ sessionsResult: makeSessionsResult([ { key: "s1", hasActiveRun: true, + activeRunIds: ["r2"], status: "running", - startedAt: terminalAt + 1, + startedAt: Date.now() - 60_000, }, ]), lastLocalTerminalReconcile: { @@ -157,7 +205,6 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 runId: "r1", phase: "done", sessionStatus: "done", - occurredAt: terminalAt, }, }); expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false); @@ -165,6 +212,22 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 expect(host.lastLocalTerminalReconcile).toBeNull(); }); + it("does not suppress an active row without run identity", () => { + const host = makeHost({ + sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: true, status: "running" }]), + lastLocalTerminalReconcile: { + sessionKey: "s1", + runId: "r1", + phase: "done", + sessionStatus: "done", + }, + }); + + expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false); + expect(rowActive(host)).toBe(true); + expect(host.lastLocalTerminalReconcile).toBeNull(); + }); + it("clears selected agent-main alias runs from canonical global history rows", () => { const host = makeHost({ sessionKey: "agent:work:main", @@ -233,44 +296,121 @@ describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875 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, + sessionsResult: makeSessionsResult([ + { key: "s1", hasActiveRun: true, activeRunIds: ["r1"], status: "running" }, + ]), }); + completeLocalRun(host, false); 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" }, + { key: "s1", hasActiveRun: true, activeRunIds: ["r1"], 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(); + it("reconciles a stale active row when the terminal toast expires", () => { + vi.useFakeTimers(); + try { + const host = makeHost({ chatRunId: "r1", chatStream: "partial..." }); + completeLocalRun(host); + host.sessionsResult = makeSessionsResult([ + { key: "s1", hasActiveRun: true, activeRunIds: ["r1"], status: "running" }, + ]); + + vi.advanceTimersByTime(CHAT_RUN_STATUS_TOAST_DURATION_MS); + + expect(host.chatRunStatus).toBeNull(); + expect(rowActive(host)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it("preserves a newer run id even when the Gateway clock trails the browser", () => { + vi.useFakeTimers(); + try { + const host = makeHost({ chatRunId: "r1", chatStream: "partial..." }); + completeLocalRun(host); + host.sessionsResult = makeSessionsResult([ + { + key: "s1", + hasActiveRun: true, + activeRunIds: ["r2"], + status: "running", + startedAt: Date.now() - 60_000, + }, + ]); + + vi.advanceTimersByTime(CHAT_RUN_STATUS_TOAST_DURATION_MS); + + expect(host.chatRunStatus).toBeNull(); + expect(rowActive(host)).toBe(true); + expect(host.lastLocalTerminalReconcile).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not clear a follow-up run adopted before the previous toast expires", () => { + vi.useFakeTimers(); + try { + const host = makeHost({ chatRunId: "r1", chatStream: "first reply" }); + completeLocalRun(host); + host.chatRunId = "r2"; + host.chatStream = "follow-up reply"; + + vi.advanceTimersByTime(CHAT_RUN_STATUS_TOAST_DURATION_MS); + + expect(host.chatRunStatus).toBeNull(); + expect(host.chatRunId).toBe("r2"); + expect(host.chatStream).toBe("follow-up reply"); + } finally { + vi.useRealTimers(); + } + }); + + it("waits for terminal status to expire before reconciling session publications", () => { + const completedAt = Date.now(); + const host = makeHost({ + chatRunStatus: { + phase: "done", + runId: "r1", + sessionKey: "s1", + occurredAt: completedAt, + }, + lastLocalTerminalReconcile: { + sessionKey: "s1", + runId: "r1", + phase: "done", + sessionStatus: "done", + }, + }); + + expect(reconcileStaleChatRunAfterSessionStatePublication(host)).toBe(false); + expect(rowActive(host)).toBe(true); + + host.chatRunStatus = null; + expect(reconcileStaleChatRunAfterSessionStatePublication(host)).toBe(true); + expect(rowActive(host)).toBe(false); + }); + + it("keeps suppressing repeated stale active refreshes for the completed run", () => { 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 }, + { key: "s1", hasActiveRun: true, activeRunIds: ["r1"], status: "running" }, ]); expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true); expect(rowActive(host)).toBe(false); diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index 73f4d2a2ae85..7fb726b6f21d 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -23,15 +23,8 @@ export type LocalTerminalReconcile = { 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"> & { @@ -195,7 +188,11 @@ function scheduleRunStatusClear(host: RunLifecycleHost, status: ChatRunUiStatus) } host.chatRunStatus = null; host.chatRunStatusClearTimer = null; - host.requestUpdate?.(); + // Terminal status temporarily masks stale active rows from session polling. + // Reconcile again as the mask expires so the composer cannot revert to Stop. + if (!reconcileStaleChatRunAfterSessionStatePublication(host)) { + host.requestUpdate?.(); + } }, CHAT_RUN_STATUS_TOAST_DURATION_MS); } @@ -301,7 +298,6 @@ export function reconcileChatRunLifecycle(host: RunLifecycleHost, options: Recon runId, phase: options.outcome, sessionStatus: options.sessionStatus ?? (options.outcome === "done" ? "done" : "killed"), - occurredAt, }; } if (options.publishRunStatus !== false) { @@ -321,27 +317,33 @@ function currentSessionRow(host: RunLifecycleHost) { // 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) +// terminal to that row — but only while its active-run identity exactly +// matches the locally completed run. Keep that identity tombstone until the +// Gateway reports terminal state or a different run, because poll lag has no +// safe time bound. (#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. + if (!row) { + // A disconnected or incomplete session result proves nothing about the + // run. Retain the identity so reconnect cannot revive the completed run. + return false; + } + if (!isSessionRunActive(row)) { + // The server now reflects a non-active state, so stop suppressing. host.lastLocalTerminalReconcile = null; return false; } - if (typeof row.startedAt === "number" && row.startedAt > recent.occurredAt) { + // Browser and Gateway clocks can differ. Only an exact active-run identity + // proves this row still describes the locally completed run. + if ( + recent.runId == null || + row.activeRunIds?.length !== 1 || + row.activeRunIds[0] !== recent.runId + ) { host.lastLocalTerminalReconcile = null; return false; } @@ -368,6 +370,17 @@ export function reconcileChatRunFromCurrentSessionRow( return reconcileChatRunFromSessionRow(host, row, options); } +export function reconcileStaleChatRunAfterSessionStatePublication(host: RunLifecycleHost): boolean { + // Both session subscriptions and direct event reconciliation can republish + // canonical rows after the local terminal projection; guard both paths. + const canReconcile = + host.chatRunStatus == null && + host.lastLocalTerminalReconcile != null && + !host.chatRunId && + host.chatStream == null; + return canReconcile && reconcileChatRunFromCurrentSessionRow(host, { publishRunStatus: false }); +} + function isSessionRowForSelectedChat( host: RunLifecycleHost, rowKey: string,