openclaw/src/routing/resolve-route.test.ts
Glucksberg fd7ca4c394 fix: normalize input peer.kind in resolveAgentRoute (#22730)
The input peer.kind from channel plugins was used as-is without
normalization via normalizeChatType(), while the binding side correctly
normalized. This caused "dm" !== "direct" mismatches in
matchesBindingScope, making plugins that use "dm" as peerKind fail to
match bindings configured with "direct".

Normalize both peer.kind and parentPeer.kind through normalizeChatType()
so that "dm" and "direct" are treated equivalently on both sides.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit b0c96702f5531287f857410303b2c3cc698a1441)
2026-02-24 04:33:50 +00:00

706 lines
20 KiB
TypeScript

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";
describe("resolveAgentRoute", () => {
test("defaults to main/default when no bindings exist", () => {
const cfg: OpenClawConfig = {};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: null,
peer: { kind: "direct", 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 controls direct-message session key isolation", () => {
const cases = [
{ dmScope: "per-peer" as const, expected: "agent:main:direct:+15551234567" },
{
dmScope: "per-channel-peer" as const,
expected: "agent:main:whatsapp:direct:+15551234567",
},
];
for (const testCase of cases) {
const cfg: OpenClawConfig = {
session: { dmScope: testCase.dmScope },
};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: null,
peer: { kind: "direct", id: "+15551234567" },
});
expect(route.sessionKey).toBe(testCase.expected);
}
});
test("identityLinks applies to direct-message scopes", () => {
const cases = [
{
dmScope: "per-peer" as const,
channel: "telegram",
peerId: "111111111",
expected: "agent:main:direct:alice",
},
{
dmScope: "per-channel-peer" as const,
channel: "discord",
peerId: "222222222222222222",
expected: "agent:main:discord:direct:alice",
},
];
for (const testCase of cases) {
const cfg: OpenClawConfig = {
session: {
dmScope: testCase.dmScope,
identityLinks: {
alice: ["telegram:111111111", "discord:222222222222222222"],
},
},
};
const route = resolveAgentRoute({
cfg,
channel: testCase.channel,
accountId: null,
peer: { kind: "direct", id: testCase.peerId },
});
expect(route.sessionKey).toBe(testCase.expected);
}
});
test("peer binding wins over account binding", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "a",
match: {
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", id: "+1000" },
},
},
{
agentId: "b",
match: { channel: "whatsapp", accountId: "biz" },
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", 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("coerces numeric peer ids to stable session keys", () => {
const cfg: OpenClawConfig = {};
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: 1468834856187203680n as unknown as string },
});
expect(route.sessionKey).toBe("agent:main:discord:channel:1468834856187203680");
});
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("peer+guild binding does not act as guild-wide fallback when peer mismatches (#14752)", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "olga",
match: {
channel: "discord",
peer: { kind: "channel", id: "CHANNEL_A" },
guildId: "GUILD_1",
},
},
{
agentId: "main",
match: {
channel: "discord",
guildId: "GUILD_1",
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "discord",
peer: { kind: "channel", id: "CHANNEL_B" },
guildId: "GUILD_1",
});
expect(route.agentId).toBe("main");
expect(route.matchedBy).toBe("binding.guild");
});
test("peer+guild binding requires guild match even when peer matches", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "wrongguild",
match: {
channel: "discord",
peer: { kind: "channel", id: "c1" },
guildId: "g1",
},
},
{
agentId: "rightguild",
match: {
channel: "discord",
guildId: "g2",
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "discord",
peer: { kind: "channel", id: "c1" },
guildId: "g2",
});
expect(route.agentId).toBe("rightguild");
expect(route.matchedBy).toBe("binding.guild");
});
test("peer+team binding does not act as team-wide fallback when peer mismatches", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "roomonly",
match: {
channel: "slack",
peer: { kind: "channel", id: "C_A" },
teamId: "T1",
},
},
{
agentId: "teamwide",
match: {
channel: "slack",
teamId: "T1",
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "slack",
teamId: "T1",
peer: { kind: "channel", id: "C_B" },
});
expect(route.agentId).toBe("teamwide");
expect(route.matchedBy).toBe("binding.team");
});
test("peer+team binding requires team match even when peer matches", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "wrongteam",
match: {
channel: "slack",
peer: { kind: "channel", id: "C1" },
teamId: "T1",
},
},
{
agentId: "rightteam",
match: {
channel: "slack",
teamId: "T2",
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "slack",
teamId: "T2",
peer: { kind: "channel", id: "C1" },
});
expect(route.agentId).toBe("rightteam");
expect(route.matchedBy).toBe("binding.team");
});
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: "direct", id: "+1000" },
});
expect(defaultRoute.agentId).toBe("defaultacct");
expect(defaultRoute.matchedBy).toBe("binding.account");
const otherRoute = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: "biz",
peer: { kind: "direct", 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: "direct", id: "+1000" },
});
expect(route.agentId).toBe("any");
expect(route.matchedBy).toBe("binding.channel");
});
test("binding accountId matching is canonicalized", () => {
const cfg: OpenClawConfig = {
bindings: [{ agentId: "biz", match: { channel: "discord", accountId: "BIZ" } }],
};
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId: " biz ",
peer: { kind: "direct", id: "u-1" },
});
expect(route.agentId).toBe("biz");
expect(route.matchedBy).toBe("binding.account");
expect(route.accountId).toBe("biz");
});
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: "direct", 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: "direct", id: "7550356539" },
});
expect(route.sessionKey).toBe("agent:main:telegram:tasks:direct: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: "direct", id: "7550356539" },
});
expect(route.sessionKey).toBe("agent:main:telegram:default:direct:7550356539");
});
describe("parentPeer binding inheritance (thread support)", () => {
const threadPeer = { kind: "channel" as const, id: "thread-456" };
const defaultParentPeer = { kind: "channel" as const, id: "parent-channel-123" };
function makeDiscordPeerBinding(agentId: string, peerId: string) {
return {
agentId,
match: {
channel: "discord" as const,
peer: { kind: "channel" as const, id: peerId },
},
};
}
function makeDiscordGuildBinding(agentId: string, guildId: string) {
return {
agentId,
match: {
channel: "discord" as const,
guildId,
},
};
}
function resolveDiscordThreadRoute(params: {
cfg: OpenClawConfig;
parentPeer?: { kind: "channel"; id: string } | null;
guildId?: string;
}) {
const parentPeer = "parentPeer" in params ? params.parentPeer : defaultParentPeer;
return resolveAgentRoute({
cfg: params.cfg,
channel: "discord",
peer: threadPeer,
parentPeer,
guildId: params.guildId,
});
}
test("thread inherits binding from parent channel when no direct match", () => {
const cfg: OpenClawConfig = {
bindings: [makeDiscordPeerBinding("adecco", defaultParentPeer.id)],
};
const route = resolveDiscordThreadRoute({ cfg });
expect(route.agentId).toBe("adecco");
expect(route.matchedBy).toBe("binding.peer.parent");
});
test("direct peer binding wins over parent peer binding", () => {
const cfg: OpenClawConfig = {
bindings: [
makeDiscordPeerBinding("thread-agent", threadPeer.id),
makeDiscordPeerBinding("parent-agent", defaultParentPeer.id),
],
};
const route = resolveDiscordThreadRoute({ cfg });
expect(route.agentId).toBe("thread-agent");
expect(route.matchedBy).toBe("binding.peer");
});
test("parent peer binding wins over guild binding", () => {
const cfg: OpenClawConfig = {
bindings: [
makeDiscordPeerBinding("parent-agent", defaultParentPeer.id),
makeDiscordGuildBinding("guild-agent", "guild-789"),
],
};
const route = resolveDiscordThreadRoute({ cfg, 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: OpenClawConfig = {
bindings: [
makeDiscordPeerBinding("other-parent-agent", "other-parent-999"),
makeDiscordGuildBinding("guild-agent", "guild-789"),
],
};
const route = resolveDiscordThreadRoute({ cfg, guildId: "guild-789" });
expect(route.agentId).toBe("guild-agent");
expect(route.matchedBy).toBe("binding.guild");
});
test("parentPeer with empty id is ignored", () => {
const cfg: OpenClawConfig = {
bindings: [makeDiscordPeerBinding("parent-agent", defaultParentPeer.id)],
};
const route = resolveDiscordThreadRoute({ cfg, parentPeer: { kind: "channel", id: "" } });
expect(route.agentId).toBe("main");
expect(route.matchedBy).toBe("default");
});
test("null parentPeer is handled gracefully", () => {
const cfg: OpenClawConfig = {
bindings: [makeDiscordPeerBinding("parent-agent", defaultParentPeer.id)],
};
const route = resolveDiscordThreadRoute({ cfg, parentPeer: null });
expect(route.agentId).toBe("main");
expect(route.matchedBy).toBe("default");
});
});
describe("backward compatibility: peer.kind dm → direct", () => {
test("legacy dm in config matches runtime direct peer", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "alex",
match: {
channel: "whatsapp",
// Legacy config uses "dm" instead of "direct"
peer: { kind: "dm" as ChatType, id: "+15551234567" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: null,
// Runtime uses canonical "direct"
peer: { kind: "direct", id: "+15551234567" },
});
expect(route.agentId).toBe("alex");
expect(route.matchedBy).toBe("binding.peer");
});
test("runtime dm peer.kind matches config direct binding (#22730)", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "alex",
match: {
channel: "whatsapp",
// Config uses canonical "direct"
peer: { kind: "direct", id: "+15551234567" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: null,
// Plugin sends "dm" instead of "direct"
peer: { kind: "dm" as ChatType, id: "+15551234567" },
});
expect(route.agentId).toBe("alex");
expect(route.matchedBy).toBe("binding.peer");
});
});
describe("role-based agent routing", () => {
type DiscordBinding = NonNullable<OpenClawConfig["bindings"]>[number];
function makeDiscordRoleBinding(
agentId: string,
params: {
roles?: string[];
peerId?: string;
includeGuildId?: boolean;
} = {},
): DiscordBinding {
return {
agentId,
match: {
channel: "discord",
...(params.includeGuildId === false ? {} : { guildId: "g1" }),
...(params.roles !== undefined ? { roles: params.roles } : {}),
...(params.peerId ? { peer: { kind: "channel", id: params.peerId } } : {}),
},
};
}
function expectDiscordRoleRoute(params: {
bindings: DiscordBinding[];
memberRoleIds?: string[];
peerId?: string;
parentPeerId?: string;
expectedAgentId: string;
expectedMatchedBy: string;
}) {
const route = resolveAgentRoute({
cfg: { bindings: params.bindings },
channel: "discord",
guildId: "g1",
...(params.memberRoleIds ? { memberRoleIds: params.memberRoleIds } : {}),
peer: { kind: "channel", id: params.peerId ?? "c1" },
...(params.parentPeerId
? {
parentPeer: { kind: "channel", id: params.parentPeerId },
}
: {}),
});
expect(route.agentId).toBe(params.expectedAgentId);
expect(route.matchedBy).toBe(params.expectedMatchedBy);
}
test("guild+roles binding matches when member has matching role", () => {
expectDiscordRoleRoute({
bindings: [makeDiscordRoleBinding("opus", { roles: ["r1"] })],
memberRoleIds: ["r1"],
expectedAgentId: "opus",
expectedMatchedBy: "binding.guild+roles",
});
});
test("guild+roles binding skipped when no matching role", () => {
expectDiscordRoleRoute({
bindings: [makeDiscordRoleBinding("opus", { roles: ["r1"] })],
memberRoleIds: ["r2"],
expectedAgentId: "main",
expectedMatchedBy: "default",
});
});
test("guild+roles is more specific than guild-only", () => {
expectDiscordRoleRoute({
bindings: [
makeDiscordRoleBinding("opus", { roles: ["r1"] }),
makeDiscordRoleBinding("sonnet"),
],
memberRoleIds: ["r1"],
expectedAgentId: "opus",
expectedMatchedBy: "binding.guild+roles",
});
});
test("peer binding still beats guild+roles", () => {
expectDiscordRoleRoute({
bindings: [
makeDiscordRoleBinding("peer-agent", { peerId: "c1", includeGuildId: false }),
makeDiscordRoleBinding("roles-agent", { roles: ["r1"] }),
],
memberRoleIds: ["r1"],
expectedAgentId: "peer-agent",
expectedMatchedBy: "binding.peer",
});
});
test("parent peer binding still beats guild+roles", () => {
expectDiscordRoleRoute({
bindings: [
makeDiscordRoleBinding("parent-agent", {
peerId: "parent-1",
includeGuildId: false,
}),
makeDiscordRoleBinding("roles-agent", { roles: ["r1"] }),
],
memberRoleIds: ["r1"],
peerId: "thread-1",
parentPeerId: "parent-1",
expectedAgentId: "parent-agent",
expectedMatchedBy: "binding.peer.parent",
});
});
test("no memberRoleIds means guild+roles doesn't match", () => {
expectDiscordRoleRoute({
bindings: [makeDiscordRoleBinding("opus", { roles: ["r1"] })],
expectedAgentId: "main",
expectedMatchedBy: "default",
});
});
test("first matching binding wins with multiple role bindings", () => {
expectDiscordRoleRoute({
bindings: [
makeDiscordRoleBinding("opus", { roles: ["r1"] }),
makeDiscordRoleBinding("sonnet", { roles: ["r2"] }),
],
memberRoleIds: ["r1", "r2"],
expectedAgentId: "opus",
expectedMatchedBy: "binding.guild+roles",
});
});
test("empty roles array treated as no role restriction", () => {
expectDiscordRoleRoute({
bindings: [makeDiscordRoleBinding("opus", { roles: [] })],
memberRoleIds: ["r1"],
expectedAgentId: "opus",
expectedMatchedBy: "binding.guild",
});
});
test("guild+roles binding does not match as guild-only when roles do not match", () => {
expectDiscordRoleRoute({
bindings: [makeDiscordRoleBinding("opus", { roles: ["admin"] })],
memberRoleIds: ["regular"],
expectedAgentId: "main",
expectedMatchedBy: "default",
});
});
test("peer+guild+roles binding does not act as guild+roles fallback when peer mismatches", () => {
expectDiscordRoleRoute({
bindings: [
makeDiscordRoleBinding("peer-roles", { peerId: "c-target", roles: ["r1"] }),
makeDiscordRoleBinding("guild-roles", { roles: ["r1"] }),
],
memberRoleIds: ["r1"],
peerId: "c-other",
expectedAgentId: "guild-roles",
expectedMatchedBy: "binding.guild+roles",
});
});
});