Skip to content

Commit a0177ef

Browse files
committed
fix(agent): decide the setup-window cancel on the counter alone
The bail also required `session.cancelled`, which any turn's activation clears. A local-only command skips the pre-prompt status check the stalled prompt is parked in, so it can queue and activate in that window; the reset erased the very cancel the check exists to catch, and the cancelled prompt went to the SDK anyway. `cancelSeq` is monotonic and incremented only in `cancel()`, in the same synchronous block that sets `cancelled`, so comparing it against the entry snapshot survives an unrelated activation. The flag stays untouched here, because an earlier turn may still be settling against it. Claude-Session: https://claude.ai/code/session_01KT3ZQ47bqT9mW4fi458Q6V
1 parent f0ae365 commit a0177ef

2 files changed

Lines changed: 65 additions & 8 deletions

File tree

packages/agent/src/adapters/claude/claude-agent.cancel-during-setup.test.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
2-
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2+
import type {
3+
SDKMessage,
4+
SDKUserMessage,
5+
} from "@anthropic-ai/claude-agent-sdk";
36
import { beforeEach, describe, expect, it, vi } from "vitest";
47
import {
58
createMockQuery,
@@ -42,9 +45,15 @@ interface Harness {
4245
finishSetup: () => void;
4346
/** Drives the queued turn to a normal completion. */
4447
completeTurn: () => Promise<void>;
48+
/** Promotes the queued turn the way a local-only command's output does. */
49+
activateQueuedTurn: () => Promise<void>;
50+
/** Ends the active turn with the SDK's terminal result. */
51+
finishTurn: () => void;
4552
/** Whether anything was handed to the SDK. */
4653
sdkReceivedMessage: () => boolean;
47-
prompt: () => Promise<{ stopReason: string; _meta?: unknown }>;
54+
/** The text of every message handed to the SDK, in order. */
55+
sdkPromptTexts: () => string[];
56+
prompt: (text?: string) => Promise<{ stopReason: string; _meta?: unknown }>;
4857
}
4958

5059
/**
@@ -124,11 +133,22 @@ function makeHarness(): Harness {
124133
session,
125134
inSetup,
126135
finishSetup: () => releaseSetup([]),
127-
prompt: () =>
136+
prompt: (text = "do the thing") =>
128137
agent.prompt({
129138
sessionId: SESSION_ID,
130-
prompt: [{ type: "text", text: "do the thing" }],
139+
prompt: [{ type: "text", text }],
131140
}) as Promise<{ stopReason: string; _meta?: unknown }>,
141+
activateQueuedTurn: async () => {
142+
query._mockHelpers.sendMessage({
143+
type: "system",
144+
subtype: "local_command_output",
145+
content: "context report",
146+
uuid: crypto.randomUUID(),
147+
session_id: SESSION_ID,
148+
} as SDKMessage);
149+
await tick();
150+
},
151+
finishTurn: () => query._mockHelpers.complete(createSuccessResult()),
132152
// Echo the turn's own user message back, then send the terminal result, the
133153
// way the SDK would for a turn that ran to completion.
134154
completeTurn: async () => {
@@ -138,6 +158,16 @@ function makeHarness(): Harness {
138158
query._mockHelpers.complete(createSuccessResult());
139159
},
140160
sdkReceivedMessage: () => pushToSdk.mock.calls.length > 0,
161+
sdkPromptTexts: () =>
162+
pushToSdk.mock.calls.map(([message]) => {
163+
const content = message.message.content;
164+
if (typeof content === "string") {
165+
return content;
166+
}
167+
return content
168+
.map((block) => (block.type === "text" ? block.text : ""))
169+
.join("");
170+
}),
141171
};
142172
}
143173

@@ -218,6 +248,30 @@ describe("cancel arriving while a prompt is being set up", () => {
218248
expect(h.session.cancelled).toBe(false);
219249
});
220250

251+
it("drops the prompt even when a later turn activates during setup", async () => {
252+
const h = makeHarness();
253+
254+
const cancelled = h.prompt();
255+
await h.inSetup;
256+
await h.agent.cancel({ sessionId: SESSION_ID });
257+
258+
// A local-only command skips the pre-prompt status check the first prompt is
259+
// parked in, so it queues and activates while that prompt is still stalled.
260+
// Activation clears `session.cancelled`, leaving the count as the only record
261+
// that a cancel landed.
262+
const local = h.prompt("/context");
263+
await h.activateQueuedTurn();
264+
expect(h.session.cancelled).toBe(false);
265+
266+
h.finishSetup();
267+
268+
await expect(cancelled).resolves.toMatchObject({ stopReason: "cancelled" });
269+
expect(h.sdkPromptTexts()).toEqual(["/context"]);
270+
271+
h.finishTurn();
272+
await expect(local).resolves.toMatchObject({ stopReason: "end_turn" });
273+
});
274+
221275
it("leaves a mismatched cancel uncounted", async () => {
222276
const h = makeHarness();
223277

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -525,10 +525,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
525525

526526
// A cancel counted during setup targets this prompt. It is not queued yet, so
527527
// `interrupt()` had nothing to stop and armed no backstop; returning here,
528-
// before the message reaches the SDK, is what actually cancels it. Leave
529-
// `cancelled` standing: an earlier turn may still be settling against it, and
530-
// `activateTurn` clears it for the next turn that runs.
531-
if (this.session.cancelSeq > cancelSeqAtEntry && this.session.cancelled) {
528+
// before the message reaches the SDK, is what actually cancels it. The counter
529+
// alone decides: `session.cancelled` belongs to the session, and `activateTurn`
530+
// clears it for whichever turn runs next, so a prompt that raced past this one
531+
// into the queue would otherwise erase the cancel this check exists to catch.
532+
// The flag is left standing here, because an earlier turn may still be settling
533+
// against it.
534+
if (this.session.cancelSeq > cancelSeqAtEntry) {
532535
return this.cancelledResponse();
533536
}
534537

0 commit comments

Comments
 (0)