pi-mono/packages/coding-agent/test/suite/agent-session-compaction-model-overrides.test.ts
David Brailovsky 6fcb48670f
fix(coding-agent): remove fixed output limits from summary requests (#11036)
Compaction summaries were limited to 0.8 * compaction.reserveTokens (13107 by default), turn-prefix summaries to 0.5 * reserveTokens, and branch summaries to a hardcoded 4096. On providers where reasoning shares the output limit (OpenAI Responses, adaptive Anthropic), high thinking levels used most of that limit and compaction failed with "generation hit the token cap", even though the model allows far more output.

Tying the limit to reserveTokens assumed the summary request is as large as the full context, so its output had to fit in the reserved space. It is usually much smaller: recent messages kept by keepRecentTokens are not sent, a split turn goes to a separate request, tool results are truncated, and tool definitions and the normal system prompt are not included. Manual compaction can also run long before the context is full. The provider layer already sizes output from the actual request, using the model's output limit reduced to fit the remaining context, as for normal turns.

Summary requests now set no maxTokens and rely on that. compaction.reserveTokens only controls when compaction starts.

generateSummary() and generateSummaryWithUsage() now ignore the reserveTokens parameter.

closes #9512, closes #9075, closes #10908
2026-10-16 13:53:16 +02:00

175 lines
6 KiB
TypeScript

import { fauxAssistantMessage } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { SessionBeforeCompactEvent } from "../../src/core/extensions/index.ts";
import { createHarness, type Harness } from "./harness.ts";
function seedHistory(harness: Harness, totalTokens = 650): string {
const model = harness.session.model!;
let recentUserId = "";
for (const label of ["old", "recent"]) {
recentUserId = harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: label.padEnd(400, "x") }],
timestamp: Date.now() - 2000,
});
const assistant = fauxAssistantMessage(label.padEnd(400, "y"), { timestamp: Date.now() - 1000 });
harness.sessionManager.appendMessage({
...assistant,
api: model.api,
provider: model.provider,
model: model.id,
usage: { ...assistant.usage, input: totalTokens, totalTokens },
});
}
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
return recentUserId;
}
// Regression coverage for #8133.
describe("AgentSession compaction model overrides", () => {
const harnesses: Harness[] = [];
afterEach(() => {
vi.restoreAllMocks();
while (harnesses.length) harnesses.pop()?.cleanup();
});
it.each(["manual", "pre-prompt", "post-run", "overflow"] as const)(
"uses model token settings for %s compaction and extension preparation",
async (path) => {
const preparations: SessionBeforeCompactEvent[] = [];
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 4000 }],
tools: [],
settings: {
compaction: {
enabled: path !== "manual",
reserveTokens: 10,
keepRecentTokens: 20000,
modelOverrides: { "faux/faux-1": { reserveTokens: 2000, keepRecentTokens: 150 } },
},
},
extensionFactories: [
(pi) => {
pi.on("session_before_compact", (event) => {
preparations.push(event);
return {
compaction: {
summary: "compacted history",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
},
};
});
},
],
});
harnesses.push(harness);
const recentUserId = seedHistory(harness, path === "pre-prompt" ? 2500 : 650);
if (path === "manual") {
await harness.session.compact();
} else {
harness.setResponses(
path === "overflow"
? [
fauxAssistantMessage("", { stopReason: "error", errorMessage: "prompt is too long" }),
fauxAssistantMessage("recovered"),
]
: [fauxAssistantMessage(path === "post-run" ? "z".repeat(8000) : "done")],
);
await harness.session.prompt("continue");
}
expect(preparations).toHaveLength(1);
expect(preparations[0]?.preparation.settings).toEqual({
enabled: path !== "manual",
reserveTokens: 2000,
keepRecentTokens: 150,
});
expect(preparations[0]?.reason).toBe(
path === "manual" ? "manual" : path === "overflow" ? "overflow" : "threshold",
);
if (path === "manual" || path === "pre-prompt") {
expect(preparations[0]?.preparation.firstKeptEntryId).toBe(recentUserId);
}
expect(harness.eventsOfType("compaction_end")).toHaveLength(1);
expect(harness.eventsOfType("compaction_end")[0]).toMatchObject({
aborted: false,
willRetry: path === "overflow",
result: { summary: "compacted history" },
});
expect(harness.getPendingResponseCount()).toBe(0);
},
);
it("uses the newly selected model without changing ordinary settings", async () => {
const harness = await createHarness({
models: [
{ id: "small", contextWindow: 4000 },
{ id: "big", contextWindow: 10000 },
],
tools: [],
settings: {
compaction: {
reserveTokens: 10,
modelOverrides: { "faux/big": { reserveTokens: 8000, keepRecentTokens: 150 } },
},
},
extensionFactories: [
(pi) => {
pi.on("session_before_compact", (event) => ({
compaction: {
summary: "big model summary",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
},
}));
},
],
});
harnesses.push(harness);
seedHistory(harness, 2500);
harness.setResponses([fauxAssistantMessage("small response"), fauxAssistantMessage("big response")]);
await harness.session.prompt("continue on small");
expect(harness.eventsOfType("compaction_start")).toHaveLength(0);
// Retain usage from the small model: the next check must use the active big model's policy.
seedHistory(harness, 2500);
await harness.session.setModel(harness.getModel("big")!);
await harness.session.prompt("continue on big");
expect(harness.eventsOfType("compaction_end")).toHaveLength(1);
expect(harness.eventsOfType("compaction_end")[0]?.result?.summary).toBe("big model summary");
expect(harness.settingsManager.getCompactionReserveTokens()).toBe(10);
await harness.session.setModel(harness.getModel("small")!);
expect(harness.settingsManager.getCompactionReserveTokens(harness.session.model)).toBe(10);
});
it("captures model identity before awaiting summarization auth", async () => {
const harness = await createHarness({
models: [{ id: "first" }, { id: "second" }],
settings: {
compaction: {
modelOverrides: {
"faux/first": { reserveTokens: 2000, keepRecentTokens: 150 },
"faux/second": { reserveTokens: 4000, keepRecentTokens: 20000 },
},
},
},
});
harnesses.push(harness);
seedHistory(harness);
const getAuth = harness.session.modelRuntime.getAuth.bind(harness.session.modelRuntime);
vi.spyOn(harness.session.modelRuntime, "getAuth").mockImplementation(async (model, ...args) => {
harness.session.agent.state.model = harness.getModel("second")!;
return getAuth(model, ...args);
});
const requests: string[] = [];
harness.setResponses([
(_context, _options, _state, model) => {
requests.push(model.id);
return fauxAssistantMessage("summary");
},
]);
await harness.session.compact();
expect(requests).toEqual(["first"]);
});
});