Skip to content

Commit 494bc5d

Browse files
committed
fix(session): order compaction repair lines critical-first
A tight cap used to slice exact names off the repair tail. Put goal, next action, and exact names first so truncation keeps the facts the next agent needs, and restore real verify coverage on the truncation path.
1 parent d4e2cb2 commit 494bc5d

3 files changed

Lines changed: 179 additions & 38 deletions

File tree

‎src/context-compactor.test.ts‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
formatPlan,
88
classifyTaskBoundary,
99
buildLLMTurnSummary,
10+
buildTurnSummary,
1011
COMPACTED_PREFIX,
1112
COMPACT_SPACER_TEXT,
1213
LEGACY_COMPACT_SPACER_TEXT,
@@ -1211,7 +1212,26 @@ describe("buildTurnSummary via createPruningCompactor", () => {
12111212
expect(summaryText).toContain("Total tool calls: 1");
12121213
});
12131214

1214-
test("truncates summary when it exceeds maxChars", async () => {
1215+
test("buildTurnSummary truncates with ellipsis when over maxChars", () => {
1216+
const maxChars = 20;
1217+
const turns: ConversationTurn[] = [
1218+
makeTurn({
1219+
role: "user",
1220+
content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }],
1221+
}),
1222+
makeTurn({
1223+
role: "assistant",
1224+
content: [
1225+
{ type: "text", text: "patch the refresh handler ".repeat(40) },
1226+
],
1227+
}),
1228+
];
1229+
const summary = buildTurnSummary(turns, maxChars);
1230+
expect(summary.endsWith("...")).toBe(true);
1231+
expect(summary.length).toBe(maxChars);
1232+
});
1233+
1234+
test("a truncated lying spine aborts instead of shipping", async () => {
12151235
const maxChars = 20;
12161236
const compactor = createPruningCompactor({
12171237
keepRecentTurns: 1,
@@ -1220,28 +1240,20 @@ describe("buildTurnSummary via createPruningCompactor", () => {
12201240
const turns: ConversationTurn[] = [
12211241
makeTurn({
12221242
role: "user",
1223-
// Short tokens so the verify pass is vacuous; length still overflows.
1224-
content: [{ type: "text", text: "yes ".repeat(200) }],
1243+
content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }],
12251244
}),
12261245
makeTurn({
12271246
role: "assistant",
1228-
content: [{ type: "text", text: "ok ".repeat(200) }],
1247+
content: [
1248+
{ type: "text", text: "patch the refresh handler ".repeat(40) },
1249+
],
12291250
}),
12301251
makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }),
12311252
];
12321253

12331254
const result = await compactor.apply(turns, mockStrategyCtx);
1234-
const summaryBlock = defined(defined(result.output[0]).content[0]) as {
1235-
text: string;
1236-
};
1237-
// The summary portion of the block is extracted from after the header line.
1238-
// The header itself is "---..." so we look at the full block text — the
1239-
// embedded buildTurnSummary output must end with "..." when truncated.
1240-
expect(summaryBlock.text).toContain("...");
1241-
// And the truncated summary must not exceed maxChars + 3 (for the "..." suffix)
1242-
const summaryStart = summaryBlock.text.indexOf("[Compacted prior context]");
1243-
const rawSummary = summaryBlock.text.slice(summaryStart);
1244-
// The raw summary lines are bounded by maxChars
1245-
expect(rawSummary.length).toBeLessThan(maxChars + 200); // header text + bounded summary
1255+
expect(result.output).toBe(turns);
1256+
expect(result.record.reason).toBe("verify failed — keeping prior context");
1257+
expect(result.record.decisions).toMatchObject({ verifyAborted: 1 });
12461258
});
12471259
});

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { describe, test, expect } from "bun:test";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
25
import { createPruningCompactor } from "./compactor.js";
36
import { condenseTurns } from "./summarizer.js";
7+
import {
8+
createCompactionArchive,
9+
wrapCompactorWithCompletenessGate,
10+
} from "./compaction-archive.js";
411
import {
512
extractContinuationFacts,
613
repairSummary,
@@ -221,6 +228,37 @@ describe("verifyOrRepair", () => {
221228
);
222229
expect(repaired).toContain(VERIFY_REPAIR_HEADING);
223230
});
231+
232+
test("repair lines are goal, next, exact names, then the rest", () => {
233+
const facts = extractContinuationFacts(droppedTurns());
234+
const misses = verifyCompactionSummary("Work continues.", facts).misses;
235+
const repaired = repairSummary("Work continues.", facts, misses);
236+
const goalAt = repaired.indexOf("Goal:");
237+
const nextAt = repaired.indexOf("Next:");
238+
const exactAt = repaired.indexOf("Exact references:");
239+
const blockersAt = repaired.indexOf("Open blockers:");
240+
const ranAt = repaired.indexOf("Ran:");
241+
expect(goalAt).toBeGreaterThan(-1);
242+
expect(nextAt).toBeGreaterThan(goalAt);
243+
expect(exactAt).toBeGreaterThan(nextAt);
244+
expect(blockersAt).toBeGreaterThan(exactAt);
245+
expect(ranAt).toBeGreaterThan(blockersAt);
246+
});
247+
248+
test("a tight cap keeps goal, next, and names before aborting on the tail", () => {
249+
const facts = extractContinuationFacts(droppedTurns());
250+
const misses = verifyCompactionSummary("Work continues.", facts).misses;
251+
const full = repairSummary("Work continues.", facts, misses);
252+
const cap = full.indexOf("Open blockers:");
253+
expect(cap).toBeGreaterThan(0);
254+
const outcome = verifyOrRepair("Work continues.", facts, cap);
255+
expect(outcome.aborted).toBe(true);
256+
const kinds = outcome.misses.map((m) => m.kind);
257+
expect(kinds).not.toContain("goal");
258+
expect(kinds).not.toContain("nextAction");
259+
expect(kinds).not.toContain("exactName");
260+
expect(kinds).toContain("verification");
261+
});
224262
});
225263

226264
describe("pruning compactor verify pass", () => {
@@ -309,6 +347,104 @@ describe("continuation facts survive many folds", () => {
309347
});
310348
});
311349

350+
describe("completeness gate plus verify repair", () => {
351+
function memoryArchive() {
352+
const dir = fs.mkdtempSync(
353+
path.join(os.tmpdir(), "compaction-verify-gate-"),
354+
);
355+
const blobs = new Map<string, Uint8Array>();
356+
const archive = createCompactionArchive({
357+
sessionId: "sess-verify-gate",
358+
contextDir: dir,
359+
writeBlob: async (key, bytes) => {
360+
blobs.set(key, bytes);
361+
},
362+
readBlob: async (key) => {
363+
const bytes = blobs.get(key);
364+
if (bytes === undefined) throw new Error(`missing ${key}`);
365+
return bytes;
366+
},
367+
});
368+
return archive;
369+
}
370+
371+
async function archiveTurns(
372+
archive: ReturnType<typeof createCompactionArchive>,
373+
turns: readonly ConversationTurn[],
374+
): Promise<void> {
375+
for (const turn of turns) {
376+
for (const block of turn.content) {
377+
if (block.type === "text" && block.text.length > 0) {
378+
await archive.recordAuthorizedPayload({
379+
kind: turn.role === "assistant" ? "assistant_text" : "user_message",
380+
payload: block.text,
381+
});
382+
} else if (block.type === "tool_call") {
383+
await archive.recordAuthorizedPayload({
384+
kind: "tool_args",
385+
payload: { name: block.name, arguments: block.arguments },
386+
callId: block.id,
387+
});
388+
} else if (block.type === "tool_result") {
389+
const text = block.content
390+
.flatMap((c) => (c.type === "text" ? [c.text] : []))
391+
.join("");
392+
await archive.recordAuthorizedPayload({
393+
kind: "tool_result",
394+
payload: text,
395+
callId: block.callId,
396+
});
397+
}
398+
}
399+
}
400+
}
401+
402+
test("two lossy folds through the gate keep facts via adopted handoffs", async () => {
403+
const archive = memoryArchive();
404+
const inner = createPruningCompactor({
405+
keepRecentTurns: 2,
406+
summaryMaxChars: 4000,
407+
summarize: async () => "Work continues. Next: fix tests.",
408+
});
409+
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
410+
let turns: ConversationTurn[] = [
411+
...droppedTurns(),
412+
textTurn("user", "recent ask"),
413+
textTurn("assistant", "recent reply"),
414+
];
415+
await archiveTurns(archive, turns);
416+
417+
const first = await wrapped.apply(turns, mockStrategyCtx);
418+
expect(first.record.reason).not.toBe("incomplete-evidence-archive");
419+
expect(first.record.reason).not.toBe(
420+
"verify failed — keeping prior context",
421+
);
422+
expect(first.record.decisions).toMatchObject({ verifyRepaired: 1 });
423+
const handoffs = (await archive.listOccurrences()).filter(
424+
(occurrence) => occurrence.provenance === "compaction-handoff",
425+
);
426+
expect(handoffs.length).toBeGreaterThan(0);
427+
expect(allText(first.output)).toContain("opaque tokens");
428+
expect(allText(first.output)).toContain("auth.ts");
429+
430+
const followUp = [
431+
textTurn("user", "follow-up after first fold"),
432+
textTurn("assistant", "progress note after first fold"),
433+
];
434+
await archiveTurns(archive, followUp);
435+
turns = [...first.output, ...followUp];
436+
437+
const second = await wrapped.apply(turns, mockStrategyCtx);
438+
expect(second.record.reason).not.toBe("incomplete-evidence-archive");
439+
expect(second.record.reason).not.toBe(
440+
"verify failed — keeping prior context",
441+
);
442+
expect(allText(second.output)).toContain("opaque tokens");
443+
expect(allText(second.output)).toContain("auth.ts");
444+
expect(allText(second.output)).toContain("refresh assertion");
445+
});
446+
});
447+
312448
describe("condenseTurns keep-set", () => {
313449
test("pins the standing goal ahead of the recency window", () => {
314450
const turns: ConversationTurn[] = [

‎src/session/compaction-verify.ts‎

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -160,22 +160,13 @@ function tokensSupported(fact: string, summary: string): boolean {
160160
return hits >= needed;
161161
}
162162

163-
function userTexts(turns: readonly ConversationTurn[]): string[] {
164-
const out: string[] = [];
165-
for (const turn of turns) {
166-
if (turn.role !== "user") continue;
167-
for (const block of turn.content) {
168-
if (block.type === "text" && block.text.trim().length > 0)
169-
out.push(block.text);
170-
}
171-
}
172-
return out;
173-
}
174-
175-
function assistantTexts(turns: readonly ConversationTurn[]): string[] {
163+
function textsForRole(
164+
turns: readonly ConversationTurn[],
165+
role: "user" | "assistant",
166+
): string[] {
176167
const out: string[] = [];
177168
for (const turn of turns) {
178-
if (turn.role !== "assistant") continue;
169+
if (turn.role !== role) continue;
179170
for (const block of turn.content) {
180171
if (block.type === "text" && block.text.trim().length > 0)
181172
out.push(block.text);
@@ -254,8 +245,8 @@ function hostnameOf(url: string): string | undefined {
254245
export function extractContinuationFacts(
255246
turns: readonly ConversationTurn[],
256247
): ContinuationFacts {
257-
const users = userTexts(turns);
258-
const assistants = assistantTexts(turns);
248+
const users = textsForRole(turns, "user");
249+
const assistants = textsForRole(turns, "assistant");
259250
const calls = toolCallArgs(turns);
260251
const blockers = erroredResultTexts(turns);
261252

@@ -394,6 +385,14 @@ export function repairSummary(
394385
if (goalMiss !== undefined) lines.push(`Goal: ${facts.goal}`);
395386
const nextMiss = misses.find((m) => m.kind === "nextAction");
396387
if (nextMiss !== undefined) lines.push(`Next: ${facts.nextAction}`);
388+
// Critical-first: goal, next action, exact names, then the rest. A cap
389+
// that slices the tail still prefers the facts the next agent needs.
390+
if (kinds.has("exactName")) {
391+
const missing = misses
392+
.filter((m) => m.kind === "exactName")
393+
.map((m) => m.detail);
394+
lines.push(`Exact references: ${missing.join(", ")}`);
395+
}
397396
// The state line carries where the work stood when it is neither the next
398397
// action nor an already-listed blocker.
399398
const stateCovered =
@@ -412,12 +411,6 @@ export function repairSummary(
412411
`Open blockers:\n${facts.blockers.map((b) => `- ${b}`).join("\n")}`,
413412
);
414413
}
415-
if (kinds.has("exactName")) {
416-
const missing = misses
417-
.filter((m) => m.kind === "exactName")
418-
.map((m) => m.detail);
419-
lines.push(`Exact references: ${missing.join(", ")}`);
420-
}
421414
if (kinds.has("verification")) {
422415
lines.push(`Ran: ${facts.verification.join("; ")}`);
423416
}

0 commit comments

Comments
 (0)