From 7e6e26ce0d5370fc375ef91364028821d8520eed Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 14:44:44 -0700 Subject: [PATCH 1/2] fix(session): commit folds when handoff carries verify overflow A repair that did not fit the summary cap aborted the fold, so the transcript never shrank. Shrink model prose to keep the carry-forward, and commit when the handoff file still holds the leftover facts. Contradictions still keep prior context. The operator notice names contradiction or repair overflow, with auth or quota first. Fixes CL-10745 --- src/session/compaction-verify.test.ts | 95 +++++++++++++++++++++- src/session/compaction-verify.ts | 92 ++++++++++++++++++--- src/session/compactor.ts | 113 +++++++++++++++++--------- src/session/runtime-assembly.test.ts | 14 +++- src/session/runtime-assembly.ts | 19 ++++- 5 files changed, 278 insertions(+), 55 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 540f0064d..4a3cb164c 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -14,6 +14,7 @@ import { repairSummary, verifyCompactionSummary, verifyOrRepair, + verifyAbortNoticeLabel, VERIFY_REPAIR_HEADING, } from "./compaction-verify.js"; import type { @@ -453,6 +454,12 @@ describe("verifyCompactionSummary", () => { }); describe("verifyOrRepair", () => { + test("names contradiction and repair overflow instead of a bare verify", () => { + expect(verifyAbortNoticeLabel("contradiction")).toBe("contradiction"); + expect(verifyAbortNoticeLabel("repair-overflow")).toBe("repair overflow"); + expect(verifyAbortNoticeLabel(undefined)).toBe("kept-prior"); + }); + test("repairs a lossy handoff instead of shipping it", () => { const facts = extractContinuationFacts(droppedTurns()); const outcome = verifyOrRepair( @@ -477,6 +484,7 @@ describe("verifyOrRepair", () => { ); expect(outcome.aborted).toBe(true); expect(outcome.repaired).toBe(false); + expect(outcome.abortReason).toBe("contradiction"); }); test("truncating repair that still misses exactName aborts", () => { @@ -495,9 +503,10 @@ describe("verifyOrRepair", () => { timestamp: 2, }, ]); - const outcome = verifyOrRepair("Fix auth now. Work continues.", facts, 90); + const outcome = verifyOrRepair("Fix auth now. Work continues.", facts, 80); expect(outcome.aborted).toBe(true); expect(outcome.repaired).toBe(false); + expect(outcome.abortReason).toBe("repair-overflow"); expect(outcome.misses.some((m) => m.kind === "exactName")).toBe(true); }); @@ -527,6 +536,26 @@ describe("verifyOrRepair", () => { expect(outcome.summary).toContain("exact-file.ts"); }); + test("shrinks model prose so a carry-forward that fits the cap still verifies", () => { + const facts = extractContinuationFacts(droppedTurns()); + const prose = "Padded model prose. ".repeat(40); + const misses = verifyCompactionSummary(prose, facts).misses; + const full = repairSummary(prose, facts, misses); + const appendixAt = full.indexOf(VERIFY_REPAIR_HEADING); + expect(appendixAt).toBeGreaterThan(200); + const appendix = full.slice(appendixAt).replace(/^\n+/, ""); + const cap = appendix.length + 8; + expect(full.length).toBeGreaterThan(cap); + const outcome = verifyOrRepair(prose, facts, cap); + expect(outcome.aborted).toBe(false); + expect(outcome.repaired).toBe(true); + expect(outcome.summary.length).toBeLessThanOrEqual(cap); + expect(outcome.summary).toContain(VERIFY_REPAIR_HEADING); + expect(verifyCompactionSummary(outcome.summary, facts).supported).toBe( + true, + ); + }); + test("a constraint-only residual after a sliced repair aborts", () => { const facts = extractContinuationFacts([ textTurn("user", "Fix auth now"), @@ -662,10 +691,74 @@ describe("pruning compactor verify pass", () => { expect(allText(result.output)).toContain("auth.ts"); } else if (kind === "aborted") { expect(result.output).toBe(turns); + expect(result.record.decisions.verifyAbortReason).toBe("contradiction"); } else { expect(allText(result.output)).not.toContain(VERIFY_REPAIR_HEADING); } }); + + test("an over-cap repair commits the fold and the handoff keeps critical facts", async () => { + const folded: ConversationTurn[] = [ + 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, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + isError: true, + content: [ + { + type: "text", + text: "ZEBRA-BLOCKER-UNIQUE token refresh exploded", + }, + ], + }, + ], + timestamp: 3, + }, + textTurn( + "assistant", + "Next step is to patch the zebra handler QUOKKA-NEXT", + ), + textTurn("user", "Do not commit generated artifacts ever."), + ]; + const turns: ConversationTurn[] = [ + ...folded, + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const compactor = createPruningCompactor({ + summaryMaxChars: 80, + compactionShape: { tailBudgetTokens: 10 }, + summarize: async () => "Work continues.", + }); + const result = await compactor.apply(turns, mockStrategyCtx); + expect(result.record.decisions.verifyAborted).toBeUndefined(); + expect(result.record.reason.startsWith("compacted")).toBe(true); + expect(result.output.length).toBeLessThan(turns.length); + const file = handoffFileText(result); + expect(file).toContain("exact-file.ts"); + expect(file).toContain("ZEBRA-BLOCKER-UNIQUE"); + expect(file).toContain("QUOKKA-NEXT"); + expect(file).toContain("generated artifacts"); + expect(file.toLowerCase()).toContain("fix auth"); + const spine = allText(result.output); + expect(spine).toContain(COMPACTED_PREFIX); + expect(spine).not.toMatch(/\bno\s+errors?\b/i); + }); }); describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index 5407dd011..39e4ead91 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -47,9 +47,22 @@ export interface VerifyOutcome { summary: string; repaired: boolean; aborted: boolean; + /** Set only when the fold must keep prior context. */ + abortReason?: VerifyAbortReason; misses: VerifyMiss[]; } +export type VerifyAbortReason = "contradiction" | "repair-overflow"; + +/** Operator-facing cause for a verify keep-prior. Never the bare word "verify". */ +export function verifyAbortNoticeLabel( + abortReason: VerifyAbortReason | undefined, +): string { + if (abortReason === "contradiction") return "contradiction"; + if (abortReason === "repair-overflow") return "repair overflow"; + return "kept-prior"; +} + export const VERIFY_REPAIR_HEADING = "## Carry-forward (verify repair)"; // Words with no identifying signal. Kept small: every extra stopword is a @@ -391,6 +404,46 @@ export function verifyCompactionSummary( return { supported: misses.length === 0, misses }; } +/** Facts are present even if the text also denies failure. */ +export function continuationTextCarriesFacts( + text: string, + facts: ContinuationFacts, +): boolean { + return verifyCompactionSummary(text, facts).misses.every( + (miss) => miss.kind === "contradiction", + ); +} + +/** Spine (or summary) explicitly denies failures the dropped turns recorded. */ +export function textDeniesRecordedFailures( + text: string, + facts: ContinuationFacts, +): boolean { + return facts.blockers.length > 0 && DENIES_FAILURE.test(text); +} + +/** + * Keep the carry-forward appendix inside the cap by shrinking model prose + * first. A naive head slice drops the critical prefix, which is what made + * every over-cap repair abort. + */ +function fitRepairWithinCap(repairedFull: string, maxChars: number): string { + if (maxChars <= 0) return ""; + if (repairedFull.length <= maxChars) return repairedFull; + const at = repairedFull.indexOf(VERIFY_REPAIR_HEADING); + if (at < 0) return repairedFull.slice(0, maxChars); + let proseEnd = at; + while (proseEnd > 0 && repairedFull[proseEnd - 1] === "\n") proseEnd -= 1; + const prose = repairedFull.slice(0, proseEnd); + const appendix = repairedFull.slice(proseEnd).replace(/^\n+/, ""); + if (appendix.length >= maxChars) return appendix.slice(0, maxChars); + const room = maxChars - appendix.length - 2; + if (room <= 0) return appendix; + const kept = prose.slice(0, room).trimEnd(); + if (kept.length === 0) return appendix; + return `${kept}\n\n${appendix}`; +} + /** * Deterministic rewrite: append missing facts verbatim under a repair * heading so the next agent resumes with names and wording intact. @@ -436,10 +489,12 @@ export function repairSummary( } /** - * 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. + * Verify a candidate summary, repairing once. A contradiction aborts + * outright: an appended correction cannot retract a false denial. When the + * repair exceeds the cap, model prose shrinks so the critical prefix is + * kept. Residual misses are repair-overflow — the compactor still commits + * when the handoff file carries those facts, and aborts only when it does + * not. Never treat a truncated repair as a faithful spine. */ export function verifyOrRepair( summary: string, @@ -450,16 +505,33 @@ export function verifyOrRepair( 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 }; + return { + summary, + repaired: false, + aborted: true, + abortReason: "contradiction", + misses: first.misses, + }; const repairedFull = repairSummary(summary, facts, first.misses); - const repaired = - repairedFull.length > maxChars - ? repairedFull.slice(0, maxChars) - : repairedFull; + const repaired = fitRepairWithinCap(repairedFull, maxChars); const second = verifyCompactionSummary(repaired, facts); + if (second.misses.some((m) => m.kind === "contradiction")) + return { + summary, + repaired: false, + aborted: true, + abortReason: "contradiction", + misses: second.misses, + }; if (second.misses.length > 0) - return { summary, repaired: false, aborted: true, misses: second.misses }; + return { + summary: repaired, + repaired: false, + aborted: true, + abortReason: "repair-overflow", + misses: second.misses, + }; return { summary: repaired, repaired: true, diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 01865ff98..d6e3a2dde 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -20,8 +20,13 @@ import { type SummaryContext, } from "./summarizer.js"; import { + continuationTextCarriesFacts, extractContinuationFacts, + repairSummary, + textDeniesRecordedFailures, verifyOrRepair, + type VerifyAbortReason, + type VerifyMiss, } from "./compaction-verify.js"; import { canonicalToolName } from "../agent/canonical-tool-name.js"; import { @@ -1066,59 +1071,93 @@ export function createPruningCompactor( // Verify pass: the handoff must still carry the dropped turns' // continuation facts (goal, next action, exact names, blockers). Lossy - // summaries repair deterministically; contradicting ones abort the fold. - const verified = verifyOrRepair( - summary, - extractContinuationFacts(summarizedTurns), - cfg.summaryMaxChars, - ); + // summaries repair deterministically. A contradiction aborts the fold. + // A repair that does not fit the summary cap still commits when the + // handoff file carries the overflow — the live spine stays the thin + // extract, never a sliced denial. + const facts = extractContinuationFacts(summarizedTurns); + const verified = verifyOrRepair(summary, facts, cfg.summaryMaxChars); + const keepPrior = ( + abortReason: VerifyAbortReason, + misses: readonly VerifyMiss[], + ) => ({ + output: turns, + record: { + strategy: this.name, + version: this.version, + parameters: { + compactionShape: shape, + }, + reason: "verify failed — keeping prior context", + decisions: { + verifyAborted: 1, + verifyAbortReason: abortReason, + verifyMissing: misses.map((m) => m.kind), + agedImageCount: aged.agedImageCount, + }, + }, + }); + if (verified.aborted && verified.abortReason === "contradiction") { + return keepPrior("contradiction", verified.misses); + } + + const priorFileText = await cfg.readPriorHandoff?.(); + const handoffOpts = { + ...(priorFileText !== undefined ? { priorFileText } : {}), + ...(summaryCtx?.activatedTools !== undefined + ? { activatedTools: summaryCtx.activatedTools } + : {}), + }; + const handoffCarries = (fileBytes: Uint8Array, spineText: string) => + continuationTextCarriesFacts( + `${new TextDecoder().decode(fileBytes)}\n${spineText}`, + facts, + ); + + let narrative = verified.summary; + let handoff = buildHandoffFold(summarizedTurns, narrative, handoffOpts); + let overflowFold = false; if (verified.aborted) { - return { - output: turns, - record: { - strategy: this.name, - version: this.version, - parameters: { - compactionShape: shape, + if (textDeniesRecordedFailures(handoff.spineText, facts)) { + return keepPrior("contradiction", [ + ...verified.misses, + { + kind: "contradiction", + detail: "spine denies failure while dropped turns record errors", }, - reason: "verify failed — keeping prior context", - decisions: { - verifyAborted: 1, - verifyMissing: verified.misses.map((m) => m.kind), - agedImageCount: aged.agedImageCount, - }, - }, - }; + ]); + } + if (!handoffCarries(handoff.blob.bytes, handoff.spineText)) { + narrative = repairSummary(narrative, facts, verified.misses); + handoff = buildHandoffFold(summarizedTurns, narrative, handoffOpts); + } + if ( + textDeniesRecordedFailures(handoff.spineText, facts) || + !handoffCarries(handoff.blob.bytes, handoff.spineText) + ) { + return keepPrior("repair-overflow", verified.misses); + } + overflowFold = true; } - summary = verified.summary; + summary = narrative; const verifyDecisions = - verified.repaired && verified.misses.length > 0 + overflowFold || (verified.repaired && verified.misses.length > 0) ? { verifyRepaired: 1, verifyMissing: verified.misses.map((m) => m.kind), } : {}; - // The summary is framed as user content: a user-role turn survives every - // adapter unchanged, a system-role turn does not (Anthropic drops - // mid-conversation system turns under a system-prompt override; Grok - // emits them as a stray mid-stream system message). Activated tools - // outlive the fold, so the handoff states them verbatim. - // // The fold writes a fat structured handoff file (goal, constraints, // decisions, evidence markers, files/commands, verification, dead ends, // next actions, verbatim exact-facts appendix) under one stable latest // key, keeping only a thin spine plus a pointer to that file in the // live prompt. The spine unions carried facts with new ones; dropped // prior spines join the evidence archive so the completeness gate - // still certifies the fold. - const priorFileText = await cfg.readPriorHandoff?.(); - const handoff = buildHandoffFold(summarizedTurns, summary, { - ...(priorFileText !== undefined ? { priorFileText } : {}), - ...(summaryCtx?.activatedTools !== undefined - ? { activatedTools: summaryCtx.activatedTools } - : {}), - }); + // still certifies the fold. `handoff` was built above from the verified + // narrative (overflow facts live in the file, not a second copy). + // The spine is user-role so every adapter keeps it: Anthropic drops + // mid-conversation system turns, and Grok emits them mid-stream. const summaryTurn: ConversationTurn = { role: "user", content: [{ type: "text", text: handoff.spineText }], diff --git a/src/session/runtime-assembly.test.ts b/src/session/runtime-assembly.test.ts index 3cd888eeb..7106ede80 100644 --- a/src/session/runtime-assembly.test.ts +++ b/src/session/runtime-assembly.test.ts @@ -668,12 +668,12 @@ describe("createSessionPruningCompactor stub fallback", () => { }); test.each([ - ["model unreachable", "verify"], + ["model unreachable", undefined], ["401 Unauthorized", "auth"], ["429 quota exceeded", "quota"], ])( "verify abort after summarizer failure (%s) keeps prior context and notifies", - async (errorMessage, reason) => { + async (errorMessage, failureKind) => { const notices: string[] = []; const kept: number[] = []; const folds: { stub: boolean }[] = []; @@ -792,8 +792,14 @@ describe("createSessionPruningCompactor stub fallback", () => { expect(kept).toEqual([1]); expect(notices).toHaveLength(1); expect(notices[0]?.toLowerCase()).toContain("fail"); - expect(notices[0]?.toLowerCase()).toContain("verify"); - expect(notices[0]?.toLowerCase()).toContain(reason); + expect(notices[0]?.toLowerCase()).toContain("contradiction"); + expect(notices[0]).not.toMatch(/\(verify\)/); + if (failureKind !== undefined) { + expect(notices[0]?.toLowerCase()).toContain(failureKind); + expect(notices[0]?.indexOf(failureKind) ?? -1).toBeLessThan( + notices[0]?.indexOf("contradiction") ?? -1, + ); + } expect(notices[0]).not.toContain("statistics-only stub"); expect(notices[0]).not.toContain("Compaction interrupted"); }, diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index e31631a8f..97d1762f0 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -56,6 +56,7 @@ import { createPruningCompactor, type CompactionShape, } from "./compactor.js"; +import { verifyAbortNoticeLabel } from "./compaction-verify.js"; import { classifySummarizerFailure, summarizerStubFallbackNotice, @@ -479,9 +480,16 @@ export interface SessionPruningCompactorArgs { function compactionFailedKeepNotice( kind: SummarizerFailureClass | undefined, + abortReason: unknown, ): string { - const reason = - kind === "auth" || kind === "quota" ? `${kind}, verify` : "verify"; + const why = verifyAbortNoticeLabel( + abortReason === "contradiction" || abortReason === "repair-overflow" + ? abortReason + : undefined, + ); + // Auth and quota stay first: the operator needs the credential failure + // even when verify also refused the fold. + const reason = kind === "auth" || kind === "quota" ? `${kind}, ${why}` : why; return `Compaction failed — keeping prior context (${reason}).`; } @@ -569,7 +577,12 @@ export function createSessionPruningCompactor( // interrupted notice. if (result.record.decisions.verifyAborted === 1) { args.onKeptPrior?.(); - args.onFailure?.(compactionFailedKeepNotice(pendingFailureKind)); + args.onFailure?.( + compactionFailedKeepNotice( + pendingFailureKind, + result.record.decisions.verifyAbortReason, + ), + ); return result; } if ( From 893c90ef4a97ea1834d15426c5c9b977088c6b0e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 15:06:32 -0700 Subject: [PATCH 2/2] fix(session): refuse lying spines and host-only url folds A shrink-to-fit repair was committing a Decisions line that denied recorded failures. A URL exact-name matched on the hostname alone, so an over-cap slice could drop the path and the fold still committed. Fixes CL-10745 --- src/session/compaction-verify.test.ts | 134 +++++++++++++++++++++++++- src/session/compaction-verify.ts | 24 ++++- src/session/compactor.ts | 30 +++--- 3 files changed, 172 insertions(+), 16 deletions(-) diff --git a/src/session/compaction-verify.test.ts b/src/session/compaction-verify.test.ts index 4a3cb164c..d3cbe5d5a 100644 --- a/src/session/compaction-verify.test.ts +++ b/src/session/compaction-verify.test.ts @@ -422,7 +422,7 @@ describe("verifyCompactionSummary", () => { } }); - test("uppercase HTTP URL scores hostname case-insensitively", () => { + test("a URL path is required; hostname alone does not cover it", () => { const facts = extractContinuationFacts([ textTurn("user", "Fix auth now"), { @@ -443,6 +443,12 @@ describe("verifyCompactionSummary", () => { "Fix auth now. Called api.com.", facts, ).misses.some((m) => m.kind === "exactName"), + ).toBe(true); + expect( + verifyCompactionSummary( + "Fix auth now. Called API.COM/v1.", + facts, + ).misses.some((m) => m.kind === "exactName"), ).toBe(false); expect( verifyCompactionSummary( @@ -759,6 +765,132 @@ describe("pruning compactor verify pass", () => { expect(spine).toContain(COMPACTED_PREFIX); expect(spine).not.toMatch(/\bno\s+errors?\b/i); }); + + test("shrink-to-fit does not ship a spine that denies recorded failures", async () => { + const folded: ConversationTurn[] = [ + textTurn("user", "Fix auth now"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "read_file", + arguments: { path: "src/auth.ts" }, + }, + ], + timestamp: 2, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + isError: true, + content: [{ type: "text", text: "token refresh assertion failed" }], + }, + ], + timestamp: 3, + }, + textTurn("assistant", "Fix the token refresh assertion next."), + textTurn("user", "No errors remain"), + textTurn("user", "Keep going on the handler"), + ]; + const turns: ConversationTurn[] = [ + ...folded, + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const prose = "Padded model prose. ".repeat(40); + const facts = extractContinuationFacts(folded); + const misses = verifyCompactionSummary(prose, facts).misses; + const full = repairSummary(prose, facts, misses); + const appendixAt = full.indexOf(VERIFY_REPAIR_HEADING); + const appendix = full.slice(appendixAt).replace(/^\n+/, ""); + const cap = appendix.length + 8; + expect(full.length).toBeGreaterThan(cap); + const shrunk = verifyOrRepair(prose, facts, cap); + expect(shrunk.aborted).toBe(false); + expect(shrunk.summary.length).toBeLessThanOrEqual(cap); + + const apply = (summaryMaxChars: number) => + createPruningCompactor({ + summaryMaxChars, + compactionShape: { tailBudgetTokens: 10 }, + summarize: async () => prose, + }).apply(turns, mockStrategyCtx); + + const fitted = await apply(cap); + const fittedSpine = allText(fitted.output); + const committed = fitted.output.length < turns.length; + if (committed) { + expect(fittedSpine).not.toMatch(/\bno\s+errors?\s+remain\b/i); + } else { + expect(fitted.output).toBe(turns); + expect(fitted.record.decisions.verifyAbortReason).toBe("contradiction"); + } + expect(committed && /\bno\s+errors?\s+remain\b/i.test(fittedSpine)).toBe( + false, + ); + + const tight = await apply(80); + expect(tight.output).toBe(turns); + expect(tight.record.decisions.verifyAbortReason).toBe("contradiction"); + }); + + test("an over-cap fold does not commit without the exact URL path", async () => { + const url = "https://api.example/hooks/QUOKKA-CALLBACK"; + const folded: ConversationTurn[] = [ + textTurn("user", "Register the callback host api.example"), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "web_fetch", + arguments: { url }, + }, + ], + timestamp: 2, + }, + textTurn("assistant", "Next step is to patch the handler QUOKKA-NEXT"), + textTurn("user", "Do not commit generated artifacts ever."), + ]; + const turns: ConversationTurn[] = [ + ...folded, + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const prose = "Work continues."; + const facts = extractContinuationFacts(folded); + expect(facts.exactNames).toContain(url); + const misses = verifyCompactionSummary(prose, facts).misses; + const full = repairSummary(prose, facts, misses); + const exactAt = full.indexOf("Exact references:"); + expect(exactAt).toBeGreaterThan(0); + expect(full.slice(0, exactAt)).toContain("api.example"); + expect(full.slice(0, exactAt)).not.toContain("QUOKKA-CALLBACK"); + + const result = await createPruningCompactor({ + summaryMaxChars: exactAt, + compactionShape: { tailBudgetTokens: 10 }, + summarize: async () => prose, + }).apply(turns, mockStrategyCtx); + + const file = handoffFileText(result); + const spine = allText(result.output); + const keptPrior = result.output === turns; + const pathKept = + file.includes("hooks/QUOKKA-CALLBACK") || + spine.includes("hooks/QUOKKA-CALLBACK"); + expect(keptPrior || pathKept).toBe(true); + if (!keptPrior) { + expect(result.record.decisions.verifyAborted).toBeUndefined(); + expect(result.record.reason.startsWith("compacted")).toBe(true); + } + }); }); describe("CL-9007 budgeted tail (shared auto+manual pipeline)", () => { diff --git a/src/session/compaction-verify.ts b/src/session/compaction-verify.ts index 39e4ead91..5af5d0b2a 100644 --- a/src/session/compaction-verify.ts +++ b/src/session/compaction-verify.ts @@ -334,17 +334,33 @@ export function extractContinuationFacts( } // Basename for paths (a summary moving `src/auth.ts` to "auth.ts" still -// names it); hostname for URLs (query strings get reworded freely). Match +// names it). A URL with a path needs that path, not the hostname alone: +// `api.example` does not cover `https://api.example/hooks/id`. Query strings +// may be dropped. A URL with no path still matches on the hostname. Match // on token/path boundaries so "oauth.ts" does not cover "auth.ts" and // "www.api.com" does not cover hostname "api.com". +function urlPathBody(url: string): string | undefined { + try { + const path = new URL(url).pathname.replace(/\/+$/, ""); + if (path.length <= 1) return undefined; + return path.slice(1).toLowerCase(); + } catch { + return undefined; + } +} + function exactNameSupported(name: string, summary: string): boolean { const lowered = summary.toLowerCase(); const loweredName = name.toLowerCase(); if (loweredName.startsWith("http://") || loweredName.startsWith("https://")) { + if (tokenAppears(loweredName, lowered)) return true; const host = hostnameOf(name); - if (host !== undefined && tokenAppears(host.toLowerCase(), lowered)) - return true; - return tokenAppears(loweredName, lowered); + if (host === undefined) return false; + const path = urlPathBody(name); + if (path === undefined) return tokenAppears(host.toLowerCase(), lowered); + return ( + tokenAppears(host.toLowerCase(), lowered) && tokenAppears(path, lowered) + ); } const base = name.split("/").pop() ?? name; return base.length > 0 && tokenAppears(base.toLowerCase(), lowered); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index d6e3a2dde..3ce3f5d61 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -1074,7 +1074,8 @@ export function createPruningCompactor( // summaries repair deterministically. A contradiction aborts the fold. // A repair that does not fit the summary cap still commits when the // handoff file carries the overflow — the live spine stays the thin - // extract, never a sliced denial. + // extract, never a denial of recorded failures. Shrink-to-fit is the + // same rule: a fitting appendix does not excuse a lying spine. const facts = extractContinuationFacts(summarizedTurns); const verified = verifyOrRepair(summary, facts, cfg.summaryMaxChars); const keepPrior = ( @@ -1117,22 +1118,29 @@ export function createPruningCompactor( let narrative = verified.summary; let handoff = buildHandoffFold(summarizedTurns, narrative, handoffOpts); let overflowFold = false; + const spineDeniesFailures = (): boolean => + textDeniesRecordedFailures(handoff.spineText, facts); + const spineContradiction = () => + keepPrior("contradiction", [ + ...verified.misses, + { + kind: "contradiction", + detail: "spine denies failure while dropped turns record errors", + }, + ]); + // Shrink-to-fit (and any other non-abort) still builds Decisions from + // folded user text. That line can deny failures the summary never said. + if (!verified.aborted && spineDeniesFailures()) { + return spineContradiction(); + } if (verified.aborted) { - if (textDeniesRecordedFailures(handoff.spineText, facts)) { - return keepPrior("contradiction", [ - ...verified.misses, - { - kind: "contradiction", - detail: "spine denies failure while dropped turns record errors", - }, - ]); - } + if (spineDeniesFailures()) return spineContradiction(); if (!handoffCarries(handoff.blob.bytes, handoff.spineText)) { narrative = repairSummary(narrative, facts, verified.misses); handoff = buildHandoffFold(summarizedTurns, narrative, handoffOpts); } if ( - textDeniesRecordedFailures(handoff.spineText, facts) || + spineDeniesFailures() || !handoffCarries(handoff.blob.bytes, handoff.spineText) ) { return keepPrior("repair-overflow", verified.misses);