From ea7a9f479e24c5a27b27293979f4c8c747adb6fa Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 11 Oct 2026 22:34:03 -0700 Subject: [PATCH 1/2] refactor(session): stop excerpting the same tail turn twice in turnCost turnCost excerpted the same tail turn twice per pick: once directly and once through excerptedTurnTokens, which re-ran excerptTailTurn. The closure token estimate excerpted picked turns a third time. Cache the first excerpt per turn in selectTail and reuse it for the closure estimate, turnCost's token count, and the emitted live copies. Adds a test seam counter (excerptTailTurnCallCount) and a proof test pinning one excerptTailTurn call per tail turn with identical values. --- src/session/compactor-excerpt-once.test.ts | 104 +++++++++++++++++++++ src/session/compactor.ts | 44 ++++++--- 2 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 src/session/compactor-excerpt-once.test.ts diff --git a/src/session/compactor-excerpt-once.test.ts b/src/session/compactor-excerpt-once.test.ts new file mode 100644 index 000000000..d4399db29 --- /dev/null +++ b/src/session/compactor-excerpt-once.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { + createPruningCompactor, + excerptTailTurnCallCount, +} from "./compactor.js"; +import type { + ConversationTurn, + ReactorState, + StrategyContext, +} from "@intx/types/runtime"; + +const mockStrategyCtx: StrategyContext = { + state: {} as ReactorState, + trigger: "test", +}; + +function textTurn( + role: ConversationTurn["role"], + text: string, +): ConversationTurn { + return { + role, + content: [{ type: "text", text }], + timestamp: Date.now(), + }; +} + +function pairTurns(id: string, resultText: string): ConversationTurn[] { + return [ + { + role: "assistant", + content: [ + { + type: "tool_call", + id, + name: "read_file", + arguments: { path: `src/${id}.ts` }, + }, + ], + timestamp: Date.now(), + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: id, + content: [{ type: "text", text: resultText }], + }, + ], + timestamp: Date.now(), + }, + ]; +} + +function liveResultText(turns: ConversationTurn[]): string { + return turns + .flatMap((t) => + t.content.flatMap((b) => { + if (b.type === "text") return [b.text]; + if (b.type === "tool_result") + return b.content.map((c) => (c.type === "text" ? c.text : "")); + return []; + }), + ) + .join("\n"); +} + +describe("CL-10535 single excerpt per tail turn", () => { + test("compactor excerpts each tail turn exactly once with identical values", async () => { + const dump = "z".repeat(12_000); + const turns: ConversationTurn[] = [ + textTurn("user", "do the work"), + ...pairTurns("a", `a-result:${dump}`), + ...pairTurns("b", `b-result:${dump}`), + ...pairTurns("c", `c-result:${dump}`), + textTurn("user", "newest ask"), + textTurn("assistant", "newest reply"), + ]; + + const before = excerptTailTurnCallCount(); + const result = await createPruningCompactor({ + // Generous budget: every tail turn is a candidate, so a full pass + // must excerpt each turn exactly once. + compactionShape: { tailBudgetTokens: 1_000_000 }, + summarize: async () => { + throw new Error("must not invent a summary: nothing excluded"); + }, + }).apply(turns, mockStrategyCtx); + + const calls = excerptTailTurnCallCount() - before; + + // One excerptTailTurn call per tail turn — never the same turn twice + // (the closure estimate, turnCost, and emitted live copies share the + // first excerpt). + expect(calls).toBe(turns.length); + + // Values stay identical to the pre-fix behavior: the oversized outputs + // are still head+tail excerpted in the live tail. + const live = liveResultText(result.output); + expect(live.match(/\[tail-shortened \d+→/g)?.length ?? 0).toBe(3); + expect(live).not.toContain(dump); + }); +}); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index ec3ea4bca..ff4170a07 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -647,6 +647,15 @@ function excerptTailText( }; } +// Test seam (CL-10535): total excerptTailTurn invocations since module load. +// selectTail reuses the first excerpt per tail turn (closure estimates, +// turnCost, and the emitted live copies share one cache), so a pass over N +// tail turns adds exactly N to this counter. +let excerptTailTurnCallCountValue = 0; +export function excerptTailTurnCallCount(): number { + return excerptTailTurnCallCountValue; +} + // Excerpted live copy of a tail turn: thinking/redacted_thinking drop except // on the last assistant with a tool_call (Anthropic continuation needs the // unmodified thinking+signature on that tool_use). Whole blocks drop so @@ -658,6 +667,7 @@ function excerptTailTurn( shape: CompactionShape, keepThinking = false, ): { turn: ConversationTurn; shortenedOutputs: number; changed: boolean } { + excerptTailTurnCallCountValue += 1; let shortenedOutputs = 0; let changed = false; const content = turn.content.flatMap((block): ConversationTurn["content"] => { @@ -716,12 +726,9 @@ function lastAssistantToolCallIndex( return undefined; } -function excerptedTurnTokens( - turn: ConversationTurn, - shape: CompactionShape, - keepThinking = false, -): number { - const { turn: live } = excerptTailTurn(turn, shape, keepThinking); +// Token estimate over an already-excerpted live turn. Does not re-excerpt: +// callers reuse the first excerptTailTurn result (CL-10535). +function liveTurnTokens(live: ConversationTurn): number { let tokens = 0; for (const block of live.content) { tokens += estimateContentBlockTokens(block); @@ -769,18 +776,27 @@ function selectTail( const budgetTokens = shape.tailBudgetTokens; const keepThinkingAt = lastAssistantToolCallIndex(turns); + // Excerpt results are cached per tail turn so each turn is excerpted at most + // once per pass: the closure token estimate, turnCost, and the emitted live + // copies all reuse the first excerpt (CL-10535). + type ExcerptResult = ReturnType; + const excerptCache = new Map(); + const excerptFor = (idx: number, turn: ConversationTurn): ExcerptResult => { + let result = excerptCache.get(idx); + if (result === undefined) { + result = excerptTailTurn(turn, shape, idx === keepThinkingAt); + excerptCache.set(idx, result); + } + return result; + }; + const turnCost = (idx: number): { tokens: number; shortened: number } => { const turn = turns[idx]; if (turn === undefined) return { tokens: 0, shortened: 0 }; - const keepThinking = idx === keepThinkingAt; - const { - turn: live, - shortenedOutputs, - changed, - } = excerptTailTurn(turn, shape, keepThinking); + const { turn: live, shortenedOutputs, changed } = excerptFor(idx, turn); if (changed) excerpted.set(idx, live); return { - tokens: excerptedTurnTokens(turn, shape, keepThinking), + tokens: liveTurnTokens(live), shortened: shortenedOutputs, }; }; @@ -836,7 +852,7 @@ function selectTail( for (const idx of closure) { const t = turns[idx]; if (t === undefined || isFoldableHandoffTurn(t)) continue; - closureTokens += excerptedTurnTokens(t, shape, idx === keepThinkingAt); + closureTokens += liveTurnTokens(excerptFor(idx, t).turn); } if (picked.size > 0 && usedTokens + closureTokens > budgetTokens) break; for (const idx of closure) pick(idx); From 65b9ffc20f2a3692b4c7db3cb43259167cbf7edc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 12 Oct 2026 16:28:56 -0700 Subject: [PATCH 2/2] refactor(session): drop excerpt-cache narration --- src/session/compactor-excerpt-once.test.ts | 9 +-------- src/session/compactor.ts | 9 --------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/session/compactor-excerpt-once.test.ts b/src/session/compactor-excerpt-once.test.ts index d4399db29..3b83eafe2 100644 --- a/src/session/compactor-excerpt-once.test.ts +++ b/src/session/compactor-excerpt-once.test.ts @@ -66,7 +66,7 @@ function liveResultText(turns: ConversationTurn[]): string { .join("\n"); } -describe("CL-10535 single excerpt per tail turn", () => { +describe("excerpt once per tail turn", () => { test("compactor excerpts each tail turn exactly once with identical values", async () => { const dump = "z".repeat(12_000); const turns: ConversationTurn[] = [ @@ -80,8 +80,6 @@ describe("CL-10535 single excerpt per tail turn", () => { const before = excerptTailTurnCallCount(); const result = await createPruningCompactor({ - // Generous budget: every tail turn is a candidate, so a full pass - // must excerpt each turn exactly once. compactionShape: { tailBudgetTokens: 1_000_000 }, summarize: async () => { throw new Error("must not invent a summary: nothing excluded"); @@ -90,13 +88,8 @@ describe("CL-10535 single excerpt per tail turn", () => { const calls = excerptTailTurnCallCount() - before; - // One excerptTailTurn call per tail turn — never the same turn twice - // (the closure estimate, turnCost, and emitted live copies share the - // first excerpt). expect(calls).toBe(turns.length); - // Values stay identical to the pre-fix behavior: the oversized outputs - // are still head+tail excerpted in the live tail. const live = liveResultText(result.output); expect(live.match(/\[tail-shortened \d+→/g)?.length ?? 0).toBe(3); expect(live).not.toContain(dump); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index ff4170a07..364ac7640 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -647,10 +647,6 @@ function excerptTailText( }; } -// Test seam (CL-10535): total excerptTailTurn invocations since module load. -// selectTail reuses the first excerpt per tail turn (closure estimates, -// turnCost, and the emitted live copies share one cache), so a pass over N -// tail turns adds exactly N to this counter. let excerptTailTurnCallCountValue = 0; export function excerptTailTurnCallCount(): number { return excerptTailTurnCallCountValue; @@ -726,8 +722,6 @@ function lastAssistantToolCallIndex( return undefined; } -// Token estimate over an already-excerpted live turn. Does not re-excerpt: -// callers reuse the first excerptTailTurn result (CL-10535). function liveTurnTokens(live: ConversationTurn): number { let tokens = 0; for (const block of live.content) { @@ -776,9 +770,6 @@ function selectTail( const budgetTokens = shape.tailBudgetTokens; const keepThinkingAt = lastAssistantToolCallIndex(turns); - // Excerpt results are cached per tail turn so each turn is excerpted at most - // once per pass: the closure token estimate, turnCost, and the emitted live - // copies all reuse the first excerpt (CL-10535). type ExcerptResult = ReturnType; const excerptCache = new Map(); const excerptFor = (idx: number, turn: ConversationTurn): ExcerptResult => {