fix(compaction): trim prefix when transcript ends in an oversized tool result (#95860)

findCutPoint defaulted cutIndex to the earliest valid cut (cutPoints[0],
keep everything) and only moved it forward to a cut point at or after the
backward token cursor. When the final entry is a toolResult whose estimate
alone meets keepRecentTokens, the cursor stops at that trailing toolResult
index, no valid cut point sits at or after it (toolResult entries are not
valid cut points), and the default stuck at keep-everything. Compaction then
summarized zero messages, so preflight and overflow compaction silently
no-op and the session loops on a context it cannot shrink.

Default cutIndex to the most recent valid cut before the forward search.
When a cut point exists at or after the cursor the search still finds it and
behavior is unchanged; only the trailing-tool-result case now keeps the
recent tail and summarizes the prefix.
This commit is contained in:
Yuval Dinodia
2026-06-23 03:34:33 -04:00
committed by GitHub
parent add9f3c6d3
commit f826a665a2
2 changed files with 81 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import type { AgentMessage } from "../../types.js";
import type { SessionTreeEntry } from "../types.js";
import { estimateTokens, findCutPoint } from "./compaction.js";
const KEEP_RECENT_TOKENS = 20000;
const LARGE_TOOL_OUTPUT = "x".repeat(120000);
function userText(text: string, timestamp: number): AgentMessage {
return { role: "user", content: [{ type: "text", text }], timestamp };
}
function assistantText(text: string, timestamp: number): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-fable-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp,
};
}
function toolResultText(text: string, timestamp: number): AgentMessage {
return {
role: "toolResult",
toolCallId: "call-1",
toolName: "bash",
content: [{ type: "text", text }],
isError: false,
timestamp,
};
}
function messageEntry(message: AgentMessage, index: number): SessionTreeEntry {
return {
type: "message",
id: `entry-${index}`,
parentId: index === 0 ? null : `entry-${index - 1}`,
timestamp: new Date(message.timestamp).toISOString(),
message,
};
}
function buildTranscript(): SessionTreeEntry[] {
const messages: AgentMessage[] = [
userText("start of the conversation", 1),
assistantText("first reply", 2),
userText("please run the command", 3),
assistantText("running it now", 4),
toolResultText(LARGE_TOOL_OUTPUT, 5),
];
return messages.map((message, index) => messageEntry(message, index));
}
describe("findCutPoint with a trailing oversized tool result", () => {
it("counts the final tool result as larger than the keep budget", () => {
const trailing = toolResultText(LARGE_TOOL_OUTPUT, 5);
expect(estimateTokens(trailing)).toBeGreaterThanOrEqual(KEEP_RECENT_TOKENS);
});
it("trims the prefix instead of keeping the whole transcript", () => {
const entries = buildTranscript();
const result = findCutPoint(entries, 0, entries.length, KEEP_RECENT_TOKENS);
expect(result.firstKeptEntryIndex).toBeGreaterThan(0);
expect(result.firstKeptEntryIndex).toBe(3);
});
});

View File

@@ -407,6 +407,7 @@ export function findCutPoint(
const messageTokens = estimateTokens(entry.message);
accumulatedTokens += messageTokens;
if (accumulatedTokens >= keepRecentTokens) {
cutIndex = cutPoints[cutPoints.length - 1];
for (const cutPoint of cutPoints) {
if (cutPoint >= i) {
cutIndex = cutPoint;