mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 19:06:08 +00:00
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:
committed by
GitHub
parent
c0e57db8f4
commit
4f8eeeaca9
@@ -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])
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,6 +90,7 @@ export type GatewaySessionRow = {
|
||||
estimatedCostUsd?: number;
|
||||
status?: SessionRunStatus;
|
||||
hasActiveRun?: boolean;
|
||||
activeRunIds?: string[];
|
||||
subagentRunState?: SubagentRunState;
|
||||
hasActiveSubagentRun?: boolean;
|
||||
startedAt?: number;
|
||||
|
||||
@@ -503,6 +503,7 @@ export type GatewaySessionRow = {
|
||||
estimatedCostUsd?: number;
|
||||
status?: SessionRunStatus;
|
||||
hasActiveRun?: boolean;
|
||||
activeRunIds?: string[];
|
||||
subagentRunState?: SubagentRunState;
|
||||
hasActiveSubagentRun?: boolean;
|
||||
startedAt?: number;
|
||||
|
||||
104
ui/src/e2e/chat-run-lifecycle.e2e.test.ts
Normal file
104
ui/src/e2e/chat-run-lifecycle.e2e.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user