Skip to content

Commit 068b9c9

Browse files
amabitosteipete
authored andcommitted
feat: wrap compaction generateSummary in retryAsync
Integrate retry logic with abort-classifier for /compact endpoint: - Wrap generateSummary calls in retryAsync with exponential backoff - Auto-skip retry on user cancellation and gateway restart (AbortError) - Config: 3 attempts, 500ms-5s delay, 20% jitter - Add comprehensive Vitest tests (5/5 passed) Related: #16809, #5744, #17143
1 parent 990cf2d commit 068b9c9

2 files changed

Lines changed: 202 additions & 8 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
2+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
3+
import * as piCodingAgent from "@mariozechner/pi-coding-agent";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { retryAsync } from "../infra/retry.js";
6+
7+
// Mock the external generateSummary function
8+
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
9+
const actual = await importOriginal<typeof piCodingAgent>();
10+
return {
11+
...actual,
12+
generateSummary: vi.fn(),
13+
};
14+
});
15+
16+
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
17+
18+
describe("compaction retry integration", () => {
19+
beforeEach(() => {
20+
mockGenerateSummary.mockClear();
21+
});
22+
23+
afterEach(() => {
24+
vi.clearAllTimers();
25+
vi.useRealTimers();
26+
});
27+
const testMessages: AgentMessage[] = [
28+
{ role: "user", content: "Test message" },
29+
{ role: "assistant", content: "Test response" },
30+
];
31+
32+
const testModel: NonNullable<ExtensionContext["model"]> = {
33+
provider: "anthropic",
34+
model: "claude-3-opus",
35+
};
36+
37+
it("should successfully call generateSummary with retry wrapper", async () => {
38+
mockGenerateSummary.mockResolvedValueOnce("Test summary");
39+
40+
const result = await retryAsync(
41+
() =>
42+
mockGenerateSummary(
43+
testMessages,
44+
testModel,
45+
1000,
46+
"test-api-key",
47+
new AbortController().signal,
48+
),
49+
{
50+
attempts: 3,
51+
minDelayMs: 500,
52+
maxDelayMs: 5000,
53+
jitter: 0.2,
54+
label: "compaction/generateSummary",
55+
},
56+
);
57+
58+
expect(result).toBe("Test summary");
59+
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
60+
});
61+
62+
it("should retry on transient error and succeed", async () => {
63+
mockGenerateSummary
64+
.mockRejectedValueOnce(new Error("Network timeout"))
65+
.mockResolvedValueOnce("Success after retry");
66+
67+
const result = await retryAsync(
68+
() =>
69+
mockGenerateSummary(
70+
testMessages,
71+
testModel,
72+
1000,
73+
"test-api-key",
74+
new AbortController().signal,
75+
),
76+
{
77+
attempts: 3,
78+
minDelayMs: 0,
79+
maxDelayMs: 0,
80+
label: "compaction/generateSummary",
81+
},
82+
);
83+
84+
expect(result).toBe("Success after retry");
85+
expect(mockGenerateSummary).toHaveBeenCalledTimes(2);
86+
});
87+
88+
it("should NOT retry on user abort", async () => {
89+
const abortErr = new Error("aborted");
90+
abortErr.name = "AbortError";
91+
(abortErr as { cause?: unknown }).cause = { source: "user" };
92+
93+
mockGenerateSummary.mockRejectedValueOnce(abortErr);
94+
95+
await expect(
96+
retryAsync(
97+
() =>
98+
mockGenerateSummary(
99+
testMessages,
100+
testModel,
101+
1000,
102+
"test-api-key",
103+
new AbortController().signal,
104+
),
105+
{
106+
attempts: 3,
107+
minDelayMs: 0,
108+
label: "compaction/generateSummary",
109+
shouldRetry: (err: unknown) => !(err instanceof Error && err.name === "AbortError"),
110+
},
111+
),
112+
).rejects.toThrow("aborted");
113+
114+
// Should NOT retry on user cancellation (AbortError filtered by shouldRetry)
115+
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
116+
});
117+
118+
it("should retry up to 3 times and then fail", async () => {
119+
mockGenerateSummary.mockRejectedValue(new Error("Persistent API error"));
120+
121+
await expect(
122+
retryAsync(
123+
() =>
124+
mockGenerateSummary(
125+
testMessages,
126+
testModel,
127+
1000,
128+
"test-api-key",
129+
new AbortController().signal,
130+
),
131+
{
132+
attempts: 3,
133+
minDelayMs: 0,
134+
maxDelayMs: 0,
135+
label: "compaction/generateSummary",
136+
},
137+
),
138+
).rejects.toThrow("Persistent API error");
139+
140+
expect(mockGenerateSummary).toHaveBeenCalledTimes(3);
141+
});
142+
143+
it("should apply exponential backoff", async () => {
144+
vi.useFakeTimers();
145+
146+
mockGenerateSummary
147+
.mockRejectedValueOnce(new Error("Error 1"))
148+
.mockRejectedValueOnce(new Error("Error 2"))
149+
.mockResolvedValueOnce("Success on 3rd attempt");
150+
151+
const delays: number[] = [];
152+
const promise = retryAsync(
153+
() =>
154+
mockGenerateSummary(
155+
testMessages,
156+
testModel,
157+
1000,
158+
"test-api-key",
159+
new AbortController().signal,
160+
),
161+
{
162+
attempts: 3,
163+
minDelayMs: 500,
164+
maxDelayMs: 5000,
165+
jitter: 0,
166+
label: "compaction/generateSummary",
167+
onRetry: (info) => delays.push(info.delayMs),
168+
},
169+
);
170+
171+
await vi.runAllTimersAsync();
172+
const result = await promise;
173+
174+
expect(result).toBe("Success on 3rd attempt");
175+
expect(mockGenerateSummary).toHaveBeenCalledTimes(3);
176+
// First retry: 500ms, second retry: 1000ms
177+
expect(delays[0]).toBe(500);
178+
expect(delays[1]).toBe(1000);
179+
180+
vi.useRealTimers();
181+
});
182+
});

src/agents/compaction.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { AgentMessage } from "@mariozechner/pi-agent-core";
22
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
33
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
4+
import { retryAsync } from "../infra/retry.js";
45
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
56
import { repairToolUseResultPairing, stripToolResultDetails } from "./session-transcript-repair.js";
67

@@ -159,14 +160,25 @@ async function summarizeChunks(params: {
159160
let summary = params.previousSummary;
160161

161162
for (const chunk of chunks) {
162-
summary = await generateSummary(
163-
chunk,
164-
params.model,
165-
params.reserveTokens,
166-
params.apiKey,
167-
params.signal,
168-
params.customInstructions,
169-
summary,
163+
summary = await retryAsync(
164+
() =>
165+
generateSummary(
166+
chunk,
167+
params.model,
168+
params.reserveTokens,
169+
params.apiKey,
170+
params.signal,
171+
params.customInstructions,
172+
summary,
173+
),
174+
{
175+
attempts: 3,
176+
minDelayMs: 500,
177+
maxDelayMs: 5000,
178+
jitter: 0.2,
179+
label: "compaction/generateSummary",
180+
shouldRetry: (err) => !(err instanceof Error && err.name === "AbortError"),
181+
},
170182
);
171183
}
172184

0 commit comments

Comments
 (0)