mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 11:31:34 +00:00
fix(sessions): recover deleted chats without endless retries (#115974)
* fix(sessions): recover deleted chats without lifecycle races * fix(sessions): narrow deleted route commit keys * test(sessions): isolate deleted-chat recovery regressions * fix(sessions): recover chats after agent removal * fix(sessions): fence recreated session deletion races * fix(sessions): preserve filtered active conversations * test(sessions): preserve canonical main during chat recovery * test(ui): follow canonical agent channels route
This commit is contained in:
committed by
GitHub
parent
9945fffc6d
commit
b44890bf2d
@@ -127,6 +127,50 @@ test("concurrent sessions.create requests adopt one canonical keyed session", as
|
||||
);
|
||||
});
|
||||
|
||||
test("keyed sessions remain recoverable across overlapping create and delete waves", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
const key = "agent:main:dashboard:concurrent-lifecycle-waves";
|
||||
|
||||
for (let wave = 0; wave < 6; wave += 1) {
|
||||
const operations = await Promise.all(
|
||||
Array.from({ length: 12 }, (_, index) =>
|
||||
index % 3 === 0
|
||||
? directSessionReq<{ deleted: boolean }>("sessions.delete", {
|
||||
key,
|
||||
deleteTranscript: false,
|
||||
})
|
||||
: directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
|
||||
agentId: "main",
|
||||
key,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
operations.every((result) => result.ok),
|
||||
`lifecycle wave ${wave}`,
|
||||
).toBe(true);
|
||||
|
||||
const recovered = await directSessionReq<{ key: string; sessionId: string }>(
|
||||
"sessions.create",
|
||||
{ agentId: "main", key },
|
||||
);
|
||||
expect(recovered.ok, `creation after lifecycle wave ${wave}`).toBe(true);
|
||||
expect(recovered.payload?.key).toBe(key);
|
||||
expect(loadSessionEntry({ sessionKey: key, storePath })?.sessionId).toBe(
|
||||
recovered.payload?.sessionId,
|
||||
);
|
||||
|
||||
const deleted = await directSessionReq<{ deleted: boolean }>("sessions.delete", {
|
||||
key,
|
||||
deleteTranscript: false,
|
||||
});
|
||||
expect(deleted.ok, `deletion after lifecycle wave ${wave}`).toBe(true);
|
||||
expect(deleted.payload?.deleted).toBe(true);
|
||||
expect(loadSessionEntry({ sessionKey: key, storePath })).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create keeps incognito rows process-local through list, spawn, reset, and delete", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
try {
|
||||
|
||||
205
ui/src/app/app-host.deleted-session.test.ts
Normal file
205
ui/src/app/app-host.deleted-session.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import type { RouteLocation, RouterState } from "@openclaw/uirouter";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RouteId } from "../app-routes.ts";
|
||||
import { createStorageMock } from "../test-helpers/storage.ts";
|
||||
import { selectShellRouteState } from "./app-host-route-state.ts";
|
||||
import { resetAppHostTestGlobals } from "./app-host.test-support.ts";
|
||||
import type { ApplicationContext } from "./context.ts";
|
||||
import "./app-host.ts";
|
||||
|
||||
type DeletedSessionShell = {
|
||||
runtime: { context: ApplicationContext };
|
||||
activeSessionKey: string;
|
||||
routeState: { routeId?: RouteId; location?: RouteLocation };
|
||||
didConsiderNativeRouteRestore: boolean;
|
||||
replaceChatWithCurrentSession: () => void;
|
||||
updateRouteState: (state: ReturnType<typeof selectShellRouteState>) => void;
|
||||
};
|
||||
|
||||
const mainKey = "agent:main:main";
|
||||
const deletedKey = "agent:main:deleted-thread";
|
||||
|
||||
function createSessionRecoveryShell(params: {
|
||||
activeSessionKey: string;
|
||||
agentIds?: string[];
|
||||
sessionKeys: string[];
|
||||
deletedSessionKeys?: string[];
|
||||
}) {
|
||||
const replace = vi.fn();
|
||||
const setSessionKey = vi.fn();
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as DeletedSessionShell;
|
||||
shell.runtime = {
|
||||
context: {
|
||||
basePath: "",
|
||||
agents: {
|
||||
state: {
|
||||
agentsList: {
|
||||
defaultId: "main",
|
||||
mainKey: "main",
|
||||
agents: (params.agentIds ?? ["main"]).map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
},
|
||||
agentSelection: { set: vi.fn(), state: { selectedId: "main" } },
|
||||
gateway: {
|
||||
setSessionKey,
|
||||
snapshot: { client: null, hello: null, phase: "connected" },
|
||||
},
|
||||
sessions: {
|
||||
state: {
|
||||
deletedSessions: (params.deletedSessionKeys ?? []).map((key) => ({
|
||||
agentId: "main",
|
||||
key,
|
||||
})),
|
||||
result: { sessions: params.sessionKeys.map((key) => ({ key })) },
|
||||
},
|
||||
},
|
||||
replace,
|
||||
} as unknown as ApplicationContext,
|
||||
};
|
||||
shell.activeSessionKey = params.activeSessionKey;
|
||||
shell.routeState = { routeId: "chat" };
|
||||
return { replace, setSessionKey, shell };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetAppHostTestGlobals();
|
||||
});
|
||||
|
||||
describe("OpenClaw shell deleted-session recovery", () => {
|
||||
it("replaces an unresolvable session with the owning agent's main chat", () => {
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: deletedKey,
|
||||
sessionKeys: [mainKey],
|
||||
});
|
||||
|
||||
shell.replaceChatWithCurrentSession();
|
||||
|
||||
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
|
||||
});
|
||||
|
||||
it("preserves the owning non-default agent when its session is deleted", () => {
|
||||
const researchKey = "agent:research:main";
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: "agent:research:deleted-thread",
|
||||
agentIds: ["main", "research"],
|
||||
sessionKeys: [mainKey, researchKey],
|
||||
});
|
||||
|
||||
shell.replaceChatWithCurrentSession();
|
||||
|
||||
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(researchKey);
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/research" });
|
||||
});
|
||||
|
||||
it("recovers to a known agent when the deleted session's owner was removed", () => {
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: "agent:retired:deleted-thread",
|
||||
agentIds: ["main"],
|
||||
sessionKeys: [mainKey],
|
||||
});
|
||||
|
||||
shell.replaceChatWithCurrentSession();
|
||||
|
||||
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
|
||||
});
|
||||
|
||||
it("does not replace a main route with the same deleted main session", () => {
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: mainKey,
|
||||
deletedSessionKeys: [mainKey],
|
||||
sessionKeys: [mainKey],
|
||||
});
|
||||
|
||||
shell.replaceChatWithCurrentSession();
|
||||
|
||||
expect(setSessionKey).not.toHaveBeenCalled();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a resolvable active session when a different chat route is not found", () => {
|
||||
const existingKey = "agent:main:existing-thread";
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: existingKey,
|
||||
sessionKeys: [existingKey],
|
||||
});
|
||||
|
||||
shell.replaceChatWithCurrentSession();
|
||||
|
||||
expect(setSessionKey).not.toHaveBeenCalled();
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", {
|
||||
pathname: "/chat/main/existing-thread",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an active session outside the filtered list when another route fails", () => {
|
||||
const existingKey = "agent:main:outside-window";
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: existingKey,
|
||||
sessionKeys: [mainKey],
|
||||
});
|
||||
shell.routeState = {
|
||||
routeId: "chat",
|
||||
location: { pathname: "/chat/main/unrelated-missing", search: "", hash: "" },
|
||||
};
|
||||
|
||||
shell.replaceChatWithCurrentSession();
|
||||
|
||||
expect(setSessionKey).not.toHaveBeenCalled();
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", {
|
||||
pathname: "/chat/main/outside-window",
|
||||
});
|
||||
});
|
||||
|
||||
it("honors a deleted session event before its stale cached row is refreshed", () => {
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: deletedKey,
|
||||
deletedSessionKeys: [deletedKey],
|
||||
sessionKeys: [deletedKey, mainKey],
|
||||
});
|
||||
|
||||
shell.replaceChatWithCurrentSession();
|
||||
|
||||
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
|
||||
});
|
||||
|
||||
it("rejects a late route commit for a session already marked deleted", () => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
const { replace, setSessionKey, shell } = createSessionRecoveryShell({
|
||||
activeSessionKey: deletedKey,
|
||||
deletedSessionKeys: [deletedKey],
|
||||
sessionKeys: [mainKey],
|
||||
});
|
||||
shell.didConsiderNativeRouteRestore = true;
|
||||
const location = {
|
||||
pathname: "/chat/main/deleted-thread",
|
||||
search: "",
|
||||
hash: "",
|
||||
} satisfies RouteLocation;
|
||||
const routerState = {
|
||||
location,
|
||||
resolvedLocation: location,
|
||||
status: "success",
|
||||
matches: [
|
||||
{
|
||||
routeId: "chat",
|
||||
location,
|
||||
data: { kind: "session", sessionKey: deletedKey },
|
||||
},
|
||||
],
|
||||
pendingMatches: [],
|
||||
cachedMatches: [],
|
||||
} as unknown as RouterState<RouteId>;
|
||||
|
||||
shell.updateRouteState(selectShellRouteState(routerState));
|
||||
|
||||
expect(shell.activeSessionKey).toBe(mainKey);
|
||||
expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey);
|
||||
expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" });
|
||||
});
|
||||
});
|
||||
@@ -485,8 +485,8 @@ describe("OpenClaw shell route session commits", () => {
|
||||
agents: {
|
||||
state: { agentsList: { defaultId: "research", mainKey: "workspace" } },
|
||||
},
|
||||
agentSelection: { state: { selectedId: null } },
|
||||
gateway: { snapshot },
|
||||
agentSelection: { set: vi.fn(), state: { selectedId: null } },
|
||||
gateway: { setSessionKey: vi.fn(), snapshot },
|
||||
sessions: { state: { result: null } },
|
||||
replace,
|
||||
} as unknown as ApplicationContext,
|
||||
|
||||
@@ -64,15 +64,19 @@ import { resolveAsciiShortcutKey } from "../lib/keyboard-shortcuts.ts";
|
||||
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
|
||||
import { resolveSessionDisplayName } from "../lib/session-display.ts";
|
||||
import {
|
||||
findUiSessionRow,
|
||||
resolveSessionPreferredFaceForKey,
|
||||
resolveSessionNavigationAgentId,
|
||||
sessionNavigationTarget,
|
||||
} from "../lib/sessions/route-navigation.ts";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
isUiGlobalSessionKey,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
resolveUiConfiguredMainKey,
|
||||
resolveUiKnownSelectedGlobalAgentId,
|
||||
uiSessionEventMatches,
|
||||
} from "../lib/sessions/session-key.ts";
|
||||
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
|
||||
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
@@ -681,6 +685,7 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
.watch(
|
||||
() => this.context?.sessions,
|
||||
(sessions, notify) => sessions.subscribe(notify),
|
||||
(sessions) => this.recoverDeletedActiveSession(sessions.state),
|
||||
)
|
||||
.watch(
|
||||
() => this.context?.runtimeConfig,
|
||||
@@ -860,6 +865,13 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
}
|
||||
private readonly handleGatewayEvent = (event: GatewayEventFrame) => {
|
||||
this.sidebarWorkboardRuntime?.handleGatewayEvent(event.event);
|
||||
if (event.event === "sessions.changed") {
|
||||
const context = this.context;
|
||||
if (context) {
|
||||
this.recoverDeletedActiveSession(context.sessions.state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.event === "session.observer") {
|
||||
const context = this.context;
|
||||
if (context) {
|
||||
@@ -1033,7 +1045,86 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
return;
|
||||
}
|
||||
const face = this.routeState.routeId === "dashboard" ? "dashboard" : "chat";
|
||||
context.replace(face, this.chatNavigationOptions(face));
|
||||
const sessionWasDeleted = (context.sessions.state.deletedSessions ?? []).some(
|
||||
({ key, agentId }) =>
|
||||
uiSessionEventMatches(
|
||||
{
|
||||
agentsList: context.agents.state.agentsList,
|
||||
hello: context.gateway.snapshot.hello,
|
||||
sessionKey,
|
||||
},
|
||||
key,
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
// Session lists are filtered and windowed. Only a failed route for this
|
||||
// active key, or an authoritative deletion, proves it needs replacement.
|
||||
const activeSessionRow = findUiSessionRow(context, sessionKey);
|
||||
const attemptedPathname = this.routeState.location?.pathname;
|
||||
const activeRouteFailed =
|
||||
!attemptedPathname ||
|
||||
attemptedPathname === sessionNavigationTarget({ context, face, sessionKey }).options.pathname;
|
||||
const parsedAgentId = parseAgentSessionKey(sessionKey)?.agentId;
|
||||
const knownAgents = context.agents.state.agentsList?.agents;
|
||||
// A parseable retired agent is not a navigable owner; never turn its
|
||||
// deleted session into another permanently unresolvable chat route.
|
||||
const replacementAgentId =
|
||||
parsedAgentId &&
|
||||
(!knownAgents || knownAgents.some((agent) => normalizeAgentId(agent.id) === parsedAgentId))
|
||||
? parsedAgentId
|
||||
: resolveSessionNavigationAgentId(context);
|
||||
const replacementSessionKey =
|
||||
!sessionWasDeleted && (activeSessionRow?.key || !activeRouteFailed)
|
||||
? sessionKey
|
||||
: buildAgentMainSessionKey({
|
||||
agentId: replacementAgentId,
|
||||
mainKey: resolveUiConfiguredMainKey({
|
||||
agentsList: context.agents.state.agentsList,
|
||||
hello: context.gateway.snapshot.hello,
|
||||
}),
|
||||
});
|
||||
// Gateway rejects deletion of a live main session. If an orphaned event
|
||||
// still names that fallback, replacing the same route would retry forever.
|
||||
if (sessionWasDeleted && replacementSessionKey === sessionKey) {
|
||||
return;
|
||||
}
|
||||
if (replacementSessionKey !== sessionKey) {
|
||||
// Commit the replacement to both selection owners before navigating;
|
||||
// otherwise a stale Gateway snapshot restores the deleted key and loops.
|
||||
this.activeSessionKey = replacementSessionKey;
|
||||
selectApplicationSession({
|
||||
selection: context.agentSelection,
|
||||
gateway: context.gateway,
|
||||
sessionKey: replacementSessionKey,
|
||||
});
|
||||
}
|
||||
context.replace(
|
||||
face,
|
||||
sessionNavigationTarget({ context, face, sessionKey: replacementSessionKey }).options,
|
||||
);
|
||||
}
|
||||
|
||||
private recoverDeletedActiveSession(sessionState: ApplicationContext["sessions"]["state"]) {
|
||||
const context = this.context;
|
||||
const routeId = this.routeState.routeId;
|
||||
const sessionKey = this.activeSessionKey.trim();
|
||||
if (!context || !routeId || !isSessionRouteId(routeId) || !sessionKey) {
|
||||
return;
|
||||
}
|
||||
const selectedSessionDeleted = sessionState.deletedSessions.some(({ key, agentId }) =>
|
||||
uiSessionEventMatches(
|
||||
{
|
||||
agentsList: context.agents.state.agentsList,
|
||||
hello: context.gateway.snapshot.hello,
|
||||
sessionKey,
|
||||
},
|
||||
key,
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
if (selectedSessionDeleted) {
|
||||
this.replaceChatWithCurrentSession();
|
||||
}
|
||||
}
|
||||
|
||||
private isSettingsTakeover(): boolean {
|
||||
@@ -1725,8 +1816,27 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
if (!pendingDiffers) {
|
||||
persistRoute(committedRouteId, committedPathname, committedSearch);
|
||||
const committedSessionKey = routeState.committedSessionKey;
|
||||
const committedSessionDeleted =
|
||||
committedSessionKey !== undefined &&
|
||||
(routeContext.sessions?.state.deletedSessions ?? []).some(({ key, agentId }) =>
|
||||
uiSessionEventMatches(
|
||||
{
|
||||
agentsList: routeContext.agents.state.agentsList,
|
||||
hello: routeContext.gateway.snapshot.hello,
|
||||
sessionKey: committedSessionKey,
|
||||
},
|
||||
key,
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
if (committedSessionDeleted) {
|
||||
// An older route can commit after deletion recovery has started.
|
||||
// Never let it persist or reselect the session we just retired.
|
||||
this.replaceChatWithCurrentSession();
|
||||
return;
|
||||
}
|
||||
persistRoute(committedRouteId, committedPathname, committedSearch);
|
||||
if (committedSessionKey) {
|
||||
this.activeSessionKey = committedSessionKey;
|
||||
selectApplicationSession({
|
||||
|
||||
@@ -95,8 +95,9 @@ describeControlUiE2e("Control UI Agents channel status", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(`${server.baseUrl}agents`);
|
||||
const response = await page.goto(`${server.baseUrl}settings/agents/main/channels`);
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/agents/main/channels");
|
||||
await page.getByRole("tab", { name: /^Channels/ }).click();
|
||||
|
||||
const discordRow = page.locator(".settings-row").filter({ hasText: "Discord" });
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { expectRequestCountStable } from "./chat-flow.test-support.ts";
|
||||
import {
|
||||
activateMenuItem,
|
||||
captureUiProof,
|
||||
controlUiSessionPath,
|
||||
controlUiSessionUrl,
|
||||
createSessionManagementE2eSuite,
|
||||
installMockGateway,
|
||||
requireRecord,
|
||||
@@ -312,6 +314,78 @@ suite.define(() => {
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers a deleted active chat without repeatedly resolving its missing session", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const routeErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error" && message.text().includes("route")) {
|
||||
routeErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
const deletedKey = "agent:main:deleted-thread";
|
||||
const mainKey = "agent:main:main";
|
||||
const updatedAt = Date.parse("2026-07-01T16:00:00.000Z");
|
||||
const mainSession = sessionRow(mainKey, "Main", updatedAt);
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
mainSession,
|
||||
sessionRow(deletedKey, "Deleted thread", updatedAt - 1_000),
|
||||
]),
|
||||
},
|
||||
sessionKey: mainKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, deletedKey));
|
||||
await page
|
||||
.locator(".agent-chat__input textarea")
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
|
||||
const requestsBeforeDeletion = (await gateway.getRequests("sessions.list")).length;
|
||||
await gateway.setMethodResponse("sessions.list", sessionsListResponse([mainSession]));
|
||||
await gateway.emitGatewayEvent("sessions.changed", {
|
||||
agentId: "main",
|
||||
reason: "delete",
|
||||
sessionKey: deletedKey,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 15_000 })
|
||||
.toBe(controlUiSessionPath(mainKey));
|
||||
await page
|
||||
.locator(".agent-chat__input textarea")
|
||||
.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.list")).length)
|
||||
.toBeGreaterThan(requestsBeforeDeletion);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const count = (await gateway.getRequests("sessions.list")).length;
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 350);
|
||||
});
|
||||
return (await gateway.getRequests("sessions.list")).length - count;
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
const settledRequestCount = (await gateway.getRequests("sessions.list")).length;
|
||||
await expectRequestCountStable(gateway, "sessions.list", settledRequestCount);
|
||||
expect(routeErrors).toEqual([]);
|
||||
await captureUiProof(page, "deleted-active-session-fallback.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a session row when the Gateway reports no deletion", async () => {
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
|
||||
@@ -56,6 +56,40 @@ function createHarness(request: GatewayBrowserClient["request"]) {
|
||||
}
|
||||
|
||||
describe("event-driven session list refresh", () => {
|
||||
it("clears a recreated session's prior deletion before the debounced refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const key = "agent:main:recreated-thread";
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method !== "sessions.list") {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
return sessionsResult(1);
|
||||
});
|
||||
const { sessions, emitEvent } = createHarness(
|
||||
request as unknown as GatewayBrowserClient["request"],
|
||||
);
|
||||
|
||||
try {
|
||||
await sessions.refresh({ force: true });
|
||||
emitEvent({
|
||||
type: "event",
|
||||
event: "sessions.changed",
|
||||
payload: { sessionKey: key, reason: "delete" },
|
||||
});
|
||||
expect(sessions.state.deletedSessions).toEqual([{ key, agentId: undefined }]);
|
||||
|
||||
emitEvent(sessionChangedEvent(key));
|
||||
|
||||
expect(sessions.state.deletedSessions).toEqual([]);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(SESSION_EVENT_REFRESH_DEBOUNCE_MS);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
sessions.dispose();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("debounces rapid session events into one trailing list refresh", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
resolveUiSelectedGlobalAgentId,
|
||||
uiSessionEventMatches,
|
||||
uiSessionRowMatchesSelectedChat,
|
||||
} from "./session-key.ts";
|
||||
import { SwarmActivityTracker } from "./swarm-activity.ts";
|
||||
@@ -1913,6 +1914,24 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined },
|
||||
],
|
||||
});
|
||||
} else if ((eventReason === "create" || eventReason === "new") && eventInfo) {
|
||||
const remainingDeletedSessions = state.deletedSessions.filter(
|
||||
({ key, agentId }) =>
|
||||
!uiSessionEventMatches(
|
||||
{
|
||||
assistantAgentId: agentId ?? gateway.snapshot.assistantAgentId,
|
||||
hello: gateway.snapshot.hello,
|
||||
sessionKey: key,
|
||||
},
|
||||
eventInfo.key,
|
||||
eventInfo.agentId,
|
||||
),
|
||||
);
|
||||
if (remainingDeletedSessions.length !== state.deletedSessions.length) {
|
||||
// Gateway create events are ordered after the prior deletion. Retire
|
||||
// only that generation's marker before the debounced list refresh.
|
||||
publish({ ...state, deletedSessions: remainingDeletedSessions });
|
||||
}
|
||||
}
|
||||
// Gateway lists are filtered and windowed. Events cannot preserve server
|
||||
// membership or ordering, so the coalesced refresh remains canonical. Only
|
||||
|
||||
Reference in New Issue
Block a user