fix: route bindings return the wrong agent when route identifiers contain separators (#110001)

* fix(routing): replace delimiter-concatenated route cache key with JSON-serialized tuple to prevent identifier collisions

* fix(routing): restore dmScope input contract alongside JSON cache key fix

* fix(routing): restore max-lines exemption on resolve-route.ts

* test(routing): correct cache collision regression

Co-authored-by: YangManBOBO <152153397+YangManBOBO@users.noreply.github.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: YangManBOBO <152153397+YangManBOBO@users.noreply.github.com>
This commit is contained in:
YangManBOBO
2026-07-21 10:45:11 +08:00
committed by GitHub
parent 2bb6b870aa
commit f8380a8324
2 changed files with 141 additions and 18 deletions

View File

@@ -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;

View File

@@ -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 {