Skip to content

Commit eb6f353

Browse files
committed
feat(session): verify compaction spine and record adopted handoffs
A repaired summary spine changes bytes, so the evidence gate reads the next fold's dropped spine as uncovered and rejects every later fold. Score the spine against continuation facts, repair or abort, and record each adopted handoff for the archive to certify.
1 parent fc5e047 commit eb6f353

6 files changed

Lines changed: 897 additions & 1 deletion

File tree

‎src/session/compaction-archive.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,35 @@ function incompleteIdentity(inner: Compactor, turns: ConversationTurn[]) {
895895
/**
896896
* Refuse a destructive compact when the evidence archive cannot certify the
897897
* dropped prefix. Historical gap:true rows are not part of the expected set.
898+
*
899+
* Persist the adopted handoff turns (the new summary spine) as archive
900+
* occurrences so a later fold can drop them. Output units already present
901+
* verbatim in the input need no recording; genuinely new user text — the
902+
* spine, coalesced turns — does, because it never passed through inbound
903+
* admission. Best effort: callers treat adoption as already certified.
898904
*/
905+
async function recordAdoptedHandoff(
906+
archive: CompactionArchive,
907+
input: readonly ConversationTurn[],
908+
output: readonly ConversationTurn[],
909+
): Promise<void> {
910+
const fresh = uncoveredContentUnits(output, input);
911+
for (const unit of fresh) {
912+
if (unit.kind !== "text" || unit.role !== "user") continue;
913+
const text = unit.text ?? "";
914+
if (text.length === 0) continue;
915+
try {
916+
await archive.recordAuthorizedPayload({
917+
kind: "user_message",
918+
payload: text,
919+
provenance: "compaction-handoff",
920+
});
921+
} catch {
922+
// Adoption stands; the next fold simply re-proves coverage another way.
923+
}
924+
}
925+
}
926+
899927
export function wrapCompactorWithCompletenessGate(
900928
inner: Compactor,
901929
archive: CompactionArchive,
@@ -921,6 +949,12 @@ export function wrapCompactorWithCompletenessGate(
921949
if (certificate.status !== "complete") {
922950
return incompleteIdentity(inner, turns);
923951
}
952+
// Record the adopted handoff. The next fold drops this spine, and the
953+
// archive can only certify bytes it has seen — the summary turn never
954+
// passes through inbound admission, so without this a repaired spine
955+
// (which the summarizer will not echo verbatim) reads as uncovered and
956+
// every later fold is rejected. Best effort: adoption is certified.
957+
await recordAdoptedHandoff(archive, turns, proposed.output);
924958
return proposed;
925959
},
926960
};
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { createPruningCompactor } from "./compactor.js";
3+
import { condenseTurns } from "./summarizer.js";
4+
import {
5+
extractContinuationFacts,
6+
repairSummary,
7+
verifyCompactionSummary,
8+
verifyOrRepair,
9+
VERIFY_REPAIR_HEADING,
10+
} from "./compaction-verify.js";
11+
import type {
12+
ConversationTurn,
13+
ReactorState,
14+
StrategyContext,
15+
} from "@intx/types/runtime";
16+
17+
const mockStrategyCtx: StrategyContext = {
18+
state: {} as ReactorState,
19+
trigger: "test",
20+
};
21+
22+
function textTurn(
23+
role: ConversationTurn["role"],
24+
text: string,
25+
extra: Partial<ConversationTurn> = {},
26+
): ConversationTurn {
27+
return {
28+
role,
29+
content: [{ type: "text", text }],
30+
timestamp: Date.now(),
31+
...extra,
32+
};
33+
}
34+
35+
function allText(turns: ConversationTurn[]): string {
36+
return turns
37+
.flatMap((t) =>
38+
t.content.filter((b) => b.type === "text").map((b) => b.text),
39+
)
40+
.join("\n");
41+
}
42+
43+
// A dropped region with a standing goal, an exact path, a verification
44+
// command, and an unresolved failure.
45+
function droppedTurns(): ConversationTurn[] {
46+
return [
47+
textTurn("user", "Migrate the auth module to opaque tokens in src/auth.ts"),
48+
textTurn("assistant", "Reading the handler first."),
49+
{
50+
role: "assistant",
51+
content: [
52+
{
53+
type: "tool_call",
54+
id: "c1",
55+
name: "read_file",
56+
arguments: { path: "src/auth.ts" },
57+
},
58+
],
59+
timestamp: 3,
60+
},
61+
{
62+
role: "user",
63+
content: [
64+
{
65+
type: "tool_result",
66+
callId: "c1",
67+
content: [{ type: "text", text: "handler source" }],
68+
},
69+
],
70+
timestamp: 4,
71+
},
72+
{
73+
role: "assistant",
74+
content: [
75+
{
76+
type: "tool_call",
77+
id: "c2",
78+
name: "run_shell",
79+
arguments: { command: "bun run test auth" },
80+
},
81+
],
82+
timestamp: 5,
83+
},
84+
{
85+
role: "user",
86+
content: [
87+
{
88+
type: "tool_result",
89+
callId: "c2",
90+
isError: true,
91+
content: [{ type: "text", text: "token refresh assertion failed" }],
92+
},
93+
],
94+
timestamp: 6,
95+
},
96+
textTurn("assistant", "Fix the token refresh assertion next."),
97+
];
98+
}
99+
100+
describe("extractContinuationFacts", () => {
101+
test("lifts goal, exact names, verification, and blockers", () => {
102+
const facts = extractContinuationFacts(droppedTurns());
103+
expect(facts.goal).toContain("opaque tokens");
104+
expect(facts.exactNames).toContain("src/auth.ts");
105+
expect(facts.verification).toContain("bun run test auth");
106+
expect(facts.blockers.join("\n")).toContain("refresh assertion failed");
107+
expect(facts.nextAction).toContain("refresh assertion next");
108+
});
109+
110+
test("empty turns yield vacuous facts, never an abort", () => {
111+
const facts = extractContinuationFacts([]);
112+
const report = verifyCompactionSummary("anything", facts);
113+
expect(report.supported).toBe(true);
114+
});
115+
});
116+
117+
describe("verifyCompactionSummary", () => {
118+
test("a faithful handoff is supported", () => {
119+
const facts = extractContinuationFacts(droppedTurns());
120+
const report = verifyCompactionSummary(
121+
"Migrating auth to opaque tokens. Read src/auth.ts, ran bun run test " +
122+
"auth; the token refresh assertion failed. Fix the token refresh " +
123+
"assertion next.",
124+
facts,
125+
);
126+
expect(report.supported).toBe(true);
127+
expect(report.misses).toEqual([]);
128+
});
129+
130+
test("a lossy handoff misses the goal and the exact name", () => {
131+
const facts = extractContinuationFacts(droppedTurns());
132+
const report = verifyCompactionSummary(
133+
"Work continues. Next: fix tests.",
134+
facts,
135+
);
136+
expect(report.supported).toBe(false);
137+
const kinds = report.misses.map((m) => m.kind);
138+
expect(kinds).toContain("goal");
139+
expect(kinds).toContain("exactName");
140+
});
141+
142+
test("denying failure while errors were dropped is a contradiction", () => {
143+
const facts = extractContinuationFacts(droppedTurns());
144+
const report = verifyCompactionSummary(
145+
"Auth migration done. No errors remain.",
146+
facts,
147+
);
148+
expect(report.misses.some((m) => m.kind === "contradiction")).toBe(true);
149+
});
150+
});
151+
152+
describe("verifyOrRepair", () => {
153+
test("repairs a lossy handoff instead of shipping it", () => {
154+
const facts = extractContinuationFacts(droppedTurns());
155+
const outcome = verifyOrRepair(
156+
"Work continues. Next: fix tests.",
157+
facts,
158+
4000,
159+
);
160+
expect(outcome.aborted).toBe(false);
161+
expect(outcome.repaired).toBe(true);
162+
expect(outcome.summary).toContain(VERIFY_REPAIR_HEADING);
163+
const rereport = verifyCompactionSummary(outcome.summary, facts);
164+
expect(rereport.misses.some((m) => m.kind === "goal")).toBe(false);
165+
expect(rereport.misses.some((m) => m.kind === "exactName")).toBe(false);
166+
});
167+
168+
test("aborts a contradicting handoff instead of shipping a lie", () => {
169+
const facts = extractContinuationFacts(droppedTurns());
170+
const outcome = verifyOrRepair(
171+
"Auth migration done. No errors remain.",
172+
facts,
173+
4000,
174+
);
175+
expect(outcome.aborted).toBe(true);
176+
expect(outcome.repaired).toBe(false);
177+
});
178+
179+
test("repairSummary names only what the handoff missed", () => {
180+
const facts = extractContinuationFacts(droppedTurns());
181+
const repaired = repairSummary(
182+
"Migrating auth to opaque tokens in src/auth.ts.",
183+
facts,
184+
verifyCompactionSummary(
185+
"Migrating auth to opaque tokens in src/auth.ts.",
186+
facts,
187+
).misses,
188+
);
189+
expect(repaired).toContain(VERIFY_REPAIR_HEADING);
190+
});
191+
});
192+
193+
describe("pruning compactor verify pass", () => {
194+
test("a lossy fold is repaired: the goal and exact path survive", async () => {
195+
const compactor = createPruningCompactor({
196+
keepRecentTurns: 2,
197+
summaryMaxChars: 2000,
198+
summarize: async () => "Work continues. Next: fix tests.",
199+
});
200+
const turns: ConversationTurn[] = [
201+
...droppedTurns(),
202+
textTurn("user", "recent ask"),
203+
textTurn("assistant", "recent reply"),
204+
];
205+
const result = await compactor.apply(turns, mockStrategyCtx);
206+
expect(allText(result.output)).toContain("opaque tokens");
207+
expect(allText(result.output)).toContain("auth.ts");
208+
expect(result.record.decisions).toMatchObject({ verifyRepaired: 1 });
209+
});
210+
211+
test("a contradicting fold aborts: prior context is kept", async () => {
212+
const compactor = createPruningCompactor({
213+
keepRecentTurns: 2,
214+
summaryMaxChars: 2000,
215+
summarize: async () => "Auth migration done. No errors remain.",
216+
});
217+
const turns: ConversationTurn[] = [
218+
...droppedTurns(),
219+
textTurn("user", "recent ask"),
220+
textTurn("assistant", "recent reply"),
221+
];
222+
const result = await compactor.apply(turns, mockStrategyCtx);
223+
expect(result.output).toBe(turns);
224+
expect(result.record.reason).toBe("verify failed — keeping prior context");
225+
expect(result.record.decisions).toMatchObject({ verifyAborted: 1 });
226+
});
227+
228+
test("a faithful fold ships without repair markers", async () => {
229+
const compactor = createPruningCompactor({
230+
keepRecentTurns: 2,
231+
summaryMaxChars: 2000,
232+
summarize: async () =>
233+
"Migrating auth to opaque tokens. Read src/auth.ts, ran bun run " +
234+
"test auth; the token refresh assertion failed. Fix the token " +
235+
"refresh assertion next.",
236+
});
237+
const turns: ConversationTurn[] = [
238+
...droppedTurns(),
239+
textTurn("user", "recent ask"),
240+
textTurn("assistant", "recent reply"),
241+
];
242+
const result = await compactor.apply(turns, mockStrategyCtx);
243+
expect(allText(result.output)).not.toContain(VERIFY_REPAIR_HEADING);
244+
expect(result.record.decisions).not.toMatchObject({ verifyRepaired: 1 });
245+
});
246+
});
247+
248+
describe("continuation facts survive many folds", () => {
249+
test("goal, exact path, and next action hold after five lossy folds", async () => {
250+
const compactor = createPruningCompactor({
251+
keepRecentTurns: 2,
252+
summaryMaxChars: 4000,
253+
summarize: async () => "Work continues. Next: fix tests.",
254+
});
255+
let turns: ConversationTurn[] = [
256+
...droppedTurns(),
257+
textTurn("user", "recent ask"),
258+
textTurn("assistant", "recent reply"),
259+
];
260+
for (let fold = 0; fold < 5; fold++) {
261+
const result = await compactor.apply(turns, mockStrategyCtx);
262+
// No fold may abort the eval: the lossy stub is repaired, not denied.
263+
expect(result.record.reason).not.toBe(
264+
"verify failed — keeping prior context",
265+
);
266+
turns = [
267+
...result.output,
268+
textTurn("user", `follow-up ${fold}`),
269+
textTurn("assistant", `progress note ${fold}`),
270+
];
271+
}
272+
const text = allText(turns);
273+
expect(text).toContain("opaque tokens");
274+
expect(text).toContain("auth.ts");
275+
expect(text).toContain("refresh assertion");
276+
});
277+
});
278+
279+
describe("condenseTurns keep-set", () => {
280+
test("pins the standing goal ahead of the recency window", () => {
281+
const turns: ConversationTurn[] = [
282+
textTurn("user", "Standing goal: migrate auth to opaque tokens"),
283+
...Array.from({ length: 8 }, (_, i) =>
284+
textTurn("user", `follow-up dump number ${i}`),
285+
),
286+
];
287+
const condensed = condenseTurns(turns);
288+
expect(condensed).toContain("opaque tokens");
289+
expect(condensed).toContain("Goal (first user message)");
290+
});
291+
});

0 commit comments

Comments
 (0)