fix(ui): bind stale run state to run identity (#100527)

Co-authored-by: Tiffany Chum <tiffanychum@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-06 02:33:40 +01:00
committed by GitHub
parent c0e57db8f4
commit 4f8eeeaca9
18 changed files with 525 additions and 104 deletions

View File

@@ -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<NonNullable<AgentEventHandlerOptions["resolveSessionActiveRunState"]>>()
.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])

View File

@@ -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 },

View File

@@ -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;

View File

@@ -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"] });
});

View File

@@ -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<Pick<GatewayRequestContext, "chatAbortControllers">>;
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<Pick<GatewayRequestContext, "chatAbortControllers">>;
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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<TEvent>(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;

View File

@@ -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<string, unknown> {
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<string, ChatAbortControllerEntry>;
}) {
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,

View File

@@ -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",

View File

@@ -7,6 +7,7 @@ export function buildGatewaySessionEventFields(params: {
displayName?: string;
parentSessionKey?: string;
hasActiveRun?: boolean;
activeRunIds?: string[];
}): Record<string, unknown> {
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,

View File

@@ -90,6 +90,7 @@ export type GatewaySessionRow = {
estimatedCostUsd?: number;
status?: SessionRunStatus;
hasActiveRun?: boolean;
activeRunIds?: string[];
subagentRunState?: SubagentRunState;
hasActiveSubagentRun?: boolean;
startedAt?: number;

View File

@@ -503,6 +503,7 @@ export type GatewaySessionRow = {
estimatedCostUsd?: number;
status?: SessionRunStatus;
hasActiveRun?: boolean;
activeRunIds?: string[];
subagentRunState?: SubagentRunState;
hasActiveSubagentRun?: boolean;
startedAt?: number;

View File

@@ -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);
});
});

View File

@@ -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"]) {

View File

@@ -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;
}

View File

@@ -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<typeof reconcileChatRunFromCurrentSessionRow>[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> = {}): 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);

View File

@@ -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<typeof globalThis.setTimeout>;
type RunLifecycleHost = Omit<Partial<Parameters<typeof resetToolStream>[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,