Skip to content

Commit 38f9cad

Browse files
committed
fix(session): excerpt each tail turn at most once in selectTail (CL-10535)
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.
1 parent 40e19ce commit 38f9cad

2 files changed

Lines changed: 134 additions & 14 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
createPruningCompactor,
4+
excerptTailTurnCallCount,
5+
} from "./compactor.js";
6+
import type {
7+
ConversationTurn,
8+
ReactorState,
9+
StrategyContext,
10+
} from "@intx/types/runtime";
11+
12+
const mockStrategyCtx: StrategyContext = {
13+
state: {} as ReactorState,
14+
trigger: "test",
15+
};
16+
17+
function textTurn(
18+
role: ConversationTurn["role"],
19+
text: string,
20+
): ConversationTurn {
21+
return {
22+
role,
23+
content: [{ type: "text", text }],
24+
timestamp: Date.now(),
25+
};
26+
}
27+
28+
function pairTurns(id: string, resultText: string): ConversationTurn[] {
29+
return [
30+
{
31+
role: "assistant",
32+
content: [
33+
{
34+
type: "tool_call",
35+
id,
36+
name: "read_file",
37+
arguments: { path: `src/${id}.ts` },
38+
},
39+
],
40+
timestamp: Date.now(),
41+
},
42+
{
43+
role: "user",
44+
content: [
45+
{
46+
type: "tool_result",
47+
callId: id,
48+
content: [{ type: "text", text: resultText }],
49+
},
50+
],
51+
timestamp: Date.now(),
52+
},
53+
];
54+
}
55+
56+
function liveResultText(turns: ConversationTurn[]): string {
57+
return turns
58+
.flatMap((t) =>
59+
t.content.flatMap((b) => {
60+
if (b.type === "text") return [b.text];
61+
if (b.type === "tool_result")
62+
return b.content.map((c) => (c.type === "text" ? c.text : ""));
63+
return [];
64+
}),
65+
)
66+
.join("\n");
67+
}
68+
69+
describe("CL-10535 single excerpt per tail turn", () => {
70+
test("compactor excerpts each tail turn exactly once with identical values", async () => {
71+
const dump = "z".repeat(12_000);
72+
const turns: ConversationTurn[] = [
73+
textTurn("user", "do the work"),
74+
...pairTurns("a", `a-result:${dump}`),
75+
...pairTurns("b", `b-result:${dump}`),
76+
...pairTurns("c", `c-result:${dump}`),
77+
textTurn("user", "newest ask"),
78+
textTurn("assistant", "newest reply"),
79+
];
80+
81+
const before = excerptTailTurnCallCount();
82+
const result = await createPruningCompactor({
83+
// Generous budget: every tail turn is a candidate, so a full pass
84+
// must excerpt each turn exactly once.
85+
compactionShape: { tailBudgetTokens: 1_000_000 },
86+
summarize: async () => {
87+
throw new Error("must not invent a summary: nothing excluded");
88+
},
89+
}).apply(turns, mockStrategyCtx);
90+
91+
const calls = excerptTailTurnCallCount() - before;
92+
93+
// One excerptTailTurn call per tail turn — never the same turn twice
94+
// (the closure estimate, turnCost, and emitted live copies share the
95+
// first excerpt).
96+
expect(calls).toBe(turns.length);
97+
98+
// Values stay identical to the pre-fix behavior: the oversized outputs
99+
// are still head+tail excerpted in the live tail.
100+
const live = liveResultText(result.output);
101+
expect(live.match(/\[tail-shortened \d+→/g)?.length ?? 0).toBe(3);
102+
expect(live).not.toContain(dump);
103+
});
104+
});

‎src/session/compactor.ts‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,15 @@ function excerptTailText(
647647
};
648648
}
649649

650+
// Test seam (CL-10535): total excerptTailTurn invocations since module load.
651+
// selectTail reuses the first excerpt per tail turn (closure estimates,
652+
// turnCost, and the emitted live copies share one cache), so a pass over N
653+
// tail turns adds exactly N to this counter.
654+
let excerptTailTurnCallCountValue = 0;
655+
export function excerptTailTurnCallCount(): number {
656+
return excerptTailTurnCallCountValue;
657+
}
658+
650659
// Excerpted live copy of a tail turn: thinking/redacted_thinking drop except
651660
// on the last assistant with a tool_call (Anthropic continuation needs the
652661
// unmodified thinking+signature on that tool_use). Whole blocks drop so
@@ -658,6 +667,7 @@ function excerptTailTurn(
658667
shape: CompactionShape,
659668
keepThinking = false,
660669
): { turn: ConversationTurn; shortenedOutputs: number; changed: boolean } {
670+
excerptTailTurnCallCountValue += 1;
661671
let shortenedOutputs = 0;
662672
let changed = false;
663673
const content = turn.content.flatMap((block): ConversationTurn["content"] => {
@@ -716,12 +726,9 @@ function lastAssistantToolCallIndex(
716726
return undefined;
717727
}
718728

719-
function excerptedTurnTokens(
720-
turn: ConversationTurn,
721-
shape: CompactionShape,
722-
keepThinking = false,
723-
): number {
724-
const { turn: live } = excerptTailTurn(turn, shape, keepThinking);
729+
// Token estimate over an already-excerpted live turn. Does not re-excerpt:
730+
// callers reuse the first excerptTailTurn result (CL-10535).
731+
function liveTurnTokens(live: ConversationTurn): number {
725732
let tokens = 0;
726733
for (const block of live.content) {
727734
tokens += estimateContentBlockTokens(block);
@@ -769,18 +776,27 @@ function selectTail(
769776
const budgetTokens = shape.tailBudgetTokens;
770777
const keepThinkingAt = lastAssistantToolCallIndex(turns);
771778

779+
// Excerpt results are cached per tail turn so each turn is excerpted at most
780+
// once per pass: the closure token estimate, turnCost, and the emitted live
781+
// copies all reuse the first excerpt (CL-10535).
782+
type ExcerptResult = ReturnType<typeof excerptTailTurn>;
783+
const excerptCache = new Map<number, ExcerptResult>();
784+
const excerptFor = (idx: number, turn: ConversationTurn): ExcerptResult => {
785+
let result = excerptCache.get(idx);
786+
if (result === undefined) {
787+
result = excerptTailTurn(turn, shape, idx === keepThinkingAt);
788+
excerptCache.set(idx, result);
789+
}
790+
return result;
791+
};
792+
772793
const turnCost = (idx: number): { tokens: number; shortened: number } => {
773794
const turn = turns[idx];
774795
if (turn === undefined) return { tokens: 0, shortened: 0 };
775-
const keepThinking = idx === keepThinkingAt;
776-
const {
777-
turn: live,
778-
shortenedOutputs,
779-
changed,
780-
} = excerptTailTurn(turn, shape, keepThinking);
796+
const { turn: live, shortenedOutputs, changed } = excerptFor(idx, turn);
781797
if (changed) excerpted.set(idx, live);
782798
return {
783-
tokens: excerptedTurnTokens(turn, shape, keepThinking),
799+
tokens: liveTurnTokens(live),
784800
shortened: shortenedOutputs,
785801
};
786802
};
@@ -836,7 +852,7 @@ function selectTail(
836852
for (const idx of closure) {
837853
const t = turns[idx];
838854
if (t === undefined || isFoldableHandoffTurn(t)) continue;
839-
closureTokens += excerptedTurnTokens(t, shape, idx === keepThinkingAt);
855+
closureTokens += liveTurnTokens(excerptFor(idx, t).turn);
840856
}
841857
if (picked.size > 0 && usedTokens + closureTokens > budgetTokens) break;
842858
for (const idx of closure) pick(idx);

0 commit comments

Comments
 (0)