diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 7d99cdb146c..131a6a5b957 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import type { ChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentRoute } from "./resolve-route.js"; @@ -419,7 +420,7 @@ describe("backward compatibility: peer.kind dm → direct", () => { match: { channel: "whatsapp", // Legacy config uses "dm" instead of "direct" - peer: { kind: "dm", id: "+15551234567" }, + peer: { kind: "dm" as ChatType, id: "+15551234567" }, }, }, ], @@ -435,3 +436,138 @@ describe("backward compatibility: peer.kind dm → direct", () => { expect(route.matchedBy).toBe("binding.peer"); }); }); + +describe("role-based agent routing", () => { + test("guild+roles binding matches when member has matching role", () => { + const cfg: OpenClawConfig = { + bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r1"], + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("opus"); + expect(route.matchedBy).toBe("binding.guild+roles"); + }); + + test("guild+roles binding skipped when no matching role", () => { + const cfg: OpenClawConfig = { + bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r2"], + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); + + test("guild+roles is more specific than guild-only", () => { + const cfg: OpenClawConfig = { + bindings: [ + { agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }, + { agentId: "sonnet", match: { channel: "discord", guildId: "g1" } }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r1"], + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("opus"); + expect(route.matchedBy).toBe("binding.guild+roles"); + }); + + test("peer binding still beats guild+roles", () => { + const cfg: OpenClawConfig = { + bindings: [ + { + agentId: "peer-agent", + match: { channel: "discord", peer: { kind: "channel", id: "c1" } }, + }, + { agentId: "roles-agent", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r1"], + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("peer-agent"); + expect(route.matchedBy).toBe("binding.peer"); + }); + + test("no memberRoleIds → guild+roles doesn't match", () => { + const cfg: OpenClawConfig = { + bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); + + test("first matching binding wins with multiple role bindings", () => { + const cfg: OpenClawConfig = { + bindings: [ + { agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }, + { agentId: "sonnet", match: { channel: "discord", guildId: "g1", roles: ["r2"] } }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r1", "r2"], + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("opus"); + expect(route.matchedBy).toBe("binding.guild+roles"); + }); + + test("empty roles array treated as no role restriction", () => { + const cfg: OpenClawConfig = { + bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: [] } }], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["r1"], + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("opus"); + expect(route.matchedBy).toBe("binding.guild"); + }); + + test("CRITICAL: guild+roles binding NOT matched as guild-only when roles don't match", () => { + const cfg: OpenClawConfig = { + bindings: [ + { agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["admin"] } }, + ], + }; + const route = resolveAgentRoute({ + cfg, + channel: "discord", + guildId: "g1", + memberRoleIds: ["regular"], + peer: { kind: "channel", id: "c1" }, + }); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); + }); +});