fix(gateway): preserve stop reason for deferred agent aborts

This commit is contained in:
Peter Steinberger
2026-05-22 08:58:27 +01:00
parent 9ff3b9f4ef
commit 5ed8bbc694
3 changed files with 75 additions and 2 deletions

View File

@@ -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);

View File

@@ -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";

View File

@@ -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 },