diff --git a/src/agents/tools/gateway-tool.test.ts b/src/agents/tools/gateway-tool.test.ts index 75039fc53d1..6e0c9e14e51 100644 --- a/src/agents/tools/gateway-tool.test.ts +++ b/src/agents/tools/gateway-tool.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ formatDoctorNonInteractiveHint: vi.fn(() => ""), callGatewayTool: vi.fn(async () => ({})), readGatewayCallOptions: vi.fn(() => ({})), + resolveGatewayTarget: vi.fn(() => undefined), })); vi.mock("../../config/commands.js", () => ({ isRestartEnabled: mocks.isRestartEnabled })); @@ -31,6 +32,7 @@ vi.mock("../../infra/restart.js", () => ({ vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool, readGatewayCallOptions: mocks.readGatewayCallOptions, + resolveGatewayTarget: mocks.resolveGatewayTarget, })); import { createGatewayTool } from "./gateway-tool.js"; @@ -174,6 +176,7 @@ describe("createGatewayTool – live delivery context guard", () => { // destination on the remote host. mocks.callGatewayTool.mockClear(); mocks.readGatewayCallOptions.mockReturnValueOnce({ gatewayUrl: "wss://remote-gw.example.com" }); + mocks.resolveGatewayTarget.mockReturnValueOnce("remote"); const tool = createGatewayTool({ agentSessionKey: "agent:main:main", agentChannel: "discord", @@ -193,6 +196,37 @@ describe("createGatewayTool – live delivery context guard", () => { expect(forwardedParams?.deliveryContext).toBeUndefined(); }); + it("forwards live RPC delivery context when gatewayUrl is a local loopback override", async () => { + // A gatewayUrl pointing to 127.0.0.1/localhost/[::1] is still the local server; + // deliveryContext must be forwarded so restart sentinels use the correct chat destination. + mocks.callGatewayTool.mockClear(); + mocks.readGatewayCallOptions.mockReturnValueOnce({ + gatewayUrl: "ws://127.0.0.1:18789", + }); + mocks.resolveGatewayTarget.mockReturnValueOnce("local"); + const tool = createGatewayTool({ + agentSessionKey: "agent:main:main", + agentChannel: "discord", + agentTo: "123456789", + }); + + await execTool(tool, { + action: "config.patch", + raw: '{"key":"value"}', + baseHash: "abc123", + sessionKey: "agent:main:main", + note: "local loopback patch", + gatewayUrl: "ws://127.0.0.1:18789", + }); + + const forwardedParams = getCallArg>(mocks.callGatewayTool, 0, 2); + expect(forwardedParams?.deliveryContext).toEqual({ + channel: "discord", + to: "123456789", + accountId: undefined, + }); + }); + 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. diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 50e0698b588..b47293f0106 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -12,7 +12,7 @@ import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, readStringParam } from "./common.js"; -import { callGatewayTool, readGatewayCallOptions } from "./gateway.js"; +import { callGatewayTool, readGatewayCallOptions, resolveGatewayTarget } from "./gateway.js"; const log = createSubsystemLogger("gateway-tool"); @@ -274,7 +274,12 @@ export function createGatewayTool(opts?: { // would write a sentinel with the wrong chat destination on the remote host, // causing post-restart wake messages to be sent to the caller's chat instead // of the session on the remote gateway. See #18612. - const isRemoteGateway = Boolean(gatewayOpts.gatewayUrl?.trim()); + // Only suppress deliveryContext for truly remote gateways. A gatewayUrl + // override pointing to a local loopback address (127.0.0.1, localhost, + // [::1]) is still the local server and should forward context normally; + // treating it as remote would fall back to extractDeliveryInfo(sessionKey) + // and reintroduce the stale heartbeat routing this patch was meant to fix. + const isRemoteGateway = resolveGatewayTarget(gatewayOpts) === "remote"; const deliveryContext = isTargetingOtherSession || isRemoteGateway ? undefined : liveDeliveryContextForRpc; return { sessionKey, note, restartDelayMs, deliveryContext }; diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index c31b7751e10..1c372403f2a 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -13,7 +13,7 @@ export type GatewayCallOptions = { timeoutMs?: number; }; -type GatewayOverrideTarget = "local" | "remote"; +export type GatewayOverrideTarget = "local" | "remote"; export function readGatewayCallOptions(params: Record): GatewayCallOptions { return { @@ -113,6 +113,23 @@ function resolveGatewayOverrideToken(params: { }).token; } +/** + * Resolves whether a GatewayCallOptions points to a local or remote gateway. + * Returns undefined when no gatewayUrl override is present (default local gateway). + * Local loopback overrides (127.0.0.1, localhost, [::1]) return "local"; + * all other URL overrides return "remote". + */ +export function resolveGatewayTarget(opts?: GatewayCallOptions): GatewayOverrideTarget | undefined { + if (trimToUndefined(opts?.gatewayUrl) === undefined) { + return undefined; + } + const cfg = loadConfig(); + return validateGatewayUrlOverrideForAgentTools({ + cfg, + urlOverride: String(opts?.gatewayUrl), + }).target; +} + export function resolveGatewayOptions(opts?: GatewayCallOptions) { const cfg = loadConfig(); const validatedOverride =