




















@@ -0,0 +1,262 @@
1+import type { AgentMessage } from "@earendil-works/pi-agent-core";
2+import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4+5+const compactionMocks = vi.hoisted(() => {
6+function readText(value: unknown): string {
7+if (typeof value === "string") {
8+return value;
9+}
10+if (Array.isArray(value)) {
11+return value.map(readText).join("");
12+}
13+if (value && typeof value === "object") {
14+const record = value as { text?: unknown; content?: unknown; arguments?: unknown };
15+return `${readText(record.text)}${readText(record.content)}${readText(record.arguments)}`;
16+}
17+return "";
18+}
19+return {
20+estimateTokens: vi.fn((message: unknown) =>
21+Math.max(1, Math.ceil(readText(message).length / 4)),
22+),
23+generateSummary: vi.fn(),
24+logWarn: vi.fn(),
25+};
26+});
27+28+vi.mock("@earendil-works/pi-coding-agent", async () => {
29+const actual = await vi.importActual<typeof import("@earendil-works/pi-coding-agent")>(
30+"@earendil-works/pi-coding-agent",
31+);
32+return {
33+ ...actual,
34+estimateTokens: compactionMocks.estimateTokens,
35+generateSummary: compactionMocks.generateSummary,
36+};
37+});
38+39+vi.mock("../logging/subsystem.js", () => ({
40+createSubsystemLogger: () => ({
41+info: vi.fn(),
42+warn: compactionMocks.logWarn,
43+error: vi.fn(),
44+debug: vi.fn(),
45+trace: vi.fn(),
46+raw: vi.fn(),
47+child: vi.fn().mockReturnThis(),
48+}),
49+}));
50+51+// Mock retryAsync to bypass retry delays while preserving the single-call semantic.
52+// summarizeChunks wraps generateSummary in retryAsync with 500-5000 ms delays;
53+// eliminating them keeps tests fast without altering the catch-block behavior under test.
54+vi.mock("../infra/retry.js", async () => {
55+const actual = await vi.importActual<typeof import("../infra/retry.js")>("../infra/retry.js");
56+return {
57+ ...actual,
58+retryAsync: async <T>(fn: () => Promise<T>) => fn(),
59+};
60+});
61+62+let summarizeWithFallback: typeof import("./compaction.js").summarizeWithFallback;
63+64+beforeAll(async () => {
65+vi.resetModules();
66+({ summarizeWithFallback } = await import("./compaction.js"));
67+});
68+69+describe("summarizeChunks partial summary preservation (#82952)", () => {
70+const testModel = {
71+id: "test",
72+name: "test",
73+contextWindow: 200_000,
74+contextTokens: 200_000,
75+maxTokens: 8192,
76+} as unknown as NonNullable<ExtensionContext["model"]>;
77+78+// Two messages sized to split into two chunks with maxChunkTokens=150.
79+// Each message is ~100 tokens (400 chars / 4), and effectiveMax = floor(150/1.2) = 125.
80+const twoChunkMessages: AgentMessage[] = [
81+{ role: "user", content: "x".repeat(400), timestamp: 1 },
82+{ role: "user", content: "y".repeat(400), timestamp: 2 },
83+];
84+85+function callSummarize(messages = twoChunkMessages) {
86+return summarizeWithFallback({
87+ messages,
88+model: testModel,
89+apiKey: "test-key", // pragma: allowlist secret
90+signal: new AbortController().signal,
91+reserveTokens: 1000,
92+maxChunkTokens: 150,
93+contextWindow: 200_000,
94+});
95+}
96+97+beforeEach(() => {
98+compactionMocks.generateSummary.mockReset();
99+compactionMocks.logWarn.mockClear();
100+});
101+102+it("returns partial summary when a later chunk fails with a non-abort error", async () => {
103+compactionMocks.generateSummary
104+.mockResolvedValueOnce("Summary of chunk 1")
105+.mockRejectedValue(new Error("API quota exceeded"));
106+107+const result = await callSummarize();
108+109+expect(result).toContain("Summary of chunk 1");
110+expect(result).toContain("[Partial summary:");
111+expect(result).toMatch(/chunks 1-1 of 2 were summarized/);
112+expect(compactionMocks.logWarn).toHaveBeenCalledWith(
113+"chunk summarization failed after retries; partial summary available",
114+expect.objectContaining({ err: expect.any(Error) }),
115+);
116+});
117+118+it("re-throws abort errors instead of returning partial summary", async () => {
119+const abortErr = new Error("aborted");
120+abortErr.name = "AbortError";
121+122+compactionMocks.generateSummary
123+.mockResolvedValueOnce("Summary of chunk 1")
124+.mockRejectedValue(abortErr);
125+126+const result = await callSummarize();
127+128+// Abort error propagates from summarizeChunks; summarizeWithFallback catches it
129+// and falls through to the final fallback (not the partial summary).
130+expect(result).not.toBe("Summary of chunk 1");
131+expect(result).toContain("Context contained");
132+expect(compactionMocks.logWarn).not.toHaveBeenCalledWith(
133+"chunk summarization failed after retries; partial summary available",
134+expect.anything(),
135+);
136+});
137+138+it("re-throws timeout errors instead of returning partial summary", async () => {
139+const timeoutErr = new Error("request timed out");
140+timeoutErr.name = "TimeoutError";
141+142+compactionMocks.generateSummary
143+.mockResolvedValueOnce("Summary of chunk 1")
144+.mockRejectedValue(timeoutErr);
145+146+const result = await callSummarize();
147+148+expect(result).not.toBe("Summary of chunk 1");
149+expect(result).toContain("Context contained");
150+expect(compactionMocks.logWarn).not.toHaveBeenCalledWith(
151+"chunk summarization failed after retries; partial summary available",
152+expect.anything(),
153+);
154+});
155+156+it("returns the full final summary when all chunks succeed", async () => {
157+compactionMocks.generateSummary
158+.mockResolvedValueOnce("Summary of chunk 1")
159+.mockResolvedValueOnce("Combined summary of chunks 1+2");
160+161+const result = await callSummarize();
162+163+expect(result).toBe("Combined summary of chunks 1+2");
164+expect(compactionMocks.generateSummary).toHaveBeenCalledTimes(2);
165+});
166+167+it("falls back to default when the first chunk fails (no partial to recover)", async () => {
168+compactionMocks.generateSummary.mockRejectedValue(new Error("network error"));
169+170+const result = await callSummarize();
171+172+// With no successful chunk, summarizeChunks rethrows into
173+// summarizeWithFallback's outer catch -> final fallback path.
174+expect(result).toContain("Context contained");
175+expect(result).not.toBe("Summary of chunk 1");
176+});
177+178+it("tries oversized-message retry before falling back to partial summary", async () => {
179+// Scenario: chunk 1 (small) succeeds, chunk 2 (has oversized message) fails.
180+// summarizeWithFallback should try the non-oversized retry, which may
181+// recover more content than the partial summary alone.
182+const mixedMessages: AgentMessage[] = [
183+// Small message (chunk 1)
184+{ role: "user", content: "Short question about code", timestamp: 1 },
185+// Oversized message (will be in chunk 2, triggers the oversized retry)
186+{ role: "assistant", content: "x".repeat(500_000), timestamp: 2 } as unknown as AgentMessage,
187+// Small message after oversized (should be recovered by oversized retry)
188+{ role: "user", content: "Follow-up question", timestamp: 3 },
189+];
190+191+compactionMocks.generateSummary
192+// Call 1: chunk 1 of full attempt (success)
193+.mockResolvedValueOnce("Summary of chunk 1")
194+// Call 2: chunk 2 of full attempt (fails - oversized message)
195+.mockRejectedValueOnce(new Error("context too long"))
196+// Call 3: oversized retry with small messages only (succeeds!)
197+.mockResolvedValueOnce("Summary of small messages (oversized retry)");
198+199+const result = await callSummarize(mixedMessages);
200+201+// The oversized retry should have recovered more content than
202+// the partial summary from chunk 1 alone.
203+expect(result).toContain("Summary of small messages (oversized retry)");
204+// The partial summary should NOT be the final result because the
205+// oversized retry succeeded.
206+expect(result).not.toContain("[Partial summary:");
207+});
208+209+it("prefers oversized retry partial summary over full attempt partial", async () => {
210+// Scenario: full attempt's chunk 1 succeeds, chunk 2 (oversized) fails.
211+// Oversized retry (small messages only) chunk 1 succeeds, chunk 2 fails.
212+// The oversized retry's partial summary should be preferred because it
213+// covers the non-oversized transcript.
214+const mixedMessages: AgentMessage[] = [
215+{ role: "user", content: "Short question", timestamp: 1 },
216+// Oversized message that will be filtered in the retry
217+{ role: "assistant", content: "x".repeat(500_000), timestamp: 2 } as unknown as AgentMessage,
218+{ role: "user", content: "a".repeat(400), timestamp: 3 },
219+{ role: "user", content: "b".repeat(400), timestamp: 4 },
220+];
221+222+compactionMocks.generateSummary
223+// Full attempt: chunk 1 succeeds, chunk 2 fails (oversized message)
224+.mockResolvedValueOnce("Full attempt chunk 1")
225+.mockRejectedValueOnce(new Error("context too long"))
226+// Oversized retry: chunk 1 succeeds, chunk 2 also fails
227+.mockResolvedValueOnce("Oversized retry chunk 1 (better coverage)")
228+.mockRejectedValue(new Error("rate limited on retry"));
229+230+const result = await callSummarize(mixedMessages);
231+232+// The oversized retry's partial summary should win, with oversized notes
233+expect(result).toContain("Oversized retry chunk 1 (better coverage)");
234+expect(result).toContain("[Partial summary:");
235+expect(result).toContain("[Large assistant");
236+expect(result).toContain("omitted from summary]");
237+});
238+239+it("preserves the latest successful summary in a 3+ chunk chain", async () => {
240+const threeChunkMessages: AgentMessage[] = [
241+{ role: "user", content: "a".repeat(400), timestamp: 1 },
242+{ role: "user", content: "b".repeat(400), timestamp: 2 },
243+{ role: "user", content: "c".repeat(400), timestamp: 3 },
244+];
245+246+compactionMocks.generateSummary
247+.mockResolvedValueOnce("Summary after chunk 1")
248+.mockResolvedValueOnce("Summary after chunks 1+2")
249+.mockRejectedValue(new Error("rate limited"));
250+251+const result = await callSummarize(threeChunkMessages);
252+253+// Chunk 3 failed -> partial summary from chunk 2 is returned with marker.
254+expect(result).toContain("Summary after chunks 1+2");
255+expect(result).toMatch(/\[Partial summary: chunks 1-2 of 3 were summarized/);
256+expect(compactionMocks.generateSummary).toHaveBeenCalledTimes(3);
257+expect(compactionMocks.logWarn).toHaveBeenCalledWith(
258+"chunk summarization failed after retries; partial summary available",
259+expect.objectContaining({ completedChunks: 2, totalChunks: 3 }),
260+);
261+});
262+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。