diff --git a/scripts/check-dead-exports.test.ts b/scripts/check-dead-exports.test.ts index 348f4b53d..fd51ecb58 100644 --- a/scripts/check-dead-exports.test.ts +++ b/scripts/check-dead-exports.test.ts @@ -20,6 +20,7 @@ import { parseAllowlistText, parseGuardConfig, parseTsPruneLine, + tsPruneSpawn, validateAllowlistEntry, validateAllowlistOwnership, validateAllowlistText, @@ -247,6 +248,19 @@ describe("pinned scan invocation", () => { expect(existsSync(join(repoRoot, config.tsconfig))).toBe(true); }); + test("ts-prune is launched with node, not as a Bun-executed bin", () => { + const spawn = tsPruneSpawn("/repo/node_modules/.bin/ts-prune", [ + "-p", + "tsconfig.json", + ]); + expect(spawn.command).toBe("node"); + expect(spawn.args).toEqual([ + "/repo/node_modules/.bin/ts-prune", + "-p", + "tsconfig.json", + ]); + }); + test("parseGuardConfig rejects an unpinned or empty invocation", () => { const valid = { tsconfig: "tsconfig.json", diff --git a/scripts/check-dead-exports.ts b/scripts/check-dead-exports.ts index 5d212fbc7..6798b0d44 100644 --- a/scripts/check-dead-exports.ts +++ b/scripts/check-dead-exports.ts @@ -272,6 +272,18 @@ export function isGuardPassing(outcome: GuardOutcome): boolean { return outcome.violations.length === 0 && outcome.unused.length === 0; } +// ts-prune's analyzer (ts-morph) under-reports unused exports when the CLI +// runs on Bun: Linux CI then treats the Darwin/Node allowlist as stale +// (1 consumer-less export vs ~230). The bin shebang is `node`, but Bun's +// spawn of that file still executes it with Bun. Always launch the CLI +// with node so the gate matches `node node_modules/ts-prune/lib/index.js`. +export function tsPruneSpawn( + tsPruneBinPath: string, + tsPruneArgs: readonly string[], +): { readonly command: string; readonly args: string[] } { + return { command: "node", args: [tsPruneBinPath, ...tsPruneArgs] }; +} + // Counts the TypeScript files the pinned tsconfig pulls into its program via // tsc --listFilesOnly: the same project ts-prune analyzes. A narrowed // tsconfig (or a moved scan root) shrinks this count, and the gate fails @@ -329,10 +341,16 @@ function main(): void { ); } const rules = parseAllowlistText(allowlistText); - const pruned = spawnSync(tsPruneBin, [...config.tsPruneArgs], { + const prune = tsPruneSpawn(tsPruneBin, config.tsPruneArgs); + const pruned = spawnSync(prune.command, prune.args, { cwd: repoRoot, encoding: "utf8", }); + if (pruned.error !== undefined) { + fail( + `ts-prune failed to start with ${prune.command}: ${pruned.error.message}`, + ); + } if (pruned.status !== 0) { fail(`ts-prune failed:\n${pruned.stderr || pruned.stdout}`); } diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 757656369..00ca2486b 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -7,6 +7,7 @@ import { formatPlan, classifyTaskBoundary, buildLLMTurnSummary, + buildTurnSummary, COMPACTED_PREFIX, COMPACT_SPACER_TEXT, LEGACY_COMPACT_SPACER_TEXT, @@ -1234,7 +1235,26 @@ describe("buildTurnSummary via createPruningCompactor", () => { expect(file).toContain("Total tool calls: 1"); }); - test("truncates summary when it exceeds maxChars", async () => { + test("buildTurnSummary truncates with ellipsis when over maxChars", () => { + const maxChars = 20; + const turns: ConversationTurn[] = [ + makeTurn({ + role: "user", + content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }], + }), + makeTurn({ + role: "assistant", + content: [ + { type: "text", text: "patch the refresh handler ".repeat(40) }, + ], + }), + ]; + const summary = buildTurnSummary(turns, maxChars); + expect(summary.endsWith("...")).toBe(true); + expect(summary.length).toBe(maxChars); + }); + + test("a truncated lying spine aborts instead of shipping", async () => { const maxChars = 20; const compactor = createPruningCompactor({ keepRecentTurns: 1, @@ -1243,27 +1263,20 @@ describe("buildTurnSummary via createPruningCompactor", () => { const turns: ConversationTurn[] = [ makeTurn({ role: "user", - content: [{ type: "text", text: "a".repeat(500) }], + content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }], }), makeTurn({ role: "assistant", - content: [{ type: "text", text: "b".repeat(500) }], + content: [ + { type: "text", text: "patch the refresh handler ".repeat(40) }, + ], }), makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }), ]; const result = await compactor.apply(turns, mockStrategyCtx); - // 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); + expect(result.output).toBe(turns); + expect(result.record.reason).toBe("verify failed — keeping prior context"); + expect(result.record.decisions).toMatchObject({ verifyAborted: 1 }); }); }); diff --git a/src/session/compaction-archive.ts b/src/session/compaction-archive.ts index 4e34ce5cf..00100d0ec 100644 --- a/src/session/compaction-archive.ts +++ b/src/session/compaction-archive.ts @@ -912,11 +912,41 @@ function isSyntheticHandoffText(text: string): boolean { return text.startsWith(COMPACTED_PREFIX); } +/** + * Persist genuinely new user text in the proposed output (the new spine) + * as archive occurrences so a later fold can drop them. Output units already + * present verbatim in the input need no recording; the spine never passed + * through inbound admission. Best effort: callers treat adoption as certified. + */ +async function recordFreshHandoffOutput( + archive: CompactionArchive, + input: readonly ConversationTurn[], + output: readonly ConversationTurn[], +): Promise { + const fresh = uncoveredContentUnits(output, input); + for (const unit of fresh) { + if (unit.kind !== "text" || unit.role !== "user") continue; + const text = unit.text ?? ""; + if (text.length === 0) continue; + try { + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: text, + provenance: "compaction-handoff", + }); + } catch { + // Adoption stands; the next fold simply re-proves coverage another way. + } + } +} + /** * 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. + * After the fold certifies, the new spine is recorded so the next fold can + * drop it even when the summarizer does not echo it verbatim. */ export function wrapCompactorWithCompletenessGate( inner: Compactor, @@ -952,6 +982,7 @@ export function wrapCompactorWithCompletenessGate( if (certificate.status !== "complete") { return incompleteIdentity(inner, turns); } + await recordFreshHandoffOutput(archive, turns, proposed.output); return proposed; }, }; diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts new file mode 100644 index 000000000..454f9a5f2 --- /dev/null +++ b/src/session/compaction-verify.test.ts @@ -0,0 +1,823 @@ +import { describe, test, expect } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createPruningCompactor } from "./compactor.js"; +import { condenseTurns } from "./summarizer.js"; +import { HANDOFF_LATEST_KEY } from "./compaction-handoff.js"; +import { + createCompactionArchive, + wrapCompactorWithCompletenessGate, +} from "./compaction-archive.js"; +import { + extractContinuationFacts, + repairSummary, + verifyCompactionSummary, + verifyOrRepair, + VERIFY_REPAIR_HEADING, +} from "./compaction-verify.js"; +import type { + ConversationTurn, + ReactorState, + StrategyContext, +} from "@intx/types/runtime"; + +const mockStrategyCtx: StrategyContext = { + state: {} as ReactorState, + trigger: "test", +}; + +function textTurn( + role: ConversationTurn["role"], + text: string, + extra: Partial = {}, +): ConversationTurn { + return { + role, + content: [{ type: "text", text }], + timestamp: Date.now(), + ...extra, + }; +} + +function allText(turns: ConversationTurn[]): string { + return turns + .flatMap((t) => + t.content.filter((b) => b.type === "text").map((b) => b.text), + ) + .join("\n"); +} + +function handoffFileText(result: { + blobs?: { key: string; bytes: Uint8Array }[]; +}): string { + const blob = result.blobs?.find((b) => b.key === HANDOFF_LATEST_KEY); + if (blob === undefined) return ""; + return new TextDecoder().decode(blob.bytes); +} + +// A dropped region with a standing goal, an exact path, a verification +// command, and an unresolved failure. +function droppedTurns(): ConversationTurn[] { + return [ + textTurn("user", "Migrate the auth module to opaque tokens in src/auth.ts"), + textTurn("assistant", "Reading the handler first."), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 3, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + content: [{ type: "text", text: "handler source" }], + }, + ], + timestamp: 4, + }, + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c2", + name: "run_shell", + arguments: { command: "bun run test auth" }, + }, + ], + timestamp: 5, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c2", + isError: true, + content: [{ type: "text", text: "token refresh assertion failed" }], + }, + ], + timestamp: 6, + }, + textTurn("assistant", "Fix the token refresh assertion next."), + ]; +} + +describe("extractContinuationFacts", () => { + test("lifts goal, exact names, verification, and blockers", () => { + const facts = extractContinuationFacts(droppedTurns()); + expect(facts.goal).toContain("opaque tokens"); + expect(facts.exactNames).toContain("src/auth.ts"); + expect(facts.verification).toContain("bun run test auth"); + expect(facts.blockers.join("\n")).toContain("refresh assertion failed"); + expect(facts.nextAction).toContain("refresh assertion next"); + }); + + test("empty turns yield vacuous facts, never an abort", () => { + const facts = extractContinuationFacts([]); + const report = verifyCompactionSummary("anything", facts); + expect(report.supported).toBe(true); + }); + + test("uppercase HTTP URL in user text is lifted into exactNames", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Call HTTP://API.COM next"), + ]); + expect(facts.exactNames).toContain("HTTP://API.COM"); + }); + + test("sentence-final period is not part of a user-text URL", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Call HTTP://API.COM."), + ]); + expect(facts.exactNames).toContain("HTTP://API.COM"); + expect(facts.exactNames).not.toContain("HTTP://API.COM."); + expect( + verifyCompactionSummary("Call api.com", facts).misses.some( + (m) => m.kind === "exactName", + ), + ).toBe(false); + }); + + test("comma glue is not part of a user-text URL", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Call HTTP://API.COM, then retry"), + ]); + expect(facts.exactNames).toContain("HTTP://API.COM"); + expect(facts.exactNames).not.toContain("HTTP://API.COM,"); + }); +}); + +describe("verifyCompactionSummary", () => { + test("a faithful handoff is supported", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "Migrating auth to opaque tokens. Read src/auth.ts, ran bun run test " + + "auth; the token refresh assertion failed. Fix the token refresh " + + "assertion next.", + facts, + ); + expect(report.supported).toBe(true); + expect(report.misses).toEqual([]); + }); + + test("a lossy handoff misses the goal and the exact name", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "Work continues. Next: fix tests.", + facts, + ); + expect(report.supported).toBe(false); + const kinds = report.misses.map((m) => m.kind); + expect(kinds).toContain("goal"); + expect(kinds).toContain("exactName"); + }); + + test("auth as a goal token does not match authored, author, preauth, or pre-auth", () => { + const facts = extractContinuationFacts([textTurn("user", "Fix auth now")]); + for (const summary of [ + "The authored notes: next step module plan is set.", + "The author notes: next step module plan is set.", + "The preauth notes: next step module plan is set.", + "The pre-auth notes: next step module plan is set.", + ]) { + const report = verifyCompactionSummary(summary, facts); + expect(report.supported).toBe(false); + expect(report.misses.some((m) => m.kind === "goal")).toBe(true); + } + }); + + test("trailing period on a goal needle does not miss an unpunctuated summary", () => { + const facts = extractContinuationFacts([textTurn("user", "Fix auth now.")]); + const report = verifyCompactionSummary("Fix auth now", facts); + expect(report.misses.some((m) => m.kind === "goal")).toBe(false); + }); + + test("trailing period on a constraint needle does not miss an unpunctuated summary", () => { + const facts = { + goal: "Fix auth now", + constraints: ["Never use emojis."], + nextAction: "", + verification: [], + blockers: [], + exactNames: [], + }; + const report = verifyCompactionSummary( + "Fix auth now. Never use emojis", + facts, + ); + expect(report.misses.some((m) => m.kind === "constraint")).toBe(false); + }); + + test("denying failure while errors were dropped is a contradiction", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "Auth migration done. No errors remain.", + facts, + ); + expect(report.misses.some((m) => m.kind === "contradiction")).toBe(true); + }); + + test("a half-overlap paraphrase does not cover the standing goal", () => { + const facts = extractContinuationFacts(droppedTurns()); + const report = verifyCompactionSummary( + "The module tokens migrate elsewhere on schedule.", + facts, + ); + expect(report.misses.some((m) => m.kind === "goal")).toBe(true); + }); + + test("oauth.ts does not cover src/auth.ts", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Read oauth.ts next.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === "src/auth.ts")).toBe(true); + }); + + test("tsconfig.json does not cover config.json", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/config.json" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Updated tsconfig.json.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === "src/config.json")).toBe( + true, + ); + }); + + test("myapi.com does not cover hostname api.com", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "web_fetch", + arguments: { url: "https://api.com/v1" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Called myapi.com.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === "https://api.com/v1")).toBe( + true, + ); + }); + + test("dotted names are whole tokens so suffixes and prefixes do not cover", () => { + const cases: { + tool: string; + args: Record; + summary: string; + detail: string; + }[] = [ + { + tool: "read_file", + args: { path: "src/config.ts" }, + summary: "Fix auth now. Updated vite.config.ts.", + detail: "src/config.ts", + }, + { + tool: "read_file", + args: { path: "src/test.ts" }, + summary: "Fix auth now. Updated auth.test.ts.", + detail: "src/test.ts", + }, + { + tool: "read_file", + args: { path: "src/auth.ts" }, + summary: "Fix auth now. Updated foo.auth.ts.", + detail: "src/auth.ts", + }, + { + tool: "read_file", + args: { path: "src/auth.ts" }, + summary: "Fix auth now. Kept auth.ts.bak.", + detail: "src/auth.ts", + }, + { + tool: "web_fetch", + args: { url: "https://api.com/v1" }, + summary: "Fix auth now. Called www.api.com.", + detail: "https://api.com/v1", + }, + ]; + for (const { tool, args, summary, detail } of cases) { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: tool, + arguments: args, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary(summary, facts); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(true); + expect(report.misses.some((m) => m.detail === detail)).toBe(true); + } + }); + + test("basename in a path still covers the exact name", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Read auth.ts next.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(false); + }); + + test("http/client.ts is covered by basename client.ts", () => { + for (const filePath of ["http/client.ts", "HTTP/Client.ts"]) { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: filePath }, + }, + ], + timestamp: 2, + }, + ]); + const report = verifyCompactionSummary( + "Fix auth now. Read client.ts next.", + facts, + ); + expect(report.misses.some((m) => m.kind === "exactName")).toBe(false); + } + }); + + test("uppercase HTTP URL scores hostname case-insensitively", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "web_fetch", + arguments: { url: "HTTP://API.COM/v1" }, + }, + ], + timestamp: 2, + }, + ]); + expect( + verifyCompactionSummary( + "Fix auth now. Called api.com.", + facts, + ).misses.some((m) => m.kind === "exactName"), + ).toBe(false); + expect( + verifyCompactionSummary( + "Fix auth now. Hit endpoint v1.", + facts, + ).misses.some((m) => m.kind === "exactName"), + ).toBe(true); + }); +}); + +describe("verifyOrRepair", () => { + test("repairs a lossy handoff instead of shipping it", () => { + const facts = extractContinuationFacts(droppedTurns()); + const outcome = verifyOrRepair( + "Work continues. Next: fix tests.", + facts, + 4000, + ); + expect(outcome.aborted).toBe(false); + expect(outcome.repaired).toBe(true); + expect(outcome.summary).toContain(VERIFY_REPAIR_HEADING); + const rereport = verifyCompactionSummary(outcome.summary, facts); + expect(rereport.misses.some((m) => m.kind === "goal")).toBe(false); + expect(rereport.misses.some((m) => m.kind === "exactName")).toBe(false); + }); + + test("aborts a contradicting handoff instead of shipping a lie", () => { + const facts = extractContinuationFacts(droppedTurns()); + const outcome = verifyOrRepair( + "Auth migration done. No errors remain.", + facts, + 4000, + ); + expect(outcome.aborted).toBe(true); + expect(outcome.repaired).toBe(false); + }); + + test("truncating repair that still misses exactName aborts", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/very-long-unique-path/exact-file.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const outcome = verifyOrRepair("Fix auth now. Work continues.", facts, 90); + expect(outcome.aborted).toBe(true); + expect(outcome.repaired).toBe(false); + expect(outcome.misses.some((m) => m.kind === "exactName")).toBe(true); + }); + + test("a roomy cap repairs and keeps the exact-name basename", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/very-long-unique-path/exact-file.ts" }, + }, + ], + timestamp: 2, + }, + ]); + const outcome = verifyOrRepair( + "Fix auth now. Work continues.", + facts, + 4000, + ); + expect(outcome.aborted).toBe(false); + expect(outcome.repaired).toBe(true); + expect(outcome.summary).toContain("exact-file.ts"); + }); + + test("a constraint-only residual after a sliced repair aborts", () => { + const facts = extractContinuationFacts([ + textTurn("user", "Fix auth now"), + textTurn("assistant", "Working on auth now."), + textTurn("user", "Do not commit generated artifacts ever."), + textTurn("assistant", "Working on auth now."), + ]); + expect( + facts.constraints.some((c) => c.includes("generated artifacts")), + ).toBe(true); + const summary = "Fix auth now. Working on auth now."; + const misses = verifyCompactionSummary(summary, facts).misses; + expect(misses.map((m) => m.kind)).toEqual(["constraint"]); + const full = repairSummary(summary, facts, misses); + const cap = full.indexOf("Constraints:"); + expect(cap).toBeGreaterThan(0); + const outcome = verifyOrRepair(summary, facts, cap); + expect(outcome.aborted).toBe(true); + expect(outcome.repaired).toBe(false); + expect(outcome.misses.some((m) => m.kind === "constraint")).toBe(true); + }); + + test("repairSummary names only what the handoff missed", () => { + const facts = { + goal: "Migrate the auth module to opaque tokens", + constraints: [ + "Never use emojis in the handoff.", + "Always keep the public API stable.", + ], + nextAction: "Fix the token refresh assertion next.", + verification: ["bun run test auth", "bun run check"], + blockers: ["token refresh assertion failed", "ECONNREFUSED on staging"], + exactNames: ["src/auth.ts", "packages/runtime/config.json"], + }; + const summary = + "Migrating auth to opaque tokens. Read src/auth.ts, ran bun run test " + + "auth; the token refresh assertion failed. Never use emojis in the " + + "handoff. Fix the token refresh assertion next."; + const misses = verifyCompactionSummary(summary, facts).misses; + const kinds = misses.map((m) => m.kind); + expect(kinds).toContain("constraint"); + expect(kinds).toContain("blocker"); + expect(kinds).toContain("verification"); + expect(kinds).toContain("exactName"); + expect(kinds).not.toContain("goal"); + expect(kinds).not.toContain("nextAction"); + const repaired = repairSummary(summary, facts, misses); + const repair = repaired.slice(repaired.indexOf(VERIFY_REPAIR_HEADING)); + expect(repair).toContain("Always keep the public API stable."); + expect(repair).not.toContain("Never use emojis"); + expect(repair).toContain("ECONNREFUSED on staging"); + expect(repair).not.toContain("token refresh assertion failed"); + expect(repair).toContain("bun run check"); + expect(repair).not.toContain("bun run test auth"); + expect(repair).toContain("packages/runtime/config.json"); + expect(repair).not.toContain("src/auth.ts"); + expect(repair).not.toContain("Goal:"); + expect(repair).not.toContain("Next:"); + }); + + test("repair lines are goal, next, exact names, then the rest", () => { + const facts = extractContinuationFacts(droppedTurns()); + const misses = verifyCompactionSummary("Work continues.", facts).misses; + const repaired = repairSummary("Work continues.", facts, misses); + const goalAt = repaired.indexOf("Goal:"); + const nextAt = repaired.indexOf("Next:"); + const exactAt = repaired.indexOf("Exact references:"); + const blockersAt = repaired.indexOf("Open blockers:"); + const ranAt = repaired.indexOf("Ran:"); + expect(goalAt).toBeGreaterThan(-1); + expect(nextAt).toBeGreaterThan(goalAt); + expect(exactAt).toBeGreaterThan(nextAt); + expect(blockersAt).toBeGreaterThan(exactAt); + expect(ranAt).toBeGreaterThan(blockersAt); + }); + + test("a tight cap keeps goal, next, and names before aborting on the tail", () => { + const facts = extractContinuationFacts(droppedTurns()); + const misses = verifyCompactionSummary("Work continues.", facts).misses; + const full = repairSummary("Work continues.", facts, misses); + const cap = full.indexOf("Open blockers:"); + expect(cap).toBeGreaterThan(0); + const outcome = verifyOrRepair("Work continues.", facts, cap); + expect(outcome.aborted).toBe(true); + const kinds = outcome.misses.map((m) => m.kind); + expect(kinds).not.toContain("goal"); + expect(kinds).not.toContain("nextAction"); + expect(kinds).not.toContain("exactName"); + expect(kinds).toContain("verification"); + }); +}); + +describe("pruning compactor verify pass", () => { + test("a lossy fold is repaired: the goal and exact path survive", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 2000, + summarize: async () => "Work continues. Next: fix tests.", + }); + const turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(allText(result.output)).toContain("opaque tokens"); + expect(allText(result.output)).toContain("auth.ts"); + expect(result.record.decisions).toMatchObject({ verifyRepaired: 1 }); + }); + + test("a contradicting fold aborts: prior context is kept", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 2000, + summarize: async () => "Auth migration done. No errors remain.", + }); + const turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(result.output).toBe(turns); + expect(result.record.reason).toBe("verify failed — keeping prior context"); + expect(result.record.decisions).toMatchObject({ verifyAborted: 1 }); + }); + + test("a faithful fold ships without repair markers", async () => { + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 2000, + summarize: async () => + "Migrating auth to opaque tokens. Read src/auth.ts, ran bun run " + + "test auth; the token refresh assertion failed. Fix the token " + + "refresh assertion next.", + }); + const turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const result = await compactor.apply(turns, mockStrategyCtx); + expect(allText(result.output)).not.toContain(VERIFY_REPAIR_HEADING); + expect(result.record.decisions).not.toMatchObject({ verifyRepaired: 1 }); + }); +}); + +describe("continuation facts survive many folds", () => { + test("verify signal holds after five lossy folds", async () => { + let priorFile: string | undefined; + const compactor = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + summarize: async () => "Work continues. Next: fix tests.", + readPriorHandoff: async () => priorFile, + }); + let turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + for (let fold = 0; fold < 5; fold++) { + const result = await compactor.apply(turns, mockStrategyCtx); + // No fold may abort the eval: the lossy stub is repaired, not denied. + expect(result.record.reason).not.toBe( + "verify failed — keeping prior context", + ); + const file = handoffFileText(result); + // The thin live spine does not carry next-action / blocker text; the + // fat handoff file does. Verify repair writes those into the narrative + // that the file persists, and later folds re-read it. + expect(file).toContain("refresh assertion"); + priorFile = file; + turns = [ + ...result.output, + textTurn("user", `follow-up ${fold}`), + textTurn("assistant", `progress note ${fold}`), + ]; + } + }); +}); + +describe("completeness gate plus verify repair", () => { + function memoryArchive() { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "compaction-verify-gate-"), + ); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "sess-verify-gate", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const bytes = blobs.get(key); + if (bytes === undefined) throw new Error(`missing ${key}`); + return bytes; + }, + }); + return archive; + } + + async function archiveTurns( + archive: ReturnType, + turns: readonly ConversationTurn[], + ): Promise { + for (const turn of turns) { + for (const block of turn.content) { + if (block.type === "text" && block.text.length > 0) { + await archive.recordAuthorizedPayload({ + kind: turn.role === "assistant" ? "assistant_text" : "user_message", + payload: block.text, + }); + } else if (block.type === "tool_call") { + await archive.recordAuthorizedPayload({ + kind: "tool_args", + payload: { name: block.name, arguments: block.arguments }, + callId: block.id, + }); + } else if (block.type === "tool_result") { + const text = block.content + .flatMap((c) => (c.type === "text" ? [c.text] : [])) + .join(""); + await archive.recordAuthorizedPayload({ + kind: "tool_result", + payload: text, + callId: block.callId, + }); + } + } + } + } + + test("two lossy folds through the gate keep facts via adopted handoffs", async () => { + const archive = memoryArchive(); + let priorFile: string | undefined; + const inner = createPruningCompactor({ + keepRecentTurns: 2, + summaryMaxChars: 4000, + summarize: async () => "Work continues. Next: fix tests.", + readPriorHandoff: async () => priorFile, + }); + const wrapped = wrapCompactorWithCompletenessGate(inner, archive); + let turns: ConversationTurn[] = [ + ...droppedTurns(), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + await archiveTurns(archive, turns); + + const first = await wrapped.apply(turns, mockStrategyCtx); + expect(first.record.reason).not.toBe("incomplete-evidence-archive"); + expect(first.record.reason).not.toBe( + "verify failed — keeping prior context", + ); + expect(first.record.decisions).toMatchObject({ verifyRepaired: 1 }); + const handoffs = (await archive.listOccurrences()).filter( + (occurrence) => occurrence.provenance === "compaction-handoff", + ); + expect(handoffs.length).toBeGreaterThan(0); + const firstFile = handoffFileText(first); + expect(firstFile).toContain("refresh assertion"); + priorFile = firstFile; + + const followUp = [ + textTurn("user", "follow-up after first fold"), + textTurn("assistant", "progress note after first fold"), + ]; + await archiveTurns(archive, followUp); + turns = [...first.output, ...followUp]; + + const second = await wrapped.apply(turns, mockStrategyCtx); + expect(second.record.reason).not.toBe("incomplete-evidence-archive"); + expect(second.record.reason).not.toBe( + "verify failed — keeping prior context", + ); + expect(handoffFileText(second)).toContain("refresh assertion"); + }); +}); + +describe("condenseTurns keep-set", () => { + test("pins the standing goal ahead of the recency window", () => { + const turns: ConversationTurn[] = [ + textTurn("user", "Standing goal: migrate auth to opaque tokens"), + ...Array.from({ length: 8 }, (_, i) => + textTurn("user", `follow-up dump number ${i}`), + ), + ]; + const condensed = condenseTurns(turns); + expect(condensed).toContain("opaque tokens"); + expect(condensed).toContain("Goal (first user message)"); + }); +}); diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts new file mode 100644 index 000000000..22f844e85 --- /dev/null +++ b/src/session/compaction-verify.ts @@ -0,0 +1,474 @@ +// Verify pass for compaction folds. +// +// After the compactor writes a summary handoff, this module scores the new +// spine against the continuation facts the dropped turns carried (goal, +// next action, constraints, verification, blockers, exact names). +// A fold that drops or contradicts those facts would leave the next agent +// without steam, so the pass repairs the handoff deterministically or aborts +// the fold. Fail closed: never ship a lying spine. + +import { type } from "arktype"; +import type { ConversationTurn } from "@intx/types/runtime"; + +export const ContinuationFacts = type({ + /** First user ask: the standing goal the folds must preserve. */ + goal: "string", + /** Constraint-like sentences lifted from user turns. */ + constraints: "string[]", + /** Last substantive assistant/user text in the dropped region. */ + nextAction: "string", + /** Verification commands run (shell/test invocations). */ + verification: "string[]", + /** Errored tool-result texts: what is still broken. */ + blockers: "string[]", + /** Exact file paths and URLs the next agent will need verbatim. */ + exactNames: "string[]", +}); +export type ContinuationFacts = typeof ContinuationFacts.infer; + +export type VerifyMissKind = + | "goal" + | "constraint" + | "nextAction" + | "exactName" + | "blocker" + | "verification" + | "contradiction"; + +export interface VerifyMiss { + kind: VerifyMissKind; + detail: string; +} + +export interface VerifyReport { + supported: boolean; + misses: VerifyMiss[]; +} + +export interface VerifyOutcome { + summary: string; + repaired: boolean; + aborted: boolean; + misses: VerifyMiss[]; +} + +export const VERIFY_REPAIR_HEADING = "## Carry-forward (verify repair)"; + +// Words that carry no identifying signal. Kept small on purpose: the scorer +// compares content words, and every extra stopword is a false mismatch. +const STOPWORDS = new Set([ + "the", + "a", + "an", + "and", + "or", + "but", + "for", + "with", + "from", + "that", + "this", + "these", + "those", + "are", + "was", + "were", + "been", + "have", + "has", + "had", + "will", + "would", + "should", + "could", + "what", + "when", + "where", + "which", + "while", + "after", + "before", + "into", + "over", + "under", + "about", + "your", + "you", + "our", + "its", + "also", + "just", + "still", + "even", + "then", + "than", + "such", + "more", + "most", + "some", + "any", + "all", + "each", + "both", + "only", +]); + +function significantTokens(text: string, cap = 24): string[] { + const out: string[] = []; + for (const raw of text.toLowerCase().split(/[^a-z0-9_./-]+/)) { + // `.` stays in the splitter so `vite.config.ts` is one token; a sentence + // period still glues to the last word (`now.`, `emojis.`). Strip it from + // the needle so an unpunctuated summary still covers the fact. + const word = raw.replace(/\.+$/, ""); + if (word.length < 4 || STOPWORDS.has(word)) continue; + if (!out.includes(word)) out.push(word); + if (out.length >= cap) break; + } + return out; +} + +// A strict majority of the fact's content words must appear in the summary. +// Short facts need all of their words: one shared word proves nothing. +// Match on token boundaries so "auth" does not score against "authored", +// hyphenated "pre-auth", or dotted "foo.auth". An interior `.` joins a +// dotted name; a sentence period does not. +function isNameChar(ch: string | undefined): boolean { + if (ch === undefined) return false; + const code = ch.charCodeAt(0); + return ( + (code >= 48 && code <= 57) || + (code >= 97 && code <= 122) || + code === 95 || + code === 45 + ); +} + +function isTokenCharAt(text: string, index: number): boolean { + if (index < 0 || index >= text.length) return false; + const ch = text[index]; + if (isNameChar(ch)) return true; + // `.` is a token char only between name chars: `vite.config.ts` stays + // one token, but `tokens.` / `api.com.` still match at a sentence stop. + return ( + ch === "." && isNameChar(text[index - 1]) && isNameChar(text[index + 1]) + ); +} + +function tokenAppears(token: string, lowered: string): boolean { + let from = 0; + for (;;) { + const i = lowered.indexOf(token, from); + if (i < 0) return false; + if ( + !isTokenCharAt(lowered, i - 1) && + !isTokenCharAt(lowered, i + token.length) + ) + return true; + from = i + 1; + } +} + +function tokensSupported(fact: string, summary: string): boolean { + const tokens = significantTokens(fact); + if (tokens.length === 0) return true; + const lowered = summary.toLowerCase(); + const hits = tokens.filter((t) => tokenAppears(t, lowered)).length; + const needed = + tokens.length <= 2 ? tokens.length : Math.floor(tokens.length / 2) + 1; + return hits >= needed; +} + +function textsForRole( + turns: readonly ConversationTurn[], + role: "user" | "assistant", +): string[] { + const out: string[] = []; + for (const turn of turns) { + if (turn.role !== role) continue; + for (const block of turn.content) { + if (block.type === "text" && block.text.trim().length > 0) + out.push(block.text); + } + } + return out; +} + +// Sentences that read like standing instructions rather than chat. +const CONSTRAINT_MARKERS = + /\b(must|must not|never|always|only|do not|don't|required|ensure|make sure|keep|without|no emojis?)\b/i; + +function extractConstraints(texts: readonly string[]): string[] { + const out: string[] = []; + for (const text of texts) { + for (const sentence of text.split(/(?<=[.!?\n])\s+/)) { + const trimmed = sentence.trim(); + if ( + trimmed.length >= 12 && + CONSTRAINT_MARKERS.test(trimmed) && + !out.includes(trimmed) + ) + out.push(trimmed.slice(0, 300)); + } + } + return out.slice(0, 8); +} + +function toolCallArgs( + turns: readonly ConversationTurn[], +): { name: string; args: Record }[] { + const out: { name: string; args: Record }[] = []; + for (const turn of turns) { + for (const block of turn.content) { + if (block.type !== "tool_call") continue; + let args: Record = {}; + if ( + block.arguments !== null && + typeof block.arguments === "object" && + !Array.isArray(block.arguments) + ) + args = block.arguments as Record; + out.push({ name: block.name, args }); + } + } + return out; +} + +function erroredResultTexts(turns: readonly ConversationTurn[]): string[] { + const out: string[] = []; + for (const turn of turns) { + for (const block of turn.content) { + if (block.type !== "tool_result" || block.isError !== true) continue; + const text = block.content + .flatMap((c) => (c.type === "text" ? [c.text] : [])) + .join("") + .trim(); + if (text.length > 0 && !out.includes(text)) out.push(text.slice(0, 300)); + } + } + return out.slice(0, 6); +} + +function hostnameOf(url: string): string | undefined { + try { + return new URL(url).hostname; + } catch { + return undefined; + } +} + +// User-text URLs sit next to sentence punct (`Call HTTP://API.COM.`). +// `new URL` keeps a trailing `.` in the hostname, so a summary that +// names `api.com` would miss the exactName. +function stripUrlGlue(match: string): string { + return match.replace(/[.,;:!]+$/, ""); +} + +/** + * Pull the continuation facts out of the turns a fold is about to drop. + * Deterministic and total: no fact means nothing to verify, never an abort. + */ +export function extractContinuationFacts( + turns: readonly ConversationTurn[], +): ContinuationFacts { + const users = textsForRole(turns, "user"); + const assistants = textsForRole(turns, "assistant"); + const calls = toolCallArgs(turns); + const blockers = erroredResultTexts(turns); + + const goal = users[0] ?? ""; + const nextAction = + assistants[assistants.length - 1] ?? users[users.length - 1] ?? ""; + + const exactNames: string[] = []; + const verification: string[] = []; + for (const { name, args } of calls) { + const path = args["path"]; + if ( + typeof path === "string" && + path.length > 0 && + !exactNames.includes(path) + ) + exactNames.push(path); + const url = args["url"]; + if (typeof url === "string" && url.length > 0 && !exactNames.includes(url)) + exactNames.push(url); + if (name === "run_shell") { + const command = args["command"]; + if ( + typeof command === "string" && + command.length > 0 && + !verification.includes(command) + ) + verification.push(command.slice(0, 200)); + } + } + for (const text of [...users, ...assistants]) { + for (const match of text.match(/https?:\/\/[^\s)]+/gi) ?? []) { + const url = stripUrlGlue(match); + if (url.length > 0 && !exactNames.includes(url)) exactNames.push(url); + } + } + + // Arktype at the boundary: the extractor's shape is the verifier's input + // contract, so a malformed fact fails here instead of scoring nonsense. + const parsed = ContinuationFacts({ + goal: goal.slice(0, 500), + constraints: extractConstraints(users), + nextAction: nextAction.slice(0, 300), + verification: verification.slice(0, 6), + blockers, + exactNames: exactNames.slice(0, 20), + }); + if (parsed instanceof type.errors) throw new Error("invalid facts"); + return parsed; +} + +// Basename for paths (a summary that moves `src/auth.ts` to "auth.ts" still +// names it); hostname for URLs (query strings get reworded freely). +// Match on token/path boundaries so "oauth.ts" does not cover "auth.ts", +// "vite.config.ts" does not cover "config.ts", and "www.api.com" does not +// cover hostname "api.com". `/` stays a non-token so a path still covers +// its basename. +function exactNameSupported(name: string, summary: string): boolean { + const lowered = summary.toLowerCase(); + const loweredName = name.toLowerCase(); + if (loweredName.startsWith("http://") || loweredName.startsWith("https://")) { + const host = hostnameOf(name); + if (host !== undefined && tokenAppears(host.toLowerCase(), lowered)) + return true; + return tokenAppears(loweredName, lowered); + } + const base = name.split("/").pop() ?? name; + return base.length > 0 && tokenAppears(base.toLowerCase(), lowered); +} + +// Claims that deny failure while the dropped turns record it. Narrow on +// purpose: only an explicit denial contradicts, never a progress report. +const DENIES_FAILURE = + /\bno\s+(errors?|failures?|blockers?|issues?)\b|\bnothing\s+(pending|failing|left|outstanding)\b/i; + +/** + * Score a candidate handoff against the dropped turns' continuation facts. + * Missing or contradicted critical facts fail the fold. + */ +export function verifyCompactionSummary( + summary: string, + facts: ContinuationFacts, +): VerifyReport { + const misses: VerifyMiss[] = []; + + if (facts.goal.trim().length > 0 && !tokensSupported(facts.goal, summary)) { + misses.push({ kind: "goal", detail: facts.goal.slice(0, 200) }); + } + for (const constraint of facts.constraints) { + if (!tokensSupported(constraint, summary)) { + misses.push({ kind: "constraint", detail: constraint.slice(0, 200) }); + } + } + if ( + facts.nextAction.trim().length > 0 && + !tokensSupported(facts.nextAction, summary) + ) { + misses.push({ kind: "nextAction", detail: facts.nextAction.slice(0, 200) }); + } + for (const name of facts.exactNames) { + if (!exactNameSupported(name, summary)) { + misses.push({ kind: "exactName", detail: name }); + } + } + for (const blocker of facts.blockers) { + if (!tokensSupported(blocker, summary)) { + misses.push({ kind: "blocker", detail: blocker.slice(0, 200) }); + } + } + for (const command of facts.verification) { + if (!tokensSupported(command, summary)) { + misses.push({ kind: "verification", detail: command.slice(0, 200) }); + } + } + if (facts.blockers.length > 0 && DENIES_FAILURE.test(summary)) { + misses.push({ + kind: "contradiction", + detail: "summary denies failure while dropped turns record errors", + }); + } + + return { supported: misses.length === 0, misses }; +} + +/** + * Deterministic rewrite: append the missing facts verbatim under a repair + * heading so the next agent resumes with names and wording intact. + */ +export function repairSummary( + summary: string, + facts: ContinuationFacts, + misses: readonly VerifyMiss[], +): string { + const kinds = new Set(misses.map((m) => m.kind)); + const lines: string[] = [VERIFY_REPAIR_HEADING]; + const goalMiss = misses.find((m) => m.kind === "goal"); + if (goalMiss !== undefined) lines.push(`Goal: ${facts.goal}`); + const nextMiss = misses.find((m) => m.kind === "nextAction"); + if (nextMiss !== undefined) lines.push(`Next: ${facts.nextAction}`); + // Critical-first: goal, next action, exact names, then the rest. A cap + // that slices the tail still prefers the facts the next agent needs. + if (kinds.has("exactName")) { + const missing = misses + .filter((m) => m.kind === "exactName") + .map((m) => m.detail); + lines.push(`Exact references: ${missing.join(", ")}`); + } + if (kinds.has("constraint")) { + const missing = misses + .filter((m) => m.kind === "constraint") + .map((m) => m.detail); + lines.push(`Constraints:\n${missing.map((c) => `- ${c}`).join("\n")}`); + } + if (kinds.has("blocker")) { + const missing = misses + .filter((m) => m.kind === "blocker") + .map((m) => m.detail); + lines.push(`Open blockers:\n${missing.map((b) => `- ${b}`).join("\n")}`); + } + if (kinds.has("verification")) { + const missing = misses + .filter((m) => m.kind === "verification") + .map((m) => m.detail); + lines.push(`Ran: ${missing.join("; ")}`); + } + return `${summary.trimEnd()}\n\n${lines.join("\n")}`; +} + +/** + * Verify a candidate handoff, repairing once or aborting the fold. + * A contradiction aborts outright: an appended correction cannot retract the + * handoff's false denial. A truncating repair that still misses any fact + * aborts too — never ship a lying spine. + */ +export function verifyOrRepair( + summary: string, + facts: ContinuationFacts, + maxChars: number, +): VerifyOutcome { + const first = verifyCompactionSummary(summary, facts); + if (first.supported) + return { summary, repaired: false, aborted: false, misses: [] }; + if (first.misses.some((m) => m.kind === "contradiction")) + return { summary, repaired: false, aborted: true, misses: first.misses }; + + const repairedFull = repairSummary(summary, facts, first.misses); + const repaired = + repairedFull.length > maxChars + ? repairedFull.slice(0, maxChars) + : repairedFull; + const second = verifyCompactionSummary(repaired, facts); + if (second.misses.length > 0) + return { summary, repaired: false, aborted: true, misses: second.misses }; + return { + summary: repaired, + repaired: true, + aborted: false, + misses: first.misses, + }; +} diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 780c0562c..dd3cd50b1 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -22,6 +22,10 @@ import type { import { ageImageBlocks } from "./attachment-store.js"; import { buildHandoffFold, COMPACTED_PREFIX } from "./compaction-handoff.js"; import type { SummaryContext } from "./summarizer.js"; +import { + extractContinuationFacts, + verifyOrRepair, +} from "./compaction-verify.js"; import { PATH_KEYED_READ_TOOLS, SEARCH_QUERY_TOOLS, @@ -1066,6 +1070,40 @@ export function createPruningCompactor( }; } + // Verify pass: the handoff must still carry the dropped turns' + // continuation facts (goal, next action, exact names, blockers). A + // lossy summary is repaired deterministically; a contradicting one + // aborts the fold so the next agent keeps the true context instead. + const verified = verifyOrRepair( + summary, + extractContinuationFacts(summarizedTurns), + cfg.summaryMaxChars, + ); + if (verified.aborted) { + return { + output: turns, + record: { + strategy: this.name, + version: this.version, + parameters: { keepRecentTurns: cfg.keepRecentTurns }, + reason: "verify failed — keeping prior context", + decisions: { + verifyAborted: 1, + verifyMissing: verified.misses.map((m) => m.kind), + agedImageCount: aged.agedImageCount, + }, + }, + }; + } + summary = verified.summary; + const verifyDecisions = + verified.repaired && verified.misses.length > 0 + ? { + verifyRepaired: 1, + verifyMissing: verified.misses.map((m) => m.kind), + } + : {}; + // A user-role turn survives every adapter unchanged. A system-role turn // does not: the Anthropic builder drops mid-conversation system turns // whenever a system-prompt override is set, and the Grok builder emits @@ -1139,6 +1177,7 @@ export function createPruningCompactor( agedImageCount: aged.agedImageCount, supersededReadCount: supersededReads.size, repeatedErrorCount: repeatedErrors.size, + ...verifyDecisions, }, }, blobs: [...aged.blobs, handoff.blob], diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 378686131..7b4e5c84f 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -171,6 +171,21 @@ const SYSTEM_INSTRUCTION = [ "turns can retrieve the evidence. Do not invent archive contents.", ].join("\n"); +// The user-message window is recency-bounded, which starves the standing +// goal once the session runs long: the summary call would only see the last +// dumps. Pin the first user message — the initiating ask — ahead of the +// recent window so the goal survives no matter how many turns pile up. +const CONDENSED_USER_WINDOW = 6; + +function withPinnedGoal(userMessages: string[]): string[] { + const recent = userMessages.slice(-CONDENSED_USER_WINDOW); + if (userMessages.length <= CONDENSED_USER_WINDOW) return recent; + const goal = userMessages[0]; + if (goal !== undefined && !recent.includes(goal)) + return [`Goal (first user message):\n${goal}`, ...recent]; + return recent; +} + // Pull a compact, model-readable excerpt out of the turns being dropped: // recent user asks, assistant reasoning snippets, tool calls and the files // they touched. Bounded so the summary call itself stays cheap. @@ -221,7 +236,7 @@ export function condenseTurns(turns: ConversationTurn[]): string { .join("\n")}` : null, userMessages.length > 0 - ? `User messages (most recent last):\n${userMessages.slice(-6).join("\n---\n")}` + ? `User messages (most recent last):\n${withPinnedGoal(userMessages).join("\n---\n")}` : null, assistantSnippets.length > 0 ? `Assistant notes (excerpts):\n${assistantSnippets.slice(-8).join("\n---\n")}` diff --git a/tests/integration/compaction-atomicity.test.ts b/tests/integration/compaction-atomicity.test.ts index ee606ab53..daac6642a 100644 --- a/tests/integration/compaction-atomicity.test.ts +++ b/tests/integration/compaction-atomicity.test.ts @@ -130,6 +130,86 @@ describe("compaction atomicity", () => { expect(result.record.reason).toBe("incomplete-evidence-archive"); }); + test("adopted handoff is recorded so the next fold can drop the spine", async () => { + const dir = tempDir(); + const blobs = new Map(); + const archive = createCompactionArchive({ + sessionId: "primary", + contextDir: dir, + writeBlob: async (key, bytes) => { + blobs.set(key, bytes); + }, + readBlob: async (key) => { + const hit = blobs.get(key); + if (hit === undefined) throw new Error(`missing ${key}`); + return hit; + }, + }); + const foldingCompactor = ( + spine: string, + keep: ConversationTurn[], + ): Compactor => ({ + name: "pruning-compactor", + version: "1", + async apply(_turns) { + return { + output: [turn(spine), ...keep], + record: { + strategy: "pruning-compactor", + version: "1", + parameters: {}, + reason: "compact", + decisions: {}, + }, + }; + }, + }); + const history1 = [turn("fact-a"), turn("fact-b")]; + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "fact-a", + }); + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "fact-b", + }); + + const first = wrapCompactorWithCompletenessGate( + foldingCompactor("[Compacted prior context] goal-line-1", [ + turn("fact-b"), + ]), + archive, + ); + const adopted1 = await first.apply(history1, ctx); + expect(adopted1.record.reason).toBe("compact"); + + // The new spine never passes through inbound admission, so adoption must + // leave it in the archive — otherwise the next fold rejects it as + // uncovered (a repaired spine is never echoed verbatim). + const handoffs = (await archive.listOccurrences()).filter( + (occurrence) => occurrence.provenance === "compaction-handoff", + ); + expect(handoffs).toHaveLength(1); + + await archive.recordAuthorizedPayload({ + kind: "user_message", + payload: "fact-c", + }); + const history2 = [...adopted1.output, turn("fact-c")]; + const second = wrapCompactorWithCompletenessGate( + foldingCompactor("[Compacted prior context] goal-line-2", [ + turn("fact-c"), + ]), + archive, + ); + const adopted2 = await second.apply(history2, ctx); + expect(adopted2.record.reason).toBe("compact"); + expect(texts(adopted2.output)).toEqual([ + "[Compacted prior context] goal-line-2", + "fact-c", + ]); + }); + test("primary complete rewrite publishes turns and evidence together", async () => { const dir = tempDir(); const store = await createOptimizedContextStore(dir);