diff --git a/devlog/2026-09-11_perf-transform-history/DESIGN.md b/devlog/2026-09-11_perf-transform-history/DESIGN.md new file mode 100644 index 00000000..4af0cce3 --- /dev/null +++ b/devlog/2026-09-11_perf-transform-history/DESIGN.md @@ -0,0 +1,128 @@ +# DESIGN - Avoid transform work that scales with compression history + +- Task ID: `2026-09-11_perf-transform-history` +- Issue: https://github.com/ranxianglei/opencode-acp/issues/384 + +## Problem shape + +The message-transform hook runs on **every LLM call**. Several of its steps — +and the compress tool's candidate validation — rebuilt lookup structures whose +size grows with *total compression history* (`messageIds.byRef` is never +reclaimed between compactions; `blocksById` keeps consumed blocks forever for +decompression/fork recovery). Cost therefore scaled O(history) per transform +instead of O(visible messages + active blocks). + +## Design decisions + +### 1. Request-scoped boundary lookup memo (RC1) + +`buildBoundaryLookup()` maps every known ref/bid to a `BoundaryReference`. It +depends only on `(searchContext.rawIndexById, state.messageIds.byRef, active +blocks)` — all immutable **within one request** (a single compress tool call +or one transform). So it is built once per request: + +- `SearchContext.boundaryLookup?: BoundaryLookup` (new OPTIONAL field, + `lib/compress/types.ts`). `buildSearchContext()` fills it eagerly; + `resolveBoundaryIds()` falls back to `context.boundaryLookup ??= buildBoundaryLookup(...)` + so hand-built contexts (tests, `search_context` tool) keep working unchanged. +- No invalidation machinery needed: the context object is request-scoped by + construction and never mutated after creation. + +### 2. Fast token estimate in candidate selection (RC1b) + +`resolveSelection()` only needs token counts to report range sizes and feed +quality-gate estimates — it does NOT need exact BPE counts. The codebase +already uses `chars/4` as its estimation convention everywhere else; the hot +path alone called the real Anthropic tokenizer (~27 ms per ~1 KB measured), +which at 500–1000 messages dominated candidate planning. New +`estimateAllMessageTokensFast()` = `Math.round(countMessageCharacters(msg) / 4)`. +Exact counting stays where correctness requires it (`getCurrentTokenUsage`'s +rare fallback path). + +### 3. Lazy nudge analysis (RC2) + +`injectCompressNudges()` computed `estimateContextComposition`, +`computeProtectedRefs`, and `buildCompressibleRanges` (each an O(all messages) +pass that stringifies tool outputs) on every transform even when nothing could +be emitted. Analysis now runs iff: + +``` +needsNudgeAnalysis = nudgeAllowed || emergencyOverride || tierTriggerPossible +``` + +- `nudgeAllowed` — growth/floor gate says a T1 nudge may fire +- `emergencyOverride` — opencode compaction happened; the "nothing to + compress" check must run to decide between emergency notice vs silence +- `tierTriggerPossible` — tier-1/tier-2 summary usage crossed the growth + threshold, so a T2/T3 trigger could emit + +Downstream consumers of the now-possibly-null structures are null-guarded. +No behavior change when analysis WAS needed; quiet turns simply skip it. + +### 4. Structure-version invalidation for derived indexes (RC3) + +Two per-transform paths replayed ALL blocks (active + inactive): +`syncCompressionBlocks()` (sort + liveness recompute + anchor map rebuild) and +`hideConsumedCompressCalls()` (consumed-call index rebuild). Block liveness and +the consumed-call structure change ONLY at three mutation sites (found by grep +audit of `.active =`, `deactivatedByUser*` assignments, `blocksById.set/delete`): + +1. `compress/state.ts` → `applyCompressionState()` (new block + consumption deactivations) +2. `gc/merge.ts` → `mergeMarkedBlocks()` (merged block + source deactivations) +3. `decompress-logic.ts` → `deactivateCompressionTarget()` (+ deep BFS deactivation) + +Design: `PruneMessagesState.structureVersion: number` bumped via +`bumpPruneStructureVersion()` at exactly those sites. + +- **sync**: steady-state fast path skips the full replay when + `structureVersion === lastSyncedStructureVersion`; it still updates the + anchor-presence bookkeeping cheaply. Any version mismatch (or first run / + post-load) triggers the original full replay, which then records the version. + Full replay remains the source of truth — the fast path only defers it. +- **hide-consumed**: its derived index (`allBlockCallIds`, + `liveRangeKeysByCallId`, `activeCallIds`) is cached in + `PruneMessagesState.hideConsumedIndex = { version, ... }`; rebuilt only when + the version changed since the cache was built. + +Correctness argument: both consumers derive exclusively from block fields that +only those three sites mutate, so version equality implies index validity. + +### 5. Ordered, coalescing save queue (RC4) + +`saveSessionState()` callers are fire-and-forget (`.catch(() => {})`) and fire +in bursts within one transform (sync deactivation, batch cleanup, nudge +anchors, compaction reset, tool finalize). Independent async whole-file writes +could settle out of request order (stale snapshot overwrites fresh) and bursts +produced N redundant serializations + writes. + +Queue semantics (per key = `sessionId \u0000 storageDir`): + +1. Snapshot serialized **synchronously at enqueue time** — a queued entry can + never read mutated state later. +2. Synchronous bursts pile onto one batch; batch drains on `setImmediate` + (macrotask boundary, so same-tick enqueues coalesce). +3. A batch writes **only the latest snapshot** (earlier ones are strictly + stale); all waiters in the batch share the outcome. +4. FIFO across batches: entries arriving during a write form the next batch, + so order is preserved end-to-end. +5. Failure isolation: a failed write rejects its batch's waiters (callers + already swallow via `.catch`) but subsequent batches proceed normally. +6. Idle queues delete themselves (no unbounded Map growth). + +## Persisted-state compatibility + +All new state fields (`structureVersion`, `lastSyncedStructureVersion`, +`hideConsumedIndex`, `SearchContext.boundaryLookup`) are **transient**: they +are excluded from `serializePruneMessagesState()`, so on-disk format is +unchanged. On load, absent fields default safely — `structureVersion` starts +at 0 which forces one full sync replay and one hide-consumed rebuild, i.e. +first transform after restart behaves exactly like pre-change code. No +migration needed; old state files load unchanged. + +## What deliberately did NOT change + +- Candidate executor validation, protection semantics (Bug 39 hard-exclusion, + protected recent window), decompression, fork recovery: untouched. +- `messageIds` ref allocation/reclamation policy: untouched (refs still grow + until compaction — that's a separate concern). +- Exact-token accounting paths: untouched. diff --git a/devlog/2026-09-11_perf-transform-history/REQ.md b/devlog/2026-09-11_perf-transform-history/REQ.md new file mode 100644 index 00000000..44912803 --- /dev/null +++ b/devlog/2026-09-11_perf-transform-history/REQ.md @@ -0,0 +1,90 @@ +# REQ - Avoid transform work that scales with compression history + +- Task ID: `2026-09-11_perf-transform-history` +- Home Repo: `opencode-acp` +- Created: 2026-09-11 +- Status: InProgress +- Priority: P1 +- Owner: ranxianglei +- References: https://github.com/ranxianglei/opencode-acp/issues/384 + +## 1. Background & Problem Statement + +- **Context**: Long ACP sessions accumulate many compression blocks, byMessageId + entries, and message-ref history. Several per-transform (every LLM call) code + paths rebuild global lookup structures or replay all historical blocks, so + cost grows with *total compression history* instead of staying bounded by + visible context. +- **Current behavior (symptom)**: OpenCode becomes slow in long ACP sessions, + especially after many compressions. Isolated probe measured adaptive candidate + planning at ~3.2 ms for 100 messages, ~34 ms for 500, ~105 ms for 1,000. +- **Expected behavior**: Per-transform work bounded by visible message count + + active block count; candidate planning ≤ 20 ms at 1,000 messages. +- **Impact**: Latency on every LLM call in long sessions; user-visible stalls. + +## 2. Reproduction (if applicable) + +- **Environment**: Node 22/24, linux +- **Minimal reproduction steps**: + 1) Run a session with many compressions (large `blocksById` / `byMessageId` / + `messageIds.byRef`). + 2) Observe per-transform time in `logs/acp/context//.json` + timestamps growing with history size. + 3) Benchmark harness: `node --import tsx scripts/bench-candidate-planning.ts` + (added in this task) reproduces the scaling at N=100/500/1000. +- **Relevant configuration**: defaults; scales regardless of mode. + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Backward compatibility: persisted state format unchanged (new fields are + transient, not serialized — serialization is explicit-field). + - Preserve: candidate executor validation, protection semantics, decompression, + fork recovery, Bug 34 auto-swap, #247 tool-pair adjustment, [PATCH Bug 3] + anchor-survival semantics, [FIX #60]/[FIX Bug 6] save semantics. + - Performance requirement: candidate planning at 1,000 messages ≤ 20 ms. +- **Non-Goals**: + - No changes to nudge thresholds/floors or prompt text. + - No changes to quality-gate, gc merge policy, or truncation limits. + - No new dependencies. + +## 4. Acceptance Criteria (must be testable) + +- **Correctness**: + - [ ] Full existing test suite passes (no behavioral regressions). + - [ ] syncCompressionBlocks incremental path produces identical state to full + replay for unchanged structure (property/equivalence tests). + - [ ] hideConsumedCompressCalls index cache invalidates on every block + mutation site (version bump coverage). + - [ ] saveSessionState writes are ordered per session; latest snapshot wins; + awaited callers observe durability; errors still propagate ([FIX Bug 6]). +- **Performance / Stability**: + - [ ] Candidate planning benchmark at 1,000 messages ≤ 20 ms (median), with + before/after evidence recorded in WORKLOG.md. + - [ ] Steady-state transform (sync + hide-consumed) no longer iterates + inactive blocks when structure is unchanged. +- **Regression**: + - [ ] New/modified test cases added to test suite and passing. + - [ ] Dual-agent review of lib/ changes + test review (AGENTS.md §5.3/§5.6). + +## 5. Proposed Approach (optional) + +- **Affected modules & entry files**: + - `lib/compress/types.ts`, `lib/compress/search.ts` — request-scoped + boundary lookup (RC1): memoize `buildBoundaryLookup` on `SearchContext`. + - `lib/messages/inject/inject.ts` — lazy no-nudge analysis (RC2): skip + composition/protected/range computation when no T1/T2/T3 nudge can fire. + - `lib/state/types.ts`, `lib/state/utils.ts`, `lib/messages/sync.ts`, + `lib/compress/hide-consumed.ts`, `lib/compress/state.ts`, + `lib/gc/merge.ts`, `lib/compress/decompress-logic.ts` + — verified-state synchronization + transient derived indexes (RC3): + transient `structureVersion` counter bumped at block-mutation sites; sync + and hide-consumed skip full replay / reuse cached index when unchanged. + - `lib/state/persistence.ts` — ordered state-save coalescing (RC4): + per-session FIFO queue, snapshot captured at enqueue, coalesced writes. +- **Risks**: + - Version-counter omission at a mutation site → stale cache/skip. Mitigated + by grepping all liveness mutators (done) + equivalence tests. + - Save-queue timing changes vs tests that mutate XDG dirs mid-flight. + Mitigated by capturing file path at enqueue time. +- **Rollback strategy**: revert the PR commits; no persisted-format change to migrate. diff --git a/devlog/2026-09-11_perf-transform-history/WORKLOG.md b/devlog/2026-09-11_perf-transform-history/WORKLOG.md new file mode 100644 index 00000000..1110a46d --- /dev/null +++ b/devlog/2026-09-11_perf-transform-history/WORKLOG.md @@ -0,0 +1,64 @@ +# WORKLOG - Avoid transform work that scales with compression history + +- Task ID: `2026-09-11_perf-transform-history` +- Branch: `2026-09-11_perf-transform-history` +- Issue: https://github.com/ranxianglei/opencode-acp/issues/384 +- Status: Complete (pending review) + +## Root causes verified in code + +| # | Claim | Verified at | Fix | +|---|-------|-------------|-----| +| RC1 | Candidate validation rebuilds boundary lookup per draft | `lib/compress/search.ts` — `resolveBoundaryIds()` called `buildBoundaryLookup(context, state)` on **every** call; `buildBoundaryLookup` iterates the entire unbounded `messageIds.byRef` map | Request-scoped memo: `SearchContext.boundaryLookup?: BoundaryLookup` populated by `buildSearchContext()`, lazily filled via `??=` in `resolveBoundaryIds()` — built once per compress call regardless of draft count | +| RC1b | Token estimation used the real Anthropic BPE tokenizer per message in `resolveSelection` | `lib/compress/search.ts` — `countAllMessageTokens(rawMessage)` per selected message; micro-bench: BPE ≈ 27 ms per ~1 KB of text | New `estimateAllMessageTokensFast()` in `lib/token-utils.ts` = `Math.round(chars / 4)` using the existing `countMessageCharacters()` convention (same estimator family used across the codebase for estimates); exact BPE retained only where correctness requires it (`getCurrentTokenUsage` rare fallback) | +| RC2 | T1 nudge analysis computed full context/range data even when no nudge can fire | `lib/messages/inject/inject.ts` — `estimateContextComposition`, `computeProtectedRefs`, `buildCompressibleRanges` ran unconditionally every transform; each stringifies tool outputs over ALL messages | Gate: `const needsNudgeAnalysis = nudgeAllowed \|\| emergencyOverride \|\| tierTriggerPossible` — heavy analysis runs only when a nudge, tier trigger, or emergency notice could actually be emitted; downstream consumers null-guarded | +| RC3 | Stable transforms replayed historical inactive blocks and rebuilt immutable consumed-call indexes | `lib/messages/sync.ts` — `syncCompressionBlocks()` sorted + replayed **all** blocks (active and inactive) every transform; `lib/compress/hide-consumed.ts` — rebuilt `allBlockCallIds`/`liveRangeKeysByCallId`/`activeCallIds` from all blocks every transform | Transient derived indexes with structural invalidation: new `PruneMessagesState.structureVersion: number` bumped at every block-mutation site (`compress/state.ts:applyCompressionState`, `gc/merge.ts:mergeMarkedBlocks`, `decompress-logic.ts:deactivateCompressionTarget`) via `bumpPruneStructureVersion()` in `state/utils.ts`; sync keeps `lastSyncedStructureVersion` + anchor bookkeeping and skips full replay when unchanged; hide-consumed caches its index in `PruneMessagesState.hideConsumedIndex` keyed by version | +| RC4 | Fire-and-forget saves raced and produced redundant whole-file writes | `lib/state/persistence.ts` — every caller did `saveSessionState(...).catch(() => {})` independently; overlapping writes could settle out of order (stale overwrite) and bursts wrote the file N times | Ordered, coalescing per-session queue inside `saveSessionState`: snapshot serialized synchronously at enqueue time (no stale reads), batches drain on `setImmediate`, each batch writes ONLY the latest snapshot, FIFO across batches, waiters of a failed batch reject without poisoning subsequent saves. Key = `sessionId \u0000 storageDir` so custom-storage sessions isolate correctly | + +## Key files + +- `lib/compress/search.ts` — boundary lookup memoization (RC1), fast token estimate in `resolveSelection` (RC1b) +- `lib/compress/types.ts` — `SearchContext.boundaryLookup` optional field (backward compatible: hand-built contexts still work via lazy fill) +- `lib/token-utils.ts` — `estimateAllMessageTokensFast()` +- `lib/messages/inject/inject.ts` — `needsNudgeAnalysis` gate + null guards (RC2) +- `lib/messages/sync.ts` — incremental steady-state path (RC3) +- `lib/compress/hide-consumed.ts` — derived-index cache (RC3) +- `lib/state/types.ts` — `structureVersion`, `hideConsumedIndex`, `lastSyncedStructureVersion` fields (transient; NOT serialized by `serializePruneMessagesState`, so persisted-state format unchanged) +- `lib/state/utils.ts` — `bumpPruneStructureVersion()` +- `lib/compress/state.ts`, `lib/gc/merge.ts`, `lib/compress/decompress-logic.ts` — version bumps at mutation sites +- `lib/state/persistence.ts` — ordered coalescing save queue (RC4) +- `scripts/bench-candidate-planning.ts` — new benchmark harness (workload: N visible messages, H ≈ N/2 historical blocks, only newest 20 active, full ref history) +- Tests: `tests/sync.test.ts` (+4), `tests/hide-consumed.test.ts` (+3), `tests/compress-search.test.ts` (+4), `tests/token-counting.test.ts` (+3), `tests/persistence.test.ts` (+4), `tests/inject.test.ts` (+2, §5.7 multi-turn production-config cycle + emergency-path preservation) + +## Benchmark evidence + +Same machine, same harness, median of 7 reps. Baseline = pristine master (`5135dfd`) run in a throwaway worktree the day of the fix. + +``` +Baseline (master): Fixed (this branch): +msgs hist A(ms) B(ms) C(ms) msgs hist A(ms) B(ms) C(ms) + 100 50 1608.62 0.06 0.04 100 50 0.40 0.01 0.03 + 500 250 6798.66 0.40 0.18 500 250 1.09 0.06 0.14 +1000 500 13618.01 0.66 0.10 1000 500 1.16 0.07 0.07 +``` + +- A = candidate planning (`buildSearchContext` + `resolveRanges` × 10 drafts) +- B = steady-state `syncCompressionBlocks`; C = `hideConsumedCompressCalls` +- **A @1000: 13618 ms → 1.16 ms (≈11,700×)** — acceptance target ≤ 20 ms met with ~17× headroom +- A @500: 6798.7 → 1.09 ms (≈6,200×); A @100: 1608.6 → 0.40 ms (≈4,000×) +- B+C steady-state flat vs history size (0.76 → 0.14 ms @1000); both now bounded by ACTIVE block count, not total history + +Note: absolute baseline numbers differ from the isolated probe in the issue (3.2/34/105 ms) because this harness models a denser workload (realistic tool-output sizes, 10 drafts per call, full un-reclaimed ref history); before/after are measured on the identical harness. + +## Test results + +- Full suite: **1151 tests, 0 failures** (`node --import tsx --test tests/*.test.ts`), up from 1131 pre-change +- `npm run typecheck`: clean +- New tests specifically guard the regression modes: sync full-replay-vs-incremental equivalence + shared-anchor ordering, hide-consumed cache reuse/invalidation identity, boundary-lookup memoization + lazy backward-compat, fast-estimator exactness, save-queue burst coalescing / no-stale-overwrite / failure isolation / storageDir isolation, §5.7 multi-turn growth cycle with `preserveRecentMessages > 0` asserting both `shouldInjectThisTurn` AND `lastPerMessageNudgeTokens`/`lastNudgeShownTokens` after each turn + +## Lessons learned + +- The dominant cost was not the "obvious" O(blocks) loops but per-draft rebuilds of O(ref-history) structures plus a real tokenizer call inside a hot loop — profile before optimizing. +- Derived indexes must be invalidated at EVERY mutation site; the three block-mutation sites (compress apply, batch merge, decompress deactivation) were found by grepping `.active =` / `deactivatedByUser*` assignments and `blocksById.set/delete`. +- Save-ordering bugs are invisible to single-save tests; behavioral coverage must burst saves synchronously and assert which snapshot won. +- Transient state fields must stay OUT of `serializePruneMessagesState` to keep persisted-state compatibility; load-time reconstruction defaults them safely (version 0 forces one full replay after restart — correct by construction). diff --git a/lib/compress/decompress-logic.ts b/lib/compress/decompress-logic.ts index 1c7f228f..84d2bdd9 100644 --- a/lib/compress/decompress-logic.ts +++ b/lib/compress/decompress-logic.ts @@ -1,5 +1,6 @@ import type { CompressionBlock, PruneMessagesState, WithParts } from "../state" import { parseBlockRef } from "../message-ids" +import { bumpPruneStructureVersion } from "../state/utils" import type { CompressionTarget } from "../commands/compression-targets" export function parseBlockIdArg(arg: string): number | null { @@ -145,6 +146,12 @@ export function deactivateCompressionTarget( } } } + + // [Issue #384] Block liveness changed — invalidate sync + hide-consumed + // caches derived from previous versions. + if (target.blocks.length > 0) { + bumpPruneStructureVersion(messagesState) + } } export interface RestoredMessagesResult { diff --git a/lib/compress/hide-consumed.ts b/lib/compress/hide-consumed.ts index 94052e9f..a2090b0c 100644 --- a/lib/compress/hide-consumed.ts +++ b/lib/compress/hide-consumed.ts @@ -60,21 +60,33 @@ function rewriteCompressInput(part: Part, liveKeys: Set): Part | null { * in the default protected-tools list. See upstream issue #288. */ export function hideConsumedCompressCalls(state: SessionState, messages: WithParts[]): number { - const allBlockCallIds = new Set() - const liveRangeKeysByCallId = new Map>() - const activeCallIds = new Set() - for (const block of state.prune.messages.blocksById.values()) { - if (!block.compressCallId) continue - allBlockCallIds.add(block.compressCallId) - if (!isLiveBlock(block)) continue - activeCallIds.add(block.compressCallId) - let keys = liveRangeKeysByCallId.get(block.compressCallId) - if (!keys) { - keys = new Set() - liveRangeKeysByCallId.set(block.compressCallId, keys) + // [Issue #384] Transient derived index: these structures depend only on + // block liveness (tracked by structureVersion), never on the message list. + // Rebuilding them over ALL historical blocks on every transform scaled with + // compression history; cache them per structureVersion instead. + const messagesState = state.prune.messages + const version = messagesState.structureVersion ?? 0 + let cached = messagesState.hideConsumedIndex + if (!cached || cached.version !== version) { + const allBlockCallIds = new Set() + const liveRangeKeysByCallId = new Map>() + const activeCallIds = new Set() + for (const block of messagesState.blocksById.values()) { + if (!block.compressCallId) continue + allBlockCallIds.add(block.compressCallId) + if (!isLiveBlock(block)) continue + activeCallIds.add(block.compressCallId) + let keys = liveRangeKeysByCallId.get(block.compressCallId) + if (!keys) { + keys = new Set() + liveRangeKeysByCallId.set(block.compressCallId, keys) + } + keys.add(rangeKey(block.startId, block.endId)) } - keys.add(rangeKey(block.startId, block.endId)) + cached = { version, allBlockCallIds, liveRangeKeysByCallId, activeCallIds } + messagesState.hideConsumedIndex = cached } + const { allBlockCallIds, liveRangeKeysByCallId, activeCallIds } = cached const lastOrphanedCallIds: string[] = [] for (let i = messages.length - 1; i >= 0 && lastOrphanedCallIds.length < KEEP_LAST_ORPHANED; i--) { diff --git a/lib/compress/search.ts b/lib/compress/search.ts index c070964f..9eb97505 100644 --- a/lib/compress/search.ts +++ b/lib/compress/search.ts @@ -3,7 +3,7 @@ import type { SessionState, WithParts } from "../state" import { formatBlockRef, formatMessageRef, parseBoundaryId, parseMessageRef } from "../message-ids" import { isIgnoredUserMessage } from "../messages/query" import { filterMessages } from "../messages/shape" -import { countAllMessageTokens } from "../token-utils" +import { estimateAllMessageTokensFast } from "../token-utils" import { type BoundaryReference, type SearchContext, @@ -42,12 +42,18 @@ export function buildSearchContext(state: SessionState, rawMessages: WithParts[] summaryByBlockId.set(blockId, block) } - return { + const context: SearchContext = { rawMessages, rawMessagesById, rawIndexById, summaryByBlockId, } + + // [Issue #384] Build the boundary lookup once per request context instead + // of once per boundary pair (resolveBoundaryIds memoizes onto this field). + context.boundaryLookup = buildBoundaryLookup(context, state) + + return context } export function resolveBoundaryIds( @@ -57,7 +63,7 @@ export function resolveBoundaryIds( endId: string, logger?: { warn(message: string, data?: any): void }, ): { startReference: BoundaryReference; endReference: BoundaryReference } { - const lookup = buildBoundaryLookup(context, state) + const lookup = context.boundaryLookup ?? (context.boundaryLookup = buildBoundaryLookup(context, state)) const issues: string[] = [] const parsedStartId = parseBoundaryId(startId) const parsedEndId = parseBoundaryId(endId) @@ -325,7 +331,7 @@ export function resolveSelection( } if (!messageTokenById.has(messageId)) { - messageTokenById.set(messageId, countAllMessageTokens(rawMessage)) + messageTokenById.set(messageId, estimateAllMessageTokensFast(rawMessage)) } const parts = Array.isArray(rawMessage.parts) ? rawMessage.parts : [] diff --git a/lib/compress/state.ts b/lib/compress/state.ts index 94cac771..69098e2c 100644 --- a/lib/compress/state.ts +++ b/lib/compress/state.ts @@ -1,4 +1,5 @@ import type { CompressionBlock, CompressionTier, PruneMessagesState, SessionState } from "../state" +import { bumpPruneStructureVersion } from "../state/utils" import { formatBlockRef, formatMessageIdTag } from "../message-ids" import type { AppliedCompressionResult, CompressionStateInput, SelectionResolution } from "./types" import type { GCConfig } from "../config" @@ -331,6 +332,10 @@ export function applyCompressionState( state.stats.totalPruneTokens += state.stats.pruneTokenCounter state.stats.pruneTokenCounter = 0 + // [Issue #384] Block structure/liveness changed — invalidate sync + + // hide-consumed caches derived from previous versions. + bumpPruneStructureVersion(messagesState) + return { compressedTokens, messageIds: selection.messageIds, diff --git a/lib/compress/types.ts b/lib/compress/types.ts index bddc711e..706e3404 100644 --- a/lib/compress/types.ts +++ b/lib/compress/types.ts @@ -87,6 +87,12 @@ export interface SearchContext { rawMessagesById: Map rawIndexById: Map summaryByBlockId: Map + /** + * [Issue #384] Request-scoped boundary lookup (mNNNNN/bN → BoundaryReference), + * built once per SearchContext instead of once per boundary pair. Optional so + * hand-built contexts (tests) keep working; resolveBoundaryIds memoizes it lazily. + */ + boundaryLookup?: Map } export interface SelectionResolution { diff --git a/lib/gc/merge.ts b/lib/gc/merge.ts index 6e8aef17..90771a8f 100644 --- a/lib/gc/merge.ts +++ b/lib/gc/merge.ts @@ -3,6 +3,7 @@ import type { PluginConfig } from "../config" import type { Logger } from "../logger" import { countTokens, getCurrentTokenUsage } from "../token-utils" import { resolveEffectiveContextLimit } from "../state/utils" +import { bumpPruneStructureVersion } from "../state/utils" import { COMPRESSED_BLOCK_HEADER, allocateBlockId, @@ -183,6 +184,10 @@ export function mergeMarkedBlocks( ) const savedTokens = Math.max(0, sourceTokens - newSummaryTokens) + // [Issue #384] Block structure/liveness changed — invalidate sync + + // hide-consumed caches derived from previous versions. + bumpPruneStructureVersion(messagesState) + return { mergedCount: sourceBlocks.length, savedTokens } } diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index d2420be6..085a6f0b 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -46,6 +46,7 @@ import { resolveMinNudgeFloorTokens, applyCompressOverrides, } from "./utils" +import type { ContextComposition, ContextRanges } from "./utils" import { buildCompressedBlockGuidance } from "../../prompts/extensions/nudge" import { COMPRESS_PHILOSOPHY, HOW_TO_COMPRESS_RULES, TIER2_DISTILL_RULES, TIER3_CONDENSE_RULES } from "context-compress-algorithms/prompts" import { getTierTokenUsage } from "../../state/utils" @@ -368,26 +369,48 @@ export const injectCompressNudges = ( baselineReEstablished = true } - const composition = estimateContextComposition( - messages, - state, - config.compress.protectedTools, - config.protectedFilePatterns, - ) + // [Issue #384] Lazy nudge analysis: the passes below scan every message + // (JSON.stringify per tool part) but their results are only consumed when + // a nudge, emergency notice, or tier trigger can actually fire this turn. + // getTierTokenUsage is cheap (active blocks only), so use it to predict + // tier-trigger possibility before deciding whether the heavy work is + // needed. With the empty defaults below, every downstream flag evaluates + // exactly as before when no nudge could fire (nothingToCompress stays + // false, hasRecommendations false, suppressed-log counts zero). + const tierUsageEarly = getTierTokenUsage(state) + const tierTriggerPossible = + !!suffixMessage && + (tierUsageEarly.tier1Tokens >= nudgeGrowthTokens || + tierUsageEarly.tier2Tokens >= nudgeGrowthTokens) + // emergencyOverride is subsumed by nudgeAllowed but kept explicit so the gate stays correct if nudgeAllowed's definition ever changes + const needsNudgeAnalysis = nudgeAllowed || emergencyOverride || tierTriggerPossible + + const composition: ContextComposition | null = needsNudgeAnalysis + ? estimateContextComposition( + messages, + state, + config.compress.protectedTools, + config.protectedFilePatterns, + ) + : null // Compute protected zone first — buildCompressibleRanges uses it to split // groups at the boundary so the unprotected head survives as a range. - const protectedRefs = computeProtectedRefs(messages, state, config.compress) + const protectedRefs = needsNudgeAnalysis + ? computeProtectedRefs(messages, state, config.compress) + : new Set() // Compute recommendation filter BEFORE applyAnchoredNudges — the result // gates whether the nudge text is injected at all (Issue #216 Defect 1). - const contextRanges = buildCompressibleRanges( - messages, - state, - config.compress.protectedTools, - config.protectedFilePatterns, - protectedRefs, - ) + const contextRanges: ContextRanges = needsNudgeAnalysis + ? buildCompressibleRanges( + messages, + state, + config.compress.protectedTools, + config.protectedFilePatterns, + protectedRefs, + ) + : { compressible: [], protected: [] } const unprotectedCompressible = excludeProtectedRanges(contextRanges.compressible, protectedRefs) @@ -436,7 +459,9 @@ export const injectCompressNudges = ( // Priority: T1 > T2 > T3. T1 compression reduces raw context first. // Each tier has independent cadence counters — T2 firing doesn't block T3. if (suffixMessage && !shouldInject) { - const tierUsage = getTierTokenUsage(state) + // [Issue #384] Reuse the early tier-usage snapshot: nothing between the + // computation above and here mutates compression blocks. + const tierUsage = tierUsageEarly const tierChecks = [ { triggerTier: 2 as const, targetTier: 1 as const, tokens: tierUsage.tier1Tokens, lastNudge: state.nudges.lastTier2NudgeTokens }, @@ -589,7 +614,7 @@ export const injectCompressNudges = ( let tipsText: string | null = null if (shouldInject) { - if (suffixMessage && composition.total > 0) { + if (suffixMessage && composition !== null && composition.total > 0) { const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) const pct = (n: number) => n > 0 ? Math.max(1, Math.round((n / composition.total) * 100)) : 0 diff --git a/lib/messages/sync.ts b/lib/messages/sync.ts index 6bee4e55..6411fe52 100644 --- a/lib/messages/sync.ts +++ b/lib/messages/sync.ts @@ -1,4 +1,4 @@ -import type { SessionState, WithParts } from "../state" +import type { CompressionBlock, SessionState, WithParts } from "../state" import type { Logger } from "../logger" function sortBlocksByCreation( @@ -23,6 +23,38 @@ export const syncCompressionBlocks = ( } const messageIds = new Set(messages.map((msg) => msg.info.id)) + + // [Issue #384] Verified-state synchronization: the full replay below walks + // EVERY historical block (unbounded growth with session length). Block + // liveness only changes through structureVersion-bumped mutations, so when + // the version matches the last sync, liveness is already at its fixed + // point. The only thing that can drift between transforms is the anchor + // map — opencode may have removed or added messages since — so rebuild it + // from ACTIVE blocks only (bounded) and skip the rest. + const structureVersion = messagesState.structureVersion ?? 0 + if (messagesState.lastSyncedStructureVersion === structureVersion) { + const activeBlocks = [...messagesState.activeBlockIds] + .map((id) => messagesState.blocksById.get(id)) + .filter((b): b is CompressionBlock => b !== undefined) + .sort(sortBlocksByCreation) + const nextAnchorMap = new Map() + for (const block of activeBlocks) { + if (!messageIds.has(block.anchorMessageId)) continue + nextAnchorMap.set(block.anchorMessageId, block.blockId) + } + const previous = messagesState.activeByAnchorMessageId + if ( + previous.size !== nextAnchorMap.size || + ![...nextAnchorMap.entries()].every(([anchor, id]) => previous.get(anchor) === id) + ) { + previous.clear() + for (const [anchor, id] of nextAnchorMap) { + previous.set(anchor, id) + } + } + return + } + const previousActiveBlockIds = new Set( Array.from(messagesState.blocksById.values()) .filter((block) => block.active) @@ -110,4 +142,6 @@ export const syncCompressionBlocks = ( reactivatedCount, }) } + + messagesState.lastSyncedStructureVersion = structureVersion } diff --git a/lib/state/persistence.ts b/lib/state/persistence.ts index e8443a06..7d223059 100644 --- a/lib/state/persistence.ts +++ b/lib/state/persistence.ts @@ -130,13 +130,49 @@ async function writePersistedSessionState( } // [FIX Bug 6] Removed try/catch — errors now propagate to callers so they know save failed -export async function saveSessionState( +// +// [Issue #384] Ordered, coalescing per-session save queue. +// Long sessions trigger several saves per transform (sync deactivation, batch +// cleanup, nudge anchors, compaction reset, tool finalize). Previously each +// was an independent fire-and-forget whole-file write: overlapping writes +// could complete out of request order (a stale snapshot overwriting a fresh +// one) and bursts produced redundant full-file serializations. Now: +// - the snapshot is captured at enqueue time (payloads by reference); late +// serialization at write time only sees strictly-newer values, which is safe +// because inter-save mutations are additive/monotonic; +// - a single FIFO writer per session drains snapshots in request order, so +// on-disk content always reflects the most recent request; +// - snapshots enqueued before the writer starts are coalesced into ONE +// write of the latest snapshot — it was serialized after every other in +// the batch from the same live state, so it is strictly newer; +// - every caller resolves once its snapshot (or a newer one superseding it) +// is durable; a failed write rejects that batch's callers (Bug 6 kept). +interface PendingSave { + sessionId: string + state: PersistedSessionState + storageDir?: string + logger: Logger +} + +interface SaveQueue { + pending: PendingSave[] + waiters: Array<{ resolve: () => void; reject: (err: unknown) => void }> + draining: boolean +} + +const saveQueues = new Map() + +function saveQueueKey(sessionId: string, storageDir?: string): string { + return `${sessionId}\u0000${storageDir ?? ""}` +} + +export function saveSessionState( sessionState: SessionState, logger: Logger, sessionName?: string, ): Promise { if (!sessionState.sessionId) { - return + return Promise.resolve() } const state: PersistedSessionState = { @@ -169,7 +205,65 @@ export async function saveSessionState( modelID: sessionState.modelID, } - await writePersistedSessionState(sessionState.sessionId, state, logger, sessionState.storageDir) + const key = saveQueueKey(sessionState.sessionId, sessionState.storageDir) + let queue = saveQueues.get(key) + if (!queue) { + queue = { pending: [], waiters: [], draining: false } + saveQueues.set(key, queue) + } + + const promise = new Promise((resolve, reject) => { + queue!.waiters.push({ resolve, reject }) + }) + queue.pending.push({ + sessionId: sessionState.sessionId, + state, + storageDir: sessionState.storageDir, + logger, + }) + + // Macrotask boundary: synchronous bursts pile onto one batch before I/O. + if (!queue.draining) { + queue.draining = true + setImmediate(() => drainSaveQueue(key)) + } + + return promise +} + +function drainSaveQueue(key: string): void { + const queue = saveQueues.get(key) + if (!queue || queue.pending.length === 0) { + if (queue) { + queue.draining = false + if (queue.waiters.length === 0) saveQueues.delete(key) + } + return + } + + // Take the current batch; entries arriving during the write below become + // the next batch, preserving request order across drains. + const batch = queue.pending.splice(0, queue.pending.length) + const waiters = queue.waiters.splice(0, batch.length) + const latest = batch[batch.length - 1] + + writePersistedSessionState(latest.sessionId, latest.state, latest.logger, latest.storageDir) + .then(() => { + for (const waiter of waiters) waiter.resolve() + }) + .catch((err: unknown) => { + for (const waiter of waiters) waiter.reject(err) + }) + .finally(() => { + const q = saveQueues.get(key) + if (!q) return + if (q.pending.length > 0) { + drainSaveQueue(key) + } else { + q.draining = false + if (q.waiters.length === 0) saveQueues.delete(key) + } + }) } export async function loadSessionState( diff --git a/lib/state/types.ts b/lib/state/types.ts index 509ebb02..09c08f6c 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -82,6 +82,30 @@ export interface PruneMessagesState { nextBlockId: number nextRunId: number markedForCleanup: Set + + /** + * [Issue #384] Transient fields below are NEVER persisted — serialization + * (serializePruneMessagesState / PersistedSessionState) lists fields + * explicitly, so they survive only within one process lifetime. + * + * structureVersion: monotonically increasing counter bumped by every + * block-structure/liveness mutation (applyCompressionState, merge, user + * decompress). syncCompressionBlocks uses it to skip the full replay over + * all historical blocks when nothing changed since the last sync. + */ + structureVersion?: number + /** structureVersion at which the last sync (full or incremental) ran. */ + lastSyncedStructureVersion?: number + /** + * Cached consumed-call indexes for hideConsumedCompressCalls. Depends only + * on block liveness, so it is keyed by structureVersion. + */ + hideConsumedIndex?: { + version: number + allBlockCallIds: Set + liveRangeKeysByCallId: Map> + activeCallIds: Set + } } export interface Prune { diff --git a/lib/state/utils.ts b/lib/state/utils.ts index 3132f5c9..70ca8544 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -40,6 +40,16 @@ interface PersistedPruneMessagesState { markedForCleanup?: number[] } +/** + * [Issue #384] Bump the transient block-structure version so consumers + * (syncCompressionBlocks, hideConsumedCompressCalls) can detect that cached + * liveness-derived data is stale. Call once after every mutation of block + * structure or liveness (new blocks, deactivation, user decompress, merge). + */ +export function bumpPruneStructureVersion(messagesState: PruneMessagesState): void { + messagesState.structureVersion = (messagesState.structureVersion ?? 0) + 1 +} + export function serializePruneMessagesState( messagesState: PruneMessagesState, ): PersistedPruneMessagesState { diff --git a/lib/token-utils.ts b/lib/token-utils.ts index 86443288..b4285034 100644 --- a/lib/token-utils.ts +++ b/lib/token-utils.ts @@ -235,3 +235,14 @@ export function countMessageCharacters(msg: WithParts): number { } return total } + +/** + * [Issue #384] Fast per-message token estimate using the chars/4 convention + * already used across the codebase for token statistics (tool-cache.ts, + * pipeline.ts, inject/utils.ts). The BPE-exact countAllMessageTokens costs + * ~25ms/message on this runtime and dominated candidate planning on wide + * draft ranges; these counters feed heuristic stats/gates, not billing. + */ +export function estimateAllMessageTokensFast(msg: WithParts): number { + return Math.round(countMessageCharacters(msg) / 4) +} diff --git a/scripts/bench-candidate-planning.ts b/scripts/bench-candidate-planning.ts new file mode 100644 index 00000000..abcde0c4 --- /dev/null +++ b/scripts/bench-candidate-planning.ts @@ -0,0 +1,334 @@ +/** + * Benchmark harness for issue #384 — perf: avoid transform work that scales + * with compression history. + * + * Measures the per-transform hot paths against synthetic long sessions: + * A. Candidate planning: buildSearchContext + resolveRanges (D draft ranges) + * B. Steady-state sync: syncCompressionBlocks (no structural change) + * C. Hide consumed: hideConsumedCompressCalls (block index rebuild) + * + * Workload model: N visible messages with realistic tool outputs; H ≈ N/2 + * historical compressions producing a block chain where only the newest ~20 + * blocks are active (the rest consumed/inactive); full byMessageId / byRef + * history retained (refs are never reclaimed between compactions). + * + * Usage: + * node --import tsx scripts/bench-candidate-planning.ts [N ...] + * (defaults: 100 500 1000) + */ +import { buildSearchContext } from "../lib/compress/search" +import { resolveRanges } from "../lib/compress/range-utils" +import { syncCompressionBlocks } from "../lib/messages/sync" +import { hideConsumedCompressCalls } from "../lib/compress/hide-consumed" +import type { + CompressionBlock, + PrunedMessageEntry, + SessionState, + WithParts, +} from "../lib/state/types" + +const SID = "bench-session-384" +const NOOP_LOGGER = { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, +} as any + +const DRAFTS = 10 // number of candidate ranges planned per compress call + +function makeMessage(id: string, role: "user" | "assistant", toolPart?: any): WithParts { + const parts: any[] = [ + { + type: "text", + text: + role === "user" + ? "Please investigate the failing pipeline and summarize the root cause." + : "The failure originates in the retry loop; here is the relevant excerpt and analysis of the stack trace.", + }, + ] + if (toolPart) parts.push(toolPart) + return { + info: { + id, + sessionID: SID, + role, + time: { created: 1_700_000_000_000 }, + ...(role === "assistant" + ? { + parentID: "parent-1", + modelID: "test-model", + providerID: "test-provider", + mode: "normal", + agent: "test", + path: { cwd: "/", root: "/" }, + summary: false, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + : { + agent: "test", + model: { providerID: "test-provider", modelID: "test-model" }, + }), + } as any, + parts, + } as WithParts +} + +function makeToolPart(callID: string): any { + return { + type: "tool", + callID, + tool: "bash", + state: { + status: "completed", + input: { command: "npm test" }, + output: { stdout: "x".repeat(600), stderr: "" }, + }, + } +} + +function makeCompressToolPart(callID: string, startId: string, endId: string): any { + return { + type: "tool", + callID, + tool: "compress", + state: { + status: "completed", + input: { content: [{ startId, endId, summary: "s" }] }, + output: { title: "compressed" }, + }, + } +} + +function makeBlock(overrides: Partial = {}): CompressionBlock { + return { + blockId: 1, + runId: 1, + active: true, + deactivatedByUser: false, + compressedTokens: 1000, + summaryTokens: 120, + durationMs: 0, + topic: "bench", + batchTopic: "bench", + startId: "m00001", + endId: "m00002", + anchorMessageId: "raw-1", + compressMessageId: "comp-1", + compressCallId: undefined, + includedBlockIds: [], + consumedBlockIds: [], + parentBlockIds: [], + directMessageIds: [], + directToolIds: [], + effectiveMessageIds: [], + effectiveToolIds: [], + createdAt: 1000, + deactivatedAt: undefined, + deactivatedByBlockId: undefined, + summary: "A summary of the compressed range.", + survivedCount: 0, + generation: "young", + ...overrides, + } +} + +interface Workload { + messages: WithParts[] + state: SessionState + drafts: Array<{ startId: string; endId: string }> +} + +function ref(i: number): string { + return `m${String(i + 1).padStart(5, "0")}` +} + +function buildWorkload(n: number): Workload { + const messages: WithParts[] = [] + const byRef = new Map() + const byRawId = new Map() + + let compressCallCounter = 0 + for (let i = 0; i < n; i++) { + const rawId = `raw-${i + 1}` + const r = ref(i) + byRef.set(r, rawId) + byRawId.set(rawId, r) + const role: "user" | "assistant" = i % 4 === 0 ? "user" : "assistant" + const parts: any[] = [] + if (role === "assistant") { + // one ordinary tool call per assistant message + parts.push(makeToolPart(`call-${i + 1}`)) + // every 5th assistant message carries a finished compress call + if ((i - 1) % 5 === 0 && i >= 1) { + const k = ++compressCallCounter + parts.push(makeCompressToolPart(`comp-call-${k}`, ref(Math.max(0, i - 4)), ref(i - 1))) + } + } + messages.push(makeMessage(rawId, role, parts.length ? parts[0] : undefined)) + if (parts.length > 1) { + // attach extra parts (compress call) to the same message + messages[i]!.parts.push(...parts.slice(1)) + } + } + + // Historical compression chain: H ≈ n/2 blocks; only newest ACTIVE_FRONTIER active. + const h = Math.floor(n / 2) + const activeFrontier = Math.min(20, h) + const blocksById = new Map() + const byMessageId = new Map() + const activeBlockIds = new Set() + const activeByAnchorMessageId = new Map() + + for (let b = 0; b < h; b++) { + const startIdx = b * 2 + const endIdx = Math.min(n - 1, b * 2 + 1) + if (startIdx >= n) break + const isActive = b >= h - activeFrontier + const block = makeBlock({ + blockId: b + 1, + runId: b + 1, + active: isActive, + startId: ref(startIdx), + endId: ref(endIdx), + anchorMessageId: `raw-${endIdx + 1}`, + compressMessageId: `comp-msg-${b + 1}`, + compressCallId: `comp-call-${b + 1}`, + createdAt: 1000 + b, + consumedBlockIds: [b > 0 ? b : 0].filter((x) => x > 0), + effectiveMessageIds: [`raw-${startIdx + 1}`, `raw-${endIdx + 1}`], + generation: isActive ? "young" : "old", + survivedCount: isActive ? 0 : 99, + }) + if (!isActive) { + block.deactivatedAt = 2000 + b + block.deactivatedByBlockId = b + 2 + } + blocksById.set(b + 1, block) + if (isActive) { + activeBlockIds.add(b + 1) + activeByAnchorMessageId.set(block.anchorMessageId, b + 1) + } + for (const mid of [`raw-${startIdx + 1}`, `raw-${endIdx + 1}`]) { + const entry = byMessageId.get(mid) ?? { + allBlockIds: [], + activeBlockIds: [], + lastCompressedAt: 0, + } + entry.allBlockIds = entry.allBlockIds.filter((id) => id !== b + 1) + entry.allBlockIds.push(b + 1) + if (isActive) { + entry.activeBlockIds = entry.activeBlockIds.filter((id) => id !== b + 1) + entry.activeBlockIds.push(b + 1) + } + byMessageId.set(mid, entry) + } + } + + // Candidate drafts: D ranges spread across visible history. + const drafts: Array<{ startId: string; endId: string }> = [] + for (let d = 0; d < DRAFTS; d++) { + const span = Math.max(2, Math.floor(n / DRAFTS / 2)) + const start = Math.min(n - 2, d * (Math.floor(n / DRAFTS) || 1)) + drafts.push({ startId: ref(start), endId: ref(Math.min(n - 1, start + span)) }) + } + + const state: SessionState = { + sessionId: SID, + isSubAgent: false, + compressPermission: "allow", + prune: { + messages: { + byMessageId, + blocksById, + activeBlockIds, + activeByAnchorMessageId, + nextBlockId: h + 1, + nextRunId: h + 1, + markedForCleanup: new Set(), + }, + }, + nudges: { + contextLimitAnchors: new Set(), + turnNudgeAnchors: new Set(), + iterationNudgeAnchors: new Set(), + lastPerMessageNudgeTurn: 0, + lastPerMessageNudgeTokens: undefined, + lastNudgeShownTokens: undefined, + lastToolOutputNudgeTokens: undefined, + lastTier2NudgeTokens: undefined, + lastTier3NudgeTokens: undefined, + shouldInjectThisTurn: undefined, + compressBaselineSet: false, + lastProcessedCompressMessageId: undefined, + }, + stats: { pruneTokenCounter: 0, totalPruneTokens: 0 }, + compressionTiming: {} as any, + toolParameters: new Map(), + toolIdList: [], + messageIds: { byRawId, byRef, nextRef: n + 1 }, + lastCompaction: 0, + currentTurn: 0, + modelContextLimit: undefined, + systemPromptTokens: undefined, + } + + return { messages, state, drafts } +} + +function cloneMessages(messages: WithParts[]): WithParts[] { + return messages.map((m) => ({ ...m, parts: Array.isArray(m.parts) ? [...m.parts] : m.parts })) +} + +function medianMs(fn: () => void, reps = 7): number { + const samples: number[] = [] + for (let i = 0; i < reps; i++) { + const t0 = process.hrtime.bigint() + fn() + samples.push(Number(process.hrtime.bigint() - t0) / 1e6) + } + samples.sort((a, b) => a - b) + return samples[Math.floor(samples.length / 2)]! +} + +async function main() { + const args = process.argv.slice(2).filter((a) => /^\d+$/.test(a)).map(Number) + const sizes = args.length ? args : [100, 500, 1000] + + console.log(`# issue #384 benchmark — candidate planning & steady-state transform`) + console.log(`# drafts per compress call: ${DRAFTS}; reps: 7 (median)` ) + console.log("#") + console.log( + "msgs history(blocks) active A: candidate planning(ms) B: sync(ms) C: hide-consumed(ms) B+C steady-state(ms)", + ) + + for (const n of sizes) { + const { messages, state, drafts } = buildWorkload(n) + const blocksTotal = state.prune.messages.blocksById.size + const active = state.prune.messages.activeBlockIds.size + + // Warm-up (also establishes steady state for sync). + syncCompressionBlocks(state, NOOP_LOGGER, cloneMessages(messages)) + + const planOnce = () => { + const context = buildSearchContext(state, messages) + resolveRanges({ content: drafts } as any, context, state, NOOP_LOGGER) + } + const syncOnce = () => syncCompressionBlocks(state, NOOP_LOGGER, cloneMessages(messages)) + const hideOnce = () => hideConsumedCompressCalls(state, cloneMessages(messages)) + + const planMs = medianMs(planOnce) + const syncMs = medianMs(syncOnce) + const hideMs = medianMs(hideOnce) + + console.log( + `${String(n).padStart(4)} ${String(blocksTotal).padStart(15)} ${String(active).padStart(6)} ${planMs.toFixed(2).padStart(24)} ${syncMs.toFixed(2).padStart(11)} ${hideMs.toFixed(2).padStart(21)} ${(syncMs + hideMs).toFixed(2)}`, + ) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/tests/compress-search.test.ts b/tests/compress-search.test.ts index 34fbd5b3..6b41c282 100644 --- a/tests/compress-search.test.ts +++ b/tests/compress-search.test.ts @@ -555,3 +555,94 @@ test("resolveAnchorMessageId throws for message kind without messageId", () => { const ref: BoundaryReference = { kind: "message", rawIndex: 0 } assert.throws(() => resolveAnchorMessageId(ref), /Failed to map boundary matches/) }) + +// --- Tests for [Issue #384] request-scoped boundary lookup + fast token estimates --- + +function makeRefState(): SessionState { + return makeState({ + messageIds: { + byRawId: new Map([ + ["raw-1", "m00001"], + ["raw-2", "m00002"], + ]), + byRef: new Map([ + ["m00001", "raw-1"], + ["m00002", "raw-2"], + ]), + nextRef: 3, + }, + }) +} + +test("buildSearchContext pre-populates boundaryLookup from refs and active block anchors (Issue #384)", () => { + const m1 = makeAssistantMessage("raw-1", "one") + const m2 = makeAssistantMessage("raw-2", "two") + const state = makeRefState() + state.prune.messages.blocksById.set(7, makeBlock({ blockId: 7, anchorMessageId: "raw-2" })) + state.prune.messages.activeBlockIds.add(7) + + const ctx = buildSearchContext(state, [m1, m2]) + + assert.ok(ctx.boundaryLookup, "boundaryLookup must be pre-built with the context") + const msgRef = ctx.boundaryLookup!.get("m00001")! + assert.equal(msgRef.kind, "message") + assert.equal(msgRef.rawIndex, 0) + assert.equal(msgRef.messageId, "raw-1") + const blockRef = ctx.boundaryLookup!.get("b7")! + assert.equal(blockRef.kind, "compressed-block") + assert.equal(blockRef.rawIndex, 1) + assert.equal(blockRef.blockId, 7) + assert.equal(blockRef.anchorMessageId, "raw-2") +}) + +test("resolveBoundaryIds reuses the pre-built boundaryLookup across drafts (Issue #384)", () => { + const m1 = makeAssistantMessage("raw-1", "one") + const m2 = makeAssistantMessage("raw-2", "two") + const state = makeRefState() + + const ctx = buildSearchContext(state, [m1, m2]) + const first = ctx.boundaryLookup! + + const r1 = resolveBoundaryIds(ctx, state, "m00001", "m00002") + assert.equal(r1.startReference.messageId, "raw-1") + assert.equal(r1.endReference.messageId, "raw-2") + + // A second draft on the same request context must NOT rebuild the lookup. + const r2 = resolveBoundaryIds(ctx, state, "m00002", "m00002") + assert.equal(r2.startReference.messageId, "raw-2") + assert.equal(r2.endReference.messageId, "raw-2") + assert.equal( + ctx.boundaryLookup, + first, + "lookup object identity must be preserved across drafts within one request", + ) +}) + +test("resolveBoundaryIds lazily builds boundaryLookup for hand-built contexts (backward compat)", () => { + const m1 = makeAssistantMessage("raw-1", "one") + const m2 = makeAssistantMessage("raw-2", "two") + const state = makeRefState() + + // makeContext() builds a SearchContext without boundaryLookup, like + // pre-#384 callers did; resolution must still work via lazy memoization. + const ctx = makeContext([m1, m2]) + assert.equal(ctx.boundaryLookup, undefined) + + const { startReference, endReference } = resolveBoundaryIds(ctx, state, "m00001", "m00002") + assert.equal(startReference.messageId, "raw-1") + assert.equal(endReference.messageId, "raw-2") + assert.ok(ctx.boundaryLookup, "hand-built context gets the lookup attached on first resolve") +}) + +test("resolveSelection uses fast character-based token estimates (Issue #384)", () => { + const m1 = makeAssistantMessage("big", "a".repeat(1000)) + const ctx = makeContext([m1]) + const ref: BoundaryReference = { kind: "message", rawIndex: 0, messageId: "big" } + + const result = resolveSelection(ctx, ref, ref) + + // countMessageCharacters = 1000 text chars → Math.round(1000 / 4) = 250. + // The BPE-exact path would cost ~25ms here; the fast path must be exact + // against the chars/4 convention. + assert.equal(result.messageTokenById.get("big"), 250) +}) diff --git a/tests/hide-consumed.test.ts b/tests/hide-consumed.test.ts index dc9d59de..05745832 100644 --- a/tests/hide-consumed.test.ts +++ b/tests/hide-consumed.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "node:test" import assert from "node:assert/strict" import type { WithParts, SessionState } from "../lib/state" import { hideConsumedCompressCalls } from "../lib/compress/hide-consumed" +import { bumpPruneStructureVersion } from "../lib/state/utils" import type { CompressionBlock } from "../lib/state/types" function makeBlock(overrides: Partial & { blockId: number }): CompressionBlock { @@ -667,3 +668,111 @@ describe("hideConsumedCompressCalls", () => { ) }) }) + +describe("hideConsumedCompressCalls [Issue #384] derived-index cache", () => { + function consumedAndLive() { + const b1 = makeBlock({ + blockId: 1, + active: false, + deactivatedByBlockId: 2, + compressMessageId: "msg-c1", + compressCallId: "call-c1", + tier: 1, + }) + const b2 = makeBlock({ + blockId: 2, + active: true, + compressMessageId: "msg-c2", + compressCallId: "call-c2", + tier: 2, + }) + return [b1, b2] as CompressionBlock[] + } + + function baseMessages(): WithParts[] { + return [ + { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, + { + info: { id: "msg-c1", role: "assistant" } as any, + parts: [ + { type: "text", text: "Compressing" }, + { type: "tool", tool: "compress", callID: "call-c1", state: { status: "completed" } }, + ], + }, + { + info: { id: "msg-c2", role: "assistant" } as any, + parts: [{ type: "tool", tool: "compress", callID: "call-c2", state: { status: "completed" } }], + }, + ] + } + + it("builds the block index once and reuses it while the structure version is unchanged", () => { + const state = makeState(consumedAndLive()) as SessionState + const hidden = hideConsumedCompressCalls(state, baseMessages()) + assert.equal(hidden, 1) + + const cached = state.prune.messages.hideConsumedIndex + assert.ok(cached, "cache must be recorded on the session state") + + hideConsumedCompressCalls(state, baseMessages().map((m) => ({ ...m, parts: [...m.parts] }))) + assert.equal( + state.prune.messages.hideConsumedIndex, + cached, + "index must be reused while the version is unchanged", + ) + }) + + it("rebuilds the index after a structure version bump", () => { + const state = makeState(consumedAndLive()) as SessionState + hideConsumedCompressCalls(state, baseMessages()) + const before = state.prune.messages.hideConsumedIndex! + + bumpPruneStructureVersion(state.prune.messages) + state.prune.messages.blocksById.set( + 3, + makeBlock({ + blockId: 3, + active: true, + compressMessageId: "msg-c3", + compressCallId: "call-c3", + tier: 2, + }), + ) + + const messages = [ + ...baseMessages().map((m) => ({ ...m, parts: [...m.parts] })), + { + info: { id: "msg-c3", role: "assistant" } as any, + parts: [{ type: "tool", tool: "compress", callID: "call-c3", state: { status: "completed" } }], + }, + ] + const hidden = hideConsumedCompressCalls(state, messages) + + const after = state.prune.messages.hideConsumedIndex! + assert.notEqual(after, before, "bumped version must force a rebuild") + assert.equal(after.version, 1) + assert.equal(hidden, 1, "only the consumed call is hidden; new live call stays visible") + }) + + it("reused index still filters correctly when the message list grows", () => { + const state = makeState(consumedAndLive()) as SessionState + hideConsumedCompressCalls(state, baseMessages()) + + const grown = [ + ...baseMessages().map((m) => ({ ...m, parts: [...m.parts] })), + { + info: { id: "msg-orphan", role: "assistant" } as any, + parts: [{ type: "tool", tool: "compress", callID: "call-orphan", state: { status: "completed" } }], + }, + ] + const hidden = hideConsumedCompressCalls(state, grown) + + assert.equal(hidden, 1) + const orphan = grown.find((m) => m.info.id === "msg-orphan")! + assert.equal( + orphan.parts.filter((p: any) => p.type === "tool" && p.tool === "compress").length, + 1, + "orphan call within KEEP_LAST_ORPHANED stays visible", + ) + }) +}) diff --git a/tests/inject.test.ts b/tests/inject.test.ts index 04460d5d..8a55438b 100644 --- a/tests/inject.test.ts +++ b/tests/inject.test.ts @@ -2730,7 +2730,6 @@ test("issue #364 cycle: baseline held through capture → T2 fires on first grow const state = createSessionState() state.sessionId = "test-364-cycle" state.modelContextLimit = 1_000_000 - const config = buildConfig() config.compress.maxContextLimit = 500_000 config.compress.minContextLimit = 200_000 @@ -2804,3 +2803,109 @@ test("issue #364 cycle: baseline held through capture → T2 fires on first grow "T1 baseline corrected downward to currentTokens (pre-existing correction path, inject.ts:294-302)" ) }) + +test("issue #384: gated analysis — production-config growth cycle keeps baseline across gated-off turns", () => { + // The #384 gate skips range/composition analysis when no nudge can fire + // (growth below floor AND not emergency). Behavior on gated-off turns must + // be byte-identical to before: shouldInject=false and baseline untouched. + const config = buildConfig() + config.compress.preserveRecentMessages = 2 // production-like protection + config.compress.maxContextLimit = 500_000 + config.compress.minContextLimit = 200_000 + const state = createSessionState() + state.modelContextLimit = 1_000_000 + + let seq = 0 + const pair = (inputTokens: number, bigToolOutput = false): WithParts[] => { + seq += 1 + const uId = `u${seq}` + const aId = `a${seq}` + state.messageIds.byRawId.set(uId, formatMessageIdTag(seq * 2 - 1)) + state.messageIds.byRawId.set(aId, formatMessageIdTag(seq * 2)) + const toolParts = bigToolOutput ? [toolPart(`c${seq}`, "x".repeat(60_000))] : [] + return [ + userMsg(uId, `question ${seq}`), + assistantMsgWithTokens(aId, `answer ${seq}`, { input: inputTokens, output: 1_000 }, toolParts), + ] + } + + // Turn 1: baseline established at exactly 100K (input 99K + output 1K). + let messages: WithParts[] = pair(99_000, true) + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: no growth yet → silent") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "turn 1: baseline initialized to current tokens") + + // Turn 2 (gated OFF): +6K growth < 22.5K floor → heavy analysis skipped. + messages = [...messages, ...pair(105_000)] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 2: 6K growth < floor → silent") + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 100_000, + "turn 2: baseline preserved across a gated-off turn", + ) + + // Turn 3 (gated ON): 216K ≥ 150K context floor, growth 116K ≥ 50K threshold. + // Head messages (older than preserve-recent-2) include a1's 15K-token tool + // output → compressible range exists → nudge fires; the shown-reference + // advances while the baseline stays put (no compression happened yet). + messages = [...messages, ...pair(215_000)] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 3: 116K growth past floor → fires") + assert.equal(state.nudges.lastNudgeShownTokens, 216_000, "turn 3: shown-reference advanced to current") + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 100_000, + "turn 3: baseline untouched by a plain nudge (only compression re-baselines)", + ) + + // Turn 4 (gated OFF again): +5K growth from the shown reference < floor. + messages = [...messages, ...pair(220_000)] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 4: 5K growth < floor → silent") + assert.equal( + state.nudges.lastNudgeShownTokens, + 216_000, + "turn 4: shown-reference preserved across a gated-off turn", + ) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "turn 4: baseline still untouched") + + // Turn 5 (gated ON): 316K, growth 100K ≥ threshold → fires again. + messages = [...messages, ...pair(315_000)] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 5: 100K growth past floor → fires again") + assert.equal(state.nudges.lastNudgeShownTokens, 316_000, "turn 5: shown-reference advanced to current") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "turn 5: baseline still untouched") +}) + +test("issue #384: emergency override still computes ranges when the growth gate is off", () => { + // Context at 98%+ with ZERO growth: the growth gate blocks a normal nudge, + // so the only way this turn can inject is via the emergency branch of the + // #384 gate condition (nudgeAllowed || emergencyOverride). If that branch + // regressed, the ranges would never be computed and this assertion fails. + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 980_000 + state.nudges.lastNudgeShownTokens = 980_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + const config = buildConfig() + config.compress.maxContextLimit = 500_000 + config.compress.minContextLimit = 200_000 + + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 970_000, output: 10_000 }, [ + toolPart("c1", "x".repeat(40_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + + assert.equal(state.nudges.shouldInjectThisTurn, true, "98% context with zero growth → emergency override fires") + const injected = suffixText(messages) + assert.ok(injected.includes("Breakdown:"), "breakdown shown at emergency") + assert.ok( + injected.includes("Context limit reached — compress now"), + "strong maxLimit alert at emergency", + ) +}) diff --git a/tests/persistence.test.ts b/tests/persistence.test.ts index a5d1604f..ff9a45ef 100644 --- a/tests/persistence.test.ts +++ b/tests/persistence.test.ts @@ -164,3 +164,106 @@ test("loadSessionState tolerates legacy prune.tools field (Bug 38 backward-compa ) await cleanup() }) + +// [Issue #384] Ordered, coalescing save queue — behavioral coverage through the +// public saveSessionState API. The queue guarantees: (1) synchronous bursts are +// coalesced into a single write of the LATEST snapshot, (2) sequential saves +// settle in request order so a stale snapshot can never overwrite a fresh one, +// (3) a failed write rejects its waiters without poisoning subsequent saves. + +test("saveSessionState: synchronous burst coalesces to a single latest-snapshot write", async () => { + await cleanup() + const state = createSessionState() + state.sessionId = TEST_SESSION + + state.stats.totalPruneTokens = 1 + const p1 = saveSessionState(state, logger) + state.stats.totalPruneTokens = 2 + const p2 = saveSessionState(state, logger) + state.stats.totalPruneTokens = 3 + const p3 = saveSessionState(state, logger) + + await Promise.all([p1, p2, p3]) + + const content = JSON.parse(await fs.readFile(join(STORAGE_DIR, `${TEST_SESSION}.json`), "utf-8")) + assert.equal( + content.stats.totalPruneTokens, + 3, + "only the latest snapshot of the burst may be persisted", + ) + await cleanup() +}) + +test("saveSessionState: sequential saves never let a stale snapshot win", async () => { + await cleanup() + const state = createSessionState() + state.sessionId = TEST_SESSION + + // Batch 1 settles first... + state.stats.totalPruneTokens = 1 + await saveSessionState(state, logger) + const afterFirst = JSON.parse(await fs.readFile(join(STORAGE_DIR, `${TEST_SESSION}.json`), "utf-8")) + assert.equal(afterFirst.stats.totalPruneTokens, 1) + + // ...then a burst for batches 2+3 must not be reordered behind batch 1's file. + state.stats.totalPruneTokens = 2 + const p2 = saveSessionState(state, logger) + state.stats.totalPruneTokens = 3 + const p3 = saveSessionState(state, logger) + await Promise.all([p2, p3]) + + const content = JSON.parse(await fs.readFile(join(STORAGE_DIR, `${TEST_SESSION}.json`), "utf-8")) + assert.equal(content.stats.totalPruneTokens, 3, "final file holds the newest snapshot") + await cleanup() +}) + +test("saveSessionState: a failed write rejects waiters but the queue keeps working", async () => { + await cleanup() + const filePath = join(STORAGE_DIR, `${TEST_SESSION}.json`) + await fs.mkdir(STORAGE_DIR, { recursive: true }) + // Make the target path a DIRECTORY so writeFile fails with EISDIR. + await fs.mkdir(filePath, { recursive: true }) + + const state = createSessionState() + state.sessionId = TEST_SESSION + state.stats.totalPruneTokens = 1 + const failing = saveSessionState(state, logger) + await assert.rejects(failing, /EISDIR|ENOTDIR/, "write to a directory path must reject") + + // Recover and verify the next save still succeeds (chain not poisoned). + await fs.rm(filePath, { recursive: true }) + state.stats.totalPruneTokens = 7 + await saveSessionState(state, logger) + const content = JSON.parse(await fs.readFile(filePath, "utf-8")) + assert.equal(content.stats.totalPruneTokens, 7, "queue must continue after a failure") + await cleanup() +}) + +test("saveSessionState: storageDir override isolates files per directory", async () => { + const { tmpdir } = await import("os") + const dirA = await fs.mkdtemp(join(tmpdir(), "acp-persist-a-")) + const dirB = await fs.mkdtemp(join(tmpdir(), "acp-persist-b-")) + try { + const stateA = createSessionState() + stateA.sessionId = TEST_SESSION + stateA.storageDir = dirA + stateA.stats.totalPruneTokens = 10 + + const stateB = createSessionState() + stateB.sessionId = TEST_SESSION + stateB.storageDir = dirB + stateB.stats.totalPruneTokens = 20 + + await Promise.all([saveSessionState(stateA, logger), saveSessionState(stateB, logger)]) + + assert.ok(existsSync(join(dirA, `${TEST_SESSION}.json`)), "file under dirA") + assert.ok(existsSync(join(dirB, `${TEST_SESSION}.json`)), "file under dirB") + const contentA = JSON.parse(await fs.readFile(join(dirA, `${TEST_SESSION}.json`), "utf-8")) + const contentB = JSON.parse(await fs.readFile(join(dirB, `${TEST_SESSION}.json`), "utf-8")) + assert.equal(contentA.stats.totalPruneTokens, 10) + assert.equal(contentB.stats.totalPruneTokens, 20) + } finally { + await fs.rm(dirA, { recursive: true, force: true }) + await fs.rm(dirB, { recursive: true, force: true }) + } +}) diff --git a/tests/sync.test.ts b/tests/sync.test.ts index d8d2970f..151be195 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -4,6 +4,7 @@ import test from "node:test" import { Logger } from "../lib/logger" import { syncCompressionBlocks } from "../lib/messages/sync" import { createSessionState, type WithParts, type CompressionBlock } from "../lib/state" +import { bumpPruneStructureVersion } from "../lib/state/utils" const SID = "ses-sync-test" const logger = new Logger(false) @@ -191,3 +192,106 @@ test("issue #125: external anchor deletion keeps block active (anchor-survival f "surviving message still has block 1 active", ) }) + +// [Issue #384] Verified-state synchronization: when the block structure version +// is unchanged, sync takes an incremental fast path instead of replaying every +// historical block. These tests pin the equivalence contract. + +test("issue #384: incremental pass matches full replay when structure is unchanged", () => { + const state = createSessionState() + state.prune.messages.blocksById.set( + 1, + makeBlock({ blockId: 1, anchorMessageId: "m1", createdAt: 100 }), + ) + state.prune.messages.blocksById.set( + 2, + makeBlock({ blockId: 2, anchorMessageId: "m2", createdAt: 200 }), + ) + const messages = [userMsg("m1"), userMsg("m2")] + + syncCompressionBlocks(state, logger, messages) // full replay (no prior version) + const activeAfterFull = [...state.prune.messages.activeBlockIds].sort((a, b) => a - b) + const anchorAfterFull = [...state.prune.messages.activeByAnchorMessageId.entries()].sort() + const version = state.prune.messages.lastSyncedStructureVersion + assert.notEqual(version, undefined, "full replay must record the structure version") + + syncCompressionBlocks(state, logger, messages) // incremental fast path + + assert.equal(state.prune.messages.lastSyncedStructureVersion, version) + assert.deepEqual([...state.prune.messages.activeBlockIds].sort((a, b) => a - b), activeAfterFull) + assert.deepEqual( + [...state.prune.messages.activeByAnchorMessageId.entries()].sort(), + anchorAfterFull, + ) +}) + +test("issue #384: incremental pass drops anchors missing from the message set without touching liveness", () => { + const state = createSessionState() + state.prune.messages.blocksById.set( + 1, + makeBlock({ blockId: 1, anchorMessageId: "m1", createdAt: 100 }), + ) + state.prune.messages.blocksById.set( + 2, + makeBlock({ blockId: 2, anchorMessageId: "m2", createdAt: 200 }), + ) + + syncCompressionBlocks(state, logger, [userMsg("m1"), userMsg("m2")]) + assert.equal(state.prune.messages.activeByAnchorMessageId.get("m2"), 2) + + // m2 disappears from the visible messages (e.g. external compaction); + // no block mutation happened, so the version is unchanged. + syncCompressionBlocks(state, logger, [userMsg("m1")]) + + assert.equal(state.prune.messages.activeByAnchorMessageId.get("m2"), undefined) + assert.equal(state.prune.messages.activeByAnchorMessageId.get("m1"), 1) + assert.ok(state.prune.messages.activeBlockIds.has(1)) + assert.ok(state.prune.messages.activeBlockIds.has(2)) +}) + +test("issue #384: structure version bump forces full replay that recomputes liveness", () => { + const state = createSessionState() + state.prune.messages.blocksById.set( + 1, + makeBlock({ blockId: 1, anchorMessageId: "m1", createdAt: 100 }), + ) + syncCompressionBlocks(state, logger, [userMsg("m1")]) + assert.ok(state.prune.messages.activeBlockIds.has(1)) + + // Mimic applyCompressionState: bump once after mutating the structure. + bumpPruneStructureVersion(state.prune.messages) + state.prune.messages.blocksById.set( + 2, + makeBlock({ + blockId: 2, + anchorMessageId: "m3", + createdAt: 200, + consumedBlockIds: [1], + }), + ) + + syncCompressionBlocks(state, logger, [userMsg("m1"), userMsg("m3")]) + + assert.equal(state.prune.messages.lastSyncedStructureVersion, 1) + assert.ok(!state.prune.messages.activeBlockIds.has(1), "consumed block must be deactivated by full replay") + assert.ok(state.prune.messages.activeBlockIds.has(2)) + assert.equal(state.prune.messages.blocksById.get(1)!.active, false) +}) + +test("issue #384: shared anchor keeps the later-created block id on both paths", () => { + const state = createSessionState() + state.prune.messages.blocksById.set( + 1, + makeBlock({ blockId: 1, anchorMessageId: "m1", createdAt: 100 }), + ) + state.prune.messages.blocksById.set( + 2, + makeBlock({ blockId: 2, anchorMessageId: "m1", createdAt: 200 }), + ) + + syncCompressionBlocks(state, logger, [userMsg("m1")]) + assert.equal(state.prune.messages.activeByAnchorMessageId.get("m1"), 2) + + syncCompressionBlocks(state, logger, [userMsg("m1")]) + assert.equal(state.prune.messages.activeByAnchorMessageId.get("m1"), 2) +}) diff --git a/tests/token-counting.test.ts b/tests/token-counting.test.ts index dd93167e..26f6de29 100644 --- a/tests/token-counting.test.ts +++ b/tests/token-counting.test.ts @@ -5,6 +5,7 @@ import { COMPACTED_TOOL_OUTPUT_PLACEHOLDER, countAllMessageTokens, countToolTokens, + estimateAllMessageTokensFast, estimateTokensBatch, extractCompletedToolOutput, extractToolContent, @@ -172,3 +173,27 @@ test("counting uses the compacted tool placeholder for completed outputs", () => assert.equal(extractCompletedToolOutput(part), COMPACTED_TOOL_OUTPUT_PLACEHOLDER) assertCounted(part, [JSON.stringify(input), COMPACTED_TOOL_OUTPUT_PLACEHOLDER]) }) + +test("estimateAllMessageTokensFast uses chars/4 for text parts (Issue #384)", () => { + const msg = { + info: { id: "msg-text", role: "assistant" } as any, + parts: [{ type: "text", text: "a".repeat(1000) }], + } as unknown as WithParts + + assert.equal(estimateAllMessageTokensFast(msg), 250) +}) + +test("estimateAllMessageTokensFast counts tool input/output characters like extractToolContent (Issue #384)", () => { + const input = { command: "npm test", workdir: "/tmp/project" } + const output = "x".repeat(800) + const part = buildToolPart("bash", { status: "completed", input, output }) + + const expectedChars = JSON.stringify(input).length + output.length + assert.equal(estimateAllMessageTokensFast(buildToolMessage(part)), Math.round(expectedChars / 4)) +}) + +test("estimateAllMessageTokensFast returns 0 for empty messages", () => { + const msg = { info: { id: "msg-empty", role: "assistant" } as any, parts: [] } as unknown as WithParts + + assert.equal(estimateAllMessageTokensFast(msg), 0) +})