Skip to content

Commit fec00b8

Browse files
committed
fix compact-boundary cancel race and add tests
1 parent e137c63 commit fec00b8

6 files changed

Lines changed: 200 additions & 12 deletions

File tree

packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,61 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
172172
}
173173
});
174174
});
175+
176+
describe("ClaudeAcpAgent.prompt — force-cancel backstop", () => {
177+
beforeEach(() => {
178+
vi.clearAllMocks();
179+
});
180+
181+
it("returns 'cancelled' when the SDK never yields after interrupt (issue #680)", async () => {
182+
const { agent } = makeAgent();
183+
const sessionId = "s-wedged";
184+
const query = installFakeSession(agent, sessionId);
185+
// Simulate a wedged SDK: interrupt() resolves but never makes next() yield.
186+
query.interrupt.mockImplementation(async () => {});
187+
// Shrink the grace period so the backstop fires promptly under real timers.
188+
(agent as unknown as { forceCancelGraceMs: number }).forceCancelGraceMs = 5;
189+
190+
const promptPromise = agent.prompt({
191+
sessionId,
192+
prompt: [{ type: "text", text: "do something slow" }],
193+
});
194+
195+
// Let the loop reach `await query.next()`, which stays pending forever.
196+
await new Promise((resolve) => setImmediate(resolve));
197+
198+
// Arms the backstop and calls the (no-op) interrupt; the timer must drive
199+
// the loop to return rather than hanging on the wedged next().
200+
await agent.cancel({ sessionId });
201+
202+
const result = await promptPromise;
203+
expect(result.stopReason).toBe("cancelled");
204+
});
205+
206+
it("clears the backstop timer on a healthy cancel (interrupt yields)", async () => {
207+
const { agent } = makeAgent();
208+
const sessionId = "s-healthy";
209+
installFakeSession(agent, sessionId);
210+
// Large grace so the test can only pass via the normal idle/done path, not
211+
// the timer; the loop must clear the armed timer in its finally.
212+
(agent as unknown as { forceCancelGraceMs: number }).forceCancelGraceMs =
213+
50_000;
214+
215+
const promptPromise = agent.prompt({
216+
sessionId,
217+
prompt: [{ type: "text", text: "do something" }],
218+
});
219+
await new Promise((resolve) => setImmediate(resolve));
220+
221+
// The mock's default interrupt() resolves next() with done, so the loop
222+
// returns through its normal path well before the 50s backstop.
223+
await agent.cancel({ sessionId });
224+
225+
const result = await promptPromise;
226+
expect(result.stopReason).toBe("cancelled");
227+
expect(
228+
(agent as unknown as { session: { forceCancelTimer?: unknown } }).session
229+
.forceCancelTimer,
230+
).toBeUndefined();
231+
});
232+
});

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
486486
// force-cancel backstop armed in interrupt() aborts this controller.
487487
const cancelController = new AbortController();
488488
this.session.cancelController = cancelController;
489-
const cancelled = new Promise<void>((resolve) => {
489+
// Resolves when the backstop aborts the controller. Named distinctly from
490+
// the `cancelled` boolean above (the queue-handoff result) to avoid two
491+
// variables named `cancelled` in this method.
492+
const cancelWake = new Promise<void>((resolve) => {
490493
cancelController.signal.addEventListener("abort", () => resolve(), {
491494
once: true,
492495
});
@@ -545,13 +548,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
545548
try {
546549
while (true) {
547550
const nextMessage = this.session.query.next();
548-
const next = await Promise.race([nextMessage, cancelled]);
551+
const next = await Promise.race([nextMessage, cancelWake]);
549552
if (cancelController.signal.aborted) {
550553
// The SDK never yielded after interrupt() (e.g. a wedged TaskOutput
551-
// block). Abandon the in-flight next() — swallowing any later
552-
// rejection so it can't surface as an unhandled rejection — and honor
553-
// the cancel per the ACP contract.
554-
void nextMessage.catch(() => {});
554+
// block). Abandon the in-flight next(); log any later rejection (an
555+
// auth/process error the SDK threw at cancel time would otherwise be
556+
// lost) but swallow it so it can't surface as an unhandled rejection,
557+
// then honor the cancel per the ACP contract.
558+
void nextMessage.catch((err) =>
559+
this.logger.warn("in-flight query.next() rejected after cancel", {
560+
sessionId: params.sessionId,
561+
error: err instanceof Error ? err.message : String(err),
562+
}),
563+
);
555564
return {
556565
stopReason: "cancelled",
557566
_meta: this.session.interruptReason
@@ -599,10 +608,16 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
599608
// (context just dropped) and replaced within seconds by the next
600609
// result. `size` keeps coming from the gateway-learned window
601610
// (getContextUsage under-reports extended 1M windows).
602-
const usedTokens = await fetchContextUsedTokens(
603-
this.session.query,
604-
this.logger,
605-
);
611+
// Race the control request against the force-cancel wake: the
612+
// loop only observes cancelWake at its top, so a wedged
613+
// getContextUsage() awaited here would otherwise re-introduce the
614+
// exact hang the backstop exists to break (issue #680). On a
615+
// forced cancel usedTokens is null and the next iteration returns
616+
// "cancelled".
617+
const usedTokens = await Promise.race([
618+
fetchContextUsedTokens(this.session.query, this.logger),
619+
cancelWake.then(() => null),
620+
]);
606621
lastAssistantTotalUsage = usedTokens ?? 0;
607622
promptReplayed = true;
608623
await this.client.sessionUpdate({
@@ -613,6 +628,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
613628
size: lastContextWindowSize,
614629
},
615630
});
631+
// No break: intentionally falls through to handleSystemMessage so
632+
// the COMPACT_BOUNDARY ext notification still fires.
616633
}
617634
if (message.subtype === "commands_changed") {
618635
// Mid-session command-list change (e.g. skills discovered as the
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, it } from "vitest";
2+
import { stripMarkerTags } from "./sdk-to-acp";
3+
4+
describe("stripMarkerTags", () => {
5+
it("strips a single marker and keeps surrounding prose", () => {
6+
expect(
7+
stripMarkerTags("before<command-name>/model</command-name>after"),
8+
).toBe("beforeafter");
9+
});
10+
11+
it("strips multiple different markers in one pass", () => {
12+
const input =
13+
"a<command-args>x</command-args>b<local-command-stdout>out</local-command-stdout>c";
14+
expect(stripMarkerTags(input)).toBe("abc");
15+
});
16+
17+
it("leaves text without markers unchanged", () => {
18+
expect(stripMarkerTags("")).toBe("");
19+
expect(stripMarkerTags("plain prose with < and > but no tags")).toBe(
20+
"plain prose with < and > but no tags",
21+
);
22+
});
23+
24+
it("passes an unclosed opener through verbatim (dead-set path)", () => {
25+
const input = "<command-name>no closing tag, prose continues";
26+
expect(stripMarkerTags(input)).toBe(input);
27+
});
28+
29+
it("does not treat an orphan closing tag as an opener", () => {
30+
expect(
31+
stripMarkerTags("</command-name>text<command-name>real</command-name>"),
32+
).toBe("</command-name>text");
33+
});
34+
35+
it("matches the nearest closing tag for a repeated opener", () => {
36+
// Lazy match: the first opener pairs with the first close, swallowing the
37+
// inner opener and its text, exactly like the original `[\s\S]*?` regex.
38+
expect(
39+
stripMarkerTags(
40+
"<command-name>outer<command-name>inner</command-name>trailing",
41+
),
42+
).toBe("trailing");
43+
});
44+
45+
it("stays linear on pathological unclosed input", () => {
46+
// A long run of openers with no close must not catastrophically backtrack.
47+
const input = `${"<command-name>".repeat(20000)}tail`;
48+
expect(stripMarkerTags(input)).toBe(input);
49+
});
50+
});

packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1009,7 +1009,8 @@ const LOCAL_COMMAND_MARKERS = [
10091009
// Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
10101010
// nearest closing tag of the same name, like a lazy regex would) without the
10111011
// catastrophic-backtracking risk of `[\s\S]*?` over pathological input.
1012-
function stripMarkerTags(text: string): string {
1012+
// Exported for unit testing.
1013+
export function stripMarkerTags(text: string): string {
10131014
const dead = new Set<string>();
10141015
let result = "";
10151016
let copiedUpTo = 0;

packages/agent/src/adapters/claude/session/models.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,27 @@ describe("resolveModelPreference", () => {
122122
expect(resolveModelPreference("gpt-5", options)).toBeNull();
123123
});
124124

125+
it("does not inherit a cross-family match from the context hint alone", () => {
126+
// `opus[1m]` must not resolve to a sonnet entry purely because both share
127+
// the "1m" hint token, with no real family token matching (#731).
128+
const sonnetOnly = [
129+
{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (1M context)" },
130+
];
131+
expect(resolveModelPreference("opus[1m]", sonnetOnly)).toBeNull();
132+
});
133+
134+
it("resolves a hinted alias to the right family when a family token matches", () => {
135+
// Both entries carry the "1m" hint; the "opus" token must break the tie so
136+
// the hint alone can't pull the match onto sonnet.
137+
const withHints = [
138+
{ value: "claude-opus-4-8", name: "Claude Opus 4.8 (1M context)" },
139+
{ value: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (1M context)" },
140+
];
141+
expect(resolveModelPreference("opus[1m]", withHints)).toBe(
142+
"claude-opus-4-8",
143+
);
144+
});
145+
125146
it("treats `best` and `default` as wildcards (no tokens contribute)", () => {
126147
expect(resolveModelPreference("best", options)).toBeNull();
127148
expect(resolveModelPreference("default", options)).toBeNull();
Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,49 @@
1+
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
12
import { describe, expect, it } from "vitest";
2-
import { formatCodexModelName } from "./models";
3+
import { formatCodexModelName, modelIdFromConfigOptions } from "./models";
34

45
describe("formatCodexModelName", () => {
56
it("uses raw lowercase model ids", () => {
67
expect(formatCodexModelName("GPT-5.5")).toBe("gpt-5.5");
78
});
89
});
10+
11+
describe("modelIdFromConfigOptions", () => {
12+
const modelOption = (currentValue: unknown): SessionConfigOption =>
13+
({
14+
id: "model",
15+
name: "Model",
16+
type: "select",
17+
category: "model",
18+
currentValue,
19+
options: [],
20+
}) as unknown as SessionConfigOption;
21+
22+
it("returns the currentValue of the model-category option", () => {
23+
expect(modelIdFromConfigOptions([modelOption("gpt-5.5-codex")])).toBe(
24+
"gpt-5.5-codex",
25+
);
26+
});
27+
28+
it("ignores non-model categories", () => {
29+
const modeOption = {
30+
id: "mode",
31+
name: "Mode",
32+
type: "select",
33+
category: "mode",
34+
currentValue: "auto",
35+
options: [],
36+
} as unknown as SessionConfigOption;
37+
expect(modelIdFromConfigOptions([modeOption])).toBeUndefined();
38+
});
39+
40+
it("returns undefined when currentValue is not a string", () => {
41+
expect(modelIdFromConfigOptions([modelOption(null)])).toBeUndefined();
42+
expect(modelIdFromConfigOptions([modelOption(123)])).toBeUndefined();
43+
});
44+
45+
it("returns undefined for null/undefined input", () => {
46+
expect(modelIdFromConfigOptions(null)).toBeUndefined();
47+
expect(modelIdFromConfigOptions(undefined)).toBeUndefined();
48+
});
49+
});

0 commit comments

Comments
 (0)