import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js"; const hoisted = vi.hoisted(() => { const callGatewayMock = vi.fn(); const sessionBindingCapabilitiesMock = vi.fn(); const sessionBindingBindMock = vi.fn(); const sessionBindingUnbindMock = vi.fn(); const sessionBindingResolveByConversationMock = vi.fn(); const sessionBindingListBySessionMock = vi.fn(); const closeSessionMock = vi.fn(); const initializeSessionMock = vi.fn(); const state = { cfg: { acp: { enabled: true, backend: "acpx", allowedAgents: ["codex"], }, session: { mainKey: "main", scope: "per-sender", }, channels: { discord: { threadBindings: { enabled: true, spawnAcpSessions: true, }, }, }, } as OpenClawConfig, }; return { callGatewayMock, sessionBindingCapabilitiesMock, sessionBindingBindMock, sessionBindingUnbindMock, sessionBindingResolveByConversationMock, sessionBindingListBySessionMock, closeSessionMock, initializeSessionMock, state, }; }); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadConfig: () => hoisted.state.cfg, }; }); vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); vi.mock("../acp/control-plane/manager.js", () => { return { getAcpSessionManager: () => ({ initializeSession: (params: unknown) => hoisted.initializeSessionMock(params), closeSession: (params: unknown) => hoisted.closeSessionMock(params), }), }; }); vi.mock("../infra/outbound/session-binding-service.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getSessionBindingService: () => ({ bind: (input: unknown) => hoisted.sessionBindingBindMock(input), getCapabilities: (params: unknown) => hoisted.sessionBindingCapabilitiesMock(params), listBySession: (targetSessionKey: string) => hoisted.sessionBindingListBySessionMock(targetSessionKey), resolveByConversation: (ref: unknown) => hoisted.sessionBindingResolveByConversationMock(ref), touch: vi.fn(), unbind: (input: unknown) => hoisted.sessionBindingUnbindMock(input), }), }; }); const { spawnAcpDirect } = await import("./acp-spawn.js"); function createSessionBinding(overrides?: Partial): SessionBindingRecord { return { bindingId: "default:child-thread", targetSessionKey: "agent:codex:acp:s1", targetKind: "session", conversation: { channel: "discord", accountId: "default", conversationId: "child-thread", parentConversationId: "parent-channel", }, status: "active", boundAt: Date.now(), metadata: { agentId: "codex", boundBy: "system", }, ...overrides, }; } describe("spawnAcpDirect", () => { beforeEach(() => { hoisted.state.cfg = { acp: { enabled: true, backend: "acpx", allowedAgents: ["codex"], }, session: { mainKey: "main", scope: "per-sender", }, channels: { discord: { threadBindings: { enabled: true, spawnAcpSessions: true, }, }, }, } satisfies OpenClawConfig; hoisted.callGatewayMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { method?: string }; if (args.method === "sessions.patch") { return { ok: true }; } if (args.method === "agent") { return { runId: "run-1" }; } if (args.method === "sessions.delete") { return { ok: true }; } return {}; }); hoisted.closeSessionMock.mockReset().mockResolvedValue({ runtimeClosed: true, metaCleared: false, }); hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { sessionKey: string; agent: string; mode: "persistent" | "oneshot"; cwd?: string; }; const runtimeSessionName = `${args.sessionKey}:runtime`; const cwd = typeof args.cwd === "string" ? args.cwd : undefined; return { runtime: { close: vi.fn().mockResolvedValue(undefined), }, handle: { sessionKey: args.sessionKey, backend: "acpx", runtimeSessionName, ...(cwd ? { cwd } : {}), agentSessionId: "codex-inner-1", backendSessionId: "acpx-1", }, meta: { backend: "acpx", agent: args.agent, runtimeSessionName, ...(cwd ? { runtimeOptions: { cwd }, cwd } : {}), identity: { state: "pending", source: "ensure", acpxSessionId: "acpx-1", agentSessionId: "codex-inner-1", lastUpdatedAt: Date.now(), }, mode: args.mode, state: "idle", lastActivityAt: Date.now(), }, }; }); hoisted.sessionBindingCapabilitiesMock.mockReset().mockReturnValue({ adapterAvailable: true, bindSupported: true, unbindSupported: true, placements: ["current", "child"], }); hoisted.sessionBindingBindMock .mockReset() .mockImplementation( async (input: { targetSessionKey: string; conversation: { accountId: string }; metadata?: Record; }) => createSessionBinding({ targetSessionKey: input.targetSessionKey, conversation: { channel: "discord", accountId: input.conversation.accountId, conversationId: "child-thread", parentConversationId: "parent-channel", }, metadata: { boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system", agentId: "codex", webhookId: "wh-1", }, }), ); hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); hoisted.sessionBindingListBySessionMock.mockReset().mockReturnValue([]); hoisted.sessionBindingUnbindMock.mockReset().mockResolvedValue([]); }); it("spawns ACP session, binds a new thread, and dispatches initial task", async () => { const result = await spawnAcpDirect( { task: "Investigate flaky tests", agentId: "codex", mode: "session", thread: true, }, { agentSessionKey: "agent:main:main", agentChannel: "discord", agentAccountId: "default", agentTo: "channel:parent-channel", agentThreadId: "requester-thread", }, ); expect(result.status).toBe("accepted"); expect(result.childSessionKey).toMatch(/^agent:codex:acp:/); expect(result.runId).toBe("run-1"); expect(result.mode).toBe("session"); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ targetKind: "session", placement: "child", }), ); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ introText: expect.not.stringContaining( "session ids: pending (available after the first reply)", ), }), }), ); const agentCall = hoisted.callGatewayMock.mock.calls .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) .find((request) => request.method === "agent"); expect(agentCall?.params?.sessionKey).toMatch(/^agent:codex:acp:/); expect(agentCall?.params?.to).toBe("channel:child-thread"); expect(agentCall?.params?.threadId).toBe("child-thread"); expect(agentCall?.params?.deliver).toBe(true); expect(hoisted.initializeSessionMock).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: expect.stringMatching(/^agent:codex:acp:/), agent: "codex", mode: "persistent", }), ); }); it("includes cwd in ACP thread intro banner when provided at spawn time", async () => { const result = await spawnAcpDirect( { task: "Check workspace", agentId: "codex", cwd: "/home/bob/clawd", mode: "session", thread: true, }, { agentSessionKey: "agent:main:main", agentChannel: "discord", agentAccountId: "default", agentTo: "channel:parent-channel", }, ); expect(result.status).toBe("accepted"); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ metadata: expect.objectContaining({ introText: expect.stringContaining("cwd: /home/bob/clawd"), }), }), ); }); it("rejects disallowed ACP agents", async () => { hoisted.state.cfg = { ...hoisted.state.cfg, acp: { enabled: true, backend: "acpx", allowedAgents: ["claudecode"], }, }; const result = await spawnAcpDirect( { task: "hello", agentId: "codex", }, { agentSessionKey: "agent:main:main", }, ); expect(result).toMatchObject({ status: "forbidden", }); }); it("requires an explicit ACP agent when no config default exists", async () => { const result = await spawnAcpDirect( { task: "hello", }, { agentSessionKey: "agent:main:main", }, ); expect(result.status).toBe("error"); expect(result.error).toContain("set `acp.defaultAgent`"); }); it("fails fast when Discord ACP thread spawn is disabled", async () => { hoisted.state.cfg = { ...hoisted.state.cfg, channels: { discord: { threadBindings: { enabled: true, spawnAcpSessions: false, }, }, }, }; const result = await spawnAcpDirect( { task: "hello", agentId: "codex", thread: true, mode: "session", }, { agentChannel: "discord", agentAccountId: "default", agentTo: "channel:parent-channel", }, ); expect(result.status).toBe("error"); expect(result.error).toContain("spawnAcpSessions=true"); }); });