From 5ed8bbc69468de6cf48a5c874876b282957fef76 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 22 May 2026 08:58:27 +0100 Subject: [PATCH] fix(gateway): preserve stop reason for deferred agent aborts --- src/gateway/chat-abort.ts | 4 ++ src/gateway/server-methods/agent.test.ts | 63 ++++++++++++++++++++++++ src/gateway/server-methods/agent.ts | 10 +++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 71242353364..9bf1df6a22d 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -14,6 +14,7 @@ export type ChatAbortControllerEntry = { ownerDeviceId?: string; providerId?: string; authProviderId?: string; + abortStopReason?: string; /** * Which RPC owns this registration. Absent (undefined) is treated as * `"chat-send"` so pre-existing callers that constructed entries without @@ -186,6 +187,9 @@ export function abortChatRunById( const bufferedText = ops.chatRunBuffers.get(runId); const partialText = bufferedText && bufferedText.trim() ? bufferedText : undefined; ops.chatAbortedRuns.set(runId, Date.now()); + if (stopReason) { + active.abortStopReason = stopReason; + } active.controller.abort(); ops.chatAbortControllers.delete(runId); ops.chatRunBuffers.delete(runId); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 3f90efd291b..cb01714f29d 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -3696,6 +3696,69 @@ describe("gateway agent handler chat.abort integration", () => { }); }); + it("preserves stop-command reason when /stop lands during the accepted ack yield", async () => { + prime(); + mocks.agentCommand.mockReturnValueOnce(new Promise(() => {})); + + const context = makeContext(); + const respond = vi.fn(); + const runId = "idem-stop-before-dispatch"; + await invokeAgent( + { + message: "hi", + agentId: "main", + sessionKey: "agent:main:main", + idempotencyKey: runId, + }, + { context, respond, reqId: runId, flushDispatch: false }, + ); + + expectRecordFields(mockCallArg(respond, 0, 1), { + runId, + sessionKey: "agent:main:main", + status: "accepted", + }); + expect(context.chatAbortControllers.has(runId)).toBe(true); + + const stopRespond = vi.fn(); + await chatHandlers["chat.send"]({ + params: { + sessionKey: "agent:main:main", + message: "/stop", + idempotencyKey: "idem-stop-command-before-dispatch", + }, + respond: stopRespond as never, + context, + req: { type: "req", id: "stop-req", method: "chat.send" }, + client: null, + isWebchatConnect: () => false, + }); + + expectRecordFields(mockCallArg(stopRespond, 0, 1), { + aborted: true, + runIds: [runId], + }); + expect(context.chatAbortControllers.has(runId)).toBe(false); + + await flushScheduledDispatchStep(); + + expect(mocks.agentCommand).not.toHaveBeenCalled(); + expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, { + runId, + status: "timeout", + summary: "aborted", + stopReason: "stop", + }); + const finalResponse = respond.mock.calls.find( + (call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout", + ); + expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], { + runId, + status: "timeout", + stopReason: "stop", + }); + }); + it("does not dispatch when chat.abort lands during pre-accept setup", async () => { prime(); const requestedSessionKey = "agent:main:legacy-main"; diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index dd7c3399a51..b2e90c34406 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -100,6 +100,7 @@ import { } from "../../utils/message-channel.js"; import { resolveAssistantIdentity } from "../assistant-identity.js"; import { + type ChatAbortControllerEntry, registerChatAbortController, resolveAgentRunExpiresAtMs, updateChatRunProvider, @@ -547,6 +548,10 @@ function setAbortedAgentDedupeEntries(params: { }); } +function resolveAbortedAgentStopReason(entry?: ChatAbortControllerEntry): string { + return entry?.abortStopReason?.trim() || "rpc"; +} + function deleteGatewayDedupeEntries(params: { dedupe: GatewayRequestContext["dedupe"]; keys: readonly string[]; @@ -1669,11 +1674,12 @@ export const agentHandlers: GatewayRequestHandlers = { let dispatched = false; try { if (activeRunAbort.controller.signal.aborted) { + const stopReason = resolveAbortedAgentStopReason(activeRunAbort.entry); setAbortedAgentDedupeEntries({ dedupe: context.dedupe, keys: agentDedupeKeys, runId, - stopReason: "rpc", + stopReason, }); respond( true, @@ -1681,7 +1687,7 @@ export const agentHandlers: GatewayRequestHandlers = { runId, status: "timeout" as const, summary: "aborted", - stopReason: "rpc", + stopReason, }, undefined, { runId },