diff --git a/src/boundaries.ts b/src/boundaries.ts index e4f1de1..2fd9680 100644 --- a/src/boundaries.ts +++ b/src/boundaries.ts @@ -1,5 +1,7 @@ import { activeBlocks, blockById } from "./state.js"; +import { isRenderedSummaryMessage, summaryMessageId } from "./prune.js"; import type { + CompressionBlock, CompressionState, CoreMessage, ResolvedBoundary, @@ -86,15 +88,25 @@ export function resolveBoundaries( ); } - const indexByRawId = new Map(); + const indexByMessageId = new Map(); input.messages.forEach((message, index) => - indexByRawId.set(message.id, index), + indexByMessageId.set(message.id, index), ); let snappedBoundaries: string[] = []; - const startAnchor = resolveAnchorIndex(start, input.state, indexByRawId, "start"); + const startAnchor = resolveAnchorIndex( + start, + input.state, + indexByMessageId, + "start", + ); if (startAnchor.snapped) snappedBoundaries.push(startAnchor.snapped); - const endAnchor = resolveAnchorIndex(end, input.state, indexByRawId, "end"); + const endAnchor = resolveAnchorIndex( + end, + input.state, + indexByMessageId, + "end", + ); if (endAnchor.snapped) snappedBoundaries.push(endAnchor.snapped); let startIndex = startAnchor.index; let endIndex = endAnchor.index; @@ -106,7 +118,11 @@ export function resolveBoundaries( const messageIds: string[] = []; for (let index = startIndex; index <= endIndex; index++) { const message = input.messages[index]; - if (message) messageIds.push(message.id); + // Synthetic summary messages are transient view representations, not + // compressible content: exclude them so they never leak into a new + // block's effectiveMessageIds/directMessageIds. + if (message && !isRenderedSummaryMessage(message)) + messageIds.push(message.id); } const boundaryKind: BoundaryKind = @@ -115,7 +131,7 @@ export function resolveBoundaries( const nestedBlockIds: string[] = []; const nestedSeen = new Set(); for (const block of activeBlocks(input.state)) { - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const anchor = visibleBlockAnchor(block, indexByMessageId); if (anchor !== null && anchor >= startIndex && anchor <= endIndex) { if (!nestedSeen.has(block.blockId)) { nestedSeen.add(block.blockId); @@ -145,7 +161,7 @@ interface AnchorResolution { function resolveAnchorIndex( boundary: ParsedBoundary, state: CompressionState, - indexByRawId: Map, + indexByMessageId: Map, endpoint: "start" | "end", ): AnchorResolution { const label = endpoint === "start" ? "startId" : "endId"; @@ -160,15 +176,15 @@ function resolveAnchorIndex( `${label}="${boundary.raw}" does not exist in this session (typo or wrong session) — run acp_status for current refs.`, ); } - const index = indexByRawId.get(rawId); + const index = indexByMessageId.get(rawId); if (index !== undefined) { return { index, snapped: null }; } - const owner = activeOwnerAnchor(state, [rawId], indexByRawId); + const owner = activeOwnerAnchor(state, [rawId], indexByMessageId); if (owner !== null) { return { index: owner, - snapped: `${label}="${boundary.raw}" refers to a message already compressed into an active block — anchored to that block's summary instead.`, + snapped: `${label}="${boundary.raw}" refers to a message already compressed into an active block — anchored to the active block covering it instead.`, }; } throw new BoundaryNotFoundError( @@ -187,12 +203,16 @@ function resolveAnchorIndex( ); } if (block.active) { - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const anchor = visibleBlockAnchor(block, indexByMessageId); if (anchor !== null) { return { index: anchor, snapped: null }; } } - const owner = activeOwnerAnchor(state, block.effectiveMessageIds, indexByRawId); + const owner = activeOwnerAnchor( + state, + block.effectiveMessageIds, + indexByMessageId, + ); if (owner !== null) { return { index: owner, @@ -209,7 +229,7 @@ function resolveAnchorIndex( throw new BoundaryNotFoundError( "consumed", endpoint, - `${label}="b${boundary.numericId}" not found in visible context (block messages consumed by a higher-tier block).`, + `${label}="b${boundary.numericId}" is an active block but none of its content (raw messages or rendered summary) is visible in the current context — run acp_status to verify.`, ); } @@ -217,38 +237,87 @@ function resolveAnchorIndex( * Snap a consumed anchor to the active block that now owns its content. * Throwing instead dead-ends compress calls that follow nudge instructions * with older (already-distilled) refs — the livelock in dog/billion-context-pi#32. + * + * Only TRUE ANCESTORS qualify: an active block that INHERITED the content by + * consuming another block. If the active block DIRECTLY compressed the + * message itself, callers must get the "already compressed — retry with the + * block's bN ref" guidance instead of a silent snap (which would turn a + * message-range retry into a same-tier duplicate block). */ function activeOwnerAnchor( state: CompressionState, ownedIds: string[], - indexByRawId: Map, + indexByMessageId: Map, ): number | null { if (ownedIds.length === 0) return null; const owned = new Set(ownedIds); let best: number | null = null; for (const block of state.blocks) { if (!block.active) continue; - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const inherited = inheritedContentIds(state, block); + let ownsInherited = false; + for (const id of owned) { + if (inherited.has(id)) { + ownsInherited = true; + break; + } + } + if (!ownsInherited) continue; + const anchor = visibleBlockAnchor(block, indexByMessageId); if (anchor === null) continue; - const ownsContent = block.effectiveMessageIds.some((id) => owned.has(id)); - if (ownsContent && (best === null || anchor < best)) { + if (best === null || anchor < best) { best = anchor; } } return best; } +/** + * Content a block INHERITED by consuming other blocks: the union of its + * children's effective coverage. Derived from `directBlockIds` rather than + * inferred as effective−direct so imported or rebuilt state shapes cannot + * flip the consumed-vs-snap decision. + */ +function inheritedContentIds( + state: CompressionState, + block: CompressionBlock, +): Set { + const ids = new Set(); + for (const childId of block.directBlockIds) { + const child = blockById(state, childId); + if (!child) continue; + for (const id of child.effectiveMessageIds) ids.add(id); + } + return ids; +} + function formatPaddedRef(index: number): string { return `m${String(index).padStart(5, "0")}`; } +/** + * Visible anchor index for a block: its rendered summary message if present + * (post-prune views replace raw messages with `acp_summary_bN`), else the + * earliest visible raw message it covers. Without the summary fallback an + * active block whose raws were pruned becomes unresolvable and cannot be + * promoted (dog/billion-context-pi#195). + */ +export function visibleBlockAnchor( + block: CompressionBlock, + indexByMessageId: Map, +): number | null { + const summaryIndex = indexByMessageId.get(summaryMessageId(block.blockId)); + if (summaryIndex !== undefined) return summaryIndex; + return earliestIndexOfIds(block.effectiveMessageIds, indexByMessageId); +} + export function earliestIndexOfIds( ids: string[], - indexByRawId: Map, + indexByMessageId: Map, ): number | null { let earliest: number | null = null; for (const id of ids) { - const index = indexByRawId.get(id); + const index = indexByMessageId.get(id); if (index !== undefined && (earliest === null || index < earliest)) { earliest = index; } diff --git a/src/compress.ts b/src/compress.ts index 8a18fbe..fe25cda 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -1,18 +1,14 @@ import { assignRefs, highestUsedIndex } from "./refs.js"; -import { prune } from "./prune.js"; +import { prune, isSummaryMessageId } from "./prune.js"; import { syncBlocks } from "./sync.js"; import { advanceSurvival, activeBlocks, blockById } from "./state.js"; -import { - allocateBlockId, - allocateRunId, - createInitialState, -} from "./state.js"; +import { allocateBlockId, allocateRunId, createInitialState } from "./state.js"; import { defaultCountTokens } from "./tokenize.js"; import { validateConfig } from "./config.js"; import { BoundaryNotFoundError, resolveBoundaries, - earliestIndexOfIds, + visibleBlockAnchor, } from "./boundaries.js"; import type { ResolvedRange } from "./boundaries.js"; import { truncateLargeToolOutputs } from "./truncate-tools.js"; @@ -147,7 +143,12 @@ export function createCore(ports: Ports = {}): CompressionCore { // default; applySingleRange enforces it as a hard backstop. const protectedMessageIds = input.protectedMessageIds ?? - computeProtectedRefs(input.messages, input.state, input.config, countTokens); + computeProtectedRefs( + input.messages, + input.state, + input.config, + countTokens, + ); const preExistingCoverage = collectCoverage(state); @@ -155,7 +156,10 @@ export function createCore(ports: Ports = {}): CompressionCore { // skipSpecs, the minCompressRange pre-check, and the per-range loop — // previously each re-resolved and silently swallowed failures, so // consumed/unknown ranges produced misleading "too small" errors. - const classifications = new Map(); + const classifications = new Map< + (typeof input.ranges)[number], + RangeResolution + >(); const classificationErrors: string[] = []; const consumedRanges: typeof input.ranges = []; for (const spec of input.ranges) { @@ -186,7 +190,10 @@ export function createCore(ports: Ports = {}): CompressionCore { error: error instanceof Error ? error : new Error(String(error)), }); classificationErrors.push( - rangeError(spec, error instanceof Error ? error.message : String(error)), + rangeError( + spec, + error instanceof Error ? error.message : String(error), + ), ); } } @@ -199,34 +206,37 @@ export function createCore(ports: Ports = {}): CompressionCore { else if (resolution.status === "unknown") unknownCount++; } - const rangeIndexSets: { spec: typeof input.ranges[number]; indices: number[] }[] = []; + // Overlap detection uses resolved boundary indices, not messageIds: a + // summary-only range (block refs over a pruned view) has empty + // messageIds after synthetic-id filtering but still occupies its + // [startIndex, endIndex] span. + const rangeSpans: { + spec: (typeof input.ranges)[number]; + start: number; + end: number; + }[] = []; for (const [spec, resolution] of classifications) { if (resolution.status !== "ok") continue; - const indices = resolution.resolved.messageIds.map((id) => - input.messages.findIndex((m) => m.id === id), - ).filter((i) => i >= 0); - rangeIndexSets.push({ spec, indices }); + rangeSpans.push({ + spec, + start: resolution.resolved.startIndex, + end: resolution.resolved.endIndex, + }); } - const sortedRanges = [...rangeIndexSets].sort((a, b) => { - const aMin = a.indices.length > 0 ? Math.min(...a.indices) : Infinity; - const bMin = b.indices.length > 0 ? Math.min(...b.indices) : Infinity; - return aMin - bMin; - }); + const sortedRanges = [...rangeSpans].sort((a, b) => a.start - b.start); // Overlapping ranges warn+skip (earliest wins) rather than aborting the // whole batch — see ISSUE-42 / dog/billion-context-pi#21. - const skipSpecs = new Set(); + const skipSpecs = new Set<(typeof input.ranges)[number]>(); let acceptedMaxIndex = -1; for (const entry of sortedRanges) { - const entryMax = entry.indices.length > 0 ? Math.max(...entry.indices) : -1; - const entryMin = entry.indices.length > 0 ? Math.min(...entry.indices) : -1; - if (entryMin >= 0 && entryMin <= acceptedMaxIndex) { + if (entry.start <= acceptedMaxIndex) { skipSpecs.add(entry.spec); warnings.push( `Skipped range (${entry.spec.startRef}..${entry.spec.endRef}) — overlaps an earlier range in the batch; the earlier range takes precedence. Keep ranges disjoint.`, ); continue; } - if (entryMax > acceptedMaxIndex) acceptedMaxIndex = entryMax; + if (entry.end > acceptedMaxIndex) acceptedMaxIndex = entry.end; } if (input.config.compress.minCompressRange > 0 && input.ranges.length > 0) { @@ -245,7 +255,10 @@ export function createCore(ports: Ports = {}): CompressionCore { totalRangeChars += msg?.text?.length ?? 0; } } - if (!hasBlockBoundaryRange && totalRangeChars < input.config.compress.minCompressRange) { + if ( + !hasBlockBoundaryRange && + totalRangeChars < input.config.compress.minCompressRange + ) { const live = activeBlocks(state) .map((b) => b.blockId) .sort((x, y) => numericBlockId(x) - numericBlockId(y)); @@ -254,11 +267,13 @@ export function createCore(ports: Ports = {}): CompressionCore { ? ` Current active blocks span ${live[0]}..${live[live.length - 1]} — retry with startId/endId set to active block IDs in that span.` : ""; const gateMessage = - resolvableCount === 0 && consumedRanges.length === 0 && unknownCount > 0 + resolvableCount === 0 && + consumedRanges.length === 0 && + unknownCount > 0 ? `None of the ${input.ranges.length} requested range(s) resolved — every ref failed with "does not exist in this session". Refs recorded before an earlier compress are stale: each successful compress renumbers the remaining refs. Run acp_status, then re-issue the compress in the same turn using only the refs it reports.` : consumedRanges.length > 0 - ? `Requested range(s) already compressed (e.g. ${consumedRanges[0]!.startRef}..${consumedRanges[0]!.endRef}); remaining compressible content ${totalRangeChars} chars < min ${input.config.compress.minCompressRange}. Nothing to do.${liveHint}` - : `Total compressible content too small (${totalRangeChars} chars across ${countedRanges} range(s), min ${input.config.compress.minCompressRange}). Combine more messages into your range(s) to meet the threshold.`; + ? `Requested range(s) already compressed (e.g. ${consumedRanges[0]!.startRef}..${consumedRanges[0]!.endRef}); remaining compressible content ${totalRangeChars} chars < min ${input.config.compress.minCompressRange}. Nothing to do.${liveHint}` + : `Total compressible content too small (${totalRangeChars} chars across ${countedRanges} range(s), min ${input.config.compress.minCompressRange}). Combine more messages into your range(s) to meet the threshold.`; return { state: input.state, result: { @@ -301,7 +316,12 @@ export function createCore(ports: Ports = {}): CompressionCore { tokensCompressed += outcome.tokens; warnings.push(...outcome.warnings); } catch (error) { - errors.push(rangeError(spec, error instanceof Error ? error.message : String(error))); + errors.push( + rangeError( + spec, + error instanceof Error ? error.message : String(error), + ), + ); } } @@ -320,13 +340,18 @@ export function createCore(ports: Ports = {}): CompressionCore { state.nudge.lastShownByTier = {}; } - return { state, result: { blocksCreated, tokensCompressed, errors, warnings } }; + return { + state, + result: { blocksCreated, tokensCompressed, errors, warnings }, + }; } function processTurn(input: ProcessTurnInput): ProcessTurnResult { const configErrors = validateConfig(input.config); if (configErrors.length > 0) { - console.warn(`[acp-kernel] Config validation warnings: ${configErrors.join("; ")}. Thresholds may not fire correctly.`); + console.warn( + `[acp-kernel] Config validation warnings: ${configErrors.join("; ")}. Thresholds may not fire correctly.`, + ); } const ctx: PipelineContext = { config: input.config, @@ -409,7 +434,14 @@ export function createCore(ports: Ports = {}): CompressionCore { return [...base, createRenderRefsNode(strategy)]; } - return { processTurn, applyCompression, defaultNodes, decompress, search, status }; + return { + processTurn, + applyCompression, + defaultNodes, + decompress, + search, + status, + }; } // --- Pipeline nodes ------------------------------------------------------- @@ -518,10 +550,7 @@ const nudgeNode: PipelineNode = { let stamped = { ...io.state.nudge }; - if ( - baseline > 0 && - ctx.tokenCount < baseline - nudgeGrowthTokens - ) { + if (baseline > 0 && ctx.tokenCount < baseline - nudgeGrowthTokens) { stamped.lastPerMessageNudgeTokens = ctx.tokenCount; stamped.lastNudgeShownTokens = 0; // The context shrank dramatically — host compaction, or a tokenCount @@ -545,7 +574,10 @@ const nudgeNode: PipelineNode = { // (lastNudgeShownTokens) suppresses lower-priority tiers within this // turn; the per-tier entry throttles re-firing of the SAME tier. if (nudge.tier !== null) { - stamped.lastShownByTier = { ...stamped.lastShownByTier, [nudge.tier]: ctx.tokenCount }; + stamped.lastShownByTier = { + ...stamped.lastShownByTier, + [nudge.tier]: ctx.tokenCount, + }; } } @@ -581,7 +613,14 @@ const emergencyTruncateNode: PipelineNode = { }; interface SingleRangeInput { - spec: { startRef: string; endRef: string; summary: string; topic?: string; compressCallId?: string; summaryMaxChars?: number }; + spec: { + startRef: string; + endRef: string; + summary: string; + topic?: string; + compressCallId?: string; + summaryMaxChars?: number; + }; messages: CoreMessage[]; state: CompressionState; runId: string; @@ -608,19 +647,26 @@ function applySingleRange(input: SingleRangeInput): SingleRangeOutcome { const rangeMessageIds = applyPairBoundaryAdjustments( resolved, input.messages, - ); + ).filter((id) => !isSummaryMessageId(id)); // Re-scan for nested blocks in the ADJUSTED range (tool-pair extension may // have pulled in messages that are anchors of existing blocks). if (rangeMessageIds.length > resolved.messageIds.length) { - const indexByRawId = new Map(); - input.messages.forEach((m, i) => indexByRawId.set(m.id, i)); - const adjustedStart = indexByRawId.get(rangeMessageIds[0]!) ?? resolved.startIndex; - const adjustedEnd = indexByRawId.get(rangeMessageIds[rangeMessageIds.length - 1]!) ?? resolved.endIndex; + const indexByMessageId = new Map(); + input.messages.forEach((m, i) => indexByMessageId.set(m.id, i)); + const adjustedStart = + rangeMessageIds.length > 0 + ? (indexByMessageId.get(rangeMessageIds[0]!) ?? resolved.startIndex) + : resolved.startIndex; + const adjustedEnd = + rangeMessageIds.length > 0 + ? (indexByMessageId.get(rangeMessageIds[rangeMessageIds.length - 1]!) ?? + resolved.endIndex) + : resolved.endIndex; const nestedSeen = new Set(resolved.nestedBlockIds); for (const block of activeBlocks(input.state)) { if (nestedSeen.has(block.blockId)) continue; - const anchor = earliestIndexOfIds(block.effectiveMessageIds, indexByRawId); + const anchor = visibleBlockAnchor(block, indexByMessageId); if (anchor !== null && anchor >= adjustedStart && anchor <= adjustedEnd) { nestedSeen.add(block.blockId); resolved.nestedBlockIds.push(block.blockId); @@ -759,7 +805,12 @@ function applySingleRange(input: SingleRangeInput): SingleRangeOutcome { } function applyPairBoundaryAdjustments( - resolved: { startIndex: number; endIndex: number; messageIds: string[]; boundaryKind: string }, + resolved: { + startIndex: number; + endIndex: number; + messageIds: string[]; + boundaryKind: string; + }, messages: CoreMessage[], ): string[] { if (resolved.boundaryKind === "block") { @@ -789,10 +840,7 @@ function applyPairBoundaryAdjustments( endIndex = toolAdjusted.endIndex; if (!changed) break; } - if ( - startIndex === resolved.startIndex && - endIndex === resolved.endIndex - ) { + if (startIndex === resolved.startIndex && endIndex === resolved.endIndex) { return resolved.messageIds; } const ids: string[] = []; @@ -824,10 +872,7 @@ function validateCompressionRange( } const effectiveMax = input.spec.summaryMaxChars ?? cfg.maxSummaryLength; - if ( - effectiveMax > 0 && - summary.length > effectiveMax - ) { + if (effectiveMax > 0 && summary.length > effectiveMax) { throw new Error( `Summary too long (${summary.length} chars, max ${effectiveMax}). Strip noise — keep critical paths, decisions, errors, and code references. Or pass summaryMaxChars to increase the limit — don't lose critical info just to fit.`, ); @@ -941,18 +986,30 @@ function pendingByTier( countTokens: (t: string) => number, minCompressRange: number, ): Record { - const out: Record = {}; + const out: Record< + number, + { pending: number; targetBlocks: CompressionBlock[] } + > = {}; const merged = recommendation?.recommendedRanges ?? []; const effective = minCompressRange > 0 ? merged.filter((r) => (r.chars ?? r.tokens * 4) >= minCompressRange) : merged; - out[1] = { pending: effective.reduce((s, r) => s + r.tokens, 0), targetBlocks: [] }; + out[1] = { + pending: effective.reduce((s, r) => s + r.tokens, 0), + targetBlocks: [], + }; const active = activeBlocks(state); const t1 = active.filter((b) => b.tier === 1); const t2 = active.filter((b) => b.tier === 2); - out[2] = { pending: t1.reduce((s, b) => s + countTokens(b.summary), 0), targetBlocks: t1 }; - out[3] = { pending: t2.reduce((s, b) => s + countTokens(b.summary), 0), targetBlocks: t2 }; + out[2] = { + pending: t1.reduce((s, b) => s + countTokens(b.summary), 0), + targetBlocks: t1, + }; + out[3] = { + pending: t2.reduce((s, b) => s + countTokens(b.summary), 0), + targetBlocks: t2, + }; return out; } @@ -1090,10 +1147,19 @@ function decideNudge(input: NudgeInput): NudgeDecision { .map((t) => `T${t} ${tiers[t]!.pending}`); const readyHint = ready.length > 0 ? `, ready: ${ready.join(", ")}` : ""; const blocked = eligible - .filter((t) => (tiers[t]?.pending ?? 0) >= nudgeGrowthTokens && (state.nudge.lastShownByTier[t] ?? 0) > 0 && tokenCount - (state.nudge.lastShownByTier[t] ?? 0) < growthFloor) + .filter( + (t) => + (tiers[t]?.pending ?? 0) >= nudgeGrowthTokens && + (state.nudge.lastShownByTier[t] ?? 0) > 0 && + tokenCount - (state.nudge.lastShownByTier[t] ?? 0) < growthFloor, + ) .map((t) => `T${t} (cadence)`); - const blockedHint = blocked.length > 0 ? `, blocked: ${blocked.join(", ")}` : ""; - const maxPending = Math.max(0, ...Object.values(tiers).map((t) => t.pending)); + const blockedHint = + blocked.length > 0 ? `, blocked: ${blocked.join(", ")}` : ""; + const maxPending = Math.max( + 0, + ...Object.values(tiers).map((t) => t.pending), + ); // Report the ACTUAL blocking condition, not a fixed template. A session // can have plenty to compress (pending >= threshold) but still not // inject because growth/floor/cadence isn't met — the old fixed @@ -1101,13 +1167,25 @@ function decideNudge(input: NudgeInput): NudgeDecision { const pendingShort = maxPending < nudgeGrowthTokens; const growthShort = growthSinceReference < growthFloor; const parts: string[] = []; - if (pendingShort) parts.push(`max compressible ${maxPending} < threshold ${nudgeGrowthTokens}`); - if (growthShort) parts.push(`growth ${growthSinceReference} < floor ${growthFloor}`); - if (parts.length === 0) parts.push(`max compressible ${maxPending}, growth ${growthSinceReference}`); + if (pendingShort) + parts.push( + `max compressible ${maxPending} < threshold ${nudgeGrowthTokens}`, + ); + if (growthShort) + parts.push(`growth ${growthSinceReference} < floor ${growthFloor}`); + if (parts.length === 0) + parts.push( + `max compressible ${maxPending}, growth ${growthSinceReference}`, + ); reason = `${parts.join("; ")}${readyHint}${blockedHint}`; } - const ctxBreakdown = computeContextBreakdown(input.messages, tokenCount, growthSinceReference, countTokens); + const ctxBreakdown = computeContextBreakdown( + input.messages, + tokenCount, + growthSinceReference, + countTokens, + ); return { shouldInject, @@ -1135,14 +1213,26 @@ function decideNudge(input: NudgeInput): NudgeDecision { }; } -function computeContextBreakdown(messages: CoreMessage[], total: number, growth: number, countTokens: (t: string) => number): ContextBreakdown { +function computeContextBreakdown( + messages: CoreMessage[], + total: number, + growth: number, + countTokens: (t: string) => number, +): ContextBreakdown { const count = countTokens ?? ((t: string) => Math.ceil(t.length / 4)); - let system = 0, tool = 0, summaries = 0, code = 0, text = 0; + let system = 0, + tool = 0, + summaries = 0, + code = 0, + text = 0; for (const msg of messages) { const tokens = count(msg.text ?? ""); if (msg.text?.startsWith("[Compressed conversation section]")) { summaries += tokens; - } else if (msg.contentType === "tool-call" || msg.contentType === "tool-result") { + } else if ( + msg.contentType === "tool-call" || + msg.contentType === "tool-result" + ) { tool += tokens; } else if (msg.role === "system") { system += tokens; diff --git a/src/index.ts b/src/index.ts index fb0d6f0..09848ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,41 +27,97 @@ export { rawForRef, BLOCKED_REF, } from "./refs.js"; -export { prune, SUMMARY_HEADER } from "./prune.js"; +export { + prune, + SUMMARY_HEADER, + summaryMessageId, + isSummaryMessageId, + isRenderedSummaryMessage, +} from "./prune.js"; export { syncBlocks } from "./sync.js"; -export { resolveBoundaries, parseBoundary, BoundaryNotFoundError } from "./boundaries.js"; -export { defaultCountTokens, estimateTokensFast, createBpeTokenizer } from "./tokenize.js"; +export { + resolveBoundaries, + parseBoundary, + BoundaryNotFoundError, + visibleBlockAnchor, +} from "./boundaries.js"; +export { + defaultCountTokens, + estimateTokensFast, + createBpeTokenizer, +} from "./tokenize.js"; export type { TokenCountFn } from "./tokenize.js"; export { renderNudgeText, formatRanges } from "./nudge-text.js"; export type { NudgeVoice, RenderedNudge } from "./nudge-text.js"; -export { COMPRESS_PHILOSOPHY, HOW_TO_COMPRESS_RULES, TIER2_DISTILL_RULES, TIER3_CONDENSE_RULES } from "./compression-rules.js"; +export { + COMPRESS_PHILOSOPHY, + HOW_TO_COMPRESS_RULES, + TIER2_DISTILL_RULES, + TIER3_CONDENSE_RULES, +} from "./compression-rules.js"; export { defaultPrompts, resolvePrompts } from "./prompts.js"; export type { Prompts, ResolvePromptsOptions } from "./prompts.js"; export { truncateLargeToolOutputs } from "./truncate-tools.js"; export type { TruncateOptions, TruncateResult } from "./truncate-tools.js"; export { - parseBlockIdArg, - findBlocksOverlappingMessages, - findActiveAncestor, - deactivateBlock, - buildRestoredContentPreview, - collectBlockContent, + parseBlockIdArg, + findBlocksOverlappingMessages, + findActiveAncestor, + deactivateBlock, + buildRestoredContentPreview, + collectBlockContent, +} from "./decompress.js"; +export type { + DeactivateOptions, + CollectedContentResult, + CollectContentOptions, } from "./decompress.js"; -export type { DeactivateOptions, CollectedContentResult, CollectContentOptions } from "./decompress.js"; export { buildStatusReport, buildRecap } from "./report.js"; export type { StatusReportOptions } from "./report.js"; export { hideConsumedCompressCalls } from "./hide-consumed.js"; export type { HideConsumedResult } from "./hide-consumed.js"; export { rebuildCompressionState } from "./rebuild.js"; export type { RebuildResult, RebuildPorts } from "./rebuild.js"; -export { renderVisibleRefs, renderRefsNode, createRenderRefsNode } from "./render-refs.js"; +export { + renderVisibleRefs, + renderRefsNode, + createRenderRefsNode, +} from "./render-refs.js"; export type { RenderStrategy } from "./render-refs.js"; export { resolveTransformChannel } from "./transform-channel.js"; export type { TransformChannel } from "./transform-channel.js"; -export { searchBlocks, searchBlocksAsync, blockDocs, messageDocs } from "./search.js"; -export { clearDocFeatures, docCacheInfo, docFeatures, setDocCacheCap } from "./search.js"; -export type { SearchResult, SearchOptions, SearchAlgorithm, AsyncSearchAlgorithm, AnySearchAlgorithm, SearchDoc, SearchDocKind, ScoredBlock, MessageRole, RoleWeights, MessageInput } from "./search.js"; -export { DEFAULT_ALGORITHM, DEFAULT_ROLE_WEIGHTS, registerSearchAlgorithm, getSearchAlgorithm, listSearchAlgorithms } from "./search.js"; +export { + searchBlocks, + searchBlocksAsync, + blockDocs, + messageDocs, +} from "./search.js"; +export { + clearDocFeatures, + docCacheInfo, + docFeatures, + setDocCacheCap, +} from "./search.js"; +export type { + SearchResult, + SearchOptions, + SearchAlgorithm, + AsyncSearchAlgorithm, + AnySearchAlgorithm, + SearchDoc, + SearchDocKind, + ScoredBlock, + MessageRole, + RoleWeights, + MessageInput, +} from "./search.js"; +export { + DEFAULT_ALGORITHM, + DEFAULT_ROLE_WEIGHTS, + registerSearchAlgorithm, + getSearchAlgorithm, + listSearchAlgorithms, +} from "./search.js"; export { isMessageProtected, matchToolPattern } from "./protected.js"; export { runPipeline, diff --git a/src/prune.ts b/src/prune.ts index 2bfbf35..3bc8c89 100644 --- a/src/prune.ts +++ b/src/prune.ts @@ -3,6 +3,41 @@ import type { CompressionState, CoreMessage } from "./types.js"; export const SUMMARY_HEADER = "[Compressed conversation section]"; +// Reserved prefix for rendered-summary ids. Hosts own their message ids and +// must never assign one with this prefix; kernel-generated ids are mNNNNN +// refs and bN block ids. +const SUMMARY_ID_PREFIX = "acp_summary_"; + +/** + * The transient visible id of an active block's rendered summary message. + * This is a VIEW-ONLY representation: it must never be persisted into + * `effectiveMessageIds`/`directMessageIds` (the durable coverage is the + * block's raw message ids). + */ +export function summaryMessageId(blockId: string): string { + return `${SUMMARY_ID_PREFIX}${blockId}`; +} + +export function isSummaryMessageId(id: string): boolean { + return id.startsWith(SUMMARY_ID_PREFIX); +} + +/** + * True when a message is a rendered block summary (the exact shape prune + * emits). The id prefix alone is not sufficient — a host-authored message + * that happens to carry a reserved id must not be treated as a rendered + * summary (it would be silently dropped from ranges or deleted by rebuild). + */ +export function isRenderedSummaryMessage( + message: Pick, +): boolean { + return ( + isSummaryMessageId(message.id) && + message.role === "system" && + message.contentType === "text" + ); +} + export interface PruneOptions { injectSummaries?: boolean; } @@ -21,9 +56,16 @@ export function prune( ); const indexById = new Map(); - messages.forEach((message, index) => indexById.set(message.id, index)); + const summaryIndexById = new Map(); + messages.forEach((message, index) => { + indexById.set(message.id, index); + if (isRenderedSummaryMessage(message)) + summaryIndexById.set(message.id, index); + }); - const anchors = inject ? collectSummaryAnchors(state, indexById) : []; + const anchors = inject + ? collectSummaryAnchors(state, indexById, summaryIndexById) + : []; return stripOrphanedReasoning( stripOrphanedToolResults( @@ -44,9 +86,23 @@ interface SummaryAnchor { function collectSummaryAnchors( state: CompressionState, indexById: Map, + summaryIndexById: Map, ): SummaryAnchor[] { const anchors: SummaryAnchor[] = []; for (const block of activeBlocks(state)) { + // Prefer the position of an already-rendered summary (hosts may pass a + // previously-pruned view): keeps the summary stable in place instead of + // jumping to index 0 when the raw ids are no longer in the input. + const existingIndex = summaryIndexById.get(summaryMessageId(block.blockId)); + if (existingIndex !== undefined) { + anchors.push({ + blockId: block.blockId, + summary: block.summary, + topic: block.topic, + insertAt: existingIndex, + }); + continue; + } let earliest: number | null = null; for (const id of block.effectiveMessageIds) { const index = indexById.get(id); @@ -73,6 +129,9 @@ function rebuildMessages( ): CoreMessage[] { const result: CoreMessage[] = []; const pending = [...anchors]; + const anchoredSummaryIds = new Set( + anchors.map((anchor) => summaryMessageId(anchor.blockId)), + ); for (let index = 0; index < messages.length; index++) { while (pending.length > 0 && pending[0]!.insertAt === index) { @@ -83,6 +142,15 @@ function rebuildMessages( continue; } if (covered.has(messages[index]!.id)) continue; + // A stale copy of this block's summary from a previously-pruned view: + // the freshly rendered one above replaces it. Only rendered-summary + // shaped messages qualify — a host message that merely reuses the + // reserved prefix is content, not a stale copy. + if ( + isRenderedSummaryMessage(messages[index]!) && + anchoredSummaryIds.has(messages[index]!.id) + ) + continue; result.push(messages[index]!); } @@ -100,7 +168,7 @@ function renderSummary(anchor: SummaryAnchor): CoreMessage { : SUMMARY_HEADER; const text = body.length === 0 ? topicLine : `${topicLine}\n${body}`; return { - id: `acp_summary_${anchor.blockId}`, + id: summaryMessageId(anchor.blockId), role: "system", contentType: "text", text, diff --git a/src/sync.ts b/src/sync.ts index 520ed2a..452d5d8 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,3 +1,4 @@ +import { summaryMessageId } from "./prune.js"; import type { CompressionState, CoreMessage } from "./types.js"; export interface SyncResult { @@ -63,9 +64,13 @@ export function syncBlocks( continue; } block.active = true; - const stillPresent = block.effectiveMessageIds.some((id) => - presentIds.has(id), - ); + // A block whose raw messages were replaced by its rendered summary + // (pruned view) is still present — the summary IS the block's visible + // representation. Without this, hosts passing pruned views would lose + // block activity every turn. + const stillPresent = + block.effectiveMessageIds.some((id) => presentIds.has(id)) || + presentIds.has(summaryMessageId(block.blockId)); if (!stillPresent) { block.active = false; deactivated.push(block.blockId); diff --git a/tests/compress.test.ts b/tests/compress.test.ts index fb91767..84eabdd 100644 --- a/tests/compress.test.ts +++ b/tests/compress.test.ts @@ -263,7 +263,11 @@ test("blocks are never deactivated for age (no maxBlockAge behavior)", () => { config: config(), tokenCount: 95000, }); - assert.equal(result.state.blocks[0]!.active, true, "block must stay active regardless of age"); + assert.equal( + result.state.blocks[0]!.active, + true, + "block must stay active regardless of age", + ); }); test("applyCompression reports error for unknown boundary ref", () => { @@ -289,22 +293,49 @@ test("applyCompression reports error for unknown boundary ref", () => { test("batch compress attributes per-range errors and keeps partial success", () => { const core = createCore(); const state = createInitialState(); - const messages = [msg("a", "alpha"), msg("b", "beta"), msg("c", "gamma"), msg("d", "delta")]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + const messages = [ + msg("a", "alpha"), + msg("b", "beta"), + msg("c", "gamma"), + msg("d", "delta"), + ]; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const result = core.applyCompression({ ranges: [ - { startRef: "m00001", endRef: "m00002", summary: "x".repeat(60), topic: "ok" }, - { startRef: "m00003", endRef: "m00004", summary: "y".repeat(22), topic: "short" }, + { + startRef: "m00001", + endRef: "m00002", + summary: "x".repeat(60), + topic: "ok", + }, + { + startRef: "m00003", + endRef: "m00004", + summary: "y".repeat(22), + topic: "short", + }, ], messages, state, - config: config({ compress: { minCompressRange: 0, maxSummaryLength: 0, minSummaryLength: 50 } }), + config: config({ + compress: { + minCompressRange: 0, + maxSummaryLength: 0, + minSummaryLength: 50, + }, + }), }); assert.equal(result.result.blocksCreated, 1, "valid range still compresses"); assert.equal(result.result.errors.length, 1); - assert.match(result.result.errors[0]!, /^range m00003\.\.m00004: Summary too short \(22 chars, min 50\)/); + assert.match( + result.result.errors[0]!, + /^range m00003\.\.m00004: Summary too short \(22 chars, min 50\)/, + ); }); test("retrying a consumed range reports already-compressed guidance, not too-small", () => { @@ -317,7 +348,10 @@ test("retrying a consumed range reports already-compressed guidance, not too-sma msg("c", "gamma"), msg("d", "delta"), ]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const { state: after } = core.applyCompression({ ranges: [{ startRef: "m00002", endRef: "m00003", summary: "intro recap" }], @@ -331,14 +365,23 @@ test("retrying a consumed range reports already-compressed guidance, not too-sma ranges: [{ startRef: "m00002", endRef: "m00003", summary: "intro recap" }], messages: pruned, state: after, - config: config({ compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 } }), + config: config({ + compress: { + minCompressRange: 5000, + maxSummaryLength: 0, + minSummaryLength: 0, + }, + }), }); assert.equal(retry.result.blocksCreated, 0); assert.equal(retry.result.errors.length, 1); assert.match(retry.result.errors[0]!, /already compressed/); assert.match(retry.result.errors[0]!, /Current active blocks span/); - assert.doesNotMatch(retry.result.errors[0]!, /Total compressible content too small/); + assert.doesNotMatch( + retry.result.errors[0]!, + /Total compressible content too small/, + ); }); test("consumed plus fresh-but-small range is not misreported as too small", () => { @@ -351,7 +394,10 @@ test("consumed plus fresh-but-small range is not misreported as too small", () = msg("c", "gamma"), msg("d", "delta"), ]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const { state: after } = core.applyCompression({ ranges: [{ startRef: "m00002", endRef: "m00003", summary: "intro recap" }], @@ -368,7 +414,13 @@ test("consumed plus fresh-but-small range is not misreported as too small", () = ], messages: pruned, state: after, - config: config({ compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 } }), + config: config({ + compress: { + minCompressRange: 5000, + maxSummaryLength: 0, + minSummaryLength: 0, + }, + }), }); assert.equal(retry.result.blocksCreated, 0); @@ -388,7 +440,10 @@ test("all-unknown batch reports stale refs instead of too-small (billion-context msg("c", "gamma"), msg("d", "delta"), ]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const result = core.applyCompression({ ranges: [ @@ -397,12 +452,21 @@ test("all-unknown batch reports stale refs instead of too-small (billion-context ], messages, state, - config: config({ compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 } }), + config: config({ + compress: { + minCompressRange: 5000, + maxSummaryLength: 0, + minSummaryLength: 0, + }, + }), }); assert.equal(result.result.blocksCreated, 0); assert.equal(result.result.errors.length, 3); - assert.match(result.result.errors[0]!, /None of the 2 requested range\(s\) resolved/); + assert.match( + result.result.errors[0]!, + /None of the 2 requested range\(s\) resolved/, + ); assert.match(result.result.errors[0]!, /renumbers the remaining refs/); assert.match(result.result.errors[0]!, /Run acp_status/); assert.doesNotMatch(result.result.errors[0]!, /too small/); @@ -420,7 +484,10 @@ test("consumed plus unknown ranges keep the already-compressed message", () => { msg("c", "gamma"), msg("d", "delta"), ]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const { state: after } = core.applyCompression({ ranges: [{ startRef: "m00002", endRef: "m00003", summary: "intro recap" }], @@ -437,12 +504,21 @@ test("consumed plus unknown ranges keep the already-compressed message", () => { ], messages: pruned, state: after, - config: config({ compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 } }), + config: config({ + compress: { + minCompressRange: 5000, + maxSummaryLength: 0, + minSummaryLength: 0, + }, + }), }); assert.equal(retry.result.blocksCreated, 0); assert.match(retry.result.errors[0]!, /already compressed/); - assert.doesNotMatch(retry.result.errors[0]!, /None of the 2 requested range\(s\) resolved/); + assert.doesNotMatch( + retry.result.errors[0]!, + /None of the 2 requested range\(s\) resolved/, + ); assert.match( retry.result.errors.find((e) => e.startsWith("range m00050..m00060")) ?? "", /does not exist in this session/, @@ -453,17 +529,29 @@ test("fresh small content without consumed ranges keeps the too-small message", const core = createCore(); const state = createInitialState(); const messages = [msg("a", "alpha"), msg("b", "beta")]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const result = core.applyCompression({ ranges: [{ startRef: "m00001", endRef: "m00002", summary: "a and b" }], messages, state, - config: config({ compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 } }), + config: config({ + compress: { + minCompressRange: 5000, + maxSummaryLength: 0, + minSummaryLength: 0, + }, + }), }); assert.equal(result.result.blocksCreated, 0); - assert.match(result.result.errors[0]!, /^Total compressible content too small \(\d+ chars across 1 range\(s\), min 5000\)/); + assert.match( + result.result.errors[0]!, + /^Total compressible content too small \(\d+ chars across 1 range\(s\), min 5000\)/, + ); }); test("consumed plus fresh content above threshold proceeds with a warning", () => { @@ -477,7 +565,10 @@ test("consumed plus fresh content above threshold proceeds with a warning", () = msg("c", big), msg("d", "delta"), ]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const { state: after } = core.applyCompression({ ranges: [{ startRef: "m00002", endRef: "m00003", summary: "intro recap" }], @@ -494,13 +585,21 @@ test("consumed plus fresh content above threshold proceeds with a warning", () = ], messages: pruned, state: after, - config: config({ compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 } }), + config: config({ + compress: { + minCompressRange: 5000, + maxSummaryLength: 0, + minSummaryLength: 0, + }, + }), }); assert.equal(retry.result.blocksCreated, 1); assert.equal(retry.result.errors.length, 0); assert.ok( - retry.result.warnings.some((w) => /Skipped range \(m00002\.\.m00003\) — already compressed/.test(w)), + retry.result.warnings.some((w) => + /Skipped range \(m00002\.\.m00003\) — already compressed/.test(w), + ), `expected consumed warning in: ${JSON.stringify(retry.result.warnings)}`, ); }); @@ -509,7 +608,10 @@ test("empty summary is attributed to its range", () => { const core = createCore(); const state = createInitialState(); const messages = [msg("a", "alpha"), msg("b", "beta")]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const result = core.applyCompression({ ranges: [{ startRef: "m00001", endRef: "m00002", summary: "" }], @@ -520,14 +622,20 @@ test("empty summary is attributed to its range", () => { assert.equal(result.result.blocksCreated, 0); assert.equal(result.result.errors.length, 1); - assert.match(result.result.errors[0]!, /^range m00001\.\.m00002: Summary is empty/); + assert.match( + result.result.errors[0]!, + /^range m00001\.\.m00002: Summary is empty/, + ); }); test("invalid refs are reported per-range without failing the batch", () => { const core = createCore(); const state = createInitialState(); const messages = [msg("a", "alpha"), msg("b", "beta")]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const result = core.applyCompression({ ranges: [ @@ -541,14 +649,20 @@ test("invalid refs are reported per-range without failing the batch", () => { assert.equal(result.result.blocksCreated, 1, "valid range still compresses"); assert.equal(result.result.errors.length, 1); - assert.match(result.result.errors[0]!, /^range m999999\.\.m00002: Invalid boundary ref/); + assert.match( + result.result.errors[0]!, + /^range m999999\.\.m00002: Invalid boundary ref/, + ); }); test("unknown ref (valid format, never allocated) names the ref and suggests acp_status", () => { const core = createCore(); const state = createInitialState(); const messages = [msg("a", "alpha")]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const result = core.applyCompression({ ranges: [{ startRef: "m00099", endRef: "m00100", summary: "nope" }], @@ -572,7 +686,10 @@ test("consumed ranges warn+skip when minCompressRange is 0", () => { msg("b", "beta"), msg("c", "gamma"), ]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; const { state: after } = core.applyCompression({ ranges: [{ startRef: "m00002", endRef: "m00003", summary: "intro recap" }], @@ -592,20 +709,38 @@ test("consumed ranges warn+skip when minCompressRange is 0", () => { assert.equal(retry.result.blocksCreated, 0); assert.equal(retry.result.errors.length, 0); assert.ok( - retry.result.warnings.some((w) => /Skipped range \(m00002\.\.m00003\) — already compressed/.test(w)), + retry.result.warnings.some((w) => + /Skipped range \(m00002\.\.m00003\) — already compressed/.test(w), + ), `expected consumed warning in: ${JSON.stringify(retry.result.warnings)}`, ); }); test("resolveBoundaries throws typed BoundaryNotFoundError with kind and endpoint", () => { const state = createInitialState(); - const messages = [msg("u", "the task"), msg("a", "alpha"), msg("b", "beta"), msg("c", "gamma")]; - state.messageRefs = assignRefs(messages, { existing: state.messageRefs, nextIndex: 1 }).map; + const messages = [ + msg("u", "the task"), + msg("a", "alpha"), + msg("b", "beta"), + msg("c", "gamma"), + ]; + state.messageRefs = assignRefs(messages, { + existing: state.messageRefs, + nextIndex: 1, + }).map; assert.throws( - () => resolveBoundaries({ startRef: "m00099", endRef: "m00001", messages, state }), + () => + resolveBoundaries({ + startRef: "m00099", + endRef: "m00001", + messages, + state, + }), (e: unknown) => - e instanceof BoundaryNotFoundError && e.kind === "unknown" && e.endpoint === "start", + e instanceof BoundaryNotFoundError && + e.kind === "unknown" && + e.endpoint === "start", ); const core = createCore(); @@ -617,9 +752,17 @@ test("resolveBoundaries throws typed BoundaryNotFoundError with kind and endpoin }); const pruned = prune(messages, after); assert.throws( - () => resolveBoundaries({ startRef: "m00002", endRef: "m00003", messages: pruned, state: after }), + () => + resolveBoundaries({ + startRef: "m00002", + endRef: "m00003", + messages: pruned, + state: after, + }), (e: unknown) => - e instanceof BoundaryNotFoundError && e.kind === "consumed" && e.endpoint === "start", + e instanceof BoundaryNotFoundError && + e.kind === "consumed" && + e.endpoint === "start", ); }); @@ -691,7 +834,9 @@ test("consumed block anchor snaps to the active owning block instead of failing state.nextBlockId = 111; const result = core.applyCompression({ - ranges: [{ startRef: "b2", endRef: "b110", summary: "distilled span", topic: "t" }], + ranges: [ + { startRef: "b2", endRef: "b110", summary: "distilled span", topic: "t" }, + ], messages, state, config: config(), @@ -709,8 +854,14 @@ test("consumed block anchor snaps to the active owning block instead of failing assert.ok(created, "new block allocated after b110"); assert.equal(created!.tier, 2); assert.deepEqual(created!.directBlockIds, ["b110"]); - assert.equal(result.state.blocks.find((b) => b.blockId === "b110")!.active, false); - assert.equal(result.state.blocks.find((b) => b.blockId === "b50")!.active, true); + assert.equal( + result.state.blocks.find((b) => b.blockId === "b110")!.active, + false, + ); + assert.equal( + result.state.blocks.find((b) => b.blockId === "b50")!.active, + true, + ); }); test("consumed message anchor snaps to the active block covering it", () => { @@ -735,6 +886,22 @@ test("consumed message anchor snaps to the active block covering it", () => { nextIndex: 1, }).map; state.blocks.push( + { + // Consumed child kept in state (kernel invariant: applySingleRange + // deactivates but never deletes) so b50's inheritance is resolvable. + blockId: "b2", + runId: "r1", + tier: 1, + topic: "t", + summary: "s2", + directMessageIds: ["a", "b"], + effectiveMessageIds: ["a", "b"], + directBlockIds: [], + createdAt: 0, + survivedCount: 0, + generation: "young", + active: false, + }, { blockId: "b50", runId: "r1", @@ -767,7 +934,14 @@ test("consumed message anchor snaps to the active block covering it", () => { const visible = full.slice(2); const result = core.applyCompression({ - ranges: [{ startRef: "m00001", endRef: "b110", summary: "distilled span", topic: "t" }], + ranges: [ + { + startRef: "m00001", + endRef: "b110", + summary: "distilled span", + topic: "t", + }, + ], messages: visible, state, config: config(), @@ -823,10 +997,18 @@ test("gate error names the current active block span when anchors stay consumed" ); const result = core.applyCompression({ - ranges: [{ startRef: "b2", endRef: "b110", summary: "distilled span", topic: "t" }], + ranges: [ + { startRef: "b2", endRef: "b110", summary: "distilled span", topic: "t" }, + ], messages, state, - config: config({ compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 } }), + config: config({ + compress: { + minCompressRange: 5000, + maxSummaryLength: 0, + minSummaryLength: 0, + }, + }), }); assert.equal(result.result.blocksCreated, 0); diff --git a/tests/regression-promote-after-prune.test.ts b/tests/regression-promote-after-prune.test.ts new file mode 100644 index 0000000..267809d --- /dev/null +++ b/tests/regression-promote-after-prune.test.ts @@ -0,0 +1,547 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createCore } from "../src/compress.js"; +import { createInitialState } from "../src/state.js"; +import { prune, isSummaryMessageId, summaryMessageId } from "../src/prune.js"; +import { syncBlocks } from "../src/sync.js"; +import type { CompressionState, Config, CoreMessage } from "../src/types.js"; + +// Regression tests for dog/billion-context-pi#195: compress cannot promote an +// active block after pruning replaces its raw messages with the synthetic +// `acp_summary_bN` message. Most tests FAIL against the pre-fix code; the +// consumed-semantics test pins behavior that must be preserved (it passed +// pre-fix only by accident — the anchor lookup failed — and must not regress +// now that visibleBlockAnchor makes those anchors resolvable). + +function msg( + id: string, + text: string, + role: CoreMessage["role"] = "user", +): CoreMessage { + return { id, role, contentType: "text", text }; +} + +function config(overrides: Partial = {}): Config { + return { + tiers: { enabled: true, tier2Trigger: 5, tier3Trigger: 10 }, + nudge: { + maxContextLimitPct: 0.55, + minContextLimitPct: 0.45, + frequency: 5, + iterationThreshold: 15, + force: "soft", + growthRatio: 0.05, + growthFloor: 6000, + growthCap: 50000, + minGrowthFloor: 5000, + minGrowthRatio: 0.45, + emergencyThresholdPct: 0.98, + tier2GrowthMultiplier: 1.5, + }, + promotionThreshold: 5, + truncate: { threshold: 1 }, + compress: { minCompressRange: 0, maxSummaryLength: 0, minSummaryLength: 0 }, + protectedTools: [], + preserveRecentMessages: 5, + preserveRecentTokens: 0, + modelContextLimit: 100000, + ...overrides, + }; +} + +interface BlockSpec { + blockId: string; + effectiveMessageIds: string[]; + directMessageIds?: string[]; + directBlockIds?: string[]; + tier?: 1 | 2 | 3; + active?: boolean; +} + +function makeState(specs: BlockSpec[], nextBlockId: number): CompressionState { + const state = createInitialState(); + state.blocks = specs.map((spec) => ({ + blockId: spec.blockId, + runId: "r1", + tier: spec.tier ?? 1, + topic: undefined, + summary: `T${spec.tier ?? 1} summary for ${spec.blockId}.`, + directMessageIds: [...(spec.directMessageIds ?? spec.effectiveMessageIds)], + effectiveMessageIds: [...spec.effectiveMessageIds], + directBlockIds: [...(spec.directBlockIds ?? [])], + compressedTokens: 100, + createdAt: Date.now(), + survivedCount: 0, + generation: "young" as const, + active: spec.active ?? true, + })); + state.nextBlockId = nextBlockId; + return state; +} + +test("promote-after-prune: single T1 block promotes to T2 after its raws were replaced by the summary", () => { + const core = createCore(); + const cfg = config(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + + const turn = core.processTurn({ + messages, + state, + config: cfg, + tokenCount: 5000, + }); + const visibleIds = turn.messages.map((m) => m.id); + // Prune replaced the covered raws with the synthetic summary… + assert.ok( + visibleIds.includes(summaryMessageId("b2")), + "summary should be visible", + ); + assert.ok(!visibleIds.includes("raw-3"), "raw-3 should be hidden"); + assert.ok(!visibleIds.includes("raw-4"), "raw-4 should be hidden"); + // …and sync must NOT have deactivated the block just because its raws vanished. + const b2 = turn.state.blocks.find((b) => b.blockId === "b2")!; + assert.equal(b2.active, true, "b2 must stay active post-prune"); + + const applied = core.applyCompression({ + ranges: [{ startRef: "b2", endRef: "b2", summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual( + applied.result.errors, + [], + `unexpected errors: ${applied.result.errors.join("; ")}`, + ); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b2"]); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), ["raw-3", "raw-4"]); + // The synthetic id must never leak into durable coverage. + for (const list of [ + newBlock.effectiveMessageIds, + newBlock.directMessageIds, + ]) { + for (const id of list) { + assert.ok( + !id.startsWith("acp_summary_"), + `synthetic id leaked into block: ${id}`, + ); + } + } + assert.equal( + applied.state.blocks.find((b) => b.blockId === "b2")!.active, + false, + ); +}); + +test("promote-after-prune: two T1 blocks promote into one T2 consuming both", () => { + const core = createCore(); + const cfg = config(); + const messages = Array.from({ length: 11 }, (_, i) => + msg( + `raw-${i + 1}`, + `m${i + 1} `.repeat(200), + i % 2 === 0 ? "user" : "assistant", + ), + ); + const state = makeState( + [ + { blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }, + { blockId: "b3", effectiveMessageIds: ["raw-5", "raw-6"] }, + ], + 4, + ); + + const turn = core.processTurn({ + messages, + state, + config: cfg, + tokenCount: 9000, + }); + const applied = core.applyCompression({ + ranges: [{ startRef: "b2", endRef: "b3", summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual( + applied.result.errors, + [], + `unexpected errors: ${applied.result.errors.join("; ")}`, + ); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b2", "b3"]); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), [ + "raw-3", + "raw-4", + "raw-5", + "raw-6", + ]); +}); + +test("promote-after-prune: inclusive bN..bM selection consumes every active block in the span, including ones visible only via their summary", () => { + const core = createCore(); + const cfg = config({ preserveRecentMessages: 3 }); + const messages = Array.from({ length: 9 }, (_, i) => + msg( + `raw-${i + 1}`, + `m${i + 1} `.repeat(200), + i % 2 === 0 ? "user" : "assistant", + ), + ); + // raw-1 is the first user message (always kept visible by prune), so + // coverage starts at raw-3. + const state = makeState( + [ + { blockId: "b1", effectiveMessageIds: ["raw-3", "raw-4"] }, + { blockId: "b2", effectiveMessageIds: ["raw-5", "raw-6"] }, + { blockId: "b3", effectiveMessageIds: ["raw-7", "raw-8"] }, + ], + 4, + ); + + const turn = core.processTurn({ + messages, + state, + config: cfg, + tokenCount: 8000, + }); + const visibleIds = turn.messages.map((m) => m.id); + for (const id of ["raw-3", "raw-4", "raw-5", "raw-6", "raw-7", "raw-8"]) { + assert.ok(!visibleIds.includes(id), `${id} should be hidden`); + } + for (const id of ["acp_summary_b1", "acp_summary_b2", "acp_summary_b3"]) { + assert.ok(visibleIds.includes(id), `${id} should be visible`); + } + + const applied = core.applyCompression({ + ranges: [{ startRef: "b1", endRef: "b3", summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual( + applied.result.errors, + [], + `unexpected errors: ${applied.result.errors.join("; ")}`, + ); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b1", "b2", "b3"]); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), [ + "raw-3", + "raw-4", + "raw-5", + "raw-6", + "raw-7", + "raw-8", + ]); + for (const id of ["b1", "b2", "b3"]) { + assert.equal( + applied.state.blocks.find((b) => b.blockId === id)!.active, + false, + ); + } +}); + +test("control: compressing a block BEFORE pruning still works (no behavior change)", () => { + const core = createCore(); + const cfg = config(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + + const applied = core.applyCompression({ + ranges: [{ startRef: "b2", endRef: "b2", summary: "S".repeat(80) }], + messages, + state, + config: cfg, + }); + assert.deepEqual(applied.result.errors, []); + assert.equal(applied.result.blocksCreated, 1); + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 2); + assert.deepEqual(newBlock.directBlockIds, ["b2"]); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), ["raw-3", "raw-4"]); +}); + +test("syncBlocks: a block whose only visible representation is its rendered summary stays active", () => { + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + const prunedView = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + { + id: summaryMessageId("b2"), + role: "system" as const, + contentType: "text" as const, + text: "[Compressed conversation section]\nsum", + }, + msg("raw-5", "u5 ".repeat(200)), + ]; + const { state: synced, deactivated } = syncBlocks(prunedView, state); + assert.deepEqual(deactivated, []); + assert.equal(synced.blocks.find((b) => b.blockId === "b2")!.active, true); +}); + +test("prune idempotency: re-pruning an already-pruned view keeps the summary in place without duplicating it", () => { + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + const full = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const once = prune(full, state); + const twice = prune(once, state); + assert.deepEqual( + twice.map((m) => m.id), + once.map((m) => m.id), + ); + const summaries = twice.filter((m) => m.id === summaryMessageId("b2")); + assert.equal(summaries.length, 1); + assert.equal( + twice.indexOf(summaries[0]!), + once.indexOf(once.find((m) => m.id === summaryMessageId("b2"))!), + ); +}); + +test("promote-after-prune: retrying an m-ref of directly-compressed content still throws consumed on a pruned view", () => { + const core = createCore(); + const cfg = config(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + + const turn = core.processTurn({ + messages, + state, + config: cfg, + tokenCount: 5000, + }); + assert.ok( + turn.messages.some((m) => m.id === summaryMessageId("b2")), + "summary should be visible", + ); + // Refs assigned pre-prune survive pruning — the model can still cite them. + const ref = turn.state.messageRefs.byRaw["raw-3"]; + assert.ok(ref, "raw-3 must keep its ref even after being pruned"); + + const applied = core.applyCompression({ + ranges: [{ startRef: ref!, endRef: ref!, summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + // b2 DIRECTLY compressed raw-3 (it is in directMessageIds): no snap, no + // same-tier duplicate block — the caller must get the bN retry guidance. + assert.deepEqual( + applied.result.errors, + [], + `unexpected errors: ${applied.result.errors.join("; ")}`, + ); + assert.equal(applied.result.blocksCreated, 0); + assert.equal(applied.state.blocks.length, 1); + assert.equal( + applied.state.blocks.find((b) => b.blockId === "b2")!.active, + true, + ); + assert.ok( + applied.result.warnings.some((w) => /already compressed/.test(w)), + `expected consumed guidance, got: ${JSON.stringify(applied.result.warnings)}`, + ); +}); + +test("promote-after-prune: m-ref range spanning a pruned region consumes the block without leaking synthetic ids", () => { + const core = createCore(); + const cfg = config({ preserveRecentMessages: 0 }); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + msg("raw-8", "a8 ".repeat(200), "assistant"), + ]; + const state = makeState( + [{ blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"] }], + 3, + ); + + const turn = core.processTurn({ + messages, + state, + config: cfg, + tokenCount: 5000, + }); + const startRef = turn.state.messageRefs.byRaw["raw-2"]!; + const endRef = turn.state.messageRefs.byRaw["raw-7"]!; + + const applied = core.applyCompression({ + ranges: [{ startRef, endRef, summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual( + applied.result.errors, + [], + `unexpected errors: ${applied.result.errors.join("; ")}`, + ); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + // The block under the summary is consumed via its summary anchor… + assert.deepEqual(newBlock.directBlockIds, ["b2"]); + // …and its raw coverage is inherited, while the synthetic id never leaks. + assert.deepEqual(newBlock.effectiveMessageIds.sort(), [ + "raw-2", + "raw-3", + "raw-4", + "raw-5", + "raw-6", + "raw-7", + ]); + assert.deepEqual(newBlock.directMessageIds.sort(), [ + "raw-2", + "raw-5", + "raw-6", + "raw-7", + ]); + for (const id of [ + ...newBlock.effectiveMessageIds, + ...newBlock.directMessageIds, + ]) { + assert.ok(!isSummaryMessageId(id), `synthetic id leaked into block: ${id}`); + } + assert.equal( + applied.state.blocks.find((b) => b.blockId === "b2")!.active, + false, + ); +}); + +test("promote-after-prune: compressing a consumed block's bN ref snaps to its active ancestor and distills to T3", () => { + const core = createCore(); + const cfg = config(); + const messages = [ + msg("raw-1", "u1 ".repeat(200)), + msg("raw-2", "a1 ".repeat(200), "assistant"), + msg("raw-3", "u3 ".repeat(200)), + msg("raw-4", "a4 ".repeat(200), "assistant"), + msg("raw-5", "u5 ".repeat(200)), + msg("raw-6", "a6 ".repeat(200), "assistant"), + msg("raw-7", "u7 ".repeat(200)), + ]; + const state = makeState( + [ + { blockId: "b2", effectiveMessageIds: ["raw-3", "raw-4"], active: false }, + { blockId: "b3", effectiveMessageIds: ["raw-5", "raw-6"], active: false }, + { + blockId: "b5", + effectiveMessageIds: ["raw-3", "raw-4", "raw-5", "raw-6"], + directMessageIds: [], + directBlockIds: ["b2", "b3"], + tier: 2, + }, + ], + 6, + ); + + const turn = core.processTurn({ + messages, + state, + config: cfg, + tokenCount: 5000, + }); + assert.ok( + turn.messages.some((m) => m.id === summaryMessageId("b5")), + "b5 summary should be visible", + ); + + const applied = core.applyCompression({ + ranges: [{ startRef: "b2", endRef: "b2", summary: "S".repeat(80) }], + messages: turn.messages, + state: turn.state, + config: cfg, + }); + assert.deepEqual( + applied.result.errors, + [], + `unexpected errors: ${applied.result.errors.join("; ")}`, + ); + assert.ok( + applied.result.warnings.some((w) => + /was consumed by a higher-tier block/.test(w), + ), + `expected ancestor-snap guidance, got: ${JSON.stringify(applied.result.warnings)}`, + ); + assert.equal(applied.result.blocksCreated, 1); + + const newBlock = applied.state.blocks[applied.state.blocks.length - 1]!; + assert.equal(newBlock.tier, 3); + assert.deepEqual(newBlock.directBlockIds, ["b5"]); + assert.deepEqual(newBlock.directMessageIds, []); + assert.deepEqual(newBlock.effectiveMessageIds.sort(), [ + "raw-3", + "raw-4", + "raw-5", + "raw-6", + ]); + for (const id of newBlock.effectiveMessageIds) { + assert.ok(!isSummaryMessageId(id), `synthetic id leaked into block: ${id}`); + } + assert.equal( + applied.state.blocks.find((b) => b.blockId === "b5")!.active, + false, + ); +});