Skip to content

Commit 4caced6

Browse files
committed
fix(session): emit excerpted tail copies when nothing is summarized
A keep-set that covers every foldable turn still has to ship the excerpted live copies the tail selector already paid for. Returning the unexcerpted turns keeps the occupancy that armed the compact. Skip re-excerpting only on the structured marker so a body that merely mentions the prefix is still shortened.
1 parent 7703277 commit 4caced6

2 files changed

Lines changed: 86 additions & 7 deletions

File tree

‎src/session/compaction-verify.test.ts‎

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import os from "node:os";
44
import path from "node:path";
55
import { createPruningCompactor } from "./compactor.js";
66
import { condenseTurns } from "./summarizer.js";
7-
import { HANDOFF_LATEST_KEY } from "./compaction-handoff.js";
7+
import { COMPACTED_PREFIX, HANDOFF_LATEST_KEY } from "./compaction-handoff.js";
88
import {
99
createCompactionArchive,
1010
wrapCompactorWithCompletenessGate,
@@ -786,6 +786,27 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => {
786786
.join("\n");
787787
}
788788

789+
function liveTokenEstimate(turns: ConversationTurn[]): number {
790+
let chars = 0;
791+
for (const turn of turns) {
792+
for (const block of turn.content) {
793+
if (block.type === "text") chars += block.text.length;
794+
else if (block.type === "tool_call")
795+
chars += JSON.stringify(block.arguments).length;
796+
else if (block.type === "tool_result") {
797+
for (const part of block.content) {
798+
if (part.type === "text") chars += part.text.length;
799+
}
800+
}
801+
}
802+
}
803+
return Math.ceil(chars / 4);
804+
}
805+
806+
function countStructuredTailExcerpts(text: string): number {
807+
return text.match(/\[tail-shortened \d+→/g)?.length ?? 0;
808+
}
809+
789810
test("large tool outputs in the tail are shortened rather than copied verbatim", async () => {
790811
const result = await tailCompactor().apply(tailSession(), mockStrategyCtx);
791812
expect(result.record.reason.startsWith("compacted")).toBe(true);
@@ -840,6 +861,57 @@ describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => {
840861
compactionShape: { tailBudgetTokens: 7500, pairSafe: true },
841862
});
842863
});
864+
865+
test("budget-swallow still emits excerpted tail copies; live tokens ≤ budget; shortenedToolOutputs matches live sentinels", async () => {
866+
const dump = "z".repeat(12_000);
867+
const turns: ConversationTurn[] = [
868+
textTurn("user", "do the work"),
869+
...pairTurns("a", "read_file", { path: "src/a.ts" }, dump),
870+
...pairTurns("b", "read_file", { path: "src/b.ts" }, dump),
871+
...pairTurns("c", "read_file", { path: "src/c.ts" }, dump),
872+
textTurn("user", "newest ask"),
873+
textTurn("assistant", "newest reply"),
874+
];
875+
const result = await createPruningCompactor({
876+
keepRecentTurns: 2,
877+
compactionShape: { tailBudgetTokens: 7500 },
878+
summarize: async () => {
879+
throw new Error("must not invent a summary on the budget-swallow path");
880+
},
881+
}).apply(turns, mockStrategyCtx);
882+
883+
expect(result.record.reason).toBe("no compaction needed");
884+
expect(allText(result.output)).not.toContain(COMPACTED_PREFIX);
885+
const live = liveResultText(result.output);
886+
expect(live).not.toContain(dump);
887+
expect(result.record.decisions).toMatchObject({ shortenedToolOutputs: 3 });
888+
expect(countStructuredTailExcerpts(live)).toBe(3);
889+
expect(liveTokenEstimate(result.output)).toBeLessThanOrEqual(7500);
890+
});
891+
892+
test("a body containing the substring but not the structured marker is still excerpted", async () => {
893+
const bait = "[tail-shortened ";
894+
const body = `${bait}in the docs\n${"z".repeat(8000)}`;
895+
expect(body.includes(bait)).toBe(true);
896+
expect(/\[tail-shortened \d+→/.test(body)).toBe(false);
897+
898+
const turns: ConversationTurn[] = [
899+
textTurn("user", "goal"),
900+
...pairTurns("bait", "read_file", { path: "src/bait.ts" }, body),
901+
textTurn("user", "newest ask"),
902+
textTurn("assistant", "newest reply"),
903+
];
904+
const result = await createPruningCompactor({
905+
keepRecentTurns: 2,
906+
compactionShape: { tailBudgetTokens: 7500 },
907+
}).apply(turns, mockStrategyCtx);
908+
909+
const live = liveResultText(result.output);
910+
expect(live).toMatch(/\[tail-shortened \d+→/);
911+
expect(live).not.toContain("z".repeat(8000));
912+
expect(result.record.decisions).toMatchObject({ shortenedToolOutputs: 1 });
913+
expect(countStructuredTailExcerpts(live)).toBe(1);
914+
});
843915
});
844916

845917
describe("continuation facts survive many folds", () => {

‎src/session/compactor.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,9 @@ function estimateTextTokens(chars: number): number {
946946
// rides unchanged (summarized from its shortened text, never re-expanded raw
947947
// and never re-shortened into nested sentinels).
948948
const TAIL_EXCERPT_SENTINEL = "[tail-shortened ";
949+
// Match the stamped marker (`[tail-shortened N→`), not a raw prefix: a body
950+
// that happens to mention the substring must still be excerpted.
951+
const TAIL_EXCERPT_MARKER = /\[tail-shortened \d+→/;
949952

950953
// Shorten one oversized text part of a tail tool result to a head+tail
951954
// excerpt. The excerpt carries a sentinel, the original length, and the kept
@@ -957,7 +960,7 @@ function excerptTailText(
957960
): { text: string; shortened: boolean } {
958961
if (
959962
text.length <= shape.maxTailToolOutputChars ||
960-
text.includes(TAIL_EXCERPT_SENTINEL)
963+
TAIL_EXCERPT_MARKER.test(text)
961964
)
962965
return { text, shortened: false };
963966
const headChars = shape.excerptHead
@@ -1301,13 +1304,17 @@ export function createPruningCompactor(
13011304
...carriedSpines,
13021305
];
13031306

1304-
// Keep-set covered everything foldable: nothing to replace. Leave the
1305-
// input untouched rather than rewriting the head with an empty summary —
1306-
// but return the image-aged turns (plus their spill blobs), not the raw
1307-
// input, so eager aging outside the tail is not silently dropped.
1307+
// Keep-set covered everything foldable: nothing to replace. Do not
1308+
// invent an empty summary — but still emit the excerpted live copies
1309+
// selectTail already paid for (plus image-aged turns and their spill
1310+
// blobs). Returning the unexcerpted aged turns would keep the occupancy
1311+
// that armed the compact on a short-but-bulky first fold.
13081312
if (summarizedTurns.length === 0) {
13091313
return {
1310-
output: aged.turns,
1314+
output:
1315+
tail.excerpted.size === 0
1316+
? aged.turns
1317+
: aged.turns.map((turn, idx) => tail.excerpted.get(idx) ?? turn),
13111318
record: {
13121319
strategy: this.name,
13131320
version: this.version,

0 commit comments

Comments
 (0)