diff --git a/src/agents/tools/gateway-tool.test.ts b/src/agents/tools/gateway-tool.test.ts index 11e207c3376..81ac3b7649f 100644 --- a/src/agents/tools/gateway-tool.test.ts +++ b/src/agents/tools/gateway-tool.test.ts @@ -167,4 +167,53 @@ describe("createGatewayTool – live delivery context guard", () => { expect(sentinelPayload?.deliveryContext?.channel).toBe("discord"); expect(sentinelPayload?.deliveryContext?.to).toBe("123456789"); }); + + it("does not forward live RPC delivery context when a non-default agent passes sessionKey='main'", async () => { + // "main" resolves to "agent:main:main" (default agent), which differs from the + // current session "agent:shopping-claw:main". Live context must NOT be forwarded. + mocks.callGatewayTool.mockClear(); + const tool = createGatewayTool({ + agentSessionKey: "agent:shopping-claw:main", + agentChannel: "discord", + agentTo: "123456789", + }); + + await execTool(tool, { + action: "config.patch", + raw: '{"key":"value"}', + baseHash: "abc123", + sessionKey: "main", // targets default agent — different from current shopping-claw session + note: "cross-agent patch", + }); + + const forwardedParams = getCallArg>(mocks.callGatewayTool, 0, 2); + expect(forwardedParams?.deliveryContext).toBeUndefined(); + }); + + it("does not forward live restart context when a non-default agent passes sessionKey='main'", async () => { + // "main" resolves to "agent:main:main" (default agent), which differs from the + // current session "agent:shopping-claw:main". Sentinel must not carry this session's thread. + mocks.writeRestartSentinel.mockClear(); + mocks.extractDeliveryInfo.mockReturnValueOnce({ + deliveryContext: { channel: "telegram", to: "+19995550001", accountId: undefined }, + threadId: undefined, + }); + + const tool = createGatewayTool({ + agentSessionKey: "agent:shopping-claw:main", + agentChannel: "discord", + agentTo: "123456789", + }); + + await execTool(tool, { action: "restart", sessionKey: "main" }); + + const sentinelPayload = getCallArg<{ deliveryContext?: { channel?: string; to?: string } }>( + mocks.writeRestartSentinel, + 0, + 0, + ); + // Should fall back to extractDeliveryInfo() for the targeted session, not current session's live context + expect(sentinelPayload?.deliveryContext?.channel).toBe("telegram"); + expect(sentinelPayload?.deliveryContext?.to).toBe("+19995550001"); + }); }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 09f24880cbf..27c89750764 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -116,9 +116,29 @@ export function createGatewayTool(opts?: { // session, the live context belongs to the wrong session and would // misroute the post-restart reply. Fall back to extractDeliveryInfo() // so the server uses the correct routing for the target session. + // Canonicalize both keys before comparing so that aliases like "main" + // and "agent:main:main" are treated as the same session. Without this, + // an operator passing sessionKey="main" would be incorrectly treated as + // targeting a different session, suppressing live deliveryContext and + // falling back to the stale session store. See #18612. + const ownKey = opts?.agentSessionKey?.trim() || undefined; + const agentId = resolveAgentIdFromSessionKey(ownKey); + // Canonicalize each key using its OWN agentId — not the current session's. + // If a non-default agent passes sessionKey="main", resolveAgentIdFromSessionKey + // returns DEFAULT_AGENT_ID ("main") so "main" → "agent:main:main". Using the + // current session's agentId instead would map "main" to the current agent's main + // session, falsely treating a cross-agent request as same-session. See #18612. + const canonicalizeOwn = (k: string) => + canonicalizeMainSessionAlias({ cfg: opts?.config, agentId, sessionKey: k }); + const canonicalizeTarget = (k: string) => + canonicalizeMainSessionAlias({ + cfg: opts?.config, + agentId: resolveAgentIdFromSessionKey(k), + sessionKey: k, + }); const isTargetingOtherSession = explicitSessionKey != null && - explicitSessionKey !== (opts?.agentSessionKey?.trim() || undefined); + canonicalizeTarget(explicitSessionKey) !== (ownKey ? canonicalizeOwn(ownKey) : undefined); // Only forward live context when both channel and to are present. // Forwarding a partial context (channel without to) causes the server // to write a sentinel without `to`, and scheduleRestartSentinelWake @@ -228,12 +248,26 @@ export function createGatewayTool(opts?: { ? Math.floor(params.restartDelayMs) : undefined; // Only forward live context when the target session is this agent's - // own session. When an explicit sessionKey points to a different - // session, omit deliveryContext so the server falls back to - // extractDeliveryInfo(sessionKey) which uses that session's routing. + // own session. Canonicalize both keys before comparing so that aliases + // like "main" and "agent:main:main" are treated as the same session. + // When an explicit sessionKey points to a different session, omit + // deliveryContext so the server falls back to extractDeliveryInfo(sessionKey). + const rpcOwnKey = opts?.agentSessionKey?.trim() || undefined; + const rpcAgentId = resolveAgentIdFromSessionKey(rpcOwnKey); + // Same cross-agent alias fix as the restart path: derive agentId from each key + // independently so that "main" resolves to the default agent, not the current one. + const rpcCanonicalizeOwn = (k: string) => + canonicalizeMainSessionAlias({ cfg: opts?.config, agentId: rpcAgentId, sessionKey: k }); + const rpcCanonicalizeTarget = (k: string) => + canonicalizeMainSessionAlias({ + cfg: opts?.config, + agentId: resolveAgentIdFromSessionKey(k), + sessionKey: k, + }); const isTargetingOtherSession = explicitSessionKey != null && - explicitSessionKey !== (opts?.agentSessionKey?.trim() || undefined); + rpcCanonicalizeTarget(explicitSessionKey) !== + (rpcOwnKey ? rpcCanonicalizeOwn(rpcOwnKey) : undefined); const deliveryContext = isTargetingOtherSession ? undefined : liveDeliveryContextForRpc; return { sessionKey, note, restartDelayMs, deliveryContext }; };