mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 12:54:07 +00:00
fix(webchat): suppress stale active session rows (#87962)
This commit is contained in:
committed by
GitHub
parent
9a1b95c1e6
commit
e452d16cea
193
ui/src/ui/chat/run-lifecycle.test.ts
Normal file
193
ui/src/ui/chat/run-lifecycle.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSessionRunActive } from "../session-run-state.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import {
|
||||
reconcileChatRunFromCurrentSessionRow,
|
||||
reconcileChatRunLifecycle,
|
||||
STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS,
|
||||
} from "./run-lifecycle.ts";
|
||||
|
||||
type ReconcileHost = Parameters<typeof reconcileChatRunFromCurrentSessionRow>[0];
|
||||
type TestRow = { key: string; hasActiveRun?: boolean; status?: string; startedAt?: number };
|
||||
|
||||
function makeSessionsResult(rows: TestRow[]): SessionsListResult {
|
||||
return { sessions: rows } as unknown as SessionsListResult;
|
||||
}
|
||||
|
||||
function makeHost(over: Partial<ReconcileHost> = {}): ReconcileHost {
|
||||
return {
|
||||
sessionKey: "s1",
|
||||
chatRunId: null,
|
||||
chatStream: null,
|
||||
sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: true, status: "running" }]),
|
||||
requestUpdate: () => {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function rowActive(host: ReconcileHost): boolean {
|
||||
const row = host.sessionsResult?.sessions.find((r) => r.key === host.sessionKey);
|
||||
return Boolean(row && isSessionRunActive(row));
|
||||
}
|
||||
|
||||
describe("reconcileChatRunFromCurrentSessionRow stale-active suppression (#87875)", () => {
|
||||
it("suppresses a stale active row after a recent local completion", () => {
|
||||
const host = makeHost({
|
||||
lastLocalTerminalReconcile: {
|
||||
sessionKey: "s1",
|
||||
runId: "r1",
|
||||
phase: "done",
|
||||
sessionStatus: "done",
|
||||
occurredAt: Date.now(),
|
||||
},
|
||||
});
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
|
||||
expect(rowActive(host)).toBe(false);
|
||||
expect(host.lastLocalTerminalReconcile?.runId).toBe("r1");
|
||||
});
|
||||
|
||||
it("does NOT clear a genuinely recovered active run with no recent local completion", () => {
|
||||
const host = makeHost({ lastLocalTerminalReconcile: null });
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
|
||||
expect(rowActive(host)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores and clears a local terminal reconcile older than the window", () => {
|
||||
const host = makeHost({
|
||||
lastLocalTerminalReconcile: {
|
||||
sessionKey: "s1",
|
||||
runId: "r1",
|
||||
phase: "done",
|
||||
sessionStatus: "done",
|
||||
occurredAt: Date.now() - STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS - 1_000,
|
||||
},
|
||||
});
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
|
||||
expect(rowActive(host)).toBe(true);
|
||||
expect(host.lastLocalTerminalReconcile).toBeNull();
|
||||
});
|
||||
|
||||
it("does not suppress when the recent completion was for a different session", () => {
|
||||
const host = makeHost({
|
||||
sessionKey: "s2",
|
||||
sessionsResult: makeSessionsResult([{ key: "s2", hasActiveRun: true, status: "running" }]),
|
||||
lastLocalTerminalReconcile: {
|
||||
sessionKey: "s1",
|
||||
runId: "r1",
|
||||
phase: "done",
|
||||
sessionStatus: "done",
|
||||
occurredAt: Date.now(),
|
||||
},
|
||||
});
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
|
||||
expect(rowActive(host)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the flag once the server poll reports a non-active row", () => {
|
||||
const host = makeHost({
|
||||
sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: false, status: "done" }]),
|
||||
lastLocalTerminalReconcile: {
|
||||
sessionKey: "s1",
|
||||
runId: "r1",
|
||||
phase: "done",
|
||||
sessionStatus: "done",
|
||||
occurredAt: Date.now(),
|
||||
},
|
||||
});
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
|
||||
expect(host.lastLocalTerminalReconcile).toBeNull();
|
||||
});
|
||||
|
||||
it("does not arm stale-row suppression from generic lifecycle cleanup", () => {
|
||||
const host = makeHost({
|
||||
chatRunId: "orphaned-run",
|
||||
chatStream: "stale stream",
|
||||
});
|
||||
reconcileChatRunLifecycle(host, {
|
||||
outcome: "interrupted",
|
||||
sessionStatus: "killed",
|
||||
runId: "orphaned-run",
|
||||
sessionKey: "s1",
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
publishRunStatus: false,
|
||||
});
|
||||
expect(host.lastLocalTerminalReconcile ?? null).toBeNull();
|
||||
host.sessionsResult = makeSessionsResult([
|
||||
{ key: "s1", hasActiveRun: true, status: "running" },
|
||||
]);
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
|
||||
expect(rowActive(host)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress a newer active row after a follow-up run starts", () => {
|
||||
const terminalAt = Date.now();
|
||||
const host = makeHost({
|
||||
sessionsResult: makeSessionsResult([
|
||||
{
|
||||
key: "s1",
|
||||
hasActiveRun: true,
|
||||
status: "running",
|
||||
startedAt: terminalAt + 1,
|
||||
},
|
||||
]),
|
||||
lastLocalTerminalReconcile: {
|
||||
sessionKey: "s1",
|
||||
runId: "r1",
|
||||
phase: "done",
|
||||
sessionStatus: "done",
|
||||
occurredAt: terminalAt,
|
||||
},
|
||||
});
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(false);
|
||||
expect(rowActive(host)).toBe(true);
|
||||
expect(host.lastLocalTerminalReconcile).toBeNull();
|
||||
});
|
||||
|
||||
it("arms suppression on a completed turn, then suppresses the racing refresh", () => {
|
||||
const host = makeHost({
|
||||
chatRunId: "r1",
|
||||
chatStream: "partial...",
|
||||
sessionsResult: makeSessionsResult([{ key: "s1", hasActiveRun: true, status: "running" }]),
|
||||
});
|
||||
reconcileChatRunLifecycle(host, {
|
||||
outcome: "done",
|
||||
sessionStatus: "done",
|
||||
runId: "r1",
|
||||
sessionKey: "s1",
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
publishRunStatus: false,
|
||||
armLocalTerminalReconcile: true,
|
||||
});
|
||||
expect(host.lastLocalTerminalReconcile?.sessionKey).toBe("s1");
|
||||
expect(host.chatRunId ?? null).toBeNull();
|
||||
// A racing sessions.list refresh re-introduces a stale active row.
|
||||
host.sessionsResult = makeSessionsResult([
|
||||
{ key: "s1", hasActiveRun: true, status: "running" },
|
||||
]);
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
|
||||
expect(rowActive(host)).toBe(false);
|
||||
expect(host.lastLocalTerminalReconcile?.runId).toBe("r1");
|
||||
});
|
||||
|
||||
it("keeps suppressing multiple stale active refreshes within the window", () => {
|
||||
const terminalAt = Date.now();
|
||||
const host = makeHost({
|
||||
lastLocalTerminalReconcile: {
|
||||
sessionKey: "s1",
|
||||
runId: "r1",
|
||||
phase: "done",
|
||||
sessionStatus: "done",
|
||||
occurredAt: terminalAt,
|
||||
},
|
||||
});
|
||||
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
|
||||
host.sessionsResult = makeSessionsResult([
|
||||
{ key: "s1", hasActiveRun: true, status: "running", startedAt: terminalAt - 1 },
|
||||
]);
|
||||
expect(reconcileChatRunFromCurrentSessionRow(host)).toBe(true);
|
||||
expect(rowActive(host)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,20 @@ export type ChatRunUiStatus = {
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
export type LocalTerminalReconcile = {
|
||||
sessionKey: string;
|
||||
runId: string | null;
|
||||
phase: ChatRunUiStatus["phase"];
|
||||
sessionStatus: SessionRunStatus;
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
// A terminal chat event clears local run state before the periodic
|
||||
// sessions.list poll catches up. Within this window a stale "active" row for
|
||||
// the just-completed selected session is treated as poll lag and reconciled
|
||||
// back to terminal, so the composer does not snap back to in-progress. (#87875)
|
||||
export const STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS = 10_000;
|
||||
|
||||
type TimerHandle = ReturnType<typeof globalThis.setTimeout>;
|
||||
|
||||
type RunLifecycleHost = Omit<Partial<Parameters<typeof resetToolStream>[0]>, "hello"> & {
|
||||
@@ -26,6 +40,7 @@ type RunLifecycleHost = Omit<Partial<Parameters<typeof resetToolStream>[0]>, "he
|
||||
chatRunStatus?: ChatRunUiStatus | null;
|
||||
chatRunStatusClearTimer?: TimerHandle | number | null;
|
||||
sessionsResult?: SessionsListResult | null;
|
||||
lastLocalTerminalReconcile?: LocalTerminalReconcile | null;
|
||||
requestUpdate?: () => void;
|
||||
};
|
||||
|
||||
@@ -42,6 +57,7 @@ type ReconcileOptions = {
|
||||
clearSideResultTerminalRuns?: boolean;
|
||||
clearRunStatus?: boolean;
|
||||
publishRunStatus?: boolean;
|
||||
armLocalTerminalReconcile?: boolean;
|
||||
};
|
||||
|
||||
function toSessionKey(value: string | null | undefined): string | null {
|
||||
@@ -184,6 +200,15 @@ export function reconcileChatRunLifecycle(host: RunLifecycleHost, options: Recon
|
||||
occurredAt,
|
||||
};
|
||||
reconcileSessionRows(host, options, occurredAt);
|
||||
if (options.armLocalTerminalReconcile) {
|
||||
host.lastLocalTerminalReconcile = {
|
||||
sessionKey,
|
||||
runId,
|
||||
phase: options.outcome,
|
||||
sessionStatus: options.sessionStatus ?? (options.outcome === "done" ? "done" : "killed"),
|
||||
occurredAt,
|
||||
};
|
||||
}
|
||||
if (options.publishRunStatus !== false) {
|
||||
host.chatRunStatus = status;
|
||||
scheduleRunStatusClear(host, status);
|
||||
@@ -198,12 +223,48 @@ function currentSessionRow(host: RunLifecycleHost) {
|
||||
return host.sessionsResult?.sessions.find((row) => row.key === host.sessionKey);
|
||||
}
|
||||
|
||||
// After a terminal chat event clears local run state, a racing sessions.list
|
||||
// refresh can still carry a stale "active" row for the session we just
|
||||
// finished, which would drive the composer back to in-progress. Re-apply
|
||||
// terminal to that row — but only while we hold a recent LOCAL terminal
|
||||
// reconcile for the currently selected session, so a genuinely recovered
|
||||
// active run (e.g. opening WebChat to a session already running elsewhere) is
|
||||
// never cleared. (#87875)
|
||||
function reconcileStaleSelectedSessionRunAfterLocalCompletion(host: RunLifecycleHost): boolean {
|
||||
const recent = host.lastLocalTerminalReconcile;
|
||||
if (!recent || recent.sessionKey !== host.sessionKey) {
|
||||
return false;
|
||||
}
|
||||
if (Date.now() - recent.occurredAt > STALE_ACTIVE_ROW_RECONCILE_WINDOW_MS) {
|
||||
host.lastLocalTerminalReconcile = null;
|
||||
return false;
|
||||
}
|
||||
const row = currentSessionRow(host);
|
||||
if (!row || !isSessionRunActive(row)) {
|
||||
// No row, or the server already reflects a non-active state — the poll has
|
||||
// caught up, so stop suppressing.
|
||||
host.lastLocalTerminalReconcile = null;
|
||||
return false;
|
||||
}
|
||||
if (typeof row.startedAt === "number" && row.startedAt > recent.occurredAt) {
|
||||
host.lastLocalTerminalReconcile = null;
|
||||
return false;
|
||||
}
|
||||
reconcileSessionRows(
|
||||
host,
|
||||
{ outcome: recent.phase, sessionStatus: recent.sessionStatus, sessionKey: recent.sessionKey },
|
||||
Date.now(),
|
||||
);
|
||||
host.requestUpdate?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function reconcileChatRunFromCurrentSessionRow(
|
||||
host: RunLifecycleHost,
|
||||
options: { publishRunStatus?: boolean } = {},
|
||||
): boolean {
|
||||
if (!host.chatRunId && host.chatStream == null) {
|
||||
return false;
|
||||
return reconcileStaleSelectedSessionRunAfterLocalCompletion(host);
|
||||
}
|
||||
const row = currentSessionRow(host);
|
||||
if (!row) {
|
||||
|
||||
@@ -114,6 +114,24 @@ describe("handleChatEvent", () => {
|
||||
expect(handleChatEvent(state, payload)).toBe(null);
|
||||
});
|
||||
|
||||
it("does not arm stale active-row suppression for an unowned selected-session final", () => {
|
||||
const state = createState({ sessionKey: "main" }) as ChatState & {
|
||||
lastLocalTerminalReconcile?: unknown;
|
||||
};
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "observed-run",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Observed reply" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("final");
|
||||
expect(state.lastLocalTerminalReconcile).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores selected-agent global events for another agent", () => {
|
||||
const state = createState({
|
||||
sessionKey: "global",
|
||||
|
||||
@@ -927,6 +927,7 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
sessionKeys: sessionMatches ? [state.sessionKey, payload.sessionKey] : [],
|
||||
clearLocalRun: true,
|
||||
clearChatStream: true,
|
||||
armLocalTerminalReconcile: hadActiveRunBeforeEvent && activeRunMatches,
|
||||
});
|
||||
|
||||
if (payload.state === "delta") {
|
||||
|
||||
Reference in New Issue
Block a user