diff --git a/src/gateway/server-methods/agent.deleted-agent.test.ts b/src/gateway/server-methods/agent.deleted-agent.test.ts index 6f9da0b569c5..fef9acd09a02 100644 --- a/src/gateway/server-methods/agent.deleted-agent.test.ts +++ b/src/gateway/server-methods/agent.deleted-agent.test.ts @@ -12,6 +12,7 @@ import type { RespondFn } from "./types.js"; const agentCommandFromIngressMock = vi.hoisted(() => vi.fn()); const performGatewaySessionResetMock = vi.hoisted(() => vi.fn()); +const parseMessageWithAttachmentsMock = vi.hoisted(() => vi.fn()); vi.mock("../../commands/agent.js", () => ({ agentCommandFromIngress: agentCommandFromIngressMock, @@ -23,11 +24,21 @@ vi.mock("../session-reset-service.js", () => ({ emitGatewaySessionStartPluginHook: vi.fn(), })); +vi.mock("../chat-attachments.js", async () => { + const actual = + await vi.importActual("../chat-attachments.js"); + return { + ...actual, + parseMessageWithAttachments: parseMessageWithAttachmentsMock, + }; +}); + describe("agent RPC deleted-agent guard", () => { beforeEach(() => { resetDeletedAgentSessionMocks(); agentCommandFromIngressMock.mockReset(); performGatewaySessionResetMock.mockReset(); + parseMessageWithAttachmentsMock.mockReset(); }); it("rejects keys belonging to a deleted agent", async () => { @@ -59,6 +70,43 @@ describe("agent RPC deleted-agent guard", () => { expect(agentCommandFromIngressMock).not.toHaveBeenCalled(); }); + it("rejects deleted-agent sessions before media offload or dedupe reservation", async () => { + const orphanKey = mockDeletedAgentSession(); + + const respond = vi.fn() as unknown as RespondFn; + const dedupe = new Map(); + + await agentHandlers.agent({ + req: { id: "req-attach" } as never, + params: { + sessionKey: orphanKey, + message: "see attachment", + idempotencyKey: "run-attach", + attachments: [ + { type: "file", mimeType: "application/pdf", fileName: "doc.pdf", content: "aGVsbG8=" }, + ], + }, + respond, + context: { + dedupe, + chatAbortControllers: new Map(), + getRuntimeConfig: () => ({}), + } as never, + client: null, + isWebchatConnect: () => false, + }); + + expect(respond).toHaveBeenCalledWith(false, undefined, { + code: ErrorCodes.INVALID_REQUEST, + message: 'Agent "deleted-agent" no longer exists in configuration', + }); + // Guard runs before attachment parsing (inbound media offload), the + // pre-accept dedupe reservation, and run dispatch. + expect(parseMessageWithAttachmentsMock).not.toHaveBeenCalled(); + expect(dedupe.size).toBe(0); + expect(agentCommandFromIngressMock).not.toHaveBeenCalled(); + }); + it.each(["/reset", "/reset follow up"])( "rejects deleted-agent session keys before %s handling", async (message) => { diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index e60fd50c2ec2..1dc5904cec4b 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -1505,6 +1505,17 @@ export const agentHandlers: GatewayRequestHandlers = { return; } } + // Reject orphaned deleted-agent session keys before any side effect (dedupe + // reservation, attachment media offload, reset, dispatch), matching the + // chat.send / sessions.send invariant. Voice-wake auto-routing below only + // retargets to knownAgents, so a post-routing key is never deleted; guarding + // the original requestedSessionKey here covers every dispatch path. + if ( + requestedSessionKey && + respondDeletedAgentSessionForKey({ sessionKey: requestedSessionKey, agentId, respond }) + ) { + return; + } // Reserve the run before awaited attachment/session/delivery work so duplicate calls dedupe and // pre-registration chat.abort can be made durable by idempotency key. const preAcceptedReservedSessionKey = @@ -1691,13 +1702,6 @@ export const agentHandlers: GatewayRequestHandlers = { let skipAgentInitialSessionTouch = false; let pendingChatRun: { sessionKey: string; agentId?: string } | undefined; - if ( - requestedSessionKey && - respondDeletedAgentSessionForKey({ sessionKey: requestedSessionKey, agentId, respond }) - ) { - return; - } - const resetCommandMatch = message.match(RESET_COMMAND_RE); if (resetCommandMatch && requestedSessionKey) { if (abortForLifecycleRotation({ sessionKey: requestedSessionKey, agentId })) {