import type { AcpRuntime } from "@openclaw/acp-core/runtime/types"; import { afterEach, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; import { withGatewayToolCallerIdentity } from "../../agents/tools/gateway-caller-context.js"; import { callInProcessGatewayTool } from "../../agents/tools/in-process-gateway.js"; import type { CliDeps } from "../../cli/deps.types.js"; import { loadSessionEntry, upsertSessionEntryCore, } from "../../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import * as gatewayCall from "../../gateway/call.js"; import { withLocalGatewayRequestScope } from "../../gateway/local-request-context.js"; import { createGatewayMethodRegistry } from "../../gateway/methods/registry.js"; import { createLazyCoreHandlers } from "../../gateway/server-methods/lazy-core-handlers.js"; import { sessionDeleteHandlers } from "../../gateway/server-methods/sessions-delete.js"; import { withOperatorToolGatewayAuthority } from "../../gateway/server-plugin-in-process-dispatch.js"; import { GatewayRequestEntryLifetime } from "../../gateway/server-request-entry.js"; import { getSessionBindingService, registerSessionBindingAdapter, unregisterSessionBindingAdapter, type SessionBindingAdapter, type SessionBindingRecord, } from "../../infra/outbound/session-binding-service.js"; import { getPluginRuntimeGatewayRequestScope, withPluginRuntimeGatewayRequestScope, } from "../../plugins/runtime/gateway-request-scope.js"; import { createDeferredCore } from "../../shared/deferred.js"; import { ensureGatewayOwnerProfile } from "../../state/user-profiles.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime/registry.js"; import { AcpSessionManager, getAcpSessionManager, testing as managerTesting } from "./manager.js"; import { disposeAcpSessionManagerInstance } from "./manager.lifecycle.js"; import { DEFAULT_DEPS } from "./manager.types.js"; import { cleanupFailedAcpSpawn } from "./spawn.js"; const backendId = "provisional-cleanup-fixture"; const agentId = "main"; const sessionKey = "agent:main:acp:provisional-cleanup"; type Initialized = Awaited< ReturnType["initializeSession"]> >; // This fixture opens no browser; browser ownership has its own lifecycle tests. vi.mock("../../browser-lifecycle-cleanup.js", () => ({ cleanupBrowserSessionsForLifecycleEnd: async () => {}, })); beforeAll(async () => { // Prepare the real lazy cleanup graph outside request deadlines. Vitest source // transformation is not part of the running Gateway's ten-second RPC budget. await Promise.all([ import("../../gateway/server-methods/sessions-delete.js"), import("../../gateway/server-methods/sessions.runtime.js"), import("../../agents/embedded-agent.js"), import("../../agents/agent-bundle-mcp-tools.js"), import("../../agents/bash-process-registry.js"), ]); }); afterEach(() => vi.restoreAllMocks()); async function withCleanupFixture( run: (fixture: { cfg: OpenClawConfig; manager: ReturnType; runtime: AcpRuntime; initialize: () => Promise; cleanup: (initialized: Initialized) => Promise; close: ReturnType>; ensureSession: ReturnType>; prepareFreshSession: ReturnType>>; socket: MockInstance; bindings: Map; bind: ReturnType>>; }) => Promise, ) { return await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const cfg: OpenClawConfig = { acp: { enabled: true, backend: backendId, allowedAgents: [agentId] }, agents: { ownership: "explicit", defaults: { systemAgent: { agentId } }, entries: { main: { workspace: state.workspaceDir } }, }, channels: { discord: { threadBindings: { enabled: true } } }, }; await state.writeConfig(cfg); const close = vi.fn(async () => {}); const ensureSession = vi.fn(async (input) => ({ agentId: input.agentId, sessionKey: input.sessionKey, backend: backendId, runtimeSessionName: input.sessionKey, backendSessionId: `runtime:${input.sessionKey}`, })); const prepareFreshSession = vi.fn>( async () => {}, ); const runtime: AcpRuntime = { ownerAwareSessions: 1, ensureSession, close, prepareFreshSession, async cancel() {}, runTurn() { throw new Error("No provider turn belongs in this cleanup fixture"); }, }; const bindings = new Map(); const bind = vi.fn>(async (input) => { const binding: SessionBindingRecord = { bindingId: `binding:${input.targetSessionKey}`, targetSessionKey: input.targetSessionKey, targetKind: input.targetKind, conversation: input.conversation, status: "active", boundAt: Date.now(), metadata: input.metadata, }; bindings.set(input.targetSessionKey, binding); return binding; }); const adapter: SessionBindingAdapter = { channel: "discord", accountId: "default", capabilities: { placements: ["current", "child"], bindSupported: true, unbindSupported: true, }, bind, listBySession: (key) => (bindings.has(key) ? [bindings.get(key)!] : []), resolveByConversation: () => null, unbind: async (input) => { const removed = [...bindings.values()].filter( (record) => record.targetSessionKey === input.targetSessionKey, ); for (const record of removed) { bindings.delete(record.targetSessionKey); } return removed; }, }; registerSessionBindingAdapter(adapter); registerAcpRuntimeBackend({ id: backendId, runtime }); managerTesting.resetAcpSessionManagerForTests(); const manager = getAcpSessionManager(); const socket = vi .spyOn(gatewayCall, "callGateway") .mockRejectedValue(new Error("Raw WebSocket transport is unavailable")); try { await withLocalGatewayRequestScope({ deps: {} as CliDeps, getRuntimeConfig: () => cfg }, () => run({ cfg, manager, runtime, close, ensureSession, prepareFreshSession, socket, bindings, bind, initialize: () => manager.initializeSession({ cfg, sessionKey, agentId, agent: agentId, mode: "persistent", }), cleanup: (initialized) => cleanupFailedAcpSpawn({ cfg, sessionKey, agentId, sessionEntry: initialized.sessionEntry, deleteTranscript: true, closeRuntimeOnFailure: initialized.closeRuntimeOnFailure, }), }), ); } finally { await disposeAcpSessionManagerInstance(manager, "fixture-cleanup"); managerTesting.resetAcpSessionManagerForTests(); unregisterAcpRuntimeBackend(backendId); unregisterSessionBindingAdapter({ channel: "discord", accountId: "default", adapter }); } }); } describe("failed ACP provisional cleanup", () => { it.each(["live Gateway", "retired Gateway", "expired operator"] as const)( "retains local resource cleanup after %s ownership", async (owner) => { await withCleanupFixture( async ({ initialize, cleanup, close, ensureSession, socket, manager }) => { const scope = getPluginRuntimeGatewayRequestScope(); if (!scope?.context) { throw new Error("Expected the local Gateway owner"); } const initialized = await initialize(); const before = loadSessionEntry({ sessionKey, agentId }); let current = true; const resolveGatewayContext = () => (current ? scope.context : undefined); await withPluginRuntimeGatewayRequestScope({ ...scope, resolveGatewayContext }, () => withGatewayToolCallerIdentity( { agentId, sessionKey: "agent:main:main", operationalRunInstance: { instanceId: "parent-instance", runId: "parent-run" }, receiptAuthority: () => false, gatewayContextResolver: resolveGatewayContext, }, async () => { if (owner === "expired operator") { const profile = ensureGatewayOwnerProfile("Cleanup owner"); const release = createDeferredCore(); let late: Promise | undefined; await withOperatorToolGatewayAuthority( { authenticatedUserProfile: { profileId: profile.id, displayName: profile.displayName, hasAvatar: false, updatedAt: profile.updatedAt, }, scopes: ["operator.admin"], }, async () => { late = release.promise.then(() => cleanup(initialized)); }, ); release.resolve(); await late; } else { current = owner !== "retired Gateway"; await cleanup(initialized); } }, ), ); expect(loadSessionEntry({ sessionKey, agentId })).toEqual( owner === "live Gateway" ? undefined : before, ); expect(close).toHaveBeenCalledOnce(); expect(ensureSession).toHaveBeenCalledOnce(); expect(socket).not.toHaveBeenCalled(); await initialized.closeRuntimeOnFailure(); await disposeAcpSessionManagerInstance(manager, "repeat-disposal"); expect(close).toHaveBeenCalledOnce(); }, ); }, ); it("releases the original handle when Gateway retirement during lazy preparation prevents admission", async () => { await withCleanupFixture(async ({ initialize, cleanup, close, ensureSession, socket }) => { const scope = getPluginRuntimeGatewayRequestScope(); if (!scope?.context) { throw new Error("Expected local Gateway owner"); } const initialized = await initialize(); const before = loadSessionEntry({ sessionKey, agentId }); const entered = createDeferredCore(); const release = createDeferredCore(); const deletion = vi.fn(sessionDeleteHandlers["sessions.delete"]!); const lazy = createLazyCoreHandlers({ methods: ["sessions.delete"], loadHandlers: async () => { entered.resolve(); await release.promise; return { "sessions.delete": deletion }; }, }); const registry = createGatewayMethodRegistry([ { name: "sessions.delete", owner: { kind: "core", area: "sessions" }, scope: "operator.admin", handler: lazy["sessions.delete"]!, }, ]); const lifetime = new GatewayRequestEntryLifetime(); const context = { ...scope.context, requestEntryLifetime: lifetime, getGatewayMethodRegistry: () => registry, }; let current = true; await withPluginRuntimeGatewayRequestScope( { ...scope, context, resolveGatewayContext: () => (current ? context : undefined) }, async () => { const cleaning = cleanup(initialized); try { await entered.promise; current = false; lifetime.beginClose(); } finally { release.resolve(); } await cleaning; await lifetime.sealAndJoin(); expect(deletion).not.toHaveBeenCalled(); expect(close).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledWith({ handle: initialized.handle, reason: "spawn-failed", }); expect(ensureSession).toHaveBeenCalledOnce(); expect(loadSessionEntry({ sessionKey, agentId })).toEqual(before); expect(socket).not.toHaveBeenCalled(); }, ); }); }); it.each(["before admission", "after admission"] as const)( "does not repeat resource cleanup after a deadline %s", async (boundary) => { await withCleanupFixture(async ({ initialize, cleanup, close, ensureSession, socket }) => { const scope = getPluginRuntimeGatewayRequestScope(); if (!scope?.context) { throw new Error("Expected local Gateway owner"); } const initialized = await initialize(); const before = loadSessionEntry({ sessionKey, agentId }); const entered = createDeferredCore(); const release = createDeferredCore(); const deletion = vi.fn(sessionDeleteHandlers["sessions.delete"]!); const lazy = createLazyCoreHandlers({ methods: ["sessions.delete"], loadHandlers: async () => { if (boundary === "before admission") { entered.resolve(); await release.promise; } return { "sessions.delete": deletion }; }, }); if (boundary === "after admission") { close.mockImplementationOnce(async () => { entered.resolve(); await release.promise; }); } const registry = createGatewayMethodRegistry([ { name: "sessions.delete", owner: { kind: "core", area: "sessions" }, scope: "operator.admin", handler: lazy["sessions.delete"]!, }, ]); const executions: Promise[] = []; const context = { ...scope.context, getGatewayMethodRegistry: () => registry, trackExecution: (work: () => T | Promise) => { const tracked = scope.context!.trackExecution(work); executions.push(tracked); return tracked; }, }; const retainedRelease = vi.fn(initialized.closeRuntimeOnFailure); await withPluginRuntimeGatewayRequestScope( { ...scope, context, resolveGatewayContext: () => context }, async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const cleaning = cleanup({ ...initialized, closeRuntimeOnFailure: retainedRelease }); try { await entered.promise; await vi.advanceTimersByTimeAsync(10_001); await cleaning; expect(retainedRelease).toHaveBeenCalledTimes( boundary === "before admission" ? 1 : 0, ); expect(close).toHaveBeenCalledOnce(); } finally { vi.useRealTimers(); release.resolve(); await cleaning; await Promise.allSettled(executions); } expect(deletion).toHaveBeenCalledTimes(boundary === "before admission" ? 0 : 1); expect(loadSessionEntry({ sessionKey, agentId })).toEqual( boundary === "before admission" ? before : undefined, ); expect(close).toHaveBeenCalledOnce(); expect(ensureSession).toHaveBeenCalledOnce(); expect(socket).not.toHaveBeenCalled(); }, ); }); }, ); it("deletes the provisional shell after the initializer owns metadata-failure release", async () => { await withCleanupFixture(async ({ cfg, close, ensureSession, socket }) => { const shell = await upsertSessionEntryCore( { sessionKey, agentId }, { sessionId: "metadata-failure-shell", updatedAt: Date.now() }, ); if (!shell) { throw new Error("Expected provisional shell"); } const manager = new AcpSessionManager({ ...DEFAULT_DEPS, upsertSessionMeta: async () => { throw new Error("metadata write refused"); }, }); try { await expect( manager.initializeSession({ cfg, sessionKey, agentId, agent: agentId, mode: "persistent", }), ).rejects.toThrow("metadata write refused"); expect(close).toHaveBeenCalledExactlyOnceWith({ handle: expect.objectContaining({ sessionKey }), reason: "init-meta-failed", }); await cleanupFailedAcpSpawn({ cfg, sessionKey, agentId, sessionEntry: shell, deleteTranscript: true, }); expect(loadSessionEntry({ sessionKey, agentId })).toBeUndefined(); expect(ensureSession).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce(); expect(socket).not.toHaveBeenCalled(); } finally { await disposeAcpSessionManagerInstance(manager, "fixture-cleanup"); } }); }); it("uses pending-identity prepare-fresh cleanup without rehydrating or forcing a close", async () => { await withCleanupFixture( async ({ ensureSession, initialize, cleanup, close, prepareFreshSession, manager }) => { ensureSession.mockImplementationOnce(async (input) => ({ sessionKey: input.sessionKey, agentId: input.agentId, backend: backendId, runtimeSessionName: input.sessionKey, })); const initialized = await initialize(); await cleanup(initialized); expect(loadSessionEntry({ sessionKey, agentId })).toBeUndefined(); expect(ensureSession).toHaveBeenCalledOnce(); expect(close).not.toHaveBeenCalled(); expect(prepareFreshSession).toHaveBeenCalledOnce(); await initialized.closeRuntimeOnFailure(); expect(close).not.toHaveBeenCalled(); expect(manager.getObservabilitySnapshot().runtimeCache.activeSessions).toBe(0); }, ); }); it.each(["replace", "reset"] as const)( "keeps a %s successor and its binding untouched by stale cleanup", async (mutation) => { await withCleanupFixture( async ({ initialize, cleanup, close, ensureSession, bindings, socket }) => { const original = await initialize(); await callInProcessGatewayTool( mutation === "replace" ? "sessions.delete" : "sessions.reset", { key: sessionKey, agentId }, ); const successor = await initialize(); if (mutation === "replace") { expect(successor.sessionEntry.sessionId).not.toBe(original.sessionEntry.sessionId); } else { expect(successor.sessionEntry.sessionId).toBe(original.sessionEntry.sessionId); expect(successor.sessionEntry.lifecycleRevision).not.toBe( original.sessionEntry.lifecycleRevision, ); } const binding = await getSessionBindingService().bind({ targetSessionKey: sessionKey, targetKind: "session", placement: "current", conversation: { channel: "discord", accountId: "default", conversationId: "successor-thread", }, }); const before = loadSessionEntry({ sessionKey, agentId }); close.mockClear(); ensureSession.mockClear(); await cleanup(original); expect(loadSessionEntry({ sessionKey, agentId })).toEqual(before); expect(bindings.get(sessionKey)).toEqual(binding); expect(close).not.toHaveBeenCalled(); expect(ensureSession).not.toHaveBeenCalled(); expect(socket).not.toHaveBeenCalled(); }, ); }, ); it("releases an initialization that finishes after its Gateway and manager retire", async () => { await withCleanupFixture( async ({ manager, initialize, cleanup, ensureSession, close, socket }) => { const scope = getPluginRuntimeGatewayRequestScope(); if (!scope?.context) { throw new Error("Expected local Gateway owner"); } let current = true; const entered = createDeferredCore(); const release = createDeferredCore(); const originalEnsure = ensureSession.getMockImplementation()!; ensureSession.mockImplementationOnce(async (input) => { entered.resolve(); await release.promise; return await originalEnsure(input); }); await withPluginRuntimeGatewayRequestScope( { ...scope, resolveGatewayContext: () => (current ? scope.context : undefined) }, async () => { const initializing = initialize(); try { await entered.promise; current = false; await disposeAcpSessionManagerInstance(manager, "gateway-shutdown"); expect(close).not.toHaveBeenCalled(); } finally { release.resolve(); } const initialized = await initializing; await cleanup(initialized); expect(close).toHaveBeenCalledOnce(); expect(ensureSession).toHaveBeenCalledOnce(); expect(socket).not.toHaveBeenCalled(); expect(manager.getObservabilitySnapshot().runtimeCache.activeSessions).toBe(0); }, ); }, ); }); it("does not release a newer handle through a retained initialization callback", async () => { await withCleanupFixture(async ({ manager, initialize, close }) => { const original = await initialize(); const successor = await initialize(); expect(successor.handle).not.toBe(original.handle); await original.closeRuntimeOnFailure(); expect(close).not.toHaveBeenCalled(); await disposeAcpSessionManagerInstance(manager, "gateway-shutdown"); await successor.closeRuntimeOnFailure(); expect(close).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledWith({ handle: successor.handle, reason: "gateway-shutdown" }); }); }); it("rolls back a refused /acp conversation binding through the local session owner", async () => { const { handleAcpCommand } = await import("../../auto-reply/reply/commands-acp.js"); const { buildCommandTestParams } = await import("../../auto-reply/reply/commands.test-harness.js"); await withCleanupFixture(async ({ cfg, bind, ensureSession, close, socket }) => { bind.mockRejectedValueOnce(new Error("Conversation binding refused")); const params = buildCommandTestParams("/acp spawn main --bind here", cfg, { Provider: "discord", Surface: "discord", OriginatingChannel: "discord", OriginatingTo: "channel:fixture-room", SenderId: "fixture-owner", AccountId: "default", }); params.command.senderIsOwner = true; const result = await handleAcpCommand(params, true); expect(result?.reply?.text).toContain("Conversation binding refused"); expect(bind).toHaveBeenCalledOnce(); expect(ensureSession).toHaveBeenCalledOnce(); const childKey = ensureSession.mock.calls[0]![0].sessionKey; expect(loadSessionEntry({ sessionKey: childKey, agentId })).toBeUndefined(); expect(close).toHaveBeenCalledOnce(); expect(socket).not.toHaveBeenCalled(); }); }); });