Skip to content

Commit f59be4f

Browse files
authored
Merge pull request #685 from code-yeongyu/fix/compaction-esc-cancel
fix(coding-agent): suppress aborted compaction stream errors
2 parents 060946a + 1116bd8 commit f59be4f

3 files changed

Lines changed: 180 additions & 13 deletions

File tree

packages/coding-agent/src/core/extensions/builtin/compaction/changes.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Builtin compaction extension changes
22

3+
## Treat caller-aborted summary stream failures as cancellation (2026-08-03)
4+
5+
### What changed
6+
7+
- `runExtensionCompaction()` now converts a summary-generation rejection to the existing
8+
`undefined` cancellation result when its caller signal has been aborted.
9+
- Non-abort stream and provider failures are still rethrown unchanged.
10+
- A focused regression test reproduces the late stream-result rejection seen after ESC and
11+
separately proves an ordinary stream failure remains visible.
12+
13+
### Why
14+
15+
- The compaction watchdog can stop waiting as soon as ESC aborts the caller signal, while the
16+
provider stream's final result rejects a moment later with
17+
`Assistant message stream consumption was cancelled`.
18+
- That late rejection escaped the documented `runExtensionCompaction()` cancellation contract,
19+
causing the builtin extension runner to print an error and stack trace after the normal
20+
`Auto-compaction cancelled` notice.
21+
22+
### Why an extension could not do this
23+
24+
- This is the builtin compaction extension's own summary-stream consumption boundary. No external
25+
extension hook can intercept the private stream result before `runExtensionCompaction()` returns
26+
to the extension runner.
27+
28+
### Expected merge-conflict zones
29+
30+
- `speculative.ts` around `runExtensionCompaction()` and its call to `generateSummaryMessage()`.
31+
- Compaction stream cancellation tests under `test/compaction/`.
32+
333
## Reset the cap per provider turn and retain a safe deterministic suffix (2026-08-03)
434

535
### What changed

packages/coding-agent/src/core/extensions/builtin/compaction/speculative.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -461,19 +461,25 @@ export async function runExtensionCompaction(
461461

462462
while (true) {
463463
if (signal?.aborted) return undefined;
464-
const response = await generateSummaryMessage({
465-
context,
466-
messages,
467-
onProgress,
468-
prompt,
469-
signal,
470-
snapshot,
471-
auth: {
472-
apiKey: auth.apiKey,
473-
headers: auth.headers,
474-
extraBody: auth.extraBody,
475-
},
476-
});
464+
let response: Message | undefined;
465+
try {
466+
response = await generateSummaryMessage({
467+
context,
468+
messages,
469+
onProgress,
470+
prompt,
471+
signal,
472+
snapshot,
473+
auth: {
474+
apiKey: auth.apiKey,
475+
headers: auth.headers,
476+
extraBody: auth.extraBody,
477+
},
478+
});
479+
} catch (error) {
480+
if (signal?.aborted) return undefined;
481+
throw error;
482+
}
477483
if (!response) return undefined;
478484

479485
if (isAssistantMessage(response) && isContextOverflow(response, snapshot.contextWindow)) {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import {
2+
createAssistantMessageEventStream,
3+
type FauxModelDefinition,
4+
fauxAssistantMessage,
5+
registerFauxProvider,
6+
} from "@earendil-works/pi-ai";
7+
import { afterEach, describe, expect, it, vi } from "vitest";
8+
import { AuthStorage } from "../../src/core/auth-storage.ts";
9+
import {
10+
createSpeculativeCompactionSnapshot,
11+
runExtensionCompaction,
12+
type SpeculativeCompactionContext,
13+
} from "../../src/core/extensions/builtin/compaction/speculative.ts";
14+
import { ModelRegistry } from "../../src/core/model-registry.ts";
15+
import { SessionManager } from "../../src/core/session-manager.ts";
16+
17+
const registrations: Array<{ unregister: () => void }> = [];
18+
19+
type Registration = ReturnType<typeof registerFauxProvider>;
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
for (const registration of registrations.splice(0)) {
24+
registration.unregister();
25+
}
26+
});
27+
28+
function createContext(registration: Registration): SpeculativeCompactionContext {
29+
const model = registration.getModel();
30+
const authStorage = AuthStorage.inMemory();
31+
authStorage.setRuntimeApiKey(model.provider, "faux-key");
32+
const modelRegistry = ModelRegistry.inMemory(authStorage);
33+
modelRegistry.registerProvider(model.provider, {
34+
baseUrl: model.baseUrl,
35+
apiKey: "faux-key",
36+
api: registration.api,
37+
models: registration.models.map((registeredModel) => ({
38+
id: registeredModel.id,
39+
name: registeredModel.name,
40+
api: registeredModel.api,
41+
reasoning: registeredModel.reasoning,
42+
input: registeredModel.input,
43+
cost: registeredModel.cost,
44+
contextWindow: registeredModel.contextWindow,
45+
maxTokens: registeredModel.maxTokens,
46+
baseUrl: registeredModel.baseUrl,
47+
})),
48+
});
49+
const sessionManager = SessionManager.inMemory();
50+
sessionManager.appendMessage({
51+
role: "user",
52+
content: [{ type: "text", text: "first user ".repeat(12_000) }],
53+
timestamp: Date.now() - 3_000,
54+
});
55+
sessionManager.appendMessage({
56+
...fauxAssistantMessage("first assistant ".repeat(12_000), { timestamp: Date.now() - 2_000 }),
57+
api: model.api,
58+
provider: model.provider,
59+
model: model.id,
60+
usage: {
61+
input: 50_000,
62+
output: 0,
63+
cacheRead: 0,
64+
cacheWrite: 0,
65+
totalTokens: 50_000,
66+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
67+
},
68+
});
69+
sessionManager.appendMessage({
70+
role: "user",
71+
content: [{ type: "text", text: "second user ".repeat(12_000) }],
72+
timestamp: Date.now() - 1_000,
73+
});
74+
75+
return {
76+
model,
77+
modelRegistry,
78+
sessionManager,
79+
getContextUsage: () => ({ tokens: 50_000, contextWindow: model.contextWindow, percent: 25 }),
80+
getMessageRevision: () => 1,
81+
applyCompaction: async () => ({ applied: true, reason: "ok" }),
82+
};
83+
}
84+
85+
function createPendingSummary() {
86+
const model: FauxModelDefinition = {
87+
id: "faux-compaction-abort",
88+
reasoning: false,
89+
contextWindow: 128_000,
90+
maxTokens: 16_384,
91+
};
92+
const registration = registerFauxProvider({ models: [model] });
93+
registrations.push(registration);
94+
const context = createContext(registration);
95+
const snapshot = createSpeculativeCompactionSnapshot(context, { generation: 1 });
96+
if (!snapshot) throw new Error("expected a compaction snapshot");
97+
98+
const stream = createAssistantMessageEventStream();
99+
const started = Promise.withResolvers<void>();
100+
vi.spyOn(context.modelRegistry!.modelRuntime, "stream").mockImplementation(() => {
101+
started.resolve();
102+
return stream;
103+
});
104+
105+
return { context, snapshot, started: started.promise, stream };
106+
}
107+
108+
describe("speculative compaction stream cancellation", () => {
109+
it("treats a late stream rejection after caller abort as cancellation", async () => {
110+
const { context, snapshot, started, stream } = createPendingSummary();
111+
const controller = new AbortController();
112+
113+
const result = runExtensionCompaction(context, snapshot, controller.signal);
114+
await started;
115+
controller.abort();
116+
stream.fail(new Error("Assistant message stream consumption was cancelled"));
117+
118+
await expect(result).resolves.toBeUndefined();
119+
});
120+
121+
it("still surfaces a stream rejection when the caller did not abort", async () => {
122+
const { context, snapshot, started, stream } = createPendingSummary();
123+
const failure = new Error("provider stream failed");
124+
125+
const result = runExtensionCompaction(context, snapshot);
126+
await started;
127+
stream.fail(failure);
128+
129+
await expect(result).rejects.toBe(failure);
130+
});
131+
});

0 commit comments

Comments
 (0)