Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions src/context-compactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -982,7 +982,17 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
mockStrategyCtx,
);
expect(compactedTurns(result2.output)).toHaveLength(1);
expect(allText(result2.output)).toContain("UNIQUE_SUCCESS_SUMMARY");
// CL-8744: the narrative lives in the fat handoff file, not the prompt.
// The live output carries only the thin spine plus its pointer.
expect(allText(result2.output)).not.toContain("UNIQUE_SUCCESS_SUMMARY");
const handoffBlob = defined(defined(result2.blobs)[0]);
expect(handoffBlob.contentType).toBe("text/markdown");
expect(new TextDecoder().decode(handoffBlob.bytes)).toContain(
"UNIQUE_SUCCESS_SUMMARY",
);
expect(allText(result2.output)).toContain(
`Handoff: tool-output:///${handoffBlob.key}`,
);
expect(hasConsecutiveSameRole(result2.output)).toBe(false);
});
});
Expand Down Expand Up @@ -1204,11 +1214,24 @@ describe("buildTurnSummary via createPruningCompactor", () => {
];

const result = await compactor.apply(turns, mockStrategyCtx);
const summaryText = (
const spineText = (
defined(defined(result.output[0]).content[0]) as { text: string }
).text;
expect(summaryText).toContain("read_file");
expect(summaryText).toContain("Total tool calls: 1");
// CL-8744: the live output carries only the thin spine (goal one-liner,
// evidence echo, explicit pointer) — file lists and counts stay in the
// fat handoff file, where they cannot make the next spine novel.
expect(spineText).toContain("[Compacted prior context]");
expect(spineText).toContain("Handoff: tool-output:///");
expect(spineText).not.toContain("src/foo.ts");
// The structured tool memory lives in the fat handoff file.
const file = new TextDecoder().decode(
defined(defined(result.blobs)[0]).bytes,
);
expect(file).toContain("src/foo.ts");
expect(file).toContain("paths: src/foo.ts");
expect(file).toContain("turns: 2, tool calls: 1");
expect(file).toContain("read_file");
expect(file).toContain("Total tool calls: 1");
});

test("truncates summary when it exceeds maxChars", async () => {
Expand All @@ -1230,17 +1253,17 @@ describe("buildTurnSummary via createPruningCompactor", () => {
];

const result = await compactor.apply(turns, mockStrategyCtx);
const summaryBlock = defined(defined(result.output[0]).content[0]) as {
text: string;
};
// The summary portion of the block is extracted from after the header line.
// The header itself is "---..." so we look at the full block text — the
// embedded buildTurnSummary output must end with "..." when truncated.
expect(summaryBlock.text).toContain("...");
// And the truncated summary must not exceed maxChars + 3 (for the "..." suffix)
const summaryStart = summaryBlock.text.indexOf("[Compacted prior context]");
const rawSummary = summaryBlock.text.slice(summaryStart);
// The raw summary lines are bounded by maxChars
expect(rawSummary.length).toBeLessThan(maxChars + 200); // header text + bounded summary
// CL-8744: the deterministic narrative lives in the fat handoff file's
// Summary section; the live output carries only the thin spine.
const file = new TextDecoder().decode(
defined(defined(result.blobs)[0]).bytes,
);
const narrative = defined(
file.split("## Summary (this fold — may paraphrase)\n")[1],
).split("## Exact facts")[0];
// The embedded buildTurnSummary output ends with "..." when truncated...
expect(narrative).toContain("...");
// ...and the truncated narrative stays within maxChars + 3 ("..." suffix).
expect(defined(narrative?.trim()).length).toBeLessThanOrEqual(maxChars + 3);
});
});
5 changes: 5 additions & 0 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ import {
resolveLiveSessionSources,
} from "../session/assemble-runtime.js";
import type { CompactionArchive } from "../session/compaction-archive.js";
import { tryReadPriorHandoffFile } from "../session/compaction-handoff.js";
import { emitPluginWarningSummary } from "../plugins/diagnostics.js";
import { createModelSummarizer } from "../session/summarizer.js";
import { ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js";
Expand Down Expand Up @@ -848,6 +849,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
const tools = activatedToolNames.list();
return tools.length > 0 ? { activatedTools: tools } : undefined;
},
readPriorHandoff: () =>
tryReadPriorHandoffFile(
currentStorage?.readBlob.bind(currentStorage),
),
telemetry: liveTelemetry,
onFolded: () => {
// Fold restarts the cached prefix, so catch promotions still
Expand Down
146 changes: 146 additions & 0 deletions src/session/compaction-archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -998,4 +998,150 @@ describe("wrapCompactorWithCompletenessGate", () => {
expect(result.output).toHaveLength(1);
expect(result.output[0]).not.toBe(turns[1]);
});

test("second fold that grows the spine still compact-succeeds by adopting the prior handoff", async () => {
const { wrapCompactorWithCompletenessGate } =
await import("./compaction-archive.js");
const { createPruningCompactor } = await import("./compactor.js");
const { COMPACTED_PREFIX, buildHandoffFold } =
await import("./compaction-handoff.js");
const { archive } = memoryArchive();

const first = buildHandoffFold(
[
{
role: "user",
content: [{ type: "text", text: "Ship the widget." }],
timestamp: 1,
},
],
"first narrative",
);
const priorSpine = first.spineText;
await archive.recordAuthorizedPayload({
kind: "user_message",
payload: "Must never write to /tmp. [[evidence:decision|op|no-tmp]]",
});
await archive.recordAuthorizedPayload({
kind: "assistant_text",
payload: "ok",
});
await archive.recordAuthorizedPayload({
kind: "assistant_text",
payload: "working",
});

const inner = createPruningCompactor({
keepRecentTurns: 2,
maxAnchorTurns: 0,
summaryMaxChars: 500,
});
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
const turns: import("@intx/types/runtime").ConversationTurn[] = [
{
role: "user",
content: [{ type: "text", text: priorSpine }],
timestamp: 1,
},
{
role: "user",
content: [{ type: "text", text: "Continue the widget." }],
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "working" }],
timestamp: 3,
},
{
role: "user",
content: [
{
type: "text",
text: "Must never write to /tmp. [[evidence:decision|op|no-tmp]]",
},
],
timestamp: 4,
},
{
role: "assistant",
content: [{ type: "text", text: "ok" }],
timestamp: 5,
},
{
role: "user",
content: [{ type: "text", text: "keep one" }],
timestamp: 6,
},
{
role: "assistant",
content: [{ type: "text", text: "keep two" }],
timestamp: 7,
},
];
const result = await wrapped.apply(turns, ctx);
expect(result.record.reason).not.toBe("incomplete-evidence-archive");
const spine = result.output[0]?.content.find((b) => b.type === "text");
expect(spine?.type).toBe("text");
if (spine?.type !== "text") throw new Error("unreachable");
expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true);
expect(spine.text).toContain("Must never write to /tmp.");
expect(spine.text).toContain("[[evidence:decision|op|no-tmp]]");
});

test("pre-format fat Compacted prior context summaries still compact", async () => {
const { wrapCompactorWithCompletenessGate } =
await import("./compaction-archive.js");
const { createPruningCompactor } = await import("./compactor.js");
const { COMPACTED_PREFIX } = await import("./compaction-handoff.js");
const { archive } = memoryArchive();
const fatSummary = `${COMPACTED_PREFIX}\nLong pre-format narrative about the auth migration and every file that was touched.`;
await archive.recordAuthorizedPayload({
kind: "user_message",
payload: "next ask after the old summary",
});
await archive.recordAuthorizedPayload({
kind: "assistant_text",
payload: "working",
});
const inner = createPruningCompactor({
keepRecentTurns: 2,
maxAnchorTurns: 0,
summaryMaxChars: 500,
});
const wrapped = wrapCompactorWithCompletenessGate(inner, archive);
const turns: import("@intx/types/runtime").ConversationTurn[] = [
{
role: "user",
content: [{ type: "text", text: fatSummary }],
timestamp: 1,
},
{
role: "user",
content: [{ type: "text", text: "next ask after the old summary" }],
timestamp: 2,
},
{
role: "assistant",
content: [{ type: "text", text: "working" }],
timestamp: 3,
},
{
role: "user",
content: [{ type: "text", text: "keep one" }],
timestamp: 4,
},
{
role: "assistant",
content: [{ type: "text", text: "keep two" }],
timestamp: 5,
},
];
const result = await wrapped.apply(turns, ctx);
expect(result.record.reason).not.toBe("incomplete-evidence-archive");
const spine = result.output[0]?.content.find((b) => b.type === "text");
expect(spine?.type).toBe("text");
if (spine?.type !== "text") throw new Error("unreachable");
expect(spine.text.startsWith(COMPACTED_PREFIX)).toBe(true);
});
});
31 changes: 31 additions & 0 deletions src/session/compaction-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type ToolRecordingLifecycle,
} from "./compaction-archive-schema.js";
import { parseAgedImageMarker } from "./attachment-uri.js";
import { COMPACTED_PREFIX } from "./compaction-handoff.js";

const INDEX_DIR = "evidence-archive";
const INDEX_FILE = "index.jsonl";
Expand Down Expand Up @@ -892,9 +893,30 @@ function incompleteIdentity(inner: Compactor, turns: ConversationTurn[]) {
};
}

/**
* Archive a synthetic `[Compacted prior context]` turn as a user_message so
* the completeness gate can certify dropping it when the next spine grows.
*/
export async function recordAdoptedHandoff(
archive: CompactionArchive,
text: string,
): Promise<ArchiveOccurrence> {
return archive.recordAuthorizedPayload({
kind: "user_message",
payload: text,
provenance: "adopted-handoff",
});
}

function isSyntheticHandoffText(text: string): boolean {
return text.startsWith(COMPACTED_PREFIX);
}

/**
* Refuse a destructive compact when the evidence archive cannot certify the
* dropped prefix. Historical gap:true rows are not part of the expected set.
* Synthetic handoff spines (and pre-format fat summaries) are adopted into
* the archive as user_message so a later fold may change the live spine.
*/
export function wrapCompactorWithCompletenessGate(
inner: Compactor,
Expand All @@ -908,6 +930,15 @@ export function wrapCompactorWithCompletenessGate(
const proposed = await inner.apply(turns, ctx);
const units = uncoveredContentUnits(turns, proposed.output);
if (units.length === 0) return proposed;
for (const unit of units) {
if (
unit.kind === "text" &&
unit.role === "user" &&
unit.text !== undefined &&
isSyntheticHandoffText(unit.text)
)
await recordAdoptedHandoff(archive, unit.text);
}
const occurrences = await archive.listOccurrences();
const covering = coveringOccurrence(
units,
Expand Down
Loading
Loading