import { describe, expect, test } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentRoute } from "./resolve-route.js"; describe("resolveAgentRoute", () => { test("defaults to main/default when no bindings exist", () => { const cfg: OpenClawConfig = {}; const route = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: null, peer: { kind: "dm", id: "+15551234567" }, }); expect(route.agentId).toBe("main"); expect(route.accountId).toBe("default"); expect(route.sessionKey).toBe("agent:main:main"); expect(route.matchedBy).toBe("default"); }); test("dmScope=per-peer isolates DM sessions by sender id", () => { const cfg: OpenClawConfig = { session: { dmScope: "per-peer" }, }; const route = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: null, peer: { kind: "dm", id: "+15551234567" }, }); expect(route.sessionKey).toBe("agent:main:dm:+15551234567"); }); test("dmScope=per-channel-peer isolates DM sessions per channel and sender", () => { const cfg: OpenClawConfig = { session: { dmScope: "per-channel-peer" }, }; const route = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: null, peer: { kind: "dm", id: "+15551234567" }, }); expect(route.sessionKey).toBe("agent:main:whatsapp:dm:+15551234567"); }); test("identityLinks collapses per-peer DM sessions across providers", () => { const cfg: OpenClawConfig = { session: { dmScope: "per-peer", identityLinks: { alice: ["telegram:111111111", "discord:222222222222222222"], }, }, }; const route = resolveAgentRoute({ cfg, channel: "telegram", accountId: null, peer: { kind: "dm", id: "111111111" }, }); expect(route.sessionKey).toBe("agent:main:dm:alice"); }); test("identityLinks applies to per-channel-peer DM sessions", () => { const cfg: OpenClawConfig = { session: { dmScope: "per-channel-peer", identityLinks: { alice: ["telegram:111111111", "discord:222222222222222222"], }, }, }; const route = resolveAgentRoute({ cfg, channel: "discord", accountId: null, peer: { kind: "dm", id: "222222222222222222" }, }); expect(route.sessionKey).toBe("agent:main:discord:dm:alice"); }); test("peer binding wins over account binding", () => { const cfg: OpenClawConfig = { bindings: [ { agentId: "a", match: { channel: "whatsapp", accountId: "biz", peer: { kind: "dm", id: "+1000" }, }, }, { agentId: "b", match: { channel: "whatsapp", accountId: "biz" }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: "biz", peer: { kind: "dm", id: "+1000" }, }); expect(route.agentId).toBe("a"); expect(route.sessionKey).toBe("agent:a:main"); expect(route.matchedBy).toBe("binding.peer"); }); test("discord channel peer binding wins over guild binding", () => { const cfg: OpenClawConfig = { bindings: [ { agentId: "chan", match: { channel: "discord", accountId: "default", peer: { kind: "channel", id: "c1" }, }, }, { agentId: "guild", match: { channel: "discord", accountId: "default", guildId: "g1", }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", accountId: "default", peer: { kind: "channel", id: "c1" }, guildId: "g1", }); expect(route.agentId).toBe("chan"); expect(route.sessionKey).toBe("agent:chan:discord:channel:c1"); expect(route.matchedBy).toBe("binding.peer"); }); test("guild binding wins over account binding when peer not bound", () => { const cfg: OpenClawConfig = { bindings: [ { agentId: "guild", match: { channel: "discord", accountId: "default", guildId: "g1", }, }, { agentId: "acct", match: { channel: "discord", accountId: "default" }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", accountId: "default", peer: { kind: "channel", id: "c1" }, guildId: "g1", }); expect(route.agentId).toBe("guild"); expect(route.matchedBy).toBe("binding.guild"); }); test("missing accountId in binding matches default account only", () => { const cfg: OpenClawConfig = { bindings: [{ agentId: "defaultAcct", match: { channel: "whatsapp" } }], }; const defaultRoute = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: undefined, peer: { kind: "dm", id: "+1000" }, }); expect(defaultRoute.agentId).toBe("defaultacct"); expect(defaultRoute.matchedBy).toBe("binding.account"); const otherRoute = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: "biz", peer: { kind: "dm", id: "+1000" }, }); expect(otherRoute.agentId).toBe("main"); }); test("accountId=* matches any account as a channel fallback", () => { const cfg: OpenClawConfig = { bindings: [ { agentId: "any", match: { channel: "whatsapp", accountId: "*" }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: "biz", peer: { kind: "dm", id: "+1000" }, }); expect(route.agentId).toBe("any"); expect(route.matchedBy).toBe("binding.channel"); }); test("defaultAgentId is used when no binding matches", () => { const cfg: OpenClawConfig = { agents: { list: [{ id: "home", default: true, workspace: "~/openclaw-home" }], }, }; const route = resolveAgentRoute({ cfg, channel: "whatsapp", accountId: "biz", peer: { kind: "dm", id: "+1000" }, }); expect(route.agentId).toBe("home"); expect(route.sessionKey).toBe("agent:home:main"); }); }); test("dmScope=per-account-channel-peer isolates DM sessions per account, channel and sender", () => { const cfg: OpenClawConfig = { session: { dmScope: "per-account-channel-peer" }, }; const route = resolveAgentRoute({ cfg, channel: "telegram", accountId: "tasks", peer: { kind: "dm", id: "7550356539" }, }); expect(route.sessionKey).toBe("agent:main:telegram:tasks:dm:7550356539"); }); test("dmScope=per-account-channel-peer uses default accountId when not provided", () => { const cfg: OpenClawConfig = { session: { dmScope: "per-account-channel-peer" }, }; const route = resolveAgentRoute({ cfg, channel: "telegram", accountId: null, peer: { kind: "dm", id: "7550356539" }, }); expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539"); }); describe("parentPeer binding inheritance (thread support)", () => { test("thread inherits binding from parent channel when no direct match", () => { const cfg: MoltbotConfig = { bindings: [ { agentId: "adecco", match: { channel: "discord", peer: { kind: "channel", id: "parent-channel-123" }, }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", peer: { kind: "channel", id: "thread-456" }, parentPeer: { kind: "channel", id: "parent-channel-123" }, }); expect(route.agentId).toBe("adecco"); expect(route.matchedBy).toBe("binding.peer.parent"); }); test("direct peer binding wins over parent peer binding", () => { const cfg: MoltbotConfig = { bindings: [ { agentId: "thread-agent", match: { channel: "discord", peer: { kind: "channel", id: "thread-456" }, }, }, { agentId: "parent-agent", match: { channel: "discord", peer: { kind: "channel", id: "parent-channel-123" }, }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", peer: { kind: "channel", id: "thread-456" }, parentPeer: { kind: "channel", id: "parent-channel-123" }, }); expect(route.agentId).toBe("thread-agent"); expect(route.matchedBy).toBe("binding.peer"); }); test("parent peer binding wins over guild binding", () => { const cfg: MoltbotConfig = { bindings: [ { agentId: "parent-agent", match: { channel: "discord", peer: { kind: "channel", id: "parent-channel-123" }, }, }, { agentId: "guild-agent", match: { channel: "discord", guildId: "guild-789", }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", peer: { kind: "channel", id: "thread-456" }, parentPeer: { kind: "channel", id: "parent-channel-123" }, guildId: "guild-789", }); expect(route.agentId).toBe("parent-agent"); expect(route.matchedBy).toBe("binding.peer.parent"); }); test("falls back to guild binding when no parent peer match", () => { const cfg: MoltbotConfig = { bindings: [ { agentId: "other-parent-agent", match: { channel: "discord", peer: { kind: "channel", id: "other-parent-999" }, }, }, { agentId: "guild-agent", match: { channel: "discord", guildId: "guild-789", }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", peer: { kind: "channel", id: "thread-456" }, parentPeer: { kind: "channel", id: "parent-channel-123" }, guildId: "guild-789", }); expect(route.agentId).toBe("guild-agent"); expect(route.matchedBy).toBe("binding.guild"); }); test("parentPeer with empty id is ignored", () => { const cfg: MoltbotConfig = { bindings: [ { agentId: "parent-agent", match: { channel: "discord", peer: { kind: "channel", id: "parent-channel-123" }, }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", peer: { kind: "channel", id: "thread-456" }, parentPeer: { kind: "channel", id: "" }, }); expect(route.agentId).toBe("main"); expect(route.matchedBy).toBe("default"); }); test("null parentPeer is handled gracefully", () => { const cfg: MoltbotConfig = { bindings: [ { agentId: "parent-agent", match: { channel: "discord", peer: { kind: "channel", id: "parent-channel-123" }, }, }, ], }; const route = resolveAgentRoute({ cfg, channel: "discord", peer: { kind: "channel", id: "thread-456" }, parentPeer: null, }); expect(route.agentId).toBe("main"); expect(route.matchedBy).toBe("default"); }); });