Skip to content

Commit d512c73

Browse files
committed
fix(gateway): preserve stop reason for deferred agent aborts
1 parent d72cdfb commit d512c73

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

src/gateway/chat-abort.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type ChatAbortControllerEntry = {
1414
ownerDeviceId?: string;
1515
providerId?: string;
1616
authProviderId?: string;
17+
abortStopReason?: string;
1718
/**
1819
* Which RPC owns this registration. Absent (undefined) is treated as
1920
* `"chat-send"` so pre-existing callers that constructed entries without
@@ -186,6 +187,9 @@ export function abortChatRunById(
186187
const bufferedText = ops.chatRunBuffers.get(runId);
187188
const partialText = bufferedText && bufferedText.trim() ? bufferedText : undefined;
188189
ops.chatAbortedRuns.set(runId, Date.now());
190+
if (stopReason) {
191+
active.abortStopReason = stopReason;
192+
}
189193
active.controller.abort();
190194
ops.chatAbortControllers.delete(runId);
191195
ops.chatRunBuffers.delete(runId);

src/gateway/server-methods/agent.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3696,6 +3696,69 @@ describe("gateway agent handler chat.abort integration", () => {
36963696
});
36973697
});
36983698

3699+
it("preserves stop-command reason when /stop lands during the accepted ack yield", async () => {
3700+
prime();
3701+
mocks.agentCommand.mockReturnValueOnce(new Promise(() => {}));
3702+
3703+
const context = makeContext();
3704+
const respond = vi.fn();
3705+
const runId = "idem-stop-before-dispatch";
3706+
await invokeAgent(
3707+
{
3708+
message: "hi",
3709+
agentId: "main",
3710+
sessionKey: "agent:main:main",
3711+
idempotencyKey: runId,
3712+
},
3713+
{ context, respond, reqId: runId, flushDispatch: false },
3714+
);
3715+
3716+
expectRecordFields(mockCallArg(respond, 0, 1), {
3717+
runId,
3718+
sessionKey: "agent:main:main",
3719+
status: "accepted",
3720+
});
3721+
expect(context.chatAbortControllers.has(runId)).toBe(true);
3722+
3723+
const stopRespond = vi.fn();
3724+
await chatHandlers["chat.send"]({
3725+
params: {
3726+
sessionKey: "agent:main:main",
3727+
message: "/stop",
3728+
idempotencyKey: "idem-stop-command-before-dispatch",
3729+
},
3730+
respond: stopRespond as never,
3731+
context,
3732+
req: { type: "req", id: "stop-req", method: "chat.send" },
3733+
client: null,
3734+
isWebchatConnect: () => false,
3735+
});
3736+
3737+
expectRecordFields(mockCallArg(stopRespond, 0, 1), {
3738+
aborted: true,
3739+
runIds: [runId],
3740+
});
3741+
expect(context.chatAbortControllers.has(runId)).toBe(false);
3742+
3743+
await flushScheduledDispatchStep();
3744+
3745+
expect(mocks.agentCommand).not.toHaveBeenCalled();
3746+
expectRecordFields(context.dedupe.get(`agent:${runId}`)?.payload, {
3747+
runId,
3748+
status: "timeout",
3749+
summary: "aborted",
3750+
stopReason: "stop",
3751+
});
3752+
const finalResponse = respond.mock.calls.find(
3753+
(call: unknown[]) => (call[1] as { status?: unknown } | undefined)?.status === "timeout",
3754+
);
3755+
expectRecordFields(requireValue(finalResponse, "terminal response missing")[1], {
3756+
runId,
3757+
status: "timeout",
3758+
stopReason: "stop",
3759+
});
3760+
});
3761+
36993762
it("does not dispatch when chat.abort lands during pre-accept setup", async () => {
37003763
prime();
37013764
const requestedSessionKey = "agent:main:legacy-main";

src/gateway/server-methods/agent.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import {
100100
} from "../../utils/message-channel.js";
101101
import { resolveAssistantIdentity } from "../assistant-identity.js";
102102
import {
103+
type ChatAbortControllerEntry,
103104
registerChatAbortController,
104105
resolveAgentRunExpiresAtMs,
105106
updateChatRunProvider,
@@ -547,6 +548,10 @@ function setAbortedAgentDedupeEntries(params: {
547548
});
548549
}
549550

551+
function resolveAbortedAgentStopReason(entry?: ChatAbortControllerEntry): string {
552+
return entry?.abortStopReason?.trim() || "rpc";
553+
}
554+
550555
function deleteGatewayDedupeEntries(params: {
551556
dedupe: GatewayRequestContext["dedupe"];
552557
keys: readonly string[];
@@ -1669,19 +1674,20 @@ export const agentHandlers: GatewayRequestHandlers = {
16691674
let dispatched = false;
16701675
try {
16711676
if (activeRunAbort.controller.signal.aborted) {
1677+
const stopReason = resolveAbortedAgentStopReason(activeRunAbort.entry);
16721678
setAbortedAgentDedupeEntries({
16731679
dedupe: context.dedupe,
16741680
keys: agentDedupeKeys,
16751681
runId,
1676-
stopReason: "rpc",
1682+
stopReason,
16771683
});
16781684
respond(
16791685
true,
16801686
{
16811687
runId,
16821688
status: "timeout" as const,
16831689
summary: "aborted",
1684-
stopReason: "rpc",
1690+
stopReason,
16851691
},
16861692
undefined,
16871693
{ runId },

0 commit comments

Comments
 (0)