import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const managerMocks = vi.hoisted(() => ({ resolveSession: vi.fn(), closeSession: vi.fn(), initializeSession: vi.fn(), updateSessionRuntimeOptions: vi.fn(), })); const sessionMetaMocks = vi.hoisted(() => ({ readAcpSessionEntry: vi.fn(), })); vi.mock("./control-plane/manager.js", () => ({ getAcpSessionManager: () => ({ resolveSession: managerMocks.resolveSession, closeSession: managerMocks.closeSession, initializeSession: managerMocks.initializeSession, updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions, }), })); vi.mock("./runtime/session-meta.js", () => ({ readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry, })); import { buildConfiguredAcpSessionKey, ensureConfiguredAcpBindingSession, resetAcpSessionInPlace, resolveConfiguredAcpBindingRecord, resolveConfiguredAcpBindingSpecBySessionKey, } from "./persistent-bindings.js"; type ConfiguredBinding = NonNullable[number]; type BindingRecordInput = Parameters[0]; type BindingSpec = Parameters[0]["spec"]; const baseCfg = { session: { mainKey: "main", scope: "per-sender" }, agents: { list: [{ id: "codex" }, { id: "claude" }], }, } satisfies OpenClawConfig; const defaultDiscordConversationId = "1478836151241412759"; const defaultDiscordAccountId = "default"; function createCfgWithBindings( bindings: ConfiguredBinding[], overrides?: Partial, ): OpenClawConfig { return { ...baseCfg, ...overrides, bindings, } as OpenClawConfig; } function createDiscordBinding(params: { agentId: string; conversationId: string; accountId?: string; acp?: Record; }): ConfiguredBinding { return { type: "acp", agentId: params.agentId, match: { channel: "discord", accountId: params.accountId ?? defaultDiscordAccountId, peer: { kind: "channel", id: params.conversationId }, }, ...(params.acp ? { acp: params.acp } : {}), } as ConfiguredBinding; } function createTelegramGroupBinding(params: { agentId: string; conversationId: string; acp?: Record; }): ConfiguredBinding { return { type: "acp", agentId: params.agentId, match: { channel: "telegram", accountId: defaultDiscordAccountId, peer: { kind: "group", id: params.conversationId }, }, ...(params.acp ? { acp: params.acp } : {}), } as ConfiguredBinding; } function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial = {}) { return resolveConfiguredAcpBindingRecord({ cfg, channel: "discord", accountId: defaultDiscordAccountId, conversationId: defaultDiscordConversationId, ...overrides, }); } function resolveDiscordBindingSpecBySession( cfg: OpenClawConfig, conversationId = defaultDiscordConversationId, ) { const resolved = resolveBindingRecord(cfg, { conversationId }); return resolveConfiguredAcpBindingSpecBySessionKey({ cfg, sessionKey: resolved?.record.targetSessionKey ?? "", }); } function createDiscordPersistentSpec(overrides: Partial = {}): BindingSpec { return { channel: "discord", accountId: defaultDiscordAccountId, conversationId: defaultDiscordConversationId, agentId: "codex", mode: "persistent", ...overrides, } as BindingSpec; } function mockReadySession(params: { spec: BindingSpec; cwd: string }) { const sessionKey = buildConfiguredAcpSessionKey(params.spec); managerMocks.resolveSession.mockReturnValue({ kind: "ready", sessionKey, meta: { backend: "acpx", agent: params.spec.acpAgentId ?? params.spec.agentId, runtimeSessionName: "existing", mode: params.spec.mode, runtimeOptions: { cwd: params.cwd }, state: "idle", lastActivityAt: Date.now(), }, }); return sessionKey; } beforeEach(() => { managerMocks.resolveSession.mockReset(); managerMocks.closeSession.mockReset().mockResolvedValue({ runtimeClosed: true, metaCleared: true, }); managerMocks.initializeSession.mockReset().mockResolvedValue(undefined); managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined); sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined); }); describe("resolveConfiguredAcpBindingRecord", () => { it("resolves discord channel ACP binding from top-level typed bindings", () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "codex", conversationId: defaultDiscordConversationId, acp: { cwd: "/repo/openclaw" }, }), ]); const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.channel).toBe("discord"); expect(resolved?.spec.conversationId).toBe(defaultDiscordConversationId); expect(resolved?.spec.agentId).toBe("codex"); expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:discord:default:"); expect(resolved?.record.metadata?.source).toBe("config"); }); it("falls back to parent discord channel when conversation is a thread id", () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "codex", conversationId: "channel-parent-1", }), ]); const resolved = resolveBindingRecord(cfg, { conversationId: "thread-123", parentConversationId: "channel-parent-1", }); expect(resolved?.spec.conversationId).toBe("channel-parent-1"); expect(resolved?.record.conversation.conversationId).toBe("channel-parent-1"); }); it("prefers direct discord thread binding over parent channel fallback", () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "codex", conversationId: "channel-parent-1", }), createDiscordBinding({ agentId: "claude", conversationId: "thread-123", }), ]); const resolved = resolveBindingRecord(cfg, { conversationId: "thread-123", parentConversationId: "channel-parent-1", }); expect(resolved?.spec.conversationId).toBe("thread-123"); expect(resolved?.spec.agentId).toBe("claude"); }); it("prefers exact account binding over wildcard for the same discord conversation", () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "codex", conversationId: defaultDiscordConversationId, accountId: "*", }), createDiscordBinding({ agentId: "claude", conversationId: defaultDiscordConversationId, }), ]); const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.agentId).toBe("claude"); }); it("returns null when no top-level ACP binding matches the conversation", () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "codex", conversationId: "different-channel", }), ]); const resolved = resolveBindingRecord(cfg, { conversationId: "thread-123", parentConversationId: "channel-parent-1", }); expect(resolved).toBeNull(); }); it("resolves telegram forum topic bindings using canonical conversation ids", () => { const cfg = createCfgWithBindings([ createTelegramGroupBinding({ agentId: "claude", conversationId: "-1001234567890:topic:42", acp: { backend: "acpx" }, }), ]); const canonical = resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", conversationId: "-1001234567890:topic:42", }); const splitIds = resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", conversationId: "42", parentConversationId: "-1001234567890", }); expect(canonical?.spec.conversationId).toBe("-1001234567890:topic:42"); expect(splitIds?.spec.conversationId).toBe("-1001234567890:topic:42"); expect(canonical?.spec.agentId).toBe("claude"); expect(canonical?.spec.backend).toBe("acpx"); expect(splitIds?.record.targetSessionKey).toBe(canonical?.record.targetSessionKey); }); it("skips telegram non-group topic configs", () => { const cfg = createCfgWithBindings([ createTelegramGroupBinding({ agentId: "claude", conversationId: "123456789:topic:42", }), ]); const resolved = resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", conversationId: "123456789:topic:42", }); expect(resolved).toBeNull(); }); it("applies agent runtime ACP defaults for bound conversations", () => { const cfg = createCfgWithBindings( [ createDiscordBinding({ agentId: "coding", conversationId: defaultDiscordConversationId, }), ], { agents: { list: [ { id: "main" }, { id: "coding", runtime: { type: "acp", acp: { agent: "codex", backend: "acpx", mode: "oneshot", cwd: "/workspace/repo-a", }, }, }, ], }, }, ); const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.agentId).toBe("coding"); expect(resolved?.spec.acpAgentId).toBe("codex"); expect(resolved?.spec.mode).toBe("oneshot"); expect(resolved?.spec.cwd).toBe("/workspace/repo-a"); expect(resolved?.spec.backend).toBe("acpx"); }); }); describe("resolveConfiguredAcpBindingSpecBySessionKey", () => { it("maps a configured discord binding session key back to its spec", () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "codex", conversationId: defaultDiscordConversationId, acp: { backend: "acpx" }, }), ]); const spec = resolveDiscordBindingSpecBySession(cfg); expect(spec?.channel).toBe("discord"); expect(spec?.conversationId).toBe(defaultDiscordConversationId); expect(spec?.agentId).toBe("codex"); expect(spec?.backend).toBe("acpx"); }); it("returns null for unknown session keys", () => { const spec = resolveConfiguredAcpBindingSpecBySessionKey({ cfg: baseCfg, sessionKey: "agent:main:acp:binding:discord:default:notfound", }); expect(spec).toBeNull(); }); it("prefers exact account ACP settings over wildcard when session keys collide", () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "codex", conversationId: defaultDiscordConversationId, accountId: "*", acp: { backend: "wild" }, }), createDiscordBinding({ agentId: "codex", conversationId: defaultDiscordConversationId, acp: { backend: "exact" }, }), ]); const spec = resolveDiscordBindingSpecBySession(cfg); expect(spec?.backend).toBe("exact"); }); }); describe("buildConfiguredAcpSessionKey", () => { it("is deterministic for the same conversation binding", () => { const sessionKeyA = buildConfiguredAcpSessionKey({ channel: "discord", accountId: "default", conversationId: "1478836151241412759", agentId: "codex", mode: "persistent", }); const sessionKeyB = buildConfiguredAcpSessionKey({ channel: "discord", accountId: "default", conversationId: "1478836151241412759", agentId: "codex", mode: "persistent", }); expect(sessionKeyA).toBe(sessionKeyB); }); }); describe("ensureConfiguredAcpBindingSession", () => { it("keeps an existing ready session when configured binding omits cwd", async () => { const spec = createDiscordPersistentSpec(); const sessionKey = mockReadySession({ spec, cwd: "/workspace/openclaw", }); const ensured = await ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); expect(ensured).toEqual({ ok: true, sessionKey }); expect(managerMocks.closeSession).not.toHaveBeenCalled(); expect(managerMocks.initializeSession).not.toHaveBeenCalled(); }); it("reinitializes a ready session when binding config explicitly sets mismatched cwd", async () => { const spec = createDiscordPersistentSpec({ cwd: "/workspace/repo-a", }); const sessionKey = mockReadySession({ spec, cwd: "/workspace/other-repo", }); const ensured = await ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); expect(ensured).toEqual({ ok: true, sessionKey }); expect(managerMocks.closeSession).toHaveBeenCalledTimes(1); expect(managerMocks.closeSession).toHaveBeenCalledWith( expect.objectContaining({ sessionKey, clearMeta: false, }), ); expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1); }); it("initializes ACP session with runtime agent override when provided", async () => { const spec = createDiscordPersistentSpec({ agentId: "coding", acpAgentId: "codex", }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); const ensured = await ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); expect(ensured.ok).toBe(true); expect(managerMocks.initializeSession).toHaveBeenCalledWith( expect.objectContaining({ agent: "codex", }), ); }); }); describe("resetAcpSessionInPlace", () => { it("reinitializes from configured binding when ACP metadata is missing", async () => { const cfg = createCfgWithBindings([ createDiscordBinding({ agentId: "claude", conversationId: "1478844424791396446", acp: { mode: "persistent", backend: "acpx", }, }), ]); const sessionKey = buildConfiguredAcpSessionKey({ channel: "discord", accountId: "default", conversationId: "1478844424791396446", agentId: "claude", mode: "persistent", backend: "acpx", }); managerMocks.resolveSession.mockReturnValue({ kind: "none" }); const result = await resetAcpSessionInPlace({ cfg, sessionKey, reason: "new", }); expect(result).toEqual({ ok: true }); expect(managerMocks.initializeSession).toHaveBeenCalledWith( expect.objectContaining({ sessionKey, agent: "claude", mode: "persistent", backendId: "acpx", }), ); }); it("does not clear ACP metadata before reinitialize succeeds", async () => { const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4"; sessionMetaMocks.readAcpSessionEntry.mockReturnValue({ acp: { agent: "claude", mode: "persistent", backend: "acpx", runtimeOptions: { cwd: "/home/bob/clawd" }, }, }); managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable")); const result = await resetAcpSessionInPlace({ cfg: baseCfg, sessionKey, reason: "reset", }); expect(result).toEqual({ ok: false, error: "backend unavailable" }); expect(managerMocks.closeSession).toHaveBeenCalledWith( expect.objectContaining({ sessionKey, clearMeta: false, }), ); }); it("preserves harness agent ids during in-place reset even when not in agents.list", async () => { const cfg = { ...baseCfg, agents: { list: [{ id: "main" }, { id: "coding" }], }, } satisfies OpenClawConfig; const sessionKey = "agent:coding:acp:binding:discord:default:9373ab192b2317f4"; sessionMetaMocks.readAcpSessionEntry.mockReturnValue({ acp: { agent: "codex", mode: "persistent", backend: "acpx", }, }); const result = await resetAcpSessionInPlace({ cfg, sessionKey, reason: "reset", }); expect(result).toEqual({ ok: true }); expect(managerMocks.initializeSession).toHaveBeenCalledWith( expect.objectContaining({ sessionKey, agent: "codex", }), ); }); });