diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index d001b72c8070..c558810584ab 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -135,7 +135,7 @@ describe("resolveAgentRoute", () => { }); test("allows a channel route to require a stronger direct-message scope", () => { - const route = resolveRoute({ + const route = resolveAgentRoute({ cfg: { session: { dmScope: "main" } }, channel: "zalouser", peer: { kind: "direct", id: "321" }, @@ -1207,6 +1207,136 @@ describe("wildcard peer bindings (peer.id=*)", () => { }); }); +describe("resolved route cache keys", () => { + test("does not reuse a cached route when peer and guild fields contain cache separators", () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "whole-peer" }, { id: "guild-room" }] }, + bindings: [ + { + agentId: "whole-peer", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "group", id: "room\t-\tguild-1" }, + }, + }, + { + agentId: "guild-room", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "group", id: "room" }, + guildId: "guild-1", + }, + }, + ], + }; + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "group", id: "room\t-\tguild-1" }, + }), + { agentId: "whole-peer", matchedBy: "binding.peer" }, + ); + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + guildId: "guild-1", + peer: { kind: "group", id: "room" }, + }), + { agentId: "guild-room", matchedBy: "binding.peer" }, + ); + }); + + test("does not reuse a cached route when role IDs contain cache separators", () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "comma-role" }, { id: "suffix-role" }] }, + bindings: [ + { + agentId: "comma-role", + match: { + channel: "discord", + accountId: "default", + guildId: "guild-1", + roles: ["a,b"], + }, + }, + { + agentId: "suffix-role", + match: { + channel: "discord", + accountId: "default", + guildId: "guild-1", + roles: ["b,c"], + }, + }, + ], + }; + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + guildId: "guild-1", + memberRoleIds: ["a,b", "c"], + }), + { agentId: "comma-role", matchedBy: "binding.guild+roles" }, + ); + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + guildId: "guild-1", + memberRoleIds: ["a", "b,c"], + }), + { agentId: "suffix-role", matchedBy: "binding.guild+roles" }, + ); + }); + + test("does not reuse a cached route when guildId is omitted versus the literal hyphen string", () => { + const cfg: OpenClawConfig = { + agents: { list: [{ id: "main", default: true }, { id: "hyphen-guild" }] }, + bindings: [ + { + agentId: "hyphen-guild", + match: { + channel: "discord", + accountId: "default", + guildId: "-", + }, + }, + ], + }; + + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "group", id: "room" }, + }), + { agentId: "main", matchedBy: "default" }, + ); + expectResolvedRoute( + resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "group", id: "room" }, + guildId: "-", + }), + { agentId: "hyphen-guild", matchedBy: "binding.guild" }, + ); + }); +}); + describe("binding evaluation cache scalability", () => { test("does not rescan full bindings across distinct channel/account cache entries (#36915)", () => { const cacheKeyCount = 64; diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 700e5ba4ec7e..1c9f9bfb47c6 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -548,22 +548,6 @@ function formatRouteCachePeer(peer: RoutePeer | null): string { return `${peer.kind}:${peer.id}`; } -function formatRoleIdsCacheKey(roleIds: string[]): string { - const count = roleIds.length; - if (count === 0) { - return "-"; - } - if (count === 1) { - return roleIds[0] ?? "-"; - } - if (count === 2) { - const first = roleIds[0] ?? ""; - const second = roleIds[1] ?? ""; - return first <= second ? `${first},${second}` : `${second},${first}`; - } - return roleIds.toSorted().join(","); -} - function buildResolvedRouteCacheKey(params: { channel: string; accountId: string; @@ -574,7 +558,16 @@ function buildResolvedRouteCacheKey(params: { memberRoleIds: string[]; dmScope: string; }): string { - return `${params.channel}\t${params.accountId}\t${formatRouteCachePeer(params.peer)}\t${formatRouteCachePeer(params.parentPeer)}\t${params.guildId || "-"}\t${params.teamId || "-"}\t${formatRoleIdsCacheKey(params.memberRoleIds)}\t${params.dmScope}`; + return JSON.stringify([ + params.channel, + params.accountId, + formatRouteCachePeer(params.peer), + formatRouteCachePeer(params.parentPeer), + params.guildId ?? null, + params.teamId ?? null, + params.memberRoleIds.toSorted(), + params.dmScope, + ]); } function hasGuildConstraint(match: NormalizedBindingMatch): boolean {