diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index c2cf944f5f04..9721070caf06 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -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 { diff --git a/ui/src/app/app-host.deleted-session.test.ts b/ui/src/app/app-host.deleted-session.test.ts new file mode 100644 index 000000000000..339d3dc6e351 --- /dev/null +++ b/ui/src/app/app-host.deleted-session.test.ts @@ -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) => 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; + + shell.updateRouteState(selectShellRouteState(routerState)); + + expect(shell.activeSessionKey).toBe(mainKey); + expect(setSessionKey).toHaveBeenCalledExactlyOnceWith(mainKey); + expect(replace).toHaveBeenCalledExactlyOnceWith("chat", { pathname: "/chat/main" }); + }); +}); diff --git a/ui/src/app/app-host.test.ts b/ui/src/app/app-host.test.ts index ac17043d8d4f..d377ca853fc9 100644 --- a/ui/src/app/app-host.test.ts +++ b/ui/src/app/app-host.test.ts @@ -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, diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 52e45a7d2002..6e90fb0d2eb5 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -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({ diff --git a/ui/src/e2e/agent-channel-runtime-status.e2e.test.ts b/ui/src/e2e/agent-channel-runtime-status.e2e.test.ts index a73770a9e194..91e854363c21 100644 --- a/ui/src/e2e/agent-channel-runtime-status.e2e.test.ts +++ b/ui/src/e2e/agent-channel-runtime-status.e2e.test.ts @@ -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" }); diff --git a/ui/src/e2e/session-management.archive.e2e.test.ts b/ui/src/e2e/session-management.archive.e2e.test.ts index 30b2bc05eb3c..367100056cbf 100644 --- a/ui/src/e2e/session-management.archive.e2e.test.ts +++ b/ui/src/e2e/session-management.archive.e2e.test.ts @@ -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", diff --git a/ui/src/lib/sessions/index.event-refresh.test.ts b/ui/src/lib/sessions/index.event-refresh.test.ts index 917c7ac5ee7a..0a74c3332214 100644 --- a/ui/src/lib/sessions/index.event-refresh.test.ts +++ b/ui/src/lib/sessions/index.event-refresh.test.ts @@ -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) => { diff --git a/ui/src/lib/sessions/index.ts b/ui/src/lib/sessions/index.ts index d7388204d0d9..05f239c55f0c 100644 --- a/ui/src/lib/sessions/index.ts +++ b/ui/src/lib/sessions/index.ts @@ -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