pi-mono/packages/coding-agent/test/suite/agent-session-compaction.test.ts
David Brailovsky 32850ef7c5
fix(coding-agent): resume after context-limited length stops (#7540)
* fix(coding-agent): resume after context-limited length stops

Treat length stops as context overflow when prompt usage is within 1% of
Pi's configured context window. Include cache-write tokens in that usage
and allow non-zero output because OpenAI may emit reasoning tokens before
returning an incomplete response.

After compaction, remove any retryable error or truncated-length assistant
message restored from persisted session history so the interrupted agent
turn can continue.

This is specifically for handling this case for openai models:
https://developers.openai.com/api/docs/guides/reasoning

> If the generated tokens reach the context window limit or the max_output_tokens value you’ve set, you’ll receive a response with a status of incomplete and incomplete_details with reason set to max_output_tokens. This might occur before any visible output tokens are produced, meaning you could incur costs for input and reasoning tokens without receiving a visible response.

This detection currently assumes the reported prompt usage is within 1% of
the context window configured in Pi. Its reliability therefore depends on
Pi's model contextWindow metadata matching the provider's effective limit.

Maybe we should consider treating OpenAI incomplete responses as requiring compaction
regardless of the local context-window estimate?

fixes #7020

* fix(coding-agent): recover truncated responses after compaction

Classify length stops by comparing provider-reported output usage with the model's intended output limit. Compact and retry once when generation ends below that limit, avoiding unreliable context-window estimates while preserving genuine max-output stops.

Normalize OpenAI Responses incomplete reasons so only max_output_tokens is treated as a length stop. Preserve qualified provider reasons for diagnostics, surface other reasons as errors, and use neutral truncation wording in the TUI.
2026-08-03 18:07:53 +02:00

597 lines
21 KiB
TypeScript

import {
type AssistantMessage,
createAssistantMessageEventStream,
fauxAssistantMessage,
type Model,
} from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { estimateTokens } from "../../src/core/compaction/index.ts";
import { createHarness, type Harness } from "./harness.ts";
type SessionWithCompactionInternals = {
_checkCompaction: (assistantMessage: AssistantMessage, skipAbortedCheck?: boolean) => Promise<boolean>;
_runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<boolean>;
};
function createUsage(totalTokens: number) {
return {
input: totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
function createAssistant(
harness: Harness,
options: {
stopReason?: AssistantMessage["stopReason"];
errorMessage?: string;
totalTokens?: number;
timestamp?: number;
},
): AssistantMessage {
const model = harness.getModel();
return {
...fauxAssistantMessage("", {
stopReason: options.stopReason,
errorMessage: options.errorMessage,
timestamp: options.timestamp,
}),
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(options.totalTokens ?? 0),
};
}
function useSummaryStreamFn(harness: Harness, summary: string): () => number {
let callCount = 0;
harness.session.agent.streamFunction = (model) => {
callCount++;
const stream = createAssistantMessageEventStream();
queueMicrotask(() => {
const message: AssistantMessage = {
...fauxAssistantMessage(summary),
api: model.api,
provider: model.provider,
model: model.id,
usage: createUsage(10),
};
stream.push({ type: "done", reason: "stop", message });
});
return stream;
};
return () => callCount;
}
function seedCompactableSession(harness: Harness): void {
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
const now = Date.now();
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "message to compact" }],
timestamp: now - 1000,
});
const assistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 100,
timestamp: now - 500,
});
assistant.content = [{ type: "text", text: "assistant response to compact" }];
harness.sessionManager.appendMessage(assistant);
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
}
describe("AgentSession compaction characterization", () => {
const harnesses: Harness[] = [];
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
while (harnesses.length > 0) {
harnesses.pop()?.cleanup();
}
});
it("manually compacts using an extension-provided summary", async () => {
const summaryUsage = {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
};
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "summary from extension",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
usage: summaryUsage,
details: { source: "extension" },
},
}));
},
],
});
harnesses.push(harness);
await harness.session.prompt("one");
await harness.session.prompt("two");
const statsBefore = harness.session.getSessionStats();
const result = await harness.session.compact();
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
const estimatedTokensAfter = harness.session.messages.reduce((sum, message) => sum + estimateTokens(message), 0);
expect(result.summary).toBe("summary from extension");
expect(result.usage).toEqual(summaryUsage);
expect(result.estimatedTokensAfter).toBe(estimatedTokensAfter);
expect(compactionEntries).toHaveLength(1);
const compactionEntry = compactionEntries[0];
if (compactionEntry?.type === "compaction") {
expect(compactionEntry.usage).toEqual(summaryUsage);
}
const statsAfter = harness.session.getSessionStats();
expect(statsAfter.tokens.input).toBe(statsBefore.tokens.input + summaryUsage.input);
expect(statsAfter.tokens.output).toBe(statsBefore.tokens.output + summaryUsage.output);
expect(statsAfter.tokens.cacheRead).toBe(statsBefore.tokens.cacheRead + summaryUsage.cacheRead);
expect(statsAfter.tokens.cacheWrite).toBe(statsBefore.tokens.cacheWrite + summaryUsage.cacheWrite);
expect(statsAfter.cost).toBe(statsBefore.cost + summaryUsage.cost.total);
expect(harness.session.messages[0]?.role).toBe("compactionSummary");
});
it("throws when compacting without a model", async () => {
const harness = await createHarness();
harnesses.push(harness);
harness.session.agent.state.model = undefined as unknown as Model<any>;
await expect(harness.session.compact()).rejects.toThrow("No model selected");
});
it("throws when compacting without configured auth", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
await expect(harness.session.compact()).rejects.toThrow(`No API key found for ${harness.getModel().provider}.`);
});
it("manually compacts with a custom streamFn when registry auth is absent", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
const getStreamCallCount = useSummaryStreamFn(harness, "summary from custom stream");
const result = await harness.session.compact();
expect(result.summary).toContain("summary from custom stream");
expect(getStreamCallCount()).toBe(1);
});
it("manually compacts with provider-resolved bearer auth", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
const model = harness.getModel();
harness.session.modelRuntime.registerNativeProvider({
id: model.provider,
name: "Faux bearer provider",
auth: {
apiKey: {
name: "Faux bearer token",
resolve: async () => ({
auth: { headers: { Authorization: "Bearer ambient-token" } },
source: "ambient bearer token",
}),
},
},
getModels: () => harness.models,
stream: () => createAssistantMessageEventStream(),
streamSimple: () => createAssistantMessageEventStream(),
});
seedCompactableSession(harness);
harness.setResponses([
(_context, options) => {
expect(options?.apiKey).toBeUndefined();
expect(options?.headers).toEqual({ Authorization: "Bearer ambient-token" });
return fauxAssistantMessage("summary with bearer auth");
},
]);
const result = await harness.session.compact();
expect(result.summary).toContain("summary with bearer auth");
expect(harness.faux.state.callCount).toBe(1);
});
it("persists usage from pi-generated manual compaction", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
useSummaryStreamFn(harness, "summary from custom stream");
const result = await harness.session.compact();
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
expect(result.usage).toEqual(createUsage(10));
expect(compactionEntries).toHaveLength(1);
expect(compactionEntries[0]?.type === "compaction" ? compactionEntries[0].usage : undefined).toEqual(
createUsage(10),
);
});
it("auto-compacts with a custom streamFn when registry auth is absent", async () => {
const harness = await createHarness({ withConfiguredAuth: false });
harnesses.push(harness);
seedCompactableSession(harness);
const getStreamCallCount = useSummaryStreamFn(harness, "auto summary from custom stream");
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
await sessionInternals._runAutoCompaction("threshold", false);
const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction");
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
expect(compactionEntries).toHaveLength(1);
expect(compactionEnd?.result?.estimatedTokensAfter).toBeGreaterThan(0);
expect(getStreamCallCount()).toBe(1);
});
it("compacts and resumes after a length stop below the desired output limit", async () => {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }],
settings: { compaction: { keepRecentTokens: 1, reserveTokens: 0 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "overflow compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
harness.setResponses([
fauxAssistantMessage("partial response", { stopReason: "length" }),
fauxAssistantMessage("completed response"),
]);
await harness.session.prompt("x".repeat(5000));
expect(harness.faux.state.callCount).toBe(2);
expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({
reason: "overflow",
aborted: false,
willRetry: true,
});
expect(harness.session.getLastAssistantText()).toBe("completed response");
});
it("does not compact when a length stop reaches the desired output limit", async () => {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 1_000_000, maxTokens: 100 }],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("x".repeat(400), { stopReason: "length" })]);
await harness.session.prompt("hello");
expect(harness.faux.state.callCount).toBe(1);
expect(harness.eventsOfType("compaction_start")).toHaveLength(0);
});
it("stops after one compact-and-retry when a second response is also truncated", async () => {
const harness = await createHarness({
models: [{ id: "faux-1", contextWindow: 1_000_000, maxTokens: 100 }],
settings: { compaction: { keepRecentTokens: 1, reserveTokens: 0 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "overflow compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
harness.setResponses([
() => fauxAssistantMessage("x".repeat(64), { stopReason: "length", timestamp: Date.now() + 10_000 }),
() => fauxAssistantMessage("y".repeat(64), { stopReason: "length", timestamp: Date.now() + 10_000 }),
]);
await harness.session.prompt("x".repeat(5000));
expect(harness.faux.state.callCount).toBe(2);
expect(harness.eventsOfType("compaction_start").filter((event) => event.reason === "overflow")).toHaveLength(1);
expect(harness.eventsOfType("compaction_end").at(-1)?.errorMessage).toBe(
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
);
});
it("cancels in-progress manual compaction when abortCompaction is called", async () => {
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => {
return await new Promise<{ cancel: true }>((resolve) => {
event.signal.addEventListener("abort", () => resolve({ cancel: true }), { once: true });
});
});
},
],
});
harnesses.push(harness);
await harness.session.prompt("one");
await harness.session.prompt("two");
const compactPromise = harness.session.compact();
await new Promise((resolve) => setTimeout(resolve, 0));
harness.session.abortCompaction();
await expect(compactPromise).rejects.toThrow("Compaction cancelled");
});
it("resumes after threshold compaction when only agent-level queued messages exist", async () => {
vi.useFakeTimers();
const harness = await createHarness({
settings: { compaction: { keepRecentTokens: 1 } },
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "auto compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
await harness.session.prompt("first");
await harness.session.prompt("second");
harness.session.agent.followUp({
role: "custom",
customType: "test",
content: [{ type: "text", text: "queued custom" }],
display: false,
timestamp: Date.now(),
});
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
await expect(sessionInternals._runAutoCompaction("threshold", false)).resolves.toBe(true);
});
it("does not retry overflow recovery more than once", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const overflowMessage = createAssistant(harness, {
stopReason: "error",
errorMessage: "prompt is too long",
timestamp: Date.now(),
});
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
const compactionErrors: string[] = [];
harness.session.subscribe((event) => {
if (event.type === "compaction_end" && event.errorMessage) {
compactionErrors.push(event.errorMessage);
}
});
await sessionInternals._checkCompaction(overflowMessage);
await sessionInternals._checkCompaction({ ...overflowMessage, timestamp: Date.now() + 1 });
expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1);
expect(compactionErrors).toContain(
"Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.",
);
});
it("compacts successful overflow responses without retrying", async () => {
const harness = await createHarness({
settings: { compaction: { enabled: true, keepRecentTokens: 1, reserveTokens: 0 } },
models: [{ id: "faux-1", contextWindow: 1, maxTokens: 100 }],
extensionFactories: [
(pi) => {
pi.on("session_before_compact", async (event) => ({
compaction: {
summary: "successful overflow compacted",
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: event.preparation.tokensBefore,
details: {},
},
}));
},
],
});
harnesses.push(harness);
harness.setResponses([fauxAssistantMessage("completed answer")]);
await expect(harness.session.prompt("hello")).resolves.toBeUndefined();
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
expect(compactionEnd).toMatchObject({
reason: "overflow",
aborted: false,
willRetry: false,
});
expect(harness.faux.state.callCount).toBe(1);
});
it("ignores stale pre-compaction assistant usage on pre-prompt checks", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const staleTimestamp = Date.now() - 10_000;
const staleAssistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 610_000,
timestamp: staleTimestamp,
});
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "before compaction" }],
timestamp: staleTimestamp - 1000,
});
harness.sessionManager.appendMessage(staleAssistant);
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
harness.sessionManager.appendCompaction(
"summary",
firstKeptEntryId,
staleAssistant.usage.totalTokens,
undefined,
false,
);
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "after compaction" }],
timestamp: Date.now(),
});
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
await sessionInternals._checkCompaction(staleAssistant, false);
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
});
it("triggers threshold compaction for error messages using the last successful usage", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const successfulAssistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 190_000,
timestamp: Date.now(),
});
const errorAssistant = createAssistant(harness, {
stopReason: "error",
errorMessage: "529 overloaded",
timestamp: Date.now() + 1000,
});
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
successfulAssistant,
{ role: "user", content: [{ type: "text", text: "retry" }], timestamp: Date.now() + 500 },
errorAssistant,
];
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
await sessionInternals._checkCompaction(errorAssistant);
expect(runAutoCompactionSpy).toHaveBeenCalledWith("threshold", false);
});
it("does not trigger threshold compaction for error messages when no prior usage exists", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const errorAssistant = createAssistant(harness, {
stopReason: "error",
errorMessage: "529 overloaded",
timestamp: Date.now(),
});
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
errorAssistant,
];
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
await sessionInternals._checkCompaction(errorAssistant);
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
});
it("does not trigger threshold compaction when only kept pre-compaction usage exists", async () => {
const harness = await createHarness();
harnesses.push(harness);
const sessionInternals = harness.session as unknown as SessionWithCompactionInternals;
const preCompactionTimestamp = Date.now() - 10_000;
const keptAssistant = createAssistant(harness, {
stopReason: "stop",
totalTokens: 190_000,
timestamp: preCompactionTimestamp,
});
harness.sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "before compaction" }],
timestamp: preCompactionTimestamp - 1000,
});
harness.sessionManager.appendMessage(keptAssistant);
const firstKeptEntryId = harness.sessionManager.getEntries()[0]!.id;
harness.sessionManager.appendCompaction(
"summary",
firstKeptEntryId,
keptAssistant.usage.totalTokens,
undefined,
false,
);
const errorAssistant = createAssistant(harness, {
stopReason: "error",
errorMessage: "529 overloaded",
timestamp: Date.now(),
});
harness.session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "kept user" }], timestamp: preCompactionTimestamp - 1000 },
keptAssistant,
{ role: "user", content: [{ type: "text", text: "new prompt" }], timestamp: Date.now() - 500 },
errorAssistant,
];
const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false);
await sessionInternals._checkCompaction(errorAssistant);
expect(runAutoCompactionSpy).not.toHaveBeenCalled();
});
it("does not trigger threshold compaction below the threshold or when disabled", async () => {
const belowThresholdHarness = await createHarness({
settings: { compaction: { enabled: true, reserveTokens: 1000 } },
models: [{ id: "faux-1", contextWindow: 200_000 }],
});
harnesses.push(belowThresholdHarness);
const disabledHarness = await createHarness({ settings: { compaction: { enabled: false } } });
harnesses.push(disabledHarness);
const belowThresholdInternals = belowThresholdHarness.session as unknown as SessionWithCompactionInternals;
const disabledInternals = disabledHarness.session as unknown as SessionWithCompactionInternals;
const belowThresholdSpy = vi.spyOn(belowThresholdInternals, "_runAutoCompaction").mockResolvedValue(false);
const disabledSpy = vi.spyOn(disabledInternals, "_runAutoCompaction").mockResolvedValue(false);
await belowThresholdInternals._checkCompaction(
createAssistant(belowThresholdHarness, { stopReason: "stop", totalTokens: 1_000, timestamp: Date.now() }),
);
await disabledInternals._checkCompaction(
createAssistant(disabledHarness, { stopReason: "stop", totalTokens: 1_000_000, timestamp: Date.now() }),
);
expect(belowThresholdSpy).not.toHaveBeenCalled();
expect(disabledSpy).not.toHaveBeenCalled();
});
});