| 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<OpenClawConfig["bindings"]>[number]; |
| type BindingRecordInput = Parameters<typeof resolveConfiguredAcpBindingRecord>[0]; |
| type BindingSpec = Parameters<typeof 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"; |
|
|
| function createCfgWithBindings( |
| bindings: ConfiguredBinding[], |
| overrides?: Partial<OpenClawConfig>, |
| ): OpenClawConfig { |
| return { |
| ...baseCfg, |
| ...overrides, |
| bindings, |
| } as OpenClawConfig; |
| } |
|
|
| function createDiscordBinding(params: { |
| agentId: string; |
| conversationId: string; |
| accountId?: string; |
| acp?: Record<string, unknown>; |
| }): 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<string, unknown>; |
| }): 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<BindingRecordInput> = {}) { |
| 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> = {}): 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", |
| }), |
| ); |
| }); |
| }); |
|
|