import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { parseTelegramTopicConversation } from "./conversation-id.js"; import * as persistentBindingsResolveModule from "./persistent-bindings.resolve.js"; import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.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, })); type PersistentBindingsModule = Pick< typeof import("./persistent-bindings.resolve.js"), "resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey" > & Pick< typeof import("./persistent-bindings.lifecycle.js"), "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" >; let persistentBindings: PersistentBindingsModule; let lifecycleBindingsModule: Pick< typeof import("./persistent-bindings.lifecycle.js"), "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace" >; type ConfiguredBinding = NonNullable[number]; type BindingRecordInput = Parameters< PersistentBindingsModule["resolveConfiguredAcpBindingRecord"] >[0]; type BindingSpec = Parameters< PersistentBindingsModule["ensureConfiguredAcpBindingSession"] >[0]["spec"]; const baseCfg = { session: { mainKey: "main", scope: "per-sender" }, agents: { list: [{ id: "codex" }, { id: "claude" }], }, } satisfies OpenClawConfig; const defaultDiscordConversationId = "1478836151241412759"; const defaultDiscordAccountId = "default"; const discordBindings: ChannelConfiguredBindingProvider = { compileConfiguredBinding: ({ conversationId }) => { const normalized = conversationId.trim(); return normalized ? { conversationId: normalized } : null; }, matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { if (compiledBinding.conversationId === conversationId) { return { conversationId, matchPriority: 2 }; } if ( parentConversationId && parentConversationId !== conversationId && compiledBinding.conversationId === parentConversationId ) { return { conversationId: parentConversationId, matchPriority: 1 }; } return null; }, }; const telegramBindings: ChannelConfiguredBindingProvider = { compileConfiguredBinding: ({ conversationId }) => { const parsed = parseTelegramTopicConversation({ conversationId }); if (!parsed || !parsed.chatId.startsWith("-")) { return null; } return { conversationId: parsed.canonicalConversationId, parentConversationId: parsed.chatId, }; }, matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { const incoming = parseTelegramTopicConversation({ conversationId, parentConversationId, }); if (!incoming || !incoming.chatId.startsWith("-")) { return null; } if (compiledBinding.conversationId !== incoming.canonicalConversationId) { return null; } return { conversationId: incoming.canonicalConversationId, parentConversationId: incoming.chatId, matchPriority: 2, }; }, }; function isSupportedFeishuDirectConversationId(conversationId: string): boolean { const trimmed = conversationId.trim(); if (!trimmed || trimmed.includes(":")) { return false; } if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) { return false; } return true; } const feishuBindings: ChannelConfiguredBindingProvider = { compileConfiguredBinding: ({ conversationId }) => { const parsed = parseFeishuConversationId({ conversationId }); if ( !parsed || (parsed.scope !== "group_topic" && parsed.scope !== "group_topic_sender" && !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId)) ) { return null; } return { conversationId: parsed.canonicalConversationId, parentConversationId: parsed.scope === "group_topic" || parsed.scope === "group_topic_sender" ? parsed.chatId : undefined, }; }, matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => { const incoming = parseFeishuConversationId({ conversationId, parentConversationId, }); if ( !incoming || (incoming.scope !== "group_topic" && incoming.scope !== "group_topic_sender" && !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId)) ) { return null; } const matchesCanonicalConversation = compiledBinding.conversationId === incoming.canonicalConversationId; const matchesParentTopicForSenderScopedConversation = incoming.scope === "group_topic_sender" && compiledBinding.parentConversationId === incoming.chatId && compiledBinding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`; if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) { return null; } return { conversationId: matchesParentTopicForSenderScopedConversation ? compiledBinding.conversationId : incoming.canonicalConversationId, parentConversationId: incoming.scope === "group_topic" || incoming.scope === "group_topic_sender" ? incoming.chatId : undefined, matchPriority: matchesCanonicalConversation ? 2 : 1, }; }, }; function createConfiguredBindingTestPlugin( id: ChannelPlugin["id"], bindings: ChannelConfiguredBindingProvider, ): Pick { return { ...createChannelTestPluginBase({ id }), bindings, }; } 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 createFeishuBinding(params: { agentId: string; conversationId: string; accountId?: string; acp?: Record; }): ConfiguredBinding { return { type: "acp", agentId: params.agentId, match: { channel: "feishu", accountId: params.accountId ?? defaultDiscordAccountId, peer: { kind: params.conversationId.includes(":topic:") ? "group" : "direct", id: params.conversationId, }, }, ...(params.acp ? { acp: params.acp } : {}), } as ConfiguredBinding; } function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial = {}) { return persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "discord", accountId: defaultDiscordAccountId, conversationId: defaultDiscordConversationId, ...overrides, }); } function resolveDiscordBindingSpecBySession( cfg: OpenClawConfig, conversationId = defaultDiscordConversationId, ) { const resolved = resolveBindingRecord(cfg, { conversationId }); return persistentBindings.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; state?: "idle" | "running" | "error"; }) { 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: params.state ?? "idle", lastActivityAt: Date.now(), }, }); return sessionKey; } beforeEach(() => { persistentBindings = { resolveConfiguredAcpBindingRecord: persistentBindingsResolveModule.resolveConfiguredAcpBindingRecord, resolveConfiguredAcpBindingSpecBySessionKey: persistentBindingsResolveModule.resolveConfiguredAcpBindingSpecBySessionKey, ensureConfiguredAcpBindingSession: lifecycleBindingsModule.ensureConfiguredAcpBindingSession, resetAcpSessionInPlace: lifecycleBindingsModule.resetAcpSessionInPlace, }; setActivePluginRegistry( createTestRegistry([ { pluginId: "discord", plugin: createConfiguredBindingTestPlugin("discord", discordBindings), source: "test", }, { pluginId: "telegram", plugin: createConfiguredBindingTestPlugin("telegram", telegramBindings), source: "test", }, { pluginId: "feishu", plugin: createConfiguredBindingTestPlugin("feishu", feishuBindings), source: "test", }, ]), ); 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); }); beforeAll(async () => { lifecycleBindingsModule = await import("./persistent-bindings.lifecycle.js"); }); 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 sender-scoped Feishu bindings over topic inheritance", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "codex", conversationId: "oc_group_chat:topic:om_topic_root", accountId: "work", }), createFeishuBinding({ agentId: "claude", conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1", accountId: "work", }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "work", conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_sender_1", parentConversationId: "oc_group_chat", }); expect(resolved?.spec.conversationId).toBe( "oc_group_chat:topic:om_topic_root:sender:ou_sender_1", ); 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 = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", conversationId: "-1001234567890:topic:42", }); const splitIds = persistentBindings.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 = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "telegram", accountId: "default", conversationId: "123456789:topic:42", }); expect(resolved).toBeNull(); }); it("resolves Feishu DM bindings using direct peer ids", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "codex", conversationId: "ou_user_1", }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "ou_user_1", }); expect(resolved?.spec.channel).toBe("feishu"); expect(resolved?.spec.conversationId).toBe("ou_user_1"); expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:"); }); it("resolves Feishu DM bindings using user_id fallback peer ids", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "codex", conversationId: "user_123", }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "user_123", }); expect(resolved?.spec.channel).toBe("feishu"); expect(resolved?.spec.conversationId).toBe("user_123"); expect(resolved?.record.targetSessionKey).toContain("agent:codex:acp:binding:feishu:default:"); }); it("resolves Feishu topic bindings with parent chat ids", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "claude", conversationId: "oc_group_chat:topic:om_topic_root", acp: { backend: "acpx" }, }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "oc_group_chat:topic:om_topic_root", parentConversationId: "oc_group_chat", }); expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root"); expect(resolved?.spec.agentId).toBe("claude"); expect(resolved?.record.conversation.parentConversationId).toBe("oc_group_chat"); }); it("inherits configured Feishu topic bindings for sender-scoped topic conversations", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "claude", conversationId: "oc_group_chat:topic:om_topic_root", acp: { backend: "acpx" }, }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "oc_group_chat:topic:om_topic_root:sender:ou_topic_user", parentConversationId: "oc_group_chat", }); expect(resolved?.spec.conversationId).toBe("oc_group_chat:topic:om_topic_root"); expect(resolved?.spec.agentId).toBe("claude"); expect(resolved?.spec.backend).toBe("acpx"); expect(resolved?.record.conversation.conversationId).toBe("oc_group_chat:topic:om_topic_root"); }); it("rejects non-matching Feishu topic roots", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "claude", conversationId: "oc_group_chat:topic:om_topic_root", }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "oc_group_chat:topic:om_other_root", parentConversationId: "oc_group_chat", }); expect(resolved).toBeNull(); }); it("rejects Feishu non-topic group ACP bindings", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "claude", conversationId: "oc_group_chat", }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "oc_group_chat", }); 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"); }); it("derives configured binding cwd from an explicit agent workspace", () => { const cfg = createCfgWithBindings( [ createDiscordBinding({ agentId: "codex", conversationId: defaultDiscordConversationId, }), ], { agents: { list: [{ id: "codex", workspace: "/workspace/openclaw" }, { id: "claude" }], }, }, ); const resolved = resolveBindingRecord(cfg); expect(resolved?.spec.cwd).toBe(resolveAgentWorkspaceDir(cfg, "codex")); }); }); 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 = persistentBindings.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"); }); it("maps a configured Feishu user_id DM binding session key back to its spec", () => { const cfg = createCfgWithBindings([ createFeishuBinding({ agentId: "codex", conversationId: "user_123", acp: { backend: "acpx" }, }), ]); const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({ cfg, channel: "feishu", accountId: "default", conversationId: "user_123", }); const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({ cfg, sessionKey: resolved?.record.targetSessionKey ?? "", }); expect(spec?.channel).toBe("feishu"); expect(spec?.conversationId).toBe("user_123"); expect(spec?.agentId).toBe("codex"); expect(spec?.backend).toBe("acpx"); }); }); 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 persistentBindings.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 persistentBindings.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("keeps a matching ready session even when the stored ACP session is in error state", async () => { const spec = createDiscordPersistentSpec({ cwd: "/home/bob/clawd", }); const sessionKey = mockReadySession({ spec, cwd: "/home/bob/clawd", state: "error", }); const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({ cfg: baseCfg, spec, }); expect(ensured).toEqual({ ok: true, sessionKey }); expect(managerMocks.closeSession).not.toHaveBeenCalled(); expect(managerMocks.initializeSession).not.toHaveBeenCalled(); }); 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 persistentBindings.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 persistentBindings.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 persistentBindings.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 persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "reset", }); expect(result).toEqual({ ok: true }); expect(managerMocks.initializeSession).toHaveBeenCalledWith( expect.objectContaining({ sessionKey, agent: "codex", }), ); }); it("preserves configured ACP agent overrides during in-place reset when metadata omits the agent", async () => { const cfg = createCfgWithBindings( [ createDiscordBinding({ agentId: "coding", conversationId: "1478844424791396446", }), ], { agents: { list: [ { id: "main" }, { id: "coding", runtime: { type: "acp", acp: { agent: "codex", backend: "acpx", mode: "persistent", }, }, }, { id: "claude" }, ], }, }, ); const sessionKey = buildConfiguredAcpSessionKey({ channel: "discord", accountId: "default", conversationId: "1478844424791396446", agentId: "coding", acpAgentId: "codex", mode: "persistent", backend: "acpx", }); sessionMetaMocks.readAcpSessionEntry.mockReturnValue({ acp: { mode: "persistent", backend: "acpx", }, }); const result = await persistentBindings.resetAcpSessionInPlace({ cfg, sessionKey, reason: "reset", }); expect(result).toEqual({ ok: true }); expect(managerMocks.initializeSession).toHaveBeenCalledWith( expect.objectContaining({ sessionKey, agent: "codex", backendId: "acpx", }), ); }); });