Skip to content

Commit 9e20da7

Browse files
committed
test(session): cover budgeted tail pipeline for compaction folds
Red state for the shared summary-plus-small-tail pipeline: tail budget with shortened tool outputs, pair-safe cuts, whole newest user messages, repeat folds updating the prior summary, and archive retrievability. These fail against the last-N-verbatim keep window.
1 parent 6d07b36 commit 9e20da7

3 files changed

Lines changed: 430 additions & 0 deletions

File tree

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

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,9 @@ describe("wrapCompactorWithCompletenessGate", () => {
10351035
keepRecentTurns: 2,
10361036
maxAnchorTurns: 0,
10371037
summaryMaxChars: 500,
1038+
// CL-9007: pin a tiny tail budget so the fold covers the same older
1039+
// region the old keepRecentTurns cut folded.
1040+
compactionShape: { tailBudgetTokens: 10 },
10381041
});
10391042
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
10401043
const turns: import("@intx/types/runtime").ConversationTurn[] = [
@@ -1108,6 +1111,9 @@ describe("wrapCompactorWithCompletenessGate", () => {
11081111
keepRecentTurns: 2,
11091112
maxAnchorTurns: 0,
11101113
summaryMaxChars: 500,
1114+
// CL-9007: pin a tiny tail budget so the fold covers the same older
1115+
// region the old keepRecentTurns cut folded.
1116+
compactionShape: { tailBudgetTokens: 10 },
11111117
});
11121118
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
11131119
const turns: import("@intx/types/runtime").ConversationTurn[] = [
@@ -1144,4 +1150,98 @@ describe("wrapCompactorWithCompletenessGate", () => {
11441150
if (spine?.type !== "text") throw new Error("unreachable");
11451151
expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true);
11461152
});
1153+
1154+
test("CL-9007 tail excerpts stay certified while full bytes stay retrievable", async () => {
1155+
const { wrapCompactorWithCompletenessGate, recordAdoptedHandoff } =
1156+
await import("./compaction-archive.js");
1157+
const { createPruningCompactor } = await import("./compactor.js");
1158+
const { COMPACTED_PREFIX } = await import("./compaction-handoff.js");
1159+
const { archive } = memoryArchive();
1160+
const FULL = `decisive-fact-99:${"y".repeat(20_000)}`;
1161+
const FILLER = `f1:${"x".repeat(4000)}`;
1162+
await archive.recordAuthorizedPayload({
1163+
kind: "tool_args",
1164+
payload: { name: "read_file", arguments: { path: "src/f1.ts" } },
1165+
callId: "f1",
1166+
});
1167+
await archive.recordAuthorizedPayload({
1168+
kind: "tool_result",
1169+
payload: FILLER,
1170+
callId: "f1",
1171+
});
1172+
const bigOcc = await archive.recordAuthorizedPayload({
1173+
kind: "tool_result",
1174+
payload: FULL,
1175+
callId: "big-1",
1176+
});
1177+
const inner = createPruningCompactor({
1178+
keepRecentTurns: 2,
1179+
summaryMaxChars: 4000,
1180+
compactionShape: { tailBudgetTokens: 1000 },
1181+
summarize: async () =>
1182+
"Re-read src/f1.ts leftovers. Next: keep newest ask whole.",
1183+
});
1184+
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
1185+
type Turn = import("@intx/types/runtime").ConversationTurn;
1186+
const pair = (
1187+
id: string,
1188+
name: string,
1189+
args: Record<string, unknown>,
1190+
resultText: string,
1191+
): Turn[] => [
1192+
{
1193+
role: "assistant",
1194+
content: [{ type: "tool_call", id, name, arguments: args }],
1195+
timestamp: Date.now(),
1196+
},
1197+
{
1198+
role: "user",
1199+
content: [
1200+
{
1201+
type: "tool_result",
1202+
callId: id,
1203+
content: [{ type: "text", text: resultText }],
1204+
},
1205+
],
1206+
timestamp: Date.now(),
1207+
},
1208+
];
1209+
const text = (role: Turn["role"], content: string): Turn => ({
1210+
role,
1211+
content: [{ type: "text", text: content }],
1212+
timestamp: Date.now(),
1213+
});
1214+
const turns: Turn[] = [
1215+
text("user", "Migrate the auth module to opaque tokens in src/auth.ts"),
1216+
...pair("f1", "read_file", { path: "src/f1.ts" }, FILLER),
1217+
...pair("big-1", "run_shell", { command: "bun run test auth" }, FULL),
1218+
text("user", "newest ask: keep this newest user message whole verbatim"),
1219+
text("assistant", "newest reply"),
1220+
];
1221+
const result = await wrapped.apply(turns, ctx);
1222+
expect(result.record.reason).not.toBe("incomplete-evidence-archive");
1223+
expect(result.record.reason.startsWith("compacted")).toBe(true);
1224+
// Tail excerpts live inside tool_result bodies, not top-level text blocks.
1225+
const live = result.output
1226+
.flatMap((t) =>
1227+
t.content.flatMap((b) => {
1228+
if (b.type === "text") return [b.text];
1229+
if (b.type === "tool_result")
1230+
return b.content.map((c) => (c.type === "text" ? c.text : ""));
1231+
return [];
1232+
}),
1233+
)
1234+
.join("\n");
1235+
expect(live).not.toContain(FULL);
1236+
expect(live).toContain("decisive-fact-99:");
1237+
expect(live).toContain("[tail-shortened");
1238+
expect(await archive.readAuthorizedPayload(bigOcc.occurrenceId)).toBe(FULL);
1239+
const adopted = await recordAdoptedHandoff(
1240+
archive,
1241+
`${COMPACTED_PREFIX} Goal: carry me retrievable`,
1242+
);
1243+
expect(await archive.readAuthorizedPayload(adopted.occurrenceId)).toContain(
1244+
"carry me retrievable",
1245+
);
1246+
});
11471247
});

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

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,9 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => {
890890
const compactor = createPruningCompactor({
891891
keepRecentTurns: 2,
892892
summaryMaxChars: 500,
893+
// CL-9007: pin a tiny tail budget so the fold covers the same older
894+
// region the old keepRecentTurns cut folded.
895+
compactionShape: { tailBudgetTokens: 10 },
893896
});
894897
const turns: ConversationTurn[] = [
895898
userTurn("Ship the widget. Never rename src/widget.ts."),
@@ -953,6 +956,9 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => {
953956
const compactor = createPruningCompactor({
954957
keepRecentTurns: 2,
955958
summaryMaxChars: 500,
959+
// CL-9007: pin a tiny tail budget so each fold covers the same older
960+
// region the old keepRecentTurns cut folded.
961+
compactionShape: { tailBudgetTokens: 10 },
956962
readPriorHandoff: async () => latest,
957963
});
958964
const firstTurns: ConversationTurn[] = [
@@ -989,3 +995,153 @@ describe("createPruningCompactor — handoff fold (CL-8744)", () => {
989995
expect(filesSection).toContain("diagnostic.log");
990996
});
991997
});
998+
999+
describe("CL-9007 tail attachments stay whole", () => {
1000+
const PNG_B64 =
1001+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
1002+
1003+
test("a recent image attachment and its user text survive the fold whole", async () => {
1004+
const userText =
1005+
"screenshot ask: keep this newest user message whole verbatim";
1006+
const compactor = createPruningCompactor({
1007+
keepRecentTurns: 2,
1008+
summaryMaxChars: 4000,
1009+
compactionShape: { tailBudgetTokens: 1000 },
1010+
summarize: async () => "Re-read src/a.ts. Next: review the screenshot.",
1011+
});
1012+
const turns: ConversationTurn[] = [
1013+
userTurn("Migrate the auth module to opaque tokens in src/auth.ts"),
1014+
...fileReadTurns("a", "src/a.ts", `a-result:${"a".repeat(4000)}`),
1015+
...fileReadTurns("b", "src/b.ts", `b-result:${"b".repeat(4000)}`),
1016+
makeTurn({
1017+
role: "user",
1018+
content: [
1019+
{ type: "text", text: userText },
1020+
{
1021+
type: "image",
1022+
source: { kind: "base64", mimeType: "image/png", data: PNG_B64 },
1023+
},
1024+
],
1025+
}),
1026+
makeTurn({
1027+
role: "assistant",
1028+
content: [{ type: "text", text: "newest reply" }],
1029+
}),
1030+
];
1031+
const result = await compactor.apply(turns, mockStrategyCtx);
1032+
expect(result.record.reason.startsWith("compacted")).toBe(true);
1033+
const live = result.output
1034+
.flatMap((t) => t.content)
1035+
.filter((b) => b.type === "text")
1036+
.map((b) => b.text)
1037+
.join("\n");
1038+
expect(live).toContain(userText);
1039+
const image = result.output
1040+
.flatMap((t) => t.content)
1041+
.find((b) => b.type === "image");
1042+
expect(image).toEqual({
1043+
type: "image",
1044+
source: { kind: "base64", mimeType: "image/png", data: PNG_B64 },
1045+
});
1046+
});
1047+
});
1048+
1049+
describe("CL-9007 repeated compactions update the summary", () => {
1050+
const TAIL_MARK = "newest ask: carry this tail string forward";
1051+
1052+
function firstSession(): ConversationTurn[] {
1053+
return [
1054+
userTurn("Migrate the auth module to opaque tokens in src/auth.ts"),
1055+
...fileReadTurns("a", "src/a.ts", `a-result:${"a".repeat(4000)}`),
1056+
...fileReadTurns("b", "src/b.ts", `b-result:${"b".repeat(4000)}`),
1057+
userTurn(TAIL_MARK),
1058+
makeTurn({
1059+
role: "assistant",
1060+
content: [{ type: "text", text: "newest reply" }],
1061+
}),
1062+
];
1063+
}
1064+
1065+
function countSpines(turns: ConversationTurn[]): number {
1066+
return turns
1067+
.flatMap((t) => t.content)
1068+
.filter((b) => b.type === "text" && b.text.includes(COMPACTED_PREFIX))
1069+
.length;
1070+
}
1071+
1072+
function countMarkers(turns: ConversationTurn[]): number {
1073+
// Markers live inside tool_result bodies, not top-level text blocks.
1074+
return turns
1075+
.flatMap((t) =>
1076+
t.content.flatMap((b) => {
1077+
if (b.type === "text") return [b.text];
1078+
if (b.type === "tool_result")
1079+
return b.content.map((c) => (c.type === "text" ? c.text : ""));
1080+
return [];
1081+
}),
1082+
)
1083+
.reduce((sum, text) => sum + text.split("[tail-shortened").length - 1, 0);
1084+
}
1085+
1086+
function liveText(turns: ConversationTurn[]): string {
1087+
return turns
1088+
.flatMap((t) => t.content)
1089+
.filter((b) => b.type === "text")
1090+
.map((b) => b.text)
1091+
.join("\n");
1092+
}
1093+
1094+
test("a second fold updates the prior summary and carries the tail forward", async () => {
1095+
let priorFile: string | undefined;
1096+
let seenPrior: string | undefined;
1097+
let calls = 0;
1098+
const compactor = createPruningCompactor({
1099+
keepRecentTurns: 2,
1100+
summaryMaxChars: 4000,
1101+
compactionShape: { tailBudgetTokens: 1000 },
1102+
readPriorHandoff: async () => priorFile,
1103+
summarize: async (_turns, ctx) => {
1104+
calls += 1;
1105+
if (calls === 2) seenPrior = ctx?.priorSummary;
1106+
return calls === 1
1107+
? "Re-read src/a.ts. Next: keep newest ask whole."
1108+
: "Migrating auth to opaque tokens via src/a.ts. Next: keep newest ask whole.";
1109+
},
1110+
});
1111+
1112+
const first = await compactor.apply(firstSession(), mockStrategyCtx);
1113+
expect(first.record.reason.startsWith("compacted")).toBe(true);
1114+
expect(countSpines(first.output)).toBe(1);
1115+
priorFile = new TextDecoder().decode(
1116+
defined(defined(first.blobs)[0]).bytes,
1117+
);
1118+
expect(priorFile).toContain("src/a.ts");
1119+
1120+
const second = await compactor.apply(
1121+
[
1122+
...first.output,
1123+
// New tool activity after the first fold: the second tail holds this
1124+
// fresh excerpt while the first fold's excerpt is summarized from its
1125+
// shortened text — never re-summarized raw, never duplicated live.
1126+
...fileReadTurns("c", "src/c.ts", `c-result:${"c".repeat(4000)}`),
1127+
userTurn("follow-up ask"),
1128+
makeTurn({
1129+
role: "assistant",
1130+
content: [{ type: "text", text: "follow-up reply" }],
1131+
}),
1132+
],
1133+
mockStrategyCtx,
1134+
);
1135+
expect(second.record.reason.startsWith("compacted")).toBe(true);
1136+
expect(countSpines(second.output)).toBe(1);
1137+
expect(seenPrior).toContain(COMPACTED_PREFIX);
1138+
expect(seenPrior).toContain("src/a.ts");
1139+
expect(liveText(second.output)).toContain(TAIL_MARK);
1140+
expect(countMarkers(second.output)).toBe(countMarkers(first.output));
1141+
const secondFile = new TextDecoder().decode(
1142+
defined(defined(second.blobs)[0]).bytes,
1143+
);
1144+
expect(secondFile).toContain("src/a.ts");
1145+
expect(secondFile).not.toBe(priorFile);
1146+
});
1147+
});

0 commit comments

Comments
 (0)