fix(agents): keep merged delivery routes account-bound (#98240)

* fix(agents): keep merged delivery routes account-bound

mergeDeliveryContext gated route-field crossing on channel only, so a
completion origin that knew its account but not a concrete target
inherited a different account's to/threadId on the same channel. A
subagent, cron, or media completion for bot-a could be addressed to
bot-b's chat but sent through bot-a (cross-account misroute) or dropped.

This restores the account-bound guard added in 1ed8592467 and removed as
collateral by 025db6cf9e (PR #89949); same-account and missing-account
merges still backfill so the media route-pin path is preserved. Restores
the deleted regression test.

* fix(agents): centralize account-bound completion routes

---------

Co-authored-by: Peter Steinberger <steipete@golden-gate.local>
This commit is contained in:
Yuval Dinodia
2026-06-30 21:32:35 -04:00
committed by GitHub
parent 35af831fd0
commit 150ca2fedd
4 changed files with 65 additions and 34 deletions
+14 -9
View File
@@ -229,7 +229,7 @@ export function deliveryContextFromSession(
return normalizeSessionDeliveryFields(source).deliveryContext;
}
/** Merges delivery contexts without mixing target/account/thread fields across channels. */
/** Merges delivery contexts without mixing target/account/thread fields across route owners. */
export function mergeDeliveryContext(
primary?: DeliveryContext,
fallback?: DeliveryContext,
@@ -243,17 +243,22 @@ export function mergeDeliveryContext(
normalizedPrimary?.channel &&
normalizedFallback?.channel &&
normalizedPrimary.channel !== normalizedFallback.channel;
const accountsConflict =
normalizedPrimary?.accountId &&
normalizedFallback?.accountId &&
normalizedPrimary.accountId !== normalizedFallback.accountId;
const routesConflict = channelsConflict || accountsConflict;
return normalizeDeliveryContext({
channel: normalizedPrimary?.channel ?? normalizedFallback?.channel,
// Keep route fields paired to their channel; avoid crossing fields between
// unrelated channels during session context merges.
to: channelsConflict
? normalizedPrimary?.to
: (normalizedPrimary?.to ?? normalizedFallback?.to),
accountId: channelsConflict
channel: accountsConflict
? normalizedPrimary?.channel
: (normalizedPrimary?.channel ?? normalizedFallback?.channel),
// Keep route fields paired to their channel account; crossing either owner
// can address one account's target through another account's credentials.
to: routesConflict ? normalizedPrimary?.to : (normalizedPrimary?.to ?? normalizedFallback?.to),
accountId: routesConflict
? normalizedPrimary?.accountId
: (normalizedPrimary?.accountId ?? normalizedFallback?.accountId),
threadId: channelsConflict
threadId: routesConflict
? normalizedPrimary?.threadId
: (normalizedPrimary?.threadId ?? normalizedFallback?.threadId),
});
+25
View File
@@ -53,6 +53,31 @@ describe("delivery context helpers", () => {
});
});
it("does not inherit route fields from a different account on the same channel", () => {
const merged = mergeDeliveryContext(
{ channel: "telegram", accountId: "bot-a" },
{ channel: "telegram", to: "123", accountId: "bot-b", threadId: "99" },
);
expect(merged).toEqual({
channel: "telegram",
to: undefined,
accountId: "bot-a",
});
expect(merged?.threadId).toBeUndefined();
expect(
mergeDeliveryContext(
{ accountId: "bot-a" },
{ channel: "telegram", to: "123", accountId: "bot-b", threadId: "99" },
),
).toEqual({
channel: undefined,
to: undefined,
accountId: "bot-a",
});
});
it("uses fallback route fields when fallback has no channel", () => {
const merged = mergeDeliveryContext(
{ channel: "demo-channel" },