diff --git a/src/routing/bound-account-read.test.ts b/src/routing/bound-account-read.test.ts index 5e768cfef23..ce1a0cc19ac 100644 --- a/src/routing/bound-account-read.test.ts +++ b/src/routing/bound-account-read.test.ts @@ -147,4 +147,75 @@ describe("resolveFirstBoundAccountId", () => { }), ).toBeUndefined(); }); + + it("filters bindings by peer kind when caller supplies peerKind", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "direct", id: "*" }, + accountId: "bot-alpha-dm", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "channel", id: "*" }, + accountId: "bot-alpha-room", + }, + }, + ]); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!room:example.org", + peerKind: "channel", + }), + ).toBe("bot-alpha-room"); + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "@user:example.org", + peerKind: "direct", + }), + ).toBe("bot-alpha-dm"); + }); + + it("skips peer-specific bindings whose kind does not match the caller's peerKind", () => { + const cfg = cfgWithBindings([ + { + type: "route", + agentId: "bot-alpha", + match: { + channel: "matrix", + peer: { kind: "direct", id: "!room:example.org" }, + accountId: "bot-alpha-wrong-kind", + }, + }, + { + type: "route", + agentId: "bot-alpha", + match: { channel: "matrix", accountId: "bot-alpha-default" }, + }, + ]); + // Caller peerKind=channel: the direct-kind binding is ineligible even though + // its peerId would match — falls through to the channel-only binding. + expect( + resolveFirstBoundAccountId({ + cfg, + channelId: "matrix", + agentId: "bot-alpha", + peerId: "!room:example.org", + peerKind: "channel", + }), + ).toBe("bot-alpha-default"); + }); }); diff --git a/src/routing/bound-account-read.ts b/src/routing/bound-account-read.ts index 08347352958..8d493be35e0 100644 --- a/src/routing/bound-account-read.ts +++ b/src/routing/bound-account-read.ts @@ -1,3 +1,4 @@ +import { normalizeChatType, type ChatType } from "../channels/chat-type.js"; import { normalizeChatChannelId } from "../channels/ids.js"; import { listRouteBindings } from "../config/bindings.js"; import type { AgentRouteBinding } from "../config/types.agents.js"; @@ -19,6 +20,7 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { accountId: string; channelId: string; peerId?: string; + peerKind?: ChatType; } | null { if (!binding || typeof binding !== "object") { return null; @@ -36,11 +38,13 @@ function resolveNormalizedBindingMatch(binding: AgentRouteBinding): { return null; } const peerId = match.peer && typeof match.peer.id === "string" ? match.peer.id.trim() : undefined; + const peerKind = match.peer ? normalizeChatType(match.peer.kind) : undefined; return { agentId: normalizeAgentId(binding.agentId), accountId: normalizeAccountId(accountId), channelId, peerId: peerId || undefined, + peerKind: peerKind ?? undefined, }; } @@ -49,6 +53,7 @@ export function resolveFirstBoundAccountId(params: { channelId: string; agentId: string; peerId?: string; + peerKind?: ChatType; }): string | undefined { const normalizedChannel = normalizeBindingChannelId(params.channelId); if (!normalizedChannel) { @@ -56,6 +61,7 @@ export function resolveFirstBoundAccountId(params: { } const normalizedAgentId = normalizeAgentId(params.agentId); const normalizedPeerId = params.peerId?.trim() || undefined; + const normalizedPeerKind = normalizeChatType(params.peerKind) ?? undefined; let wildcardPeerMatch: string | undefined; let channelOnlyFallback: string | undefined; let peerlessPeerSpecificFallback: string | undefined; @@ -68,6 +74,13 @@ export function resolveFirstBoundAccountId(params: { ) { continue; } + // When the caller knows the peer kind and the binding declares a peer kind, + // they must match — a direct/* binding must not win for a channel caller, + // and vice versa. If either side omits the kind, we do not filter on it + // (preserves backward-compat for peerless cron callers). + if (resolved.peerKind && normalizedPeerKind && resolved.peerKind !== normalizedPeerKind) { + continue; + } if (resolved.peerId === "*") { if (normalizedPeerId) { wildcardPeerMatch ??= resolved.accountId;