From 802622af7657f8065ec1c49d3b33cf6908221c8c Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 5 Aug 2026 08:56:47 +0800 Subject: [PATCH] chore: remove orphaned old engine (dead code) (issue #42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @dog on issue #42: delete the old in-tree compression engine that became dead code after the one-shot acp-kernel rewrite (PR #274). Computed the dead set via transitive relative-import closure from index.ts: only genuinely unreachable files were deleted; everything the kernel adapter and shared infra still import is kept. Removed: - 51 source files: lib/hooks.ts, lib/compress-permission.ts, whole lib/commands/, lib/gc/, lib/ui/, lib/compress/quality-gate/, lib/messages/filter/, lib/messages/inject/, + selected files in lib/compress, lib/messages, lib/prompts. - 47 test files (those covering the deleted modules). Kept (still reachable): all lib/kernel/, shared infra (config, logger, token-utils, auth, update, host-permissions, config-validation), and old modules still imported by them (messages/{query,shape}, prompts/*, state/*, message-ids, protected-patterns, parts of compress/). Verification: typecheck PASS, build PASS (dist/index.js 175.53 KB — byte-identical to pre-deletion, confirming the code was already tree-shaken; zero runtime impact), npm test 336 pass 0 fail (961 -> 336, -625 dead-engine tests). --- devlog/2026-08-05_remove-old-engine/REQ.md | 42 + .../2026-08-05_remove-old-engine/WORKLOG.md | 62 + lib/commands/compression-targets.ts | 104 -- lib/commands/context.ts | 296 --- lib/commands/index.ts | 3 - lib/commands/stats.ts | 42 - lib/compress-permission.ts | 25 - lib/compress/decompress-logic.ts | 251 --- lib/compress/decompress.ts | 413 ---- lib/compress/hide-consumed.ts | 58 - lib/compress/hide-failed.ts | 64 - lib/compress/index.ts | 8 - lib/compress/keep-markers.ts | 132 -- lib/compress/parts.ts | 5 - lib/compress/pipeline.ts | 315 ---- lib/compress/quality-gate/algorithms/index.ts | 8 - lib/compress/quality-gate/evaluate.ts | 217 --- lib/compress/quality-gate/index.ts | 19 - lib/compress/quality-gate/registry.ts | 27 - lib/compress/quality-gate/rejection.ts | 83 - lib/compress/quality-gate/types.ts | 56 - lib/compress/range.ts | 408 ---- lib/compress/recap.ts | 61 - lib/compress/status.ts | 620 ------ lib/gc/merge.ts | 239 --- lib/hooks.ts | 401 ---- lib/messages/filter/apply.ts | 148 -- lib/messages/filter/builtin/index.ts | 23 - lib/messages/filter/builtin/omo-context.ts | 22 - .../filter/builtin/omo-mode-injection.ts | 82 - .../filter/builtin/omo-system-reminder.ts | 52 - .../filter/builtin/omo-task-directive.ts | 22 - .../filter/builtin/omo-todo-continuation.ts | 21 - lib/messages/filter/index.ts | 11 - lib/messages/filter/registry.ts | 26 - lib/messages/filter/types.ts | 96 - lib/messages/index.ts | 8 - lib/messages/inject/inject.ts | 796 -------- lib/messages/inject/policy/index.ts | 22 - lib/messages/inject/policy/registry.ts | 38 - lib/messages/inject/policy/types.ts | 6 - lib/messages/inject/utils.ts | 994 ---------- lib/messages/priority.ts | 63 - lib/messages/prune.ts | 90 - lib/messages/reasoning-strip.ts | 43 - lib/messages/sync.ts | 113 -- lib/messages/truncate-tools.ts | 102 - lib/messages/utils.ts | 254 --- lib/prompts/extensions/nudge.ts | 120 -- lib/prompts/extensions/tool.ts | 42 - lib/prompts/index.ts | 28 - lib/ui/notification.ts | 337 ---- lib/ui/utils.ts | 93 - tests/acp-status-consumed-fix.test.ts | 135 -- tests/acp-status.test.ts | 343 ---- tests/batch-compress.test.ts | 330 ---- tests/compress-range.test.ts | 377 ---- tests/compress-rollback.test.ts | 145 -- tests/compression-targets.test.ts | 78 - tests/decompress-logic.test.ts | 597 ------ tests/drop-empty-messages.test.ts | 214 --- tests/e2e-blocks-nudges.test.ts | 638 ------- tests/e2e-message-transform.test.ts | 856 --------- tests/e2e-tier-compression.test.ts | 1165 ------------ tests/e2e-tier-simulation.test.ts | 551 ------ tests/gc-merge.test.ts | 445 ----- tests/hide-consumed.test.ts | 485 ----- tests/hide-failed.test.ts | 169 -- tests/hooks-permission.test.ts | 690 ------- tests/inject-utils-pure.test.ts | 449 ----- tests/inject.test.ts | 1661 ----------------- tests/input-budget.test.ts | 38 - tests/keep-markers.test.ts | 179 -- tests/message-filter.test.ts | 551 ------ tests/message-priority.test.ts | 616 ------ tests/nudge-text.test.ts | 67 - tests/phantom-block.test.ts | 221 --- tests/preserve-recent.test.ts | 331 ---- tests/priority-classify.test.ts | 43 - tests/property-bughunt.test.ts | 361 ---- tests/property-invariants.test.ts | 681 ------- tests/proportional-baseline.test.ts | 414 ---- tests/protected-tool-exclusion.test.ts | 543 ------ tests/protection-aware-stats.test.ts | 207 -- tests/prune.test.ts | 679 ------- tests/quality-gate-enforcement.test.ts | 447 ----- .../quality-gate-pipeline-integration.test.ts | 417 ----- tests/quality-gate-registry.test.ts | 78 - tests/reasoning-strip.test.ts | 115 -- tests/recap.test.ts | 195 -- tests/regex-tag-leak.test.ts | 190 -- tests/remove-prune-regression.test.ts | 174 -- tests/smart-nudge-gating.test.ts | 89 - tests/soft-block.test.ts | 271 --- tests/stats-command.test.ts | 168 -- tests/sync.test.ts | 192 -- tests/token-usage.test.ts | 407 ---- tests/trigger-policy-integration.test.ts | 90 - tests/truncate-tools.test.ts | 358 ---- tests/visible-segments.test.ts | 251 --- 100 files changed, 104 insertions(+), 25208 deletions(-) create mode 100644 devlog/2026-08-05_remove-old-engine/REQ.md create mode 100644 devlog/2026-08-05_remove-old-engine/WORKLOG.md delete mode 100644 lib/commands/compression-targets.ts delete mode 100644 lib/commands/context.ts delete mode 100644 lib/commands/index.ts delete mode 100644 lib/commands/stats.ts delete mode 100644 lib/compress-permission.ts delete mode 100644 lib/compress/decompress-logic.ts delete mode 100644 lib/compress/decompress.ts delete mode 100644 lib/compress/hide-consumed.ts delete mode 100644 lib/compress/hide-failed.ts delete mode 100644 lib/compress/index.ts delete mode 100644 lib/compress/keep-markers.ts delete mode 100644 lib/compress/parts.ts delete mode 100644 lib/compress/pipeline.ts delete mode 100644 lib/compress/quality-gate/algorithms/index.ts delete mode 100644 lib/compress/quality-gate/evaluate.ts delete mode 100644 lib/compress/quality-gate/index.ts delete mode 100644 lib/compress/quality-gate/registry.ts delete mode 100644 lib/compress/quality-gate/rejection.ts delete mode 100644 lib/compress/quality-gate/types.ts delete mode 100644 lib/compress/range.ts delete mode 100644 lib/compress/recap.ts delete mode 100644 lib/compress/status.ts delete mode 100644 lib/gc/merge.ts delete mode 100644 lib/hooks.ts delete mode 100644 lib/messages/filter/apply.ts delete mode 100644 lib/messages/filter/builtin/index.ts delete mode 100644 lib/messages/filter/builtin/omo-context.ts delete mode 100644 lib/messages/filter/builtin/omo-mode-injection.ts delete mode 100644 lib/messages/filter/builtin/omo-system-reminder.ts delete mode 100644 lib/messages/filter/builtin/omo-task-directive.ts delete mode 100644 lib/messages/filter/builtin/omo-todo-continuation.ts delete mode 100644 lib/messages/filter/index.ts delete mode 100644 lib/messages/filter/registry.ts delete mode 100644 lib/messages/filter/types.ts delete mode 100644 lib/messages/index.ts delete mode 100644 lib/messages/inject/inject.ts delete mode 100644 lib/messages/inject/policy/index.ts delete mode 100644 lib/messages/inject/policy/registry.ts delete mode 100644 lib/messages/inject/policy/types.ts delete mode 100644 lib/messages/inject/utils.ts delete mode 100644 lib/messages/priority.ts delete mode 100644 lib/messages/prune.ts delete mode 100644 lib/messages/reasoning-strip.ts delete mode 100644 lib/messages/sync.ts delete mode 100644 lib/messages/truncate-tools.ts delete mode 100644 lib/messages/utils.ts delete mode 100644 lib/prompts/extensions/nudge.ts delete mode 100644 lib/prompts/extensions/tool.ts delete mode 100644 lib/prompts/index.ts delete mode 100644 lib/ui/notification.ts delete mode 100644 lib/ui/utils.ts delete mode 100644 tests/acp-status-consumed-fix.test.ts delete mode 100644 tests/acp-status.test.ts delete mode 100644 tests/batch-compress.test.ts delete mode 100644 tests/compress-range.test.ts delete mode 100644 tests/compress-rollback.test.ts delete mode 100644 tests/compression-targets.test.ts delete mode 100644 tests/decompress-logic.test.ts delete mode 100644 tests/drop-empty-messages.test.ts delete mode 100644 tests/e2e-blocks-nudges.test.ts delete mode 100644 tests/e2e-message-transform.test.ts delete mode 100644 tests/e2e-tier-compression.test.ts delete mode 100644 tests/e2e-tier-simulation.test.ts delete mode 100644 tests/gc-merge.test.ts delete mode 100644 tests/hide-consumed.test.ts delete mode 100644 tests/hide-failed.test.ts delete mode 100644 tests/hooks-permission.test.ts delete mode 100644 tests/inject-utils-pure.test.ts delete mode 100644 tests/inject.test.ts delete mode 100644 tests/input-budget.test.ts delete mode 100644 tests/keep-markers.test.ts delete mode 100644 tests/message-filter.test.ts delete mode 100644 tests/message-priority.test.ts delete mode 100644 tests/nudge-text.test.ts delete mode 100644 tests/phantom-block.test.ts delete mode 100644 tests/preserve-recent.test.ts delete mode 100644 tests/priority-classify.test.ts delete mode 100644 tests/property-bughunt.test.ts delete mode 100644 tests/property-invariants.test.ts delete mode 100644 tests/proportional-baseline.test.ts delete mode 100644 tests/protected-tool-exclusion.test.ts delete mode 100644 tests/protection-aware-stats.test.ts delete mode 100644 tests/prune.test.ts delete mode 100644 tests/quality-gate-enforcement.test.ts delete mode 100644 tests/quality-gate-pipeline-integration.test.ts delete mode 100644 tests/quality-gate-registry.test.ts delete mode 100644 tests/reasoning-strip.test.ts delete mode 100644 tests/recap.test.ts delete mode 100644 tests/regex-tag-leak.test.ts delete mode 100644 tests/remove-prune-regression.test.ts delete mode 100644 tests/smart-nudge-gating.test.ts delete mode 100644 tests/soft-block.test.ts delete mode 100644 tests/stats-command.test.ts delete mode 100644 tests/sync.test.ts delete mode 100644 tests/token-usage.test.ts delete mode 100644 tests/trigger-policy-integration.test.ts delete mode 100644 tests/truncate-tools.test.ts delete mode 100644 tests/visible-segments.test.ts diff --git a/devlog/2026-08-05_remove-old-engine/REQ.md b/devlog/2026-08-05_remove-old-engine/REQ.md new file mode 100644 index 00000000..12c7c997 --- /dev/null +++ b/devlog/2026-08-05_remove-old-engine/REQ.md @@ -0,0 +1,42 @@ +# REQ — remove orphaned old engine (dead code) + +Issue: dog/opencode-acp#42 (follow-up) · Branch: `2026-08-05_remove-old-engine` +Base: `2026-08-05_acp-kernel` (stacked; retarget to master after PR #274 merges) + +## Problem + +After the one-shot acp-kernel rewrite (PR #274), the old in-tree compression +engine was left on disk as dead code (unreferenced by `index.ts`, tree-shaken +from `dist/`). @dog directed: **"旧代码可以直接删除 在新分支"** — delete it, on a +new branch. + +## Goal + +Remove every source file and test that is no longer reachable from `index.ts`, +so the source tree matches what actually ships. + +## Acceptance criteria + +- `npm run typecheck` — PASS (no dangling imports). +- `npm run build` — PASS; `dist/index.js` byte-identical to pre-deletion (proves + the removed code was already dead/tree-shaken — zero runtime impact). +- `npm test` — PASS, 0 fail. Remaining tests cover only kernel + shared infra. +- No file reachable from `index.ts` is deleted (verified by transitive + import-closure trace). + +## Scope + +Computed via transitive relative-import closure from `index.ts` +(`scripts`-style trace, see WORKLOG). DEAD = unreachable. + +- **51 dead source files** removed: `lib/hooks.ts`, `lib/compress-permission.ts`, + whole `lib/commands/`, `lib/gc/`, `lib/ui/`, `lib/compress/quality-gate/`, + `lib/messages/filter/`, `lib/messages/inject/`, plus selected files in + `lib/compress/`, `lib/messages/`, `lib/prompts/`. +- **47 dead test files** removed (those importing any dead module). +- **KEPT** (still reachable): all of `lib/kernel/`, shared infra + (`config`, `logger`, `token-utils`, `auth`, `update`, `host-permissions`, + `config-validation`), and selected old modules the kernel adapter / infra + still import (`lib/messages/{query,shape}.ts`, `lib/prompts/*` except the + dead extensions, `lib/state/*`, `lib/message-ids.ts`, `lib/protected-patterns.ts`, + parts of `lib/compress/`). diff --git a/devlog/2026-08-05_remove-old-engine/WORKLOG.md b/devlog/2026-08-05_remove-old-engine/WORKLOG.md new file mode 100644 index 00000000..7c5bd712 --- /dev/null +++ b/devlog/2026-08-05_remove-old-engine/WORKLOG.md @@ -0,0 +1,62 @@ +# WORKLOG — remove orphaned old engine (dead code) + +Issue: dog/opencode-acp#42 (follow-up) · Branch: `2026-08-05_remove-old-engine` +Base: `2026-08-05_acp-kernel` + +## Method + +Computed the exact dead set via a transitive relative-import closure trace from +`index.ts` (script at `/tmp/opencode/trace-deps.mjs`): + +1. BFS from `index.ts` over relative imports → **KEEP set** (41 files). +2. `lib/` files not in KEEP → **DEAD source** (51 files). +3. Tests whose imports resolve into any DEAD source file → **DEAD tests** + (47 files); remaining 28 test files kept. + +This guarantees no reachable file is deleted — only genuinely orphaned code. + +## Removed (51 source files) + +- Whole dirs: `lib/commands/`, `lib/gc/`, `lib/ui/`, `lib/compress/quality-gate/`, + `lib/messages/filter/`, `lib/messages/inject/`. +- `lib/hooks.ts`, `lib/compress-permission.ts`. +- `lib/compress/`: decompress-logic, decompress, hide-consumed, hide-failed, + index, keep-markers, parts, pipeline, range, recap, status. +- `lib/messages/`: index, priority, prune, reasoning-strip, sync, + truncate-tools, utils. +- `lib/prompts/`: extensions/nudge, extensions/tool, index. + +## Removed (47 test files) + +All tests that imported a dead module (e.g. `tests/compress-range.test.ts`, +`tests/inject.test.ts`, `tests/e2e-*.test.ts`, `tests/quality-gate-*.test.ts`, +`tests/gc-merge.test.ts`, `tests/hooks-permission.test.ts`, …). Full list in +the trace output. + +## Kept (reachable old modules — NOT dead) + +The kernel adapter / shared infra still import these, so they must stay: +`lib/messages/{query,shape}.ts`, `lib/prompts/{system,store,compress-range, +context-limit-nudge,iteration-nudge,turn-nudge,extensions/system}.ts`, +`lib/state/{index,persistence,rebuild,state,tool-cache,types,utils}.ts`, +`lib/compress/{protected-content,range-utils,search,state,timing,types}.ts`, +`lib/message-ids.ts`, `lib/protected-patterns.ts`, `lib/config-validation.ts`. + +## Verification + +- `npm run typecheck` — **PASS** (0 errors; no dangling imports). +- `npm run build` — **PASS**. `dist/index.js` = **175.53 KB**, byte-identical + to pre-deletion → the removed code was already tree-shaken; **zero runtime + impact**, pure source-tree cleanup. +- `npm test` — **PASS** (336 tests, 0 fail). Test count dropped 961 → 336 + (−625), exactly the dead-engine tests. +- `scripts/ci/check-pr.sh 2026-08-05_remove-old-engine github/master` — PASS + (branch name, devlog REQ+WORKLOG present, version unchanged). + +## Notes + +- The identical `dist/` size confirms the earlier claim in PR #274's WORKLOG: + the old engine was already fully tree-shaken from the published bundle. This + PR is a source-hygiene follow-up, not a behaviour change. +- PR is stacked on `2026-08-05_acp-kernel` (base). Retarget to `master` once + PR #274 merges. Alternatively @dog may fold both into one merge. diff --git a/lib/commands/compression-targets.ts b/lib/commands/compression-targets.ts deleted file mode 100644 index 459fbca2..00000000 --- a/lib/commands/compression-targets.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { CompressionBlock, PruneMessagesState } from "../state" - -export interface CompressionTarget { - displayId: number - runId: number - topic: string - compressedTokens: number - durationMs: number - grouped: boolean - blocks: CompressionBlock[] -} - -function byBlockId(a: CompressionBlock, b: CompressionBlock): number { - return a.blockId - b.blockId -} - -function buildTarget(blocks: CompressionBlock[]): CompressionTarget { - const ordered = [...blocks].sort(byBlockId) - const first = ordered[0] - if (!first) { - throw new Error("Cannot build compression target from empty block list.") - } - - const grouped = first.mode === "message" - return { - displayId: first.blockId, - runId: first.runId, - topic: grouped ? first.batchTopic || first.topic : first.topic, - compressedTokens: ordered.reduce( - (total, block) => total + (block.effectiveCompressedTokens ?? block.compressedTokens), - 0, - ), - durationMs: ordered.reduce((total, block) => Math.max(total, block.durationMs), 0), - grouped, - blocks: ordered, - } -} - -function groupMessageBlocks(blocks: CompressionBlock[]): CompressionTarget[] { - const grouped = new Map() - - for (const block of blocks) { - const existing = grouped.get(block.runId) - if (existing) { - existing.push(block) - continue - } - grouped.set(block.runId, [block]) - } - - return Array.from(grouped.values()).map(buildTarget) -} - -function splitTargets(blocks: CompressionBlock[]): CompressionTarget[] { - const messageBlocks: CompressionBlock[] = [] - const singleBlocks: CompressionBlock[] = [] - - for (const block of blocks) { - if (block.mode === "message") { - messageBlocks.push(block) - } else { - singleBlocks.push(block) - } - } - - const targets = [ - ...singleBlocks.map((block) => buildTarget([block])), - ...groupMessageBlocks(messageBlocks), - ] - return targets.sort((a, b) => a.displayId - b.displayId) -} - -export function getActiveCompressionTargets( - messagesState: PruneMessagesState, -): CompressionTarget[] { - const activeBlocks = Array.from(messagesState.activeBlockIds) - .map((blockId) => messagesState.blocksById.get(blockId)) - .filter((block): block is CompressionBlock => !!block && block.active) - - return splitTargets(activeBlocks) -} - -export function resolveCompressionTarget( - messagesState: PruneMessagesState, - blockId: number, -): CompressionTarget | null { - const block = messagesState.blocksById.get(blockId) - if (!block) { - return null - } - - if (block.mode !== "message") { - return buildTarget([block]) - } - - const blocks = Array.from(messagesState.blocksById.values()).filter( - (candidate) => candidate.mode === "message" && candidate.runId === block.runId, - ) - if (blocks.length === 0) { - return null - } - - return buildTarget(blocks) -} diff --git a/lib/commands/context.ts b/lib/commands/context.ts deleted file mode 100644 index 4f6bcbc0..00000000 --- a/lib/commands/context.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * DCP Context Command - * Shows a visual breakdown of token usage in the current session. - * - * TOKEN CALCULATION STRATEGY - * ========================== - * We minimize tokenizer estimation by leveraging API-reported values wherever possible. - * - * WHAT WE GET FROM THE API (exact): - * - tokens.input : Input tokens for each assistant response - * - tokens.output : Output tokens generated (includes text + tool calls) - * - tokens.reasoning: Reasoning tokens used - * - tokens.cache : Cache read/write tokens - * - * HOW WE CALCULATE EACH CATEGORY: - * - * SYSTEM = firstAssistant.input + cache.read + cache.write - tokenizer(firstUserMessage) - * The first response's total input (input + cache.read + cache.write) - * contains system + first user message. On the first request of a - * session, the system prompt appears in cache.write (cache creation), - * not cache.read. - * - * TOOLS = tokenizer(toolInputs + toolOutputs) - prunedTokens - * We must tokenize tools anyway for pruning decisions. - * - * USER = tokenizer(all user messages) - * User messages are typically small, so estimation is acceptable. - * - * ASSISTANT = total - system - user - tools - * Calculated as residual. This absorbs: - * - Assistant text output tokens - * - Reasoning tokens (if persisted by the model) - * - Any estimation errors - * - * TOTAL = input + output + reasoning + cache.read + cache.write - * Matches opencode's UI display. - * - * WHY ASSISTANT IS THE RESIDUAL: - * If reasoning tokens persist in context (model-dependent), they semantically - * belong with "Assistant" since reasoning IS assistant-generated content. - */ - -import type { Logger } from "../logger" -import type { SessionState, WithParts } from "../state" -import { sendIgnoredMessage } from "../ui/notification" -import { formatTokenCount } from "../ui/utils" -import { isIgnoredUserMessage } from "../messages/query" -import { isMessageCompacted } from "../state/utils" -import { countTokens, extractCompletedToolOutput, getCurrentParams } from "../token-utils" -import type { AssistantMessage, TextPart, ToolPart } from "@opencode-ai/sdk/v2" - -export interface ContextCommandContext { - client: any - state: SessionState - logger: Logger - sessionId: string - messages: WithParts[] -} - -interface TokenBreakdown { - system: number - user: number - assistant: number - tools: number - toolCount: number - toolsInContextCount: number - prunedTokens: number - prunedToolCount: number - prunedMessageCount: number - total: number -} - -function analyzeTokens(state: SessionState, messages: WithParts[]): TokenBreakdown { - const breakdown: TokenBreakdown = { - system: 0, - user: 0, - assistant: 0, - tools: 0, - toolCount: 0, - toolsInContextCount: 0, - prunedTokens: state.stats.totalPruneTokens, - prunedToolCount: 0, - prunedMessageCount: 0, - total: 0, - } - - let firstAssistant: AssistantMessage | undefined - for (const msg of messages) { - if (msg.info.role === "assistant") { - const assistantInfo = msg.info as AssistantMessage - if ( - assistantInfo.tokens?.input > 0 || - assistantInfo.tokens?.cache?.read > 0 || - assistantInfo.tokens?.cache?.write > 0 - ) { - firstAssistant = assistantInfo - break - } - } - } - - let lastAssistant: AssistantMessage | undefined - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - if (msg.info.role === "assistant") { - const assistantInfo = msg.info as AssistantMessage - if (assistantInfo.tokens?.output > 0) { - lastAssistant = assistantInfo - break - } - } - } - - const apiInput = lastAssistant?.tokens?.input || 0 - const apiOutput = lastAssistant?.tokens?.output || 0 - const apiReasoning = lastAssistant?.tokens?.reasoning || 0 - const apiCacheRead = lastAssistant?.tokens?.cache?.read || 0 - const apiCacheWrite = lastAssistant?.tokens?.cache?.write || 0 - breakdown.total = apiInput + apiOutput + apiReasoning + apiCacheRead + apiCacheWrite - - const userTextParts: string[] = [] - const toolInputParts: string[] = [] - const toolOutputParts: string[] = [] - let firstUserText = "" - let foundFirstUser = false - const allToolIds = new Set() - const activeToolIds = new Set() - const prunedToolIds = new Set() - const allMessageIds = new Set() - - for (const msg of messages) { - allMessageIds.add(msg.info.id) - const parts = Array.isArray(msg.parts) ? msg.parts : [] - const isCompacted = isMessageCompacted(state, msg) - const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id) - const isMessagePruned = !!pruneEntry && pruneEntry.activeBlockIds.length > 0 - const isIgnoredUser = isIgnoredUserMessage(msg) - - for (const part of parts) { - if (part.type === "tool") { - const toolPart = part as ToolPart - if (toolPart.callID) { - allToolIds.add(toolPart.callID) - if (!isCompacted) { - activeToolIds.add(toolPart.callID) - } - if (isMessagePruned) { - prunedToolIds.add(toolPart.callID) - } - } - - if (!isCompacted) { - if (toolPart.state?.input) { - const inputStr = - typeof toolPart.state.input === "string" - ? toolPart.state.input - : JSON.stringify(toolPart.state.input) - toolInputParts.push(inputStr) - } - - const outputStr = extractCompletedToolOutput(toolPart) - if (outputStr !== undefined) { - toolOutputParts.push(outputStr) - } - } - } else if ( - part.type === "text" && - msg.info.role === "user" && - !isCompacted && - !isIgnoredUser - ) { - const textPart = part as TextPart - const text = textPart.text || "" - userTextParts.push(text) - if (!foundFirstUser) { - firstUserText += text - } - } - } - - if (msg.info.role === "user" && !isIgnoredUser && !foundFirstUser) { - foundFirstUser = true - } - } - - const toolsInContextCount = [...activeToolIds].filter((id) => !prunedToolIds.has(id)).length - - let prunedMessageCount = 0 - for (const [id, entry] of state.prune.messages.byMessageId) { - if (allMessageIds.has(id) && entry.activeBlockIds.length > 0) { - prunedMessageCount++ - } - } - - breakdown.toolCount = allToolIds.size - breakdown.toolsInContextCount = toolsInContextCount - breakdown.prunedToolCount = prunedToolIds.size - breakdown.prunedMessageCount = prunedMessageCount - - const firstUserTokens = countTokens(firstUserText) - breakdown.user = countTokens(userTextParts.join("\n")) - const toolInputTokens = countTokens(toolInputParts.join("\n")) - const toolOutputTokens = countTokens(toolOutputParts.join("\n")) - - if (firstAssistant) { - const firstInput = - (firstAssistant.tokens?.input || 0) + - (firstAssistant.tokens?.cache?.read || 0) + - (firstAssistant.tokens?.cache?.write || 0) - breakdown.system = Math.max(0, firstInput - firstUserTokens) - } - - breakdown.tools = toolInputTokens + toolOutputTokens - breakdown.assistant = Math.max( - 0, - breakdown.total - breakdown.system - breakdown.user - breakdown.tools, - ) - - return breakdown -} - -function createBar(value: number, maxValue: number, width: number, char: string = "█"): string { - if (maxValue === 0) return "" - const filled = Math.round((value / maxValue) * width) - const bar = char.repeat(Math.max(0, filled)) - return bar -} - -function formatContextMessage(breakdown: TokenBreakdown): string { - const lines: string[] = [] - const barWidth = 30 - - const toolsLabel = `Tools (${breakdown.toolsInContextCount})` - - const categories = [ - { label: "System", value: breakdown.system, char: "█" }, - { label: "User", value: breakdown.user, char: "▓" }, - { label: "Assistant", value: breakdown.assistant, char: "▒" }, - { label: toolsLabel, value: breakdown.tools, char: "░" }, - ] as const - - const maxLabelLen = Math.max(...categories.map((c) => c.label.length)) - - lines.push("╭───────────────────────────────────────────────────────────╮") - lines.push("│ ACP Context Analysis │") - lines.push("╰───────────────────────────────────────────────────────────╯") - lines.push("") - lines.push("Session Context Breakdown:") - lines.push("─".repeat(60)) - lines.push("") - - for (const cat of categories) { - const bar = createBar(cat.value, breakdown.total, barWidth, cat.char) - const percentage = - breakdown.total > 0 ? ((cat.value / breakdown.total) * 100).toFixed(1) : "0.0" - const labelWithPct = `${cat.label.padEnd(maxLabelLen)} ${percentage.padStart(5)}% ` - const valueStr = formatTokenCount(cat.value).padStart(13) - lines.push(`${labelWithPct}│${bar.padEnd(barWidth)}│${valueStr}`) - } - - lines.push("") - lines.push("─".repeat(60)) - lines.push("") - - lines.push("Summary:") - - if (breakdown.prunedTokens > 0) { - const withoutPruning = breakdown.total + breakdown.prunedTokens - const pruned = [] - if (breakdown.prunedToolCount > 0) pruned.push(`${breakdown.prunedToolCount} tools`) - if (breakdown.prunedMessageCount > 0) - pruned.push(`${breakdown.prunedMessageCount} messages`) - lines.push( - ` Pruned: ${pruned.join(", ")} (~${formatTokenCount(breakdown.prunedTokens)})`, - ) - lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`) - lines.push(` Without ACP: ~${formatTokenCount(withoutPruning)}`) - } else { - lines.push(` Current context: ~${formatTokenCount(breakdown.total)}`) - } - - lines.push("") - - return lines.join("\n") -} - -export async function handleContextCommand(ctx: ContextCommandContext): Promise { - const { client, state, logger, sessionId, messages } = ctx - - const breakdown = analyzeTokens(state, messages) - - const message = formatContextMessage(breakdown) - - const params = getCurrentParams(state, messages, logger) - await sendIgnoredMessage(client, sessionId, message, params, logger) -} diff --git a/lib/commands/index.ts b/lib/commands/index.ts deleted file mode 100644 index 614a6d13..00000000 --- a/lib/commands/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { handleContextCommand } from "./context" -export { handleStatsCommand } from "./stats" -export type { StatsCommandContext } from "./stats" diff --git a/lib/commands/stats.ts b/lib/commands/stats.ts deleted file mode 100644 index e5575f48..00000000 --- a/lib/commands/stats.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Logger } from "../logger" -import type { PluginConfig } from "../config" -import type { SessionState, WithParts } from "../state" -import { sendIgnoredMessage } from "../ui/notification" -import { buildStatusReport } from "../compress/status" - -export interface StatsCommandContext { - client: any - state: SessionState - config: PluginConfig - logger: Logger - sessionId: string - messages: WithParts[] - userInfo?: { - providerId?: string - modelId?: string - agent?: string - variant?: string - } -} - -export async function handleStatsCommand(ctx: StatsCommandContext): Promise { - const report = buildStatusReport( - { state: ctx.state, config: ctx.config }, - ctx.messages, - ) - - const text = `[ACP Status]\n${report}` - - await sendIgnoredMessage( - ctx.client, - ctx.sessionId, - text, - { - providerId: ctx.userInfo?.providerId, - modelId: ctx.userInfo?.modelId, - agent: ctx.userInfo?.agent, - variant: ctx.userInfo?.variant, - }, - ctx.logger, - ) -} diff --git a/lib/compress-permission.ts b/lib/compress-permission.ts deleted file mode 100644 index b7826343..00000000 --- a/lib/compress-permission.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { PluginConfig } from "./config" -import { type HostPermissionSnapshot, resolveEffectiveCompressPermission } from "./host-permissions" -import type { SessionState, WithParts } from "./state" -import { getLastUserMessage } from "./messages/query" - -export const compressPermission = ( - state: SessionState, - config: PluginConfig, -): "ask" | "allow" | "deny" => { - return state.compressPermission ?? config.compress.permission -} - -export const syncCompressPermissionState = ( - state: SessionState, - config: PluginConfig, - hostPermissions: HostPermissionSnapshot, - messages: WithParts[], -): void => { - const activeAgent = getLastUserMessage(messages)?.info.agent - state.compressPermission = resolveEffectiveCompressPermission( - config.compress.permission, - hostPermissions, - activeAgent, - ) -} diff --git a/lib/compress/decompress-logic.ts b/lib/compress/decompress-logic.ts deleted file mode 100644 index 1c7f228f..00000000 --- a/lib/compress/decompress-logic.ts +++ /dev/null @@ -1,251 +0,0 @@ -import type { CompressionBlock, PruneMessagesState, WithParts } from "../state" -import { parseBlockRef } from "../message-ids" -import type { CompressionTarget } from "../commands/compression-targets" - -export function parseBlockIdArg(arg: string): number | null { - const normalized = arg.trim().toLowerCase() - const blockRef = parseBlockRef(normalized) - if (blockRef !== null) { - return blockRef - } - - if (!/^[1-9]\d*$/.test(normalized)) { - return null - } - - const parsed = Number.parseInt(normalized, 10) - return Number.isInteger(parsed) && parsed > 0 ? parsed : null -} - -export type DecompressMode = "block" | "range" - -export function resolveDecompressMode(args: Record): - | { ok: true; mode: DecompressMode } - | { ok: false; error: string } { - const hasBlockId = typeof args.blockId === "string" && args.blockId.trim() !== "" - const hasStartId = typeof args.startId === "string" && args.startId.trim() !== "" - const hasEndId = typeof args.endId === "string" && args.endId.trim() !== "" - - if (hasBlockId && (hasStartId || hasEndId)) { - return { ok: false, error: "Cannot specify both blockId and startId/endId. Choose one mode." } - } - if (!hasBlockId && !(hasStartId && hasEndId)) { - return { ok: false, error: "Must specify either blockId, or both startId and endId." } - } - return { ok: true, mode: hasBlockId ? "block" : "range" } -} - -export function findActiveBlocksOverlappingMessages( - messagesState: PruneMessagesState, - messageIds: Set, -): CompressionBlock[] { - if (messageIds.size === 0) { - return [] - } - - const matched = new Map() - for (const [blockId, block] of messagesState.blocksById) { - if (!block.active) { - continue - } - const effectiveIds = block.effectiveMessageIds ?? [] - for (const msgId of effectiveIds) { - if (messageIds.has(msgId)) { - matched.set(blockId, block) - break - } - } - } - - return Array.from(matched.values()).sort((a, b) => a.blockId - b.blockId) -} - -export function findActiveParentBlockId( - messagesState: PruneMessagesState, - block: CompressionBlock, -): number | null { - const queue = [...block.parentBlockIds] - const visited = new Set() - - while (queue.length > 0) { - const parentBlockId = queue.shift() - if (parentBlockId === undefined || visited.has(parentBlockId)) { - continue - } - visited.add(parentBlockId) - - const parent = messagesState.blocksById.get(parentBlockId) - if (!parent) { - continue - } - - if (parent.active) { - return parent.blockId - } - - for (const ancestorId of parent.parentBlockIds) { - if (!visited.has(ancestorId)) { - queue.push(ancestorId) - } - } - } - - return null -} - -export function findActiveAncestorBlockId( - messagesState: PruneMessagesState, - target: CompressionTarget, -): number | null { - for (const block of target.blocks) { - const activeAncestorBlockId = findActiveParentBlockId(messagesState, block) - if (activeAncestorBlockId !== null) { - return activeAncestorBlockId - } - } - - return null -} - -export function snapshotActiveMessages(messagesState: PruneMessagesState): Map { - const activeMessages = new Map() - for (const [messageId, entry] of messagesState.byMessageId) { - if (entry.activeBlockIds.length > 0) { - activeMessages.set(messageId, entry.tokenCount) - } - } - return activeMessages -} - -export function deactivateCompressionTarget( - messagesState: PruneMessagesState, - target: CompressionTarget, - options?: { full?: boolean }, -): void { - const deactivatedAt = Date.now() - - for (const block of target.blocks) { - block.active = false - block.deactivatedByUser = true - block.deactivatedAt = deactivatedAt - block.deactivatedByBlockId = undefined - - if (options?.full) { - const visited = new Set() - const queue = [...block.consumedBlockIds] - while (queue.length > 0) { - const consumedId = queue.shift()! - if (visited.has(consumedId)) continue - visited.add(consumedId) - const consumedBlock = messagesState.blocksById.get(consumedId) - if (consumedBlock) { - consumedBlock.deactivatedByUserDeep = true - queue.push(...consumedBlock.consumedBlockIds) - } - } - } - } -} - -export interface RestoredMessagesResult { - restoredMessageCount: number - restoredTokens: number -} - -export function computeRestoredMessages( - messagesState: PruneMessagesState, - activeMessagesBefore: Map, -): RestoredMessagesResult { - let restoredMessageCount = 0 - let restoredTokens = 0 - for (const [messageId, tokenCount] of activeMessagesBefore) { - const entry = messagesState.byMessageId.get(messageId) - const isActiveNow = entry ? entry.activeBlockIds.length > 0 : false - if (!isActiveNow) { - restoredMessageCount++ - restoredTokens += tokenCount - } - } - return { restoredMessageCount, restoredTokens } -} - -export function computeReactivatedBlockIds( - messagesState: PruneMessagesState, - activeBlockIdsBefore: Set, -): number[] { - return Array.from(messagesState.activeBlockIds) - .filter((blockId) => !activeBlockIdsBefore.has(blockId)) - .sort((a, b) => a - b) -} - -const MAX_PREVIEW_LENGTH = 2000 -const MAX_MESSAGE_PREVIEW_LENGTH = 200 - -export function buildRestoredContentPreview( - messages: WithParts[], - activeMessagesBefore: Map, - messagesState: PruneMessagesState, -): string { - const restoredMessages: WithParts[] = [] - for (const msg of messages) { - const msgId = msg.info.id - if (activeMessagesBefore.has(msgId)) { - const entry = messagesState.byMessageId.get(msgId) - const isActiveNow = entry ? entry.activeBlockIds.length > 0 : false - if (!isActiveNow) { - restoredMessages.push(msg) - } - } - } - - if (restoredMessages.length === 0) { - return "" - } - - const lines: string[] = [] - let totalLength = 0 - - for (const msg of restoredMessages) { - if (totalLength >= MAX_PREVIEW_LENGTH) break - - const role = msg.info.role ?? "unknown" - const textContent = extractTextContent(msg) - const truncated = - textContent.length > MAX_MESSAGE_PREVIEW_LENGTH - ? textContent.slice(0, MAX_MESSAGE_PREVIEW_LENGTH) + "..." - : textContent - - const line = `[${role}] ${truncated}` - lines.push(line) - totalLength += line.length + 1 - } - - return lines.join("\n") -} - -function extractTextContent(msg: WithParts): string { - if (!msg.parts || msg.parts.length === 0) { - return "" - } - - const textParts: string[] = [] - for (const part of msg.parts) { - if (typeof part === "object" && part !== null) { - if ("text" in part && typeof part.text === "string") { - textParts.push(part.text) - } else if ("type" in part && part.type === "tool") { - const toolName = "tool" in part && typeof part.tool === "string" ? part.tool : "tool" - const state = part.state as Record | undefined - if (state && typeof state.output === "string") { - const output = - state.output.length > 80 - ? state.output.slice(0, 80) + "..." - : state.output - textParts.push(`[${toolName}] ${output}`) - } - } - } - } - - return textParts.join(" ").replace(/\s+/g, " ").trim() -} diff --git a/lib/compress/decompress.ts b/lib/compress/decompress.ts deleted file mode 100644 index 614941ce..00000000 --- a/lib/compress/decompress.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { type ToolContext, type ToolFactoryContext, resolveToolContext } from "./types" -import type { CompressionTarget } from "../commands/compression-targets" -import type { CompressionBlock } from "../state/types" -import type { SessionState, WithParts } from "../state" -import { ensureSessionInitialized } from "../state" -import { saveSessionState } from "../state/persistence" -import { assignMessageRefs } from "../message-ids" -import { syncCompressionBlocks } from "../messages" -import { getCurrentTokenUsage } from "../token-utils" -import { - fetchSessionMessages, - buildSearchContext, - resolveBoundaryIds, - resolveSelection, -} from "./search" -import { resolveCompressionTarget } from "../commands/compression-targets" -import { - parseBlockIdArg, - resolveDecompressMode, - findActiveAncestorBlockId, - findActiveBlocksOverlappingMessages, - snapshotActiveMessages, - deactivateCompressionTarget, - computeRestoredMessages, - computeReactivatedBlockIds, - buildRestoredContentPreview, -} from "./decompress-logic" -import { formatTokenCount } from "../ui/utils" - -interface RunContext { - ask(input: { - permission: string - patterns: string[] - always: string[] - metadata: Record - }): Promise - metadata(input: { title: string }): void - sessionID: string -} - -async function prepareDecompressSession( - ctx: ToolContext, - toolCtx: RunContext, -): Promise<{ rawMessages: WithParts[] }> { - await toolCtx.ask({ - permission: "compress", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) - - toolCtx.metadata({ title: "Decompress" }) - - const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) - - await ensureSessionInitialized( - ctx.client, - ctx.state, - toolCtx.sessionID, - ctx.logger, - rawMessages, - ctx.config, - ) - - assignMessageRefs(ctx.state, rawMessages) - - return { rawMessages } -} - -async function finalizeDecompressSession(ctx: ToolContext): Promise { - await saveSessionState(ctx.state, ctx.logger) -} - -type ResolveResult = - | { ok: true; targets: CompressionTarget[] } - | { ok: false; error: string } - -function resolveTargets( - args: Record, - state: SessionState, - rawMessages: WithParts[], - logger: { info: (msg: string, meta?: Record) => void }, -): ResolveResult { - const mode = resolveDecompressMode(args) - if (!mode.ok) { - return { ok: false, error: `Error: ${mode.error}` } - } - - const messagesState = state.prune.messages - - if (mode.mode === "block") { - return resolveSingleBlockTarget(messagesState, args.blockId as string) - } - - return resolveRangeTarget(state, rawMessages, args.startId as string, args.endId as string, logger) -} - -function resolveSingleBlockTarget( - messagesState: SessionState["prune"]["messages"], - blockIdArg: string, -): ResolveResult { - const targetBlockId = parseBlockIdArg(blockIdArg) - if (targetBlockId === null) { - return { - ok: false, - error: `Error: Invalid block ID "${blockIdArg}". Use format "b0", "b1", etc.`, - } - } - - const target = resolveCompressionTarget(messagesState, targetBlockId) - if (!target) { - return { - ok: false, - error: `Error: Block ${targetBlockId} does not exist. No compression found with that ID.`, - } - } - - const activeBlocks = target.blocks.filter((block) => block.active) - if (activeBlocks.length === 0) { - const activeAncestorBlockId = findActiveAncestorBlockId(messagesState, target) - if (activeAncestorBlockId !== null) { - return { - ok: false, - error: `Error: Block ${target.displayId} is nested inside active block ${activeAncestorBlockId}. Decompress block ${activeAncestorBlockId} first.`, - } - } - return { - ok: false, - error: `Error: Block ${target.displayId} is not active. It may have already been decompressed.`, - } - } - - return { ok: true, targets: [target] } -} - -function resolveRangeTarget( - state: SessionState, - rawMessages: WithParts[], - startId: string, - endId: string, - logger: { info: (msg: string, meta?: Record) => void }, -): ResolveResult { - const searchContext = buildSearchContext(state, rawMessages) - - let startReference - let endReference - try { - const resolved = resolveBoundaryIds(searchContext, state, startId, endId) - startReference = resolved.startReference - endReference = resolved.endReference - } catch (err) { - return { ok: false, error: `Error: ${(err as Error).message}` } - } - - let selection - try { - selection = resolveSelection(searchContext, startReference, endReference) - } catch (err) { - return { ok: false, error: `Error: ${(err as Error).message}` } - } - if (selection.messageIds.length === 0) { - return { - ok: false, - error: `Error: No messages found in range ${startId}..${endId}.`, - } - } - - const messageIdSet = new Set(selection.messageIds) - const messagesState = state.prune.messages - const overlappingBlocks = findActiveBlocksOverlappingMessages(messagesState, messageIdSet) - if (overlappingBlocks.length === 0) { - return { - ok: false, - error: `Error: No active compression blocks overlap the range ${startId}..${endId}. The content may already be fully visible. Use acp_status to review block coverage.`, - } - } - - const targetMap = new Map() - for (const block of overlappingBlocks) { - const target = resolveCompressionTarget(messagesState, block.blockId) - if (target) { - targetMap.set(target.displayId, target) - } - } - const targets = Array.from(targetMap.values()) - - logger.info("range decompress resolved", { - range: `${startId}..${endId}`, - matchedBlocks: overlappingBlocks.map((b) => b.blockId), - targets: targets.map((t) => t.displayId), - }) - - return { ok: true, targets } -} - -const TOOL_DESCRIPTION = `Restores previously compressed content. - -Use this tool when you need exact details from compressed content that the summary cannot provide. -The tool returns a condensed preview of the restored content so you can reason about it immediately. - -TWO MODES: - -1. Block mode (default): decompress a single block by ID. - - blockId: block reference to decompress (e.g., "b0", "b2") - -2. Range mode: decompress ALL blocks overlapping a message range. Use this to restore - content across multiple blocks without calling acp_status + decompress repeatedly. - - startId: starting message or block ref (e.g., "m00150") - - endId: ending message or block ref (e.g., "m00200") - - Range mode finds every active block whose effectiveMessageIds touch the range and - batch-restores them. Partial overlap decompresses the whole block (content cannot be - partially restored). Nested blocks are handled automatically. - -ARGUMENTS: -- blockId?: string — use this OR startId+endId (mutually exclusive) -- startId?: string — range start (message or block ref) -- endId?: string — range end (message or block ref) -- toFile?: string — if provided, writes restored content to this file path (must be under - /tmp or ~/.cache/opencode/) instead of inflating context. Block(s) stay compressed. - -IMPORTANT: -- Decompressing inflates context. Check context usage before decompressing. -- Message-mode blocks from the same batch (same runId) are restored together. -- TIER-AWARE: by default, decompressing a multi-tier block restores the PREVIOUS tier's - summaries (e.g., decompress T2 → T1 summaries visible, not raw messages). Use full:true - to restore all the way to original messages (can be very expensive for T2/T3 blocks). -- After decompression, the restored content will appear in full in your next context window. -- Do NOT call this tool in parallel with compress — their state mutations may conflict.` - -function buildSchema() { - return { - blockId: tool.schema - .string() - .optional() - .describe('Block reference to decompress (e.g., "b0", "b2"). Mutually exclusive with startId/endId.'), - startId: tool.schema - .string() - .optional() - .describe('Range start: message ref (e.g., "m00150") or block ref (e.g., "b2"). Used with endId.'), - endId: tool.schema - .string() - .optional() - .describe('Range end: message ref (e.g., "m00200") or block ref (e.g., "b5"). Used with startId.'), - toFile: tool.schema - .string() - .optional() - .describe("If provided, writes restored content to this file path instead of inflating context. Block stays compressed. Path must be under /tmp or ~/.cache/opencode/. Example: '/tmp/block52.txt'"), - full: tool.schema - .boolean() - .optional() - .describe("If true, restores ALL content down to original messages (multi-level decompress). Default: false — restores one tier up (e.g., decompressing a T2 block restores T1 summaries, not raw messages). Use full:true only when you need the exact original content and have context budget for it."), - } -} - -function extractMessageId(m: WithParts): string { - return (m as { id?: string }).id ?? (m as { messageId?: string }).messageId ?? "" -} - -function extractMessageText(m: WithParts): string { - const msg = m as { role?: string; type?: string; content?: unknown; text?: string } - const role = msg.role || msg.type || "unknown" - const content = - typeof msg.content === "string" - ? msg.content - : typeof msg.text === "string" - ? msg.text - : JSON.stringify(msg.content || msg.text || "") - return `[${role}]\n${content}` -} - -export function createDecompressTool(factoryCtx: ToolFactoryContext): ReturnType { - return tool({ - description: TOOL_DESCRIPTION, - args: buildSchema(), - async execute(args, toolCtx) { - const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID) - const { rawMessages } = await prepareDecompressSession(ctx, toolCtx) - - const contextUsageBefore = ctx.state.modelContextLimit - ? Math.round( - (getCurrentTokenUsage(ctx.state, rawMessages) / - ctx.state.modelContextLimit) * - 100, - ) - : undefined - - const resolved = resolveTargets(args as Record, ctx.state, rawMessages, ctx.logger) - if (!resolved.ok) { - return resolved.error - } - const targets = resolved.targets - - const messagesState = ctx.state.prune.messages - const activeBlocks: CompressionBlock[] = [] - for (const target of targets) { - for (const block of target.blocks) { - if (block.active) { - activeBlocks.push(block) - } - } - } - - if (args.toFile) { - const targetPath = args.toFile as string - const os = await import("os") - const path = await import("path") - const allowedDirs = [ - os.tmpdir() + "/", - path.join(os.homedir(), ".cache", "opencode") + "/", - ] - const resolvedPath = path.resolve(targetPath) - const isAllowed = allowedDirs.some((dir) => { - const rel = path.relative(dir, resolvedPath) - return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) - }) - if (!isAllowed) { - return `Error: toFile path must be under ${os.tmpdir()} or ~/.cache/opencode/. Got: ${targetPath}` - } - - const msgIdSet = new Set() - for (const block of activeBlocks) { - for (const id of block.effectiveMessageIds ?? []) { - msgIdSet.add(id) - } - } - const blockMessages = rawMessages.filter((m) => msgIdSet.has(extractMessageId(m))) - const lines = blockMessages.map(extractMessageText) - const { writeFile } = await import("fs/promises") - const fileContent = - lines.length > 0 - ? lines.join("\n\n---\n\n") - : (activeBlocks[0]?.summary ?? "(no content available)") - await writeFile(targetPath, fileContent, "utf-8") - - const displayIds = targets.map((t) => `b${t.displayId}`).join(", ") - return `Block(s) ${displayIds} content (${blockMessages.length} messages, ${fileContent.length} chars) written to ${targetPath}. Block(s) stay compressed — context unchanged. Use read tool to access specific parts.` - } - - const activeMessagesBefore = snapshotActiveMessages(messagesState) - const activeBlockIdsBefore = new Set(messagesState.activeBlockIds) - - for (const target of targets) { - deactivateCompressionTarget(messagesState, target, { full: args.full === true }) - } - - syncCompressionBlocks(ctx.state, ctx.logger, rawMessages) - - const { restoredMessageCount, restoredTokens } = computeRestoredMessages( - messagesState, - activeMessagesBefore, - ) - const reactivatedBlockIds = computeReactivatedBlockIds( - messagesState, - activeBlockIdsBefore, - ) - - ctx.state.stats.totalPruneTokens = Math.max( - 0, - ctx.state.stats.totalPruneTokens - restoredTokens, - ) - - const contextUsageAfter = ctx.state.modelContextLimit - ? Math.round( - (getCurrentTokenUsage(ctx.state, rawMessages) / - ctx.state.modelContextLimit) * - 100, - ) - : undefined - - await finalizeDecompressSession(ctx) - - const restoredContentPreview = buildRestoredContentPreview( - rawMessages, - activeMessagesBefore, - messagesState, - ) - - const displayIds = targets.map((t) => `b${t.displayId}`).join(", ") - const lines: string[] = [] - const headerNoun = targets.length === 1 ? "block" : "blocks" - lines.push( - `Decompressed ${headerNoun} ${displayIds}. Restored ${restoredMessageCount} message(s) (~${formatTokenCount(restoredTokens)}).`, - ) - - if (contextUsageBefore !== undefined && contextUsageAfter !== undefined) { - lines.push(`Context usage: ${contextUsageBefore}% → ${contextUsageAfter}%.`) - } - - if (reactivatedBlockIds.length > 0) { - const refs = reactivatedBlockIds.map((id) => `b${id}`).join(", ") - lines.push(`Also restored nested block(s): ${refs}.`) - } - - if (restoredContentPreview) { - lines.push("") - lines.push("RESTORED CONTENT (condensed):") - lines.push(restoredContentPreview) - } - - ctx.logger.info("Decompress tool completed", { - mode: typeof args.startId === "string" ? "range" : "block", - targetBlockIds: targets.map((t) => t.displayId), - restoredMessageCount, - restoredTokens, - reactivatedBlockIds, - }) - - return lines.join("\n") - }, - }) -} diff --git a/lib/compress/hide-consumed.ts b/lib/compress/hide-consumed.ts deleted file mode 100644 index b653dba5..00000000 --- a/lib/compress/hide-consumed.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { SessionState, WithParts } from "../state" -import { hasMeaningfulContent } from "./parts" - -const KEEP_LAST_ORPHANED = 2 - -export function hideConsumedCompressCalls(state: SessionState, messages: WithParts[]): number { - - const activeCallIds = new Set() - const allBlockCallIds = new Set() - for (const block of state.prune.messages.blocksById.values()) { - if (block.compressCallId) { - allBlockCallIds.add(block.compressCallId) - if (block.active && !block.deactivatedByUser && !block.deactivatedByUserDeep) { - activeCallIds.add(block.compressCallId) - } - } - } - - const lastOrphanedCallIds: string[] = [] - for (let i = messages.length - 1; i >= 0 && lastOrphanedCallIds.length < KEEP_LAST_ORPHANED; i--) { - const parts = Array.isArray(messages[i]?.parts) ? messages[i]!.parts : [] - for (let j = parts.length - 1; j >= 0 && lastOrphanedCallIds.length < KEEP_LAST_ORPHANED; j--) { - const p = parts[j]! - if (p.type === "tool" && p.tool === "compress" && p.callID && !allBlockCallIds.has(p.callID)) { - lastOrphanedCallIds.push(p.callID) - } - } - } - - const keepCallIds = new Set([...activeCallIds, ...lastOrphanedCallIds]) - - let hidden = 0 - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]! - const parts = Array.isArray(msg.parts) ? msg.parts : [] - let changed = false - const remaining = parts.filter((p) => { - if (p.type === "tool" && p.tool === "compress") { - if (p.callID && keepCallIds.has(p.callID)) return true - hidden++ - changed = true - return false - } - return true - }) - - if (changed) { - if (hasMeaningfulContent(remaining)) { - messages[i] = { ...msg, parts: remaining } - } else { - messages.splice(i, 1) - i-- - } - } - } - - return hidden -} diff --git a/lib/compress/hide-failed.ts b/lib/compress/hide-failed.ts deleted file mode 100644 index 1f9861ed..00000000 --- a/lib/compress/hide-failed.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { WithParts } from "../state" -import { hasMeaningfulContent } from "./parts" - -// Must run AFTER injectCompressNudges: the nudge system needs to see failed -// compress calls for baseline reset (messageHasCompressAttempt). Removing them -// first would reintroduce the issue #216 feedback loop. -// -// Keeps the MOST RECENT failed compress call so the model can retry (e.g. with -// acknowledgeRisk after a quality gate rejection). Older failures are removed -// to prevent context pollution from repeated failures. -export function hideFailedCompressCalls(messages: WithParts[]): number { - let mostRecentFailedId: string | undefined - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]! - const parts = msg.parts - if (!Array.isArray(parts)) continue - if ( - parts.some( - (p) => - p.type === "tool" && - p.tool === "compress" && - p.state?.status === "error", - ) - ) { - mostRecentFailedId = msg.info.id - break - } - } - - let hidden = 0 - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]! - if (msg.info.id === mostRecentFailedId) continue - - const parts = Array.isArray(msg.parts) ? msg.parts : [] - if (parts.length === 0) continue - - let changed = false - const remaining = parts.filter((p) => { - if ( - p.type === "tool" && - p.tool === "compress" && - p.state?.status === "error" - ) { - hidden++ - changed = true - return false - } - return true - }) - - if (changed) { - if (hasMeaningfulContent(remaining)) { - messages[i] = { ...msg, parts: remaining } - } else { - messages.splice(i, 1) - i-- - } - } - } - - return hidden -} diff --git a/lib/compress/index.ts b/lib/compress/index.ts deleted file mode 100644 index c049992f..00000000 --- a/lib/compress/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type { ToolContext } from "./types" -export { createCompressRangeTool } from "./range" -export { createDecompressTool } from "./decompress" -export { createSearchContextTool } from "./search" -export { createAcpStatusTool } from "./status" -export { createAcpContextRecapTool } from "./recap" -export { hideConsumedCompressCalls } from "./hide-consumed" -export { hideFailedCompressCalls } from "./hide-failed" diff --git a/lib/compress/keep-markers.ts b/lib/compress/keep-markers.ts deleted file mode 100644 index b197184f..00000000 --- a/lib/compress/keep-markers.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { WithParts } from "../state" -import type { SessionState } from "../state" -import type { PluginConfig } from "../config" -import { parseMessageRef, formatMessageRef } from "../message-ids" - -const KEEP_REGEX = /\[\[KEEP:(m\d+)\]\]/g -const REF_REGEX = /\[\[REF:(m\d+)\|([^\]]+)\]\]/g - -export interface KeepMarkerResult { - summary: string - expandedCount: number - refCount: number - unresolvedRefs: string[] -} - -export function resolveKeepMarkers( - summary: string, - messages: WithParts[], - state: SessionState, - config: PluginConfig, -): KeepMarkerResult { - const msgByRef = new Map() - for (const msg of messages) { - const ref = state.messageIds.byRawId.get(msg.info.id) - if (ref) msgByRef.set(ref, msg) - } - - const maxChars = config.compress?.keepEmbedMaxChars ?? 2000 - let expandedCount = 0 - let refCount = 0 - const unresolvedRefs: string[] = [] - - const expanded = summary - .replace(KEEP_REGEX, (match, ref: string) => { - const normalized = normalizeRef(ref) - const msg = normalized ? msgByRef.get(normalized) : undefined - if (!msg) { - unresolvedRefs.push(ref) - return match - } - expandedCount++ - return formatKeptMessage(msg, normalized!, maxChars) - }) - .replace(REF_REGEX, (_match, ref: string, desc: string) => { - const normalized = normalizeRef(ref) - const msg = normalized ? msgByRef.get(normalized) : undefined - if (!msg) { - unresolvedRefs.push(ref) - return _match - } - refCount++ - return `[→ ${normalized}: ${desc.trim()}]` - }) - - return { summary: expanded, expandedCount, refCount, unresolvedRefs } -} - -function normalizeRef(ref: string): string | null { - const idx = parseMessageRef(ref) - if (idx === null) return null - return formatMessageRef(idx) -} - -function formatKeptMessage(msg: WithParts, ref: string, maxChars: number): string { - const formatted = formatByType(msg) - const truncated = truncate(formatted, maxChars) - return `\n--- [${ref}: ${labelForMessage(msg)}] ---\n${truncated}\n--- end ---\n` -} - -function formatByType(msg: WithParts): string { - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - return (part as any).text as string - } - if (part.type === "tool") { - const tool = (part as any).tool || "unknown" - const state = (part as any).state || {} - const input = state.input || {} - const output = state.output || "" - - switch (tool) { - case "bash": - case "interactive_bash": { - const cmd = typeof input === "string" ? input : input.command || JSON.stringify(input) - return `$ ${cmd}\n${output}` - } - case "read": { - const fp = input.filePath || input.path || input.file || "" - return output - } - case "write": - case "edit": { - const fp = input.filePath || input.path || "" - const content = input.content || input.newString || "" - return `${fp}:\n${content}` - } - case "reply": { - return output || "[reply posted]" - } - case "grep": - case "glob": { - return output - } - default: { - if (output && typeof output === "string" && output.length > 0) { - return output - } - const compact = JSON.stringify({ tool, input }, null, 0) - return compact.length > 500 ? compact.slice(0, 500) + "..." : compact - } - } - } - } - return "[empty message]" -} - -function labelForMessage(msg: WithParts): string { - for (const part of msg.parts || []) { - if (part.type === "tool") { - const tool = (part as any).tool || "unknown" - const input = (part as any).state?.input || {} - const fp = input.filePath || input.path || input.command || "" - return fp ? `${tool}: ${String(fp).slice(0, 60)}` : tool - } - } - return msg.info.role === "user" ? "user" : "text" -} - -function truncate(text: string, maxChars: number): string { - if (text.length <= maxChars) return text - return text.slice(0, maxChars) + `\n... [truncated, ${text.length} chars total]` -} diff --git a/lib/compress/parts.ts b/lib/compress/parts.ts deleted file mode 100644 index 9409024a..00000000 --- a/lib/compress/parts.ts +++ /dev/null @@ -1,5 +0,0 @@ -const STRUCTURAL_PART_TYPES = new Set(["step-start", "step-finish", "reasoning"]) - -export function hasMeaningfulContent(parts: { type: string }[]): boolean { - return parts.some((p) => !STRUCTURAL_PART_TYPES.has(p.type)) -} diff --git a/lib/compress/pipeline.ts b/lib/compress/pipeline.ts deleted file mode 100644 index 6df95b7e..00000000 --- a/lib/compress/pipeline.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { PruneMessagesState, SessionState, SessionStats, WithParts } from "../state" -import { ensureSessionInitialized } from "../state" -import { saveSessionState } from "../state/persistence" -import { assignMessageRefs } from "../message-ids" -import { isIgnoredUserMessage, isSyntheticMessage } from "../messages/query" -import { getCurrentParams, getCurrentTokenUsage } from "../token-utils" -import { sendCompressNotification } from "../ui/notification" -import type { ToolContext } from "./types" -import { buildSearchContext, fetchSessionMessages } from "./search" -import type { SearchContext } from "./types" -import { applyPendingCompressionDurations } from "./timing" -import { evaluateBatchQuality } from "./quality-gate" - -export interface CompressionSnapshot { - messages: PruneMessagesState - stats: SessionStats -} - -export function snapshotCompressionState(state: SessionState): CompressionSnapshot { - return { - messages: structuredClone(state.prune.messages), - stats: { ...state.stats }, - } -} - -export function restoreCompressionState( - state: SessionState, - snapshot: CompressionSnapshot, -): void { - state.prune.messages = structuredClone(snapshot.messages) - state.stats = { ...snapshot.stats } -} - -interface RunContext { - ask(input: { - permission: string - patterns: string[] - always: string[] - metadata: Record - }): Promise - metadata(input: { title: string }): void - sessionID: string -} - -export interface NotificationEntry { - blockId: number - runId: number - summary: string - summaryTokens: number -} - -export interface PreparedSession { - rawMessages: WithParts[] - searchContext: SearchContext -} - -export async function prepareSession( - ctx: ToolContext, - toolCtx: RunContext, - title: string, -): Promise { - await toolCtx.ask({ - permission: "compress", - patterns: ["*"], - always: ["*"], - metadata: {}, - }) - - toolCtx.metadata({ title }) - - const rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) - - await ensureSessionInitialized( - ctx.client, - ctx.state, - toolCtx.sessionID, - ctx.logger, - rawMessages, - ctx.config, - ) - - assignMessageRefs(ctx.state, rawMessages) - - return { - rawMessages, - searchContext: buildSearchContext(ctx.state, rawMessages), - } -} - -export async function finalizeSession( - ctx: ToolContext, - toolCtx: RunContext, - rawMessages: WithParts[], - entries: NotificationEntry[], - batchTopic: string | undefined, -): Promise { - applyPendingCompressionDurations(ctx.state) - await saveSessionState(ctx.state, ctx.logger) - - if (entries.length > 0) { - const qualityReport = evaluateBatchQuality( - ctx.state, - rawMessages, - entries, - ctx.config, - ctx.logger, - ) - for (const failure of qualityReport.failures) { - const metrics = Object.fromEntries( - failure.result.metrics.map((m) => [m.name, m.value]), - ) - ctx.logger.warn("Compression quality gate FAILED", { - blockId: failure.blockId, - algorithm: ctx.config.qualityGate.algorithm, - layer: failure.result.layer, - reason: failure.result.reason, - ...metrics, - }) - } - } - - const params = getCurrentParams(ctx.state, rawMessages, ctx.logger) - const sessionMessageIds = rawMessages - .filter((msg) => !isIgnoredUserMessage(msg)) - .map((msg) => msg.info.id) - - const contextTokensBefore = getCurrentTokenUsage(ctx.state, rawMessages) - - await sendCompressNotification( - ctx.client, - ctx.logger, - ctx.config, - ctx.state, - toolCtx.sessionID, - entries, - batchTopic, - sessionMessageIds, - params, - contextTokensBefore, - ) -} - -/** - * Find the last visible (non-synthetic, non-pruned) message ID. - * Returns null if no visible message exists. - */ -export function getLastVisibleMessageId( - rawMessages: WithParts[], - state: SessionState, -): string | null { - for (let i = rawMessages.length - 1; i >= 0; i--) { - const msg = rawMessages[i] - const id = msg?.info?.id - if (!id || typeof id !== "string") continue - if (isSyntheticMessage(msg)) continue - if (state.prune.messages.byMessageId.has(id)) continue - return id - } - return null -} - -/** - * Stateless check: reject compression plans that would produce phantom blocks - * (0 new direct messages, 0 compressed tokens). A phantom block occurs when - * every message in the effective range is already active under an existing - * compression block. Returns an Error to throw if any plan is phantom. - * - * Fix for issue #93: empty compression blocks waste context (summary overhead - * with no token savings) and cause compression loops — the model sees 0 tokens - * removed, retries the same range, creates another phantom, endlessly. - * - * A message is "new" (will be newly compressed) if it is NOT currently active - * under any block. Messages active under consumed blocks are still "already - * compressed" — re-labeling them under a new block does not newly hide them - * (matches applyCompressionState's newlyCompressedMessageIds computation). - */ -export function checkPhantomBlock( - state: SessionState, - plans: Array<{ messageIds: string[]; consumedBlockIds: number[] }>, -): Error | null { - for (let i = 0; i < plans.length; i++) { - const plan = plans[i] - - // Build effective message set: selection messages + inherited from - // consumed blocks (mirrors applyCompressionState lines 79-93). - const effective = new Set(plan.messageIds) - for (const consumedId of plan.consumedBlockIds) { - const block = state.prune.messages.blocksById.get(consumedId) - if (block) { - for (const mid of block.effectiveMessageIds) { - effective.add(mid) - } - } - } - - const hasNew = [...effective].some((mid) => { - const entry = state.prune.messages.byMessageId.get(mid) - return !entry || entry.activeBlockIds.length === 0 - }) - - if (!hasNew) { - if (plan.consumedBlockIds.length >= 2) { - const tiers = new Set( - plan.consumedBlockIds.map( - (id) => state.prune.messages.blocksById.get(id)?.tier ?? 1, - ), - ) - if (tiers.size === 1) { - continue - } - } - return new Error( - `Compression range ${i + 1} contains only already-compressed messages ` + - "(0 new direct messages, 0 tokens saved). Nothing to compress — " + - 'pick a range with visible, uncompressed content. Use `acp_status({scope:"uncompressed"})` ' + - "to see which ranges are still compressible.", - ) - } - } - return null -} - -/** - * Compute the set of protected message raw IDs based on recent-message and - * recent-token rules. These IDs cannot be included in a compression plan - * unless the caller passes `dangerous: true`. - * - * Note: preserveLastUserMessage is handled by soft filtering in the compress - * pipeline (filterLastUserMessage), not here. - */ -export function computeProtectedRawIds( - rawMessages: WithParts[], - state: SessionState, - compress: import("../config").CompressConfig, -): Set { - const preserveN = compress.preserveRecentMessages ?? 5 - const preserveTokens = compress.preserveRecentTokens ?? 5000 - - const result = new Set() - - const visible: { id: string; tokens: number; isUser: boolean }[] = [] - for (const msg of rawMessages) { - const id = msg?.info?.id - if (!id || typeof id !== "string") continue - if (isSyntheticMessage(msg)) continue - if (isIgnoredUserMessage(msg)) continue - if (state.prune.messages.byMessageId.has(id)) continue - let tokens = 0 - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - tokens += Math.round(((part as any).text as string).length / 4) - } else if (part.type !== "text" && part.type !== "reasoning") { - tokens += Math.round(JSON.stringify(part).length / 4) - } - } - visible.push({ id, tokens, isUser: msg.info.role === "user" }) - } - - if (preserveN > 0) { - for (const m of visible.slice(-preserveN)) { - result.add(m.id) - } - } - - if (preserveTokens > 0) { - let tokenAccum = 0 - for (let i = visible.length - 1; i >= 0 && tokenAccum < preserveTokens; i--) { - result.add(visible[i]!.id) - tokenAccum += visible[i]!.tokens - } - } - - return result -} - -/** - * Reject compression plans that cover protected messages (last N messages - * or last N tokens). The caller must pass `dangerous: true` to proceed. - * Returns an Error to throw if the caller did not opt in. - */ -export function checkProtectedRange( - ctx: ToolContext, - allPlanMessageIds: string[][], - rawMessages: WithParts[], - dangerous: boolean, -): Error | null { - if (ctx.config.compress.lastSegmentSoftBlock === false) return null - - const protectedIds = computeProtectedRawIds(rawMessages, ctx.state, ctx.config.compress) - if (protectedIds.size === 0) return null - - const coveredProtected: string[] = [] - for (const ids of allPlanMessageIds) { - for (const id of ids) { - if (protectedIds.has(id)) { - coveredProtected.push(id) - } - } - } - - if (coveredProtected.length === 0) return null - if (dangerous) return null - - const sample = coveredProtected.slice(0, 3).join(", ") - const nMsgs = ctx.config.compress.preserveRecentMessages ?? 20 - const nToks = ctx.config.compress.preserveRecentTokens ?? 5000 - return new Error( - `This range includes ${coveredProtected.length} protected recent message(s) (${sample}), ` + - "which are likely still needed for the current task step.\n\n" + - `Protected zone: last ${nMsgs} messages + last ${nToks >= 1000 ? `${nToks / 1000}K` : nToks} tokens.\n` + - "If you are certain this content is genuinely consumed and must be compressed, " + - "re-issue the call with `dangerous: true`.\n" + - "Otherwise, compress older ranges that do not include the tail of the conversation.", - ) -} diff --git a/lib/compress/quality-gate/algorithms/index.ts b/lib/compress/quality-gate/algorithms/index.ts deleted file mode 100644 index 9527d441..00000000 --- a/lib/compress/quality-gate/algorithms/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { rougeRecallV1 } from "context-compress-algorithms/quality-gate" -import { registerQualityGate } from "../registry" - -export function ensureBuiltinGatesRegistered(): void { - registerQualityGate(rougeRecallV1) -} - -export { rougeRecallV1 } from "context-compress-algorithms/quality-gate" diff --git a/lib/compress/quality-gate/evaluate.ts b/lib/compress/quality-gate/evaluate.ts deleted file mode 100644 index aba2988d..00000000 --- a/lib/compress/quality-gate/evaluate.ts +++ /dev/null @@ -1,217 +0,0 @@ -import type { Logger } from "../../logger" -import type { PluginConfig } from "../../config" -import type { SessionState, WithParts } from "../../state/types" -import type { CompressionBlock } from "../../state/types" -import type { Part } from "@opencode-ai/sdk/v2" -import type { - QualityGateContext, - QualityGateResult, - QualityReport, -} from "./types" -import type { NotificationEntry } from "../pipeline" -import { ensureBuiltinGatesRegistered } from "./algorithms" -import { getQualityGate } from "./registry" - -const CHARS_PER_TOKEN_ESTIMATE = 4 -const TOOL_OUTPUT_MAX_CHARS = 1500 -const TOOL_INPUT_MAX_CHARS = 500 - -function extractMessageText(parts: Part[] | undefined): string { - if (!parts || !Array.isArray(parts)) return "" - let text = "" - for (const part of parts) { - if (!part || typeof part !== "object") continue - if (part.type === "text") { - text += part.text + "\n" - } else if (part.type === "tool") { - const state = part.state - const input = state.status === "completed" && typeof state.input === "object" - ? JSON.stringify(state.input).slice(0, TOOL_INPUT_MAX_CHARS) - : "" - const output = state.status === "completed" && typeof state.output === "string" - ? state.output.slice(0, TOOL_OUTPUT_MAX_CHARS) - : state.status === "completed" && typeof state.output === "object" - ? JSON.stringify(state.output).slice(0, TOOL_OUTPUT_MAX_CHARS) - : "" - text += `[tool:${part.tool}] ${input}\n${output}\n` - } - } - return text -} - -function buildContext( - block: CompressionBlock, - rawMessages: WithParts[], -): QualityGateContext | null { - const directIds = block.directMessageIds - if (!directIds || directIds.length === 0) return null - - const idToMsg = new Map() - for (const m of rawMessages) { - const id = m?.info?.id - if (typeof id === "string") idToMsg.set(id, m) - } - - const chunks: string[] = [] - for (const id of directIds) { - const m = idToMsg.get(id) - if (!m) continue - chunks.push(extractMessageText(m.parts)) - } - if (chunks.length === 0) return null - - const originalText = chunks.join("\n") - return { - block, - summary: block.summary ?? "", - originalChunks: chunks, - originalText, - originalTokens: Math.ceil(originalText.length / CHARS_PER_TOKEN_ESTIMATE), - } -} - -export function evaluateBlockQuality( - state: SessionState, - rawMessages: WithParts[], - entry: NotificationEntry, - config: PluginConfig, - logger: Logger, -): QualityGateResult | null { - const qg = config.qualityGate - if (!qg || qg.enabled !== true) return null - - ensureBuiltinGatesRegistered() - const algoName = qg.algorithm - if (!algoName) { - logger.warn("Quality gate enabled but no algorithm specified", {}) - return null - } - const gate = getQualityGate(algoName) - if (!gate) { - logger.warn("Quality gate algorithm not found in registry", { algorithm: algoName }) - return null - } - - const block = state.prune.messages.blocksById.get(entry.blockId) - if (!block) { - logger.warn("Quality gate: block not found", { blockId: entry.blockId }) - return null - } - - const ctx = buildContext(block, rawMessages) - if (!ctx) return null - - const algoConfig = (qg.algorithms && qg.algorithms[algoName]) ?? {} - try { - return gate.evaluate(ctx, algoConfig) - } catch (err) { - logger.warn("Quality gate threw — treating as pass", { - gate: gate.name, - blockId: entry.blockId, - error: err instanceof Error ? err.message : String(err), - }) - return { passed: true, metrics: [] } - } -} - -export function evaluateBatchQuality( - state: SessionState, - rawMessages: WithParts[], - entries: NotificationEntry[], - config: PluginConfig, - logger: Logger, -): QualityReport { - const failures: QualityReport["failures"] = [] - for (const entry of entries) { - const result = evaluateBlockQuality(state, rawMessages, entry, config, logger) - if (result && !result.passed) { - failures.push({ blockId: entry.blockId, result }) - } - } - return { - total: entries.length, - passed: entries.length - failures.length, - failures, - } -} - -/** - * Pre-commit quality evaluation: check summary quality BEFORE the block is - * committed to state. Builds a pseudo-block snapshot from the plan data. - * - * Returns null if quality gate is disabled or evaluation cannot run. - * Returns QualityGateResult (passed: true/false) otherwise. - * - * `compressedTokens` is estimated as the sum of messageTokenById for all - * direct message IDs. This overestimates when some messages were already - * active under consumed blocks, making the gate more conservative (stricter) - * — which is the safe direction for a blocking check. - */ -export function evaluatePreCommitQuality( - rawMessages: WithParts[], - messageIds: string[], - messageTokenById: Map, - summary: string, - config: PluginConfig, - logger: Logger, -): QualityGateResult | null { - const qg = config.qualityGate - if (!qg || qg.enabled !== true) return null - - ensureBuiltinGatesRegistered() - const algoName = qg.algorithm - if (!algoName) { - logger.warn("Quality gate enabled but no algorithm specified", {}) - return null - } - const gate = getQualityGate(algoName) - if (!gate) { - logger.warn("Quality gate algorithm not found in registry", { algorithm: algoName }) - return null - } - - if (messageIds.length === 0) return null - - const idToMsg = new Map() - for (const m of rawMessages) { - const id = m?.info?.id - if (typeof id === "string") idToMsg.set(id, m) - } - - const chunks: string[] = [] - let compressedTokens = 0 - for (const id of messageIds) { - const m = idToMsg.get(id) - if (m) chunks.push(extractMessageText(m.parts)) - compressedTokens += messageTokenById.get(id) || 0 - } - if (chunks.length === 0) return null - - const originalText = chunks.join("\n") - const pseudoBlock = { - blockId: -1, - summary, - compressedTokens, - directMessageIds: messageIds, - effectiveMessageIds: messageIds, - } - - const ctx: QualityGateContext = { - block: pseudoBlock as CompressionBlock, - summary, - originalChunks: chunks, - originalText, - originalTokens: Math.ceil(originalText.length / CHARS_PER_TOKEN_ESTIMATE), - } - - const algoConfig = (qg.algorithms && qg.algorithms[algoName]) ?? {} - try { - return gate.evaluate(ctx, algoConfig) - } catch (err) { - logger.warn("Pre-commit quality gate threw — treating as pass", { - gate: gate.name, - error: err instanceof Error ? err.message : String(err), - }) - return { passed: true, metrics: [] } - } -} diff --git a/lib/compress/quality-gate/index.ts b/lib/compress/quality-gate/index.ts deleted file mode 100644 index 11da2d6a..00000000 --- a/lib/compress/quality-gate/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type { - QualityGate, - QualityGateContext, - QualityGateResult, - QualityGateMetric, - QualityReport, -} from "./types" - -export { - registerQualityGate, - getQualityGate, - listQualityGates, - clearQualityGateRegistryForTests, -} from "./registry" - -export { evaluateBlockQuality, evaluateBatchQuality, evaluatePreCommitQuality } from "./evaluate" -export { buildQualityRejectionError, buildPreemptiveAcknowledgeError } from "./rejection" -export type { RejectionPlanInfo } from "./rejection" -export { ensureBuiltinGatesRegistered } from "./algorithms" diff --git a/lib/compress/quality-gate/registry.ts b/lib/compress/quality-gate/registry.ts deleted file mode 100644 index a0c9eb36..00000000 --- a/lib/compress/quality-gate/registry.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { QualityGate } from "./types" - -const registry = new Map() - -export function registerQualityGate(gate: QualityGate): void { - if (registry.has(gate.name)) { - const existing = registry.get(gate.name)! - if (existing !== gate && existing.version !== gate.version) { - throw new Error( - `Quality gate "${gate.name}" already registered with version ${existing.version} (attempted ${gate.version})`, - ) - } - } - registry.set(gate.name, gate) -} - -export function getQualityGate(name: string): QualityGate | undefined { - return registry.get(name) -} - -export function listQualityGates(): string[] { - return [...registry.keys()].sort() -} - -export function clearQualityGateRegistryForTests(): void { - registry.clear() -} diff --git a/lib/compress/quality-gate/rejection.ts b/lib/compress/quality-gate/rejection.ts deleted file mode 100644 index 708d2c9d..00000000 --- a/lib/compress/quality-gate/rejection.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { HOW_TO_COMPRESS_RULES } from "context-compress-algorithms/prompts" -import type { QualityGateResult } from "./types" - -export interface RejectionPlanInfo { - startId: string - endId: string - summary: string - messageIds: string[] - messageTokenById: Map -} - -function formatMetric(result: QualityGateResult, name: string): string { - const m = result.metrics.find((x) => x.name === name) - if (!m) return "?" - switch (m.format) { - case "percent": - return `${m.value.toFixed(2)}%` - case "ratio": - return m.value.toFixed(4) - default: - return String(m.value) - } -} - -function computeStats(plan: RejectionPlanInfo): { - originalTokens: number - summaryChars: number - ratio: string - retentionPct: string -} { - let originalTokens = 0 - for (const id of plan.messageIds) { - originalTokens += plan.messageTokenById.get(id) || 0 - } - const summaryChars = plan.summary.length - const ratio = originalTokens > 0 ? (originalTokens / Math.max(summaryChars / 4, 1)).toFixed(1) : "?" - const retentionPct = - originalTokens > 0 ? ((summaryChars / (originalTokens * 4)) * 100).toFixed(2) : "?" - return { originalTokens, summaryChars, ratio, retentionPct } -} - -export function buildQualityRejectionError( - plan: RejectionPlanInfo, - result: QualityGateResult, -): Error { - const stats = computeStats(plan) - const metrics = [ - `Original: ~${stats.originalTokens} tokens`, - `Summary: ${stats.summaryChars} chars`, - `Ratio: ${stats.ratio}:1`, - `Retention: ${stats.retentionPct}%`, - `Gate layer: ${result.layer ?? "unknown"}`, - `rougeF1: ${formatMetric(result, "rougeF1")}`, - `top20Recall: ${formatMetric(result, "top20Recall")}`, - ] - - const message = `⚠️ COMPRESSION REJECTED — QUALITY GATE FAILURE - -Range: ${plan.startId}–${plan.endId} -${metrics.join("\n")} - -⚠️ CRITICAL: Compression is the ONLY mechanism for preserving historical context in this session. -Once a compression is accepted, the original messages are permanently removed from visible context. -Your summary becomes the SOLE record. If it fails, subsequent work is built on a broken foundation — -memory loss → wrong assumptions → entire reasoning chain collapse. -Treat every compression with maximum care. - -${HOW_TO_COMPRESS_RULES} - -To retry: rewrite a more complete summary that preserves critical details (file paths, decisions, -exact values, errors). Then add "acknowledgeRisk": true to the compress tool call parameters. -Without acknowledgeRisk: true, the compression will be rejected again.` - - return new Error(message) -} - -export function buildPreemptiveAcknowledgeError(): Error { - return new Error( - 'Parameter "acknowledgeRisk": true was provided, but no quality gate rejection is pending. ' + - "This parameter is only valid immediately after a compression was rejected by the quality gate. " + - "Remove it and try again.", - ) -} diff --git a/lib/compress/quality-gate/types.ts b/lib/compress/quality-gate/types.ts deleted file mode 100644 index 53aed393..00000000 --- a/lib/compress/quality-gate/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { CompressionBlock } from "../../state/types" - -/** - * Quality gate framework — pluggable post-compression quality checks. - * - * Gates are non-blocking: failures warn via logger, they never reject the - * compression (the model has already moved on). The framework is designed - * so that future algorithms (external LLM judges, BERTScore, custom models) - * can be added by registering a new `QualityGate` implementation. - */ - -export interface QualityGateContext { - block: CompressionBlock - summary: string - originalChunks: string[] - originalText: string - originalTokens: number -} - -export interface QualityGateMetric { - name: string - value: number - format?: "raw" | "percent" | "ratio" -} - -export interface QualityGateResult { - passed: boolean - layer?: string - reason?: string - metrics: QualityGateMetric[] -} - -/** - * Pluggable quality-gate algorithm. - * - * Contract: - * - `name` MUST be globally unique and stable across versions (config refers to it). - * - `version` SHOULD bump when thresholds or logic change. - * - `evaluate` MUST NOT throw — on internal error, return `{ passed: true, metrics: [] }`. - * Throwing would break the compression pipeline. - */ -export interface QualityGate { - name: string - version: string - description: string - evaluate(ctx: QualityGateContext, config: unknown): QualityGateResult -} - -export interface QualityReport { - total: number - passed: number - failures: Array<{ - blockId: number - result: QualityGateResult - }> -} diff --git a/lib/compress/range.ts b/lib/compress/range.ts deleted file mode 100644 index 912fce1b..00000000 --- a/lib/compress/range.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { type ToolFactoryContext, resolveToolContext } from "./types" -import { countMessageCharacters, countTokens } from "../token-utils" -import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool" -import { - finalizeSession, - prepareSession, - snapshotCompressionState, - restoreCompressionState, - checkPhantomBlock, - type NotificationEntry, -} from "./pipeline" -import { - appendProtectedPromptInfo, - appendProtectedTools, - appendProtectedUserMessages, - filterLastUserMessage, - filterProtectedRecentMessages, - filterProtectedToolMessages, -} from "./protected-content" -import { - appendMissingBlockSummaries, - injectBlockPlaceholders, - parseBlockPlaceholders, - resolveRanges, - validateArgs, - validateNonOverlapping, - validateSummaryPlaceholders, -} from "./range-utils" -import { - COMPRESSED_BLOCK_HEADER, - allocateBlockId, - allocateRunId, - applyCompressionState, - wrapCompressedSummary, -} from "./state" -import type { CompressRangeToolArgs } from "./types" -import { resolveKeepMarkers } from "./keep-markers" -import { - buildPreemptiveAcknowledgeError, - buildQualityRejectionError, - evaluatePreCommitQuality, -} from "./quality-gate" - -function buildSchema(maxSummaryLengthHard: number) { - return { - topic: tool.schema - .string() - .optional() - .describe( - "Fallback topic for entries without their own. Omit when each content entry specifies its own topic.", - ), - content: tool.schema - .array( - tool.schema.object({ - topic: tool.schema - .string() - .optional() - .describe( - "Short label (3-5 words) for THIS range, e.g. 'Auth System Exploration'. Omit to use top-level topic. When compressing multiple unrelated ranges, give each its own topic for better quality.", - ), - startId: tool.schema - .string() - .describe( - "Message or block ID marking the beginning of range (e.g. m00001, b2)", - ), - endId: tool.schema - .string() - .describe("Message or block ID marking the end of range (e.g. m00012, b5)"), - summary: tool.schema - .string() - .describe( - "Complete technical summary replacing all content in range. Keep only essential details (conclusions, file paths, decisions, exact values, etc.).", - ), - }), - ) - .describe( - "One or more ranges to compress, each with start/end boundaries and a summary. When compressing multiple unrelated ranges in one call, give each its own topic.", - ), - summaryMaxChars: tool.schema - .number() - .optional() - .describe( - `Override max summary length (default max: ${maxSummaryLengthHard} chars). Use when content is important and needs more detail — don't lose critical info just to fit the limit.`, - ), - dangerous: tool.schema - .boolean() - .optional() - .describe( - "Set to true ONLY when you are certain the most recent message(s) must be compressed. Required when a range includes the tail of the conversation.", - ), - acknowledgeRisk: tool.schema.boolean().optional(), - } -} - -export function createCompressRangeTool(factoryCtx: ToolFactoryContext): ReturnType { - factoryCtx.prompts.reload() - const runtimePrompts = factoryCtx.prompts.getRuntimePrompts() - - return tool({ - description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION, - args: buildSchema(factoryCtx.config.compress.maxSummaryLengthHard), - async execute(args, toolCtx) { - const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID) - const input = args as CompressRangeToolArgs - validateArgs(input) - - const maxLen = - (args as { summaryMaxChars?: number }).summaryMaxChars ?? - ctx.config.compress.maxSummaryLengthHard - for (const entry of input.content) { - if (entry.summary.length > maxLen) { - throw new Error( - `Summary too long (${entry.summary.length} chars, max ${maxLen}).\n1. If this summary is nearly the same size as the original content, it may not be worth compressing — skip it.\n2. Strip noise (failed attempts, verbose outputs) but keep project-critical details (file paths, decisions, exact values).\n3. For important content needing detail, pass summaryMaxChars to increase the limit — don't lose critical info just to fit. Example: add "summaryMaxChars": 6000 to the tool call args.`, - ) - } - } - - const callId = - typeof (toolCtx as unknown as { callID?: unknown }).callID === "string" - ? (toolCtx as unknown as { callID: string }).callID - : undefined - - const { rawMessages, searchContext } = await prepareSession( - ctx, - toolCtx, - `Compress Range: ${input.topic ?? "(batch)"}`, - ) - const resolvedPlans = resolveRanges(input, searchContext, ctx.state) - validateNonOverlapping(resolvedPlans) - - const filteredPlans = resolvedPlans - .map((plan) => ({ - ...plan, - selection: filterProtectedToolMessages( - plan.selection, - searchContext, - ctx.config.compress.protectedTools, - ctx.config.protectedFilePatterns, - ), - })) - .map((plan) => ({ - ...plan, - selection: filterLastUserMessage( - plan.selection, - searchContext, - ctx.state, - ctx.config.compress, - ), - })) - .map((plan) => ({ - ...plan, - selection: filterProtectedRecentMessages( - plan.selection, - searchContext, - ctx.state, - ctx.config.compress, - ), - })) - .filter((plan) => plan.selection.messageIds.length > 0) - - if (filteredPlans.length === 0) { - throw new Error( - "All selected messages were filtered out (protected tool outputs and/or the last user message). They must remain in visible context.", - ) - } - - const minCompressRange = ctx.config.compress.minCompressRange - if (minCompressRange > 0) { - let totalChars = 0 - const counted = new Set() - for (const plan of filteredPlans) { - for (const messageId of plan.selection.messageIds) { - if (counted.has(messageId)) continue - counted.add(messageId) - const rawMessage = searchContext.rawMessagesById.get(messageId) - if (rawMessage) { - totalChars += countMessageCharacters(rawMessage) - } - } - } - // Intentionally throws after prepareSession: the char count needs - // resolved plans + rawMessages, only available post-prepare. No state - // is persisted (finalizeSession/saveSessionState never runs). - if (totalChars < minCompressRange) { - throw new Error( - `Range too small (${totalChars} chars, min ${minCompressRange}). Not worth compressing — overhead exceeds savings.`, - ) - } - } - - const notifications: NotificationEntry[] = [] - const preparedPlans: Array<{ - entry: (typeof filteredPlans)[number]["entry"] - selection: (typeof filteredPlans)[number]["selection"] - anchorMessageId: string - finalSummary: string - consumedBlockIds: number[] - }> = [] - let totalCompressedMessages = 0 - - for (const plan of filteredPlans) { - const parsedPlaceholders = parseBlockPlaceholders(plan.entry.summary) - validateSummaryPlaceholders( - parsedPlaceholders, - plan.selection.requiredBlockIds, - plan.selection.startReference, - plan.selection.endReference, - searchContext.summaryByBlockId, - ctx.logger, - ) - - const injected = injectBlockPlaceholders( - plan.entry.summary, - parsedPlaceholders, - searchContext.summaryByBlockId, - plan.selection.startReference, - plan.selection.endReference, - ) - - const summaryWithUsers = appendProtectedUserMessages( - injected.expandedSummary, - plan.selection, - searchContext, - ctx.state, - ctx.config.compress.protectUserMessages, - ) - - const summaryWithPromptInfo = appendProtectedPromptInfo( - summaryWithUsers, - plan.selection, - searchContext, - ctx.state, - ctx.config.compress.protectTags, - ) - - const summaryWithTools = await appendProtectedTools( - ctx.client, - ctx.state, - summaryWithPromptInfo, - plan.selection, - searchContext, - ctx.config.compress.protectedTools, - ctx.config.protectedFilePatterns, - ) - - const completedSummary = appendMissingBlockSummaries( - summaryWithTools, - [], - searchContext.summaryByBlockId, - injected.consumedBlockIds, - ) - - // [Plan B] Auto-detect consumed blocks: requiredBlockIds already - // covers every active block whose anchor is in [start, end]; merge - // with boundary blocks (when start/end is a bN ref) and dedup. - const boundaryConsumed = extractBoundaryConsumedBlocks( - plan.selection.startReference, - plan.selection.endReference, - ) - const seenConsumed = new Set() - const mergeConsumedBlockIds = [ - ...plan.selection.requiredBlockIds, - ...boundaryConsumed, - ].filter((id) => { - if (seenConsumed.has(id)) return false - seenConsumed.add(id) - return true - }) - - preparedPlans.push({ - entry: plan.entry, - selection: plan.selection, - anchorMessageId: plan.anchorMessageId, - finalSummary: completedSummary.expandedSummary, - consumedBlockIds: mergeConsumedBlockIds, - }) - } - - const phantomError = checkPhantomBlock( - ctx.state, - preparedPlans.map((p) => ({ - messageIds: p.selection.messageIds, - consumedBlockIds: p.consumedBlockIds, - })), - ) - if (phantomError) throw phantomError - - const acknowledgeRisk = - (args as { acknowledgeRisk?: boolean }).acknowledgeRisk === true - - const qualityGateRetryPendingBefore = ctx.state.qualityGateRetryPending - - if (acknowledgeRisk && !ctx.state.qualityGateRetryPending) { - throw buildPreemptiveAcknowledgeError() - } - if (acknowledgeRisk) { - ctx.state.qualityGateRetryPending = false - } else { - ctx.state.qualityGateRetryPending = false - for (const plan of preparedPlans) { - const result = evaluatePreCommitQuality( - rawMessages, - plan.selection.messageIds, - plan.selection.messageTokenById, - plan.finalSummary, - ctx.config, - ctx.logger, - ) - if (result && !result.passed) { - ctx.state.qualityGateRetryPending = true - throw buildQualityRejectionError( - { - startId: plan.entry.startId, - endId: plan.entry.endId, - summary: plan.finalSummary, - messageIds: plan.selection.messageIds, - messageTokenById: plan.selection.messageTokenById, - }, - result, - ) - } - } - } - - const snapshot = snapshotCompressionState(ctx.state) - const runId = allocateRunId(ctx.state) - - try { - for (const preparedPlan of preparedPlans) { - const blockId = allocateBlockId(ctx.state) - const keepResult = resolveKeepMarkers( - preparedPlan.finalSummary, - rawMessages, - ctx.state, - ctx.config, - ) - preparedPlan.finalSummary = keepResult.summary - const storedSummary = wrapCompressedSummary(blockId, preparedPlan.finalSummary) - const summaryTokens = countTokens(storedSummary) - - const applied = applyCompressionState( - ctx.state, - { - topic: preparedPlan.entry.topic ?? input.topic ?? "", - batchTopic: input.topic, - startId: preparedPlan.entry.startId, - endId: preparedPlan.entry.endId, - mode: "range", - runId, - compressMessageId: toolCtx.messageID, - compressCallId: callId, - summaryTokens, - }, - preparedPlan.selection, - preparedPlan.anchorMessageId, - blockId, - storedSummary, - preparedPlan.consumedBlockIds, - ctx.config.gc, - ) - - totalCompressedMessages += applied.messageIds.length - - notifications.push({ - blockId, - runId, - summary: preparedPlan.finalSummary, - summaryTokens, - }) - } - - await finalizeSession( - ctx, - toolCtx, - rawMessages, - notifications, - input.topic, - ) - } catch (error) { - restoreCompressionState(ctx.state, snapshot) - ctx.state.qualityGateRetryPending = qualityGateRetryPendingBefore - throw error - } - - return `Compressed ${totalCompressedMessages} messages into ${COMPRESSED_BLOCK_HEADER}.\nIMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.\n💡 Tip: Use search_context('keyword') to find compressed content when you need it later.` - }, - }) -} - -function extractBoundaryConsumedBlocks( - startReference: { kind: string; blockId?: number }, - endReference: { kind: string; blockId?: number }, -): number[] { - const consumed: number[] = [] - const seen = new Set() - for (const ref of [startReference, endReference]) { - if ( - ref.kind === "compressed-block" && - ref.blockId !== undefined && - !seen.has(ref.blockId) - ) { - seen.add(ref.blockId) - consumed.push(ref.blockId) - } - } - return consumed -} diff --git a/lib/compress/recap.ts b/lib/compress/recap.ts deleted file mode 100644 index 8b8946ca..00000000 --- a/lib/compress/recap.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import type { CompressionBlock } from "../state/types" -import { type ToolFactoryContext, resolveToolContext } from "./types" - -function formatCoverage(block: CompressionBlock): string { - const count = block.effectiveMessageIds?.length || 0 - return count > 0 ? `${count} message${count !== 1 ? "s" : ""}` : "—" -} - -const RECAP_TOOL_DESCRIPTION = `Read-only retrieval of compression block summaries. - -Call this tool to re-fetch a specific block's summary without decompressing the full original content. Useful when a past compress tool call's summary has scrolled out of context or was truncated by the provider. - -Args: -- blockId: optional block number (e.g., 5). If omitted, lists all active blocks with brief info.` - -export function createAcpContextRecapTool(factoryCtx: ToolFactoryContext): ReturnType { - return tool({ - description: RECAP_TOOL_DESCRIPTION, - args: { - blockId: tool.schema - .number() - .optional() - .describe("Block number to retrieve (e.g., 5). If omitted, lists all active blocks."), - }, - async execute(args, toolCtx) { - const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID) - const msgState = ctx.state.prune.messages - const activeIds = Array.from(msgState.activeBlockIds).sort((a, b) => a - b) - - if (activeIds.length === 0) { - return "No active compression blocks." - } - - if (args.blockId !== undefined) { - const block = msgState.blocksById.get(args.blockId) - if (!block) { - return `Block b${args.blockId} not found. Active blocks: ${activeIds.map((id) => `b${id}`).join(", ")}` - } - if (!block.active) { - return `Block b${args.blockId} is inactive (deactivated by GC or nested compression).` - } - const range = formatCoverage(block) - return `[Compressed conversation section]\n${block.summary}\n\n[Block b${args.blockId} | ${range} | topic: "${block.topic || "(none)"}"]` - } - - const lines: string[] = [] - lines.push(`Active compression blocks (${activeIds.length}):`) - for (const id of activeIds) { - const block = msgState.blocksById.get(id) - if (!block || !block.active) continue - const range = formatCoverage(block) - const summaryPreview = block.summary.slice(0, 200) - lines.push(`\nb${id} | ${range} | "${block.topic || "(none)"}"`) - lines.push(` ${summaryPreview}${block.summary.length > 200 ? "..." : ""}`) - } - lines.push(`\nCall with blockId to get the full summary: acp_context_recap({ blockId: N })`) - return lines.join("\n") - }, - }) -} diff --git a/lib/compress/status.ts b/lib/compress/status.ts deleted file mode 100644 index f4ca415f..00000000 --- a/lib/compress/status.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { tool } from "@opencode-ai/plugin" -import { type ToolContext, type ToolFactoryContext, resolveToolContext } from "./types" -import { formatAge } from "../ui/utils" -import type { CompressionBlock, WithParts } from "../state/types" -import type { SessionState } from "../state/types" -import type { PluginConfig } from "../config" -import type { Logger } from "../logger" -import { - estimateContextComposition, - buildCompressibleRanges, - computeProtectedRefs, - formatCompressibleRanges, -} from "../messages/inject/utils" -import { fetchSessionMessages } from "./search" -import { hideConsumedCompressCalls } from "./hide-consumed" -import { estimateSystemPromptTokens } from "../token-utils" - -const ACP_STATUS_TOOL_DESCRIPTION = `Show context status — overview includes compressible ranges by default. - -No args: Overview with totals, compressed blocks, and compressible ranges. -scope:"uncompressed": Compressible ranges only (default view:"ranges"). Add view:"messages" for per-message listing with tool/sort filters. -scope:"compressed": Drill into compressed blocks — list each with full details (age, generation, consumed lineage). - -Use this tool to: -- See what's consuming context + compressible ranges in one call (no args) -- Focus on ranges only (scope:"uncompressed") -- Find all messages of a specific tool type (scope:"uncompressed", view:"messages", tool:"bash") -- Check block details before decompressing (scope:"compressed")` - -function formatTokens(n: number): string { - if (!Number.isFinite(n) || n <= 0) return "0" - return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n) -} - -function pct(n: number, total: number): number { - if (n <= 0 || total <= 0) return 0 - return Math.max(1, Math.round((n / total) * 100)) -} - -function formatIdRange(block: CompressionBlock): string { - const count = block.effectiveMessageIds?.length || 0 - return count > 0 ? `${count} msg${count !== 1 ? "s" : ""}` : "—" -} - -function getEffectiveCompressedTokens( - block: CompressionBlock, - blocksById: Map, - visited: Set = new Set(), -): number { - if (block.effectiveCompressedTokens !== undefined) { - return block.effectiveCompressedTokens - } - if (visited.has(block.blockId)) return 0 - visited.add(block.blockId) - let total = block.compressedTokens || 0 - for (const consumedId of block.consumedBlockIds || []) { - const consumed = blocksById.get(consumedId) - if (consumed) { - total += getEffectiveCompressedTokens(consumed, blocksById, visited) - } - } - return total -} - -function tierLabel(block: CompressionBlock): string { - const tier = block.tier ?? 1 - return `T${tier}` -} - -function tierBreakdown(blocks: CompressionBlock[]): string | null { - const tierTokens: Record = {} - for (const b of blocks) { - const t = b.tier ?? 1 - tierTokens[t] = (tierTokens[t] || 0) + (b.summaryTokens || 0) - } - const tiers = Object.keys(tierTokens) - .map(Number) - if (tiers.length <= 1 && (!tierTokens[2] || tierTokens[2] === 0) && (!tierTokens[3] || tierTokens[3] === 0)) { - return null - } - const parts: string[] = [] - for (const t of [1, 2, 3]) { - if (tierTokens[t]) { - parts.push(`T${t}: ${formatTokens(tierTokens[t])}`) - } - } - return parts.join(" | ") -} - -function describeToolMessage(msg: WithParts): string { - for (const part of msg.parts || []) { - if (part.type === "tool") { - const toolPart = part as any - const toolName = toolPart.tool || "?" - const input = toolPart.state?.input - if (input && typeof input === "object") { - if (input.command) return `${toolName}: ${String(input.command).slice(0, 60)}` - if (input.filePath) return `${toolName}: ${String(input.filePath).slice(0, 60)}` - if (input.query) return `${toolName}: ${String(input.query).slice(0, 60)}` - if (input.pattern) return `${toolName}: ${String(input.pattern).slice(0, 60)}` - if (input.content) return `${toolName}: ${String(input.content).slice(0, 40)}` - } - return toolName - } - } - const textPart = (msg.parts || []).find((p) => p.type === "text") as any - if (textPart?.text) { - return textPart.text.slice(0, 60).replace(/\n/g, " ") - } - return "?" -} - -interface VisibleMessageInfo { - ref: string - tokens: number - tool: string - index: number -} - -export interface StatusRenderContext { - state: SessionState - config?: PluginConfig -} - -function collectVisibleMessages( - rawMessages: WithParts[], - ctx: StatusRenderContext, -): { messages: VisibleMessageInfo[]; summaryTokens: number; systemTokens: number } { - const pruneMap = ctx.state.prune.messages.byMessageId - const byRawId = ctx.state.messageIds.byRawId - const result: VisibleMessageInfo[] = [] - let summaryTokens = 0 - - const visibleMessageIds = new Set(rawMessages.map((m) => m.info.id)) - - const activeBlocks = Array.from(ctx.state.prune.messages.activeBlockIds) - .map((id) => ctx.state.prune.messages.blocksById.get(id)) - .filter((b): b is NonNullable => b !== undefined && b.active) - - for (const block of activeBlocks) { - if (block.compressMessageId && !visibleMessageIds.has(block.compressMessageId)) { - continue - } - summaryTokens += block.summaryTokens || 0 - } - - rawMessages.forEach((msg, idx) => { - const msgId = (msg.info as any)?.id || "" - const entry = pruneMap.get(msgId) - if (entry && entry.activeBlockIds.length > 0) return - - const ref = byRawId.get(msgId) - if (!ref) return - - let tokens = 0 - let toolName = "" - - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - tokens += Math.round(((part as any).text as string).length / 4) - } else if (part.type === "tool") { - const raw = JSON.stringify(part) - tokens += Math.round(raw.length / 4) - if (!toolName) { - toolName = (part as any)?.tool || "unknown" - } - } - } - - if (tokens > 0) { - result.push({ ref, tokens, tool: toolName || "text", index: idx }) - } - }) - - return { messages: result, summaryTokens, systemTokens: estimateSystemPromptTokens(rawMessages) } -} - -function renderOverview( - visibleMessages: VisibleMessageInfo[], - summaryTokens: number, - systemTokens: number, - blocks: CompressionBlock[], - fetchFailed: boolean, - rawMessages: WithParts[], - ctx: StatusRenderContext, -): string[] { - const lines: string[] = [] - - const toolTypeMap = new Map() - for (const m of visibleMessages) { - toolTypeMap.set(m.tool, (toolTypeMap.get(m.tool) || 0) + m.tokens) - } - const topToolName = Array.from(toolTypeMap.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] - - if (fetchFailed) { - lines.push("VISIBLE CONTEXT (uncompressed)") - lines.push(" (unable to fetch messages for breakdown)") - } else { - const totalTool = visibleMessages - .filter((m) => m.tool !== "text" && m.tool !== "step-finish") - .reduce((s, m) => s + m.tokens, 0) - const totalText = visibleMessages - .filter((m) => m.tool === "text") - .reduce((s, m) => s + m.tokens, 0) - const total = systemTokens + totalTool + totalText + summaryTokens - - const sysPct = pct(systemTokens, total) - const toolPct = pct(totalTool, total) - const textPct = pct(totalText, total) - const summaryPct = pct(summaryTokens, total) - - lines.push("CONTEXT BREAKDOWN") - lines.push( - ` ${formatTokens(systemTokens)} system (${sysPct}%) | ${formatTokens(totalTool)} tool (${toolPct}%) | ${formatTokens(totalText)} text (${textPct}%) | ${formatTokens(summaryTokens)} summaries (${summaryPct}%)`, - ) - - const topTypes = Array.from(toolTypeMap.entries()) - .map(([tool, tokens]) => ({ tool, tokens })) - .sort((a, b) => b.tokens - a.tokens) - .slice(0, 3) - if (topTypes.length > 0) { - lines.push( - ` Top tools: ${topTypes.map((t) => `${t.tool} (${pct(t.tokens, total)}%)`).join(", ")}`, - ) - } - } - - lines.push("") - - if (blocks.length === 0) { - lines.push("COMPRESSED BLOCKS") - lines.push(" No compressed blocks.") - } else { - const blocksById = ctx.state.prune.messages.blocksById - const totalSummary = blocks.reduce((s, b) => s + (b.summaryTokens || 0), 0) - const totalEffective = blocks.reduce( - (s, b) => s + getEffectiveCompressedTokens(b, blocksById), - 0, - ) - const header = `COMPRESSED BLOCKS — ${blocks.length} active (${formatTokens(totalSummary)} summary, ${formatTokens(totalEffective)} original)` - lines.push(header) - const breakdown = tierBreakdown(blocks) - if (breakdown) { - lines.push(` Tier usage: ${breakdown}`) - } - lines.push("") - const sorted = [...blocks].sort((a, b) => { - const effA = getEffectiveCompressedTokens(a, blocksById) - const effB = getEffectiveCompressedTokens(b, blocksById) - return effB - effA || b.createdAt - a.createdAt - }) - for (const b of sorted.slice(0, 30)) { - const ageStr = formatAge(b.createdAt) - const range = formatIdRange(b) - const topic = b.topic || "(no topic)" - const tier = tierLabel(b) - const effTokens = getEffectiveCompressedTokens(b, blocksById) - lines.push( - ` b${b.blockId} (${tier}) ${formatTokens(effTokens)}→${formatTokens(b.summaryTokens)} ${ageStr} ${range} "${topic}"`, - ) - } - } - - if (!fetchFailed) { - const pruneMap = ctx.state.prune.messages.byMessageId - const visibleRaw = rawMessages.filter((msg) => { - const msgId = (msg.info as any)?.id || "" - const entry = pruneMap.get(msgId) - return !entry || entry.activeBlockIds.length === 0 - }) - const protectedRefs = ctx.config?.compress - ? computeProtectedRefs(visibleRaw, ctx.state, ctx.config.compress) - : new Set() - const contextRanges = buildCompressibleRanges( - visibleRaw, - ctx.state, - ctx.config?.compress?.protectedTools ?? [], - ctx.config?.protectedFilePatterns ?? [], - protectedRefs, - ) - if (contextRanges.compressible.length > 0 || contextRanges.protected.length > 0) { - lines.push("") - lines.push( - formatCompressibleRanges(contextRanges.compressible, contextRanges.protected), - ) - } - } - - lines.push("") - - const hintTool = topToolName || "bash" - lines.push( - `Tip: acp_status({scope:"uncompressed", view:"messages", tool:"${hintTool}"}) for per-message listing`, - ) - - return lines -} - -function renderUncompressedRanges(rawMessages: WithParts[], ctx: StatusRenderContext): string[] { - const pruneMap = ctx.state.prune.messages.byMessageId - const visibleMessages = rawMessages.filter((msg) => { - const msgId = (msg.info as any)?.id || "" - const entry = pruneMap.get(msgId) - return !entry || entry.activeBlockIds.length === 0 - }) - - const protectedRefs = ctx.config?.compress - ? computeProtectedRefs(visibleMessages, ctx.state, ctx.config.compress) - : new Set() - const contextRanges = buildCompressibleRanges( - visibleMessages, - ctx.state, - ctx.config?.compress?.protectedTools ?? [], - ctx.config?.protectedFilePatterns ?? [], - protectedRefs, - ) - const compressible = contextRanges.compressible - const totalTokens = compressible.reduce((s, r) => s + r.tokens, 0) - const totalMsgs = compressible.reduce((s, r) => s + r.count, 0) - - const lines: string[] = [] - lines.push( - `UNCOMPRESSED — ${formatTokens(totalTokens)} | ${totalMsgs} msgs in ${compressible.length} ranges`, - ) - lines.push("") - - if (compressible.length === 0 && contextRanges.protected.length === 0) { - lines.push(" (no compressible ranges)") - } else { - lines.push(formatCompressibleRanges(compressible, contextRanges.protected)) - } - - lines.push("") - lines.push(`Per-message listing: acp_status({scope:"uncompressed", view:"messages"})`) - lines.push(`Filter by tool: acp_status({scope:"uncompressed", view:"messages", tool:"bash"})`) - - return lines -} - -function renderUncompressedDrilldown( - visibleMessages: VisibleMessageInfo[], - toolFilter: string | undefined, - sort: string, - limit: number, -): string[] { - const lines: string[] = [] - let filtered = visibleMessages - - if (toolFilter) { - filtered = filtered.filter((m) => m.tool === toolFilter) - } - - if (sort === "time") { - filtered.sort((a, b) => a.index - b.index) - } else if (sort === "tool") { - filtered.sort((a, b) => a.tool.localeCompare(b.tool) || b.tokens - a.tokens) - } else { - filtered.sort((a, b) => b.tokens - a.tokens) - } - - const totalTokens = filtered.reduce((s, m) => s + m.tokens, 0) - const allTokens = visibleMessages.reduce((s, m) => s + m.tokens, 0) - - const header = toolFilter - ? `UNCOMPRESSED — ${toolFilter}: ${formatTokens(totalTokens)} | ${filtered.length} msgs | ${pct(totalTokens, allTokens)}% of visible` - : `UNCOMPRESSED — ${formatTokens(totalTokens)} | ${filtered.length} msgs` - - lines.push(header) - lines.push(`Sorted by ${sort}`) - lines.push("") - - const shown = filtered.slice(0, limit) - for (const m of shown) { - lines.push(` ${m.ref} (${formatTokens(m.tokens)}) ${m.tool}`) - } - - if (filtered.length > shown.length) { - lines.push("") - lines.push( - `${shown.length} of ${filtered.length} shown (${filtered.length - shown.length} hidden).`, - ) - } - - if (filtered.length > 1 && sort !== "time") { - const refs = filtered.map((m) => m.index) - const minIdx = Math.min(...refs) - const maxIdx = Math.max(...refs) - const span = maxIdx - minIdx - const avgGap = span / (filtered.length - 1) - const minRef = filtered.find((m) => m.index === minIdx)?.ref || "?" - const maxRef = filtered.find((m) => m.index === maxIdx)?.ref || "?" - lines.push("") - lines.push(`Spread: ${minRef}–${maxRef} (avg gap ${avgGap.toFixed(0)} msgs)`) - } - - return lines -} - -function renderCompressedDrilldown( - blocks: CompressionBlock[], - sort: string, - limit: number, - blocksById: Map, -): string[] { - const lines: string[] = [] - let sorted = [...blocks] - - if (sort === "time") { - sorted.sort((a, b) => a.createdAt - b.createdAt) - } else if (sort === "age") { - sorted.sort((a, b) => (b.survivedCount || 0) - (a.survivedCount || 0)) - } else { - sorted.sort( - (a, b) => - getEffectiveCompressedTokens(b, blocksById) - - getEffectiveCompressedTokens(a, blocksById) || - b.createdAt - a.createdAt, - ) - } - - const totalSummary = sorted.reduce((s, b) => s + (b.summaryTokens || 0), 0) - const totalEffective = sorted.reduce( - (s, b) => s + getEffectiveCompressedTokens(b, blocksById), - 0, - ) - - lines.push( - `COMPRESSED — ${sorted.length} blocks | ${formatTokens(totalEffective)} original → ${formatTokens(totalSummary)} summary`, - ) - const breakdown = tierBreakdown(sorted) - if (breakdown) { - lines.push(`Tier usage: ${breakdown}`) - } - lines.push(`Sorted by ${sort === "time" ? "time" : sort === "age" ? "age" : "size"}`) - lines.push("") - - const shown = sorted.slice(0, limit) - for (const b of shown) { - const survived = b.survivedCount ?? 0 - const gen = b.generation ?? "young" - const effCount = b.effectiveMessageIds?.length ?? 0 - const consumed = - b.includedBlockIds && b.includedBlockIds.length > 0 - ? ` nested=[${b.includedBlockIds.map((n) => `b${n}`).join(",")}]` - : "" - const topic = b.topic || "(no topic)" - const tier = tierLabel(b) - const effTokens = getEffectiveCompressedTokens(b, blocksById) - lines.push( - ` b${b.blockId} (${tier}) ${formatTokens(effTokens)}→${formatTokens(b.summaryTokens)} ${formatAge(b.createdAt)} ${formatIdRange(b)} age=${survived} ${gen} eff=${effCount}${consumed}`, - ) - lines.push(` "${topic}"`) - } - - if (sorted.length > shown.length) { - lines.push("") - lines.push(`${shown.length} of ${sorted.length} shown.`) - } - - lines.push("") - lines.push( - "Use decompress to restore a block's content, or search_context to search within blocks.", - ) - - return lines -} - -function buildVisibleWithSummaries(rawMessages: WithParts[], ctx: ToolContext): WithParts[] { - const pruneMap = ctx.state.prune.messages.byMessageId - const visible = rawMessages.filter((msg) => { - const msgId = (msg.info as any)?.id || "" - const entry = pruneMap.get(msgId) - return !entry || entry.activeBlockIds.length === 0 - }) - - const activeBlocks = Array.from(ctx.state.prune.messages.activeBlockIds) - .map((id) => ctx.state.prune.messages.blocksById.get(id)) - .filter((b): b is NonNullable => b !== undefined && b.active) - - for (const block of activeBlocks) { - visible.push({ - info: { id: `msg_acp_summary_b${block.blockId}` } as any, - parts: [ - { type: "text", text: block.summary || "[Compressed conversation section]" } as any, - ], - } as any) - } - - return visible -} - -export interface StatusReportOptions { - scope?: "compressed" | "uncompressed" - view?: "ranges" | "messages" - tool?: string - sort?: "size" | "time" | "tool" | "age" - limit?: number -} - -export function buildStatusReport( - renderCtx: StatusRenderContext, - rawMessages: WithParts[], - options?: StatusReportOptions, -): string { - const scope = options?.scope - const view = options?.view ?? "ranges" - const toolFilter = options?.tool - const sort = options?.sort ?? "size" - const limit = options?.limit ?? 30 - - const msgState = renderCtx.state.prune.messages - const activeIds = Array.from(msgState.activeBlockIds).sort((a, b) => a - b) - const allBlocks = activeIds - .map((id) => msgState.blocksById.get(id)) - .filter((b): b is NonNullable => b !== undefined && b.active) - - const lines: string[] = [] - - if (scope === "compressed") { - lines.push(...renderCompressedDrilldown(allBlocks, sort, limit, msgState.blocksById)) - return lines.join("\n") - } - - const result = collectVisibleMessages(rawMessages, renderCtx) - const visibleMsgs = result.messages - const summaryTokens = result.summaryTokens - const systemTokens = result.systemTokens - - if (scope === "uncompressed") { - if (view === "messages") { - lines.push(...renderUncompressedDrilldown(visibleMsgs, toolFilter, sort, limit)) - } else { - lines.push(...renderUncompressedRanges(rawMessages, renderCtx)) - } - } else { - lines.push( - ...renderOverview( - visibleMsgs, - summaryTokens, - systemTokens, - allBlocks, - false, - rawMessages, - renderCtx, - ), - ) - } - - return lines.join("\n") -} - -export function createAcpStatusTool(factoryCtx: ToolFactoryContext): ReturnType { - factoryCtx.prompts.reload() - - return tool({ - description: ACP_STATUS_TOOL_DESCRIPTION, - args: { - scope: tool.schema - .string() - .optional() - .describe('Drill down: "compressed" or "uncompressed". No arg = overview of both.'), - view: tool.schema - .string() - .optional() - .describe( - 'Display format for scope:"uncompressed": "ranges" (default, grouped by turn — matches nudge format) or "messages" (per-message listing with sort/filter)', - ), - tool: tool.schema - .string() - .optional() - .describe( - 'Filter by tool type (only with scope:"uncompressed", view:"messages"). e.g., "bash", "todowrite", "write"', - ), - sort: tool.schema - .string() - .optional() - .describe('Sort order: "size" (default), "time", or "tool"'), - limit: tool.schema.number().optional().describe("Max items to list (default 30)"), - }, - async execute(args, toolCtx) { - const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID) - const scope = - args.scope === "compressed" || args.scope === "uncompressed" - ? args.scope - : undefined - const view = args.view === "messages" ? "messages" : "ranges" - const toolFilter = typeof args.tool === "string" ? args.tool : undefined - const sort = - args.sort === "time" || args.sort === "tool" || args.sort === "age" - ? args.sort - : "size" - const limit = - Number.isFinite(args.limit) && args.limit! > 0 ? Math.min(args.limit!, 200) : 30 - - if (scope === "compressed") { - return buildStatusReport( - { state: ctx.state, config: ctx.config }, - [], - { scope: "compressed", sort, limit }, - ) - } - - let rawMessages: WithParts[] = [] - try { - rawMessages = await fetchSessionMessages(ctx.client, toolCtx.sessionID) - } catch { - if (scope === "uncompressed") return "(unable to fetch messages)" - rawMessages = [] - } - - hideConsumedCompressCalls(ctx.state, rawMessages) - - return buildStatusReport( - { state: ctx.state, config: ctx.config }, - rawMessages, - { scope, view, tool: toolFilter, sort, limit }, - ) - }, - }) -} diff --git a/lib/gc/merge.ts b/lib/gc/merge.ts deleted file mode 100644 index 30207bfd..00000000 --- a/lib/gc/merge.ts +++ /dev/null @@ -1,239 +0,0 @@ -import type { CompressionBlock, SessionState, WithParts } from "../state" -import type { PluginConfig } from "../config" -import type { Logger } from "../logger" -import { countTokens, getCurrentTokenUsage } from "../token-utils" -import { - COMPRESSED_BLOCK_HEADER, - allocateBlockId, - allocateRunId, - wrapCompressedSummary, -} from "../compress/state" - -export interface MergeMarkedResult { - mergedCount: number - savedTokens: number -} - -export interface BatchCleanupResult { - tier: 0 | 1 | 2 | 3 - action: "none" | "nudge" | "merge" - mergedCount: number - savedTokens: number - nudgeText?: string -} - -function collectActiveOldGenBlocks(state: SessionState, maxOldGenSummaryLength: number): CompressionBlock[] { - const blocks: CompressionBlock[] = [] - const ids = Array.from(state.prune.messages.activeBlockIds).sort((a, b) => a - b) - for (const id of ids) { - const block = state.prune.messages.blocksById.get(id) - if (!block || !block.active) continue - if ( - block.generation === "old" || - block.generation === undefined || - block.summary.length > maxOldGenSummaryLength - ) { - blocks.push(block) - } - } - return blocks -} - -function extractSummaryBody(summary: string): string { - let body = summary - const headerPrefix = COMPRESSED_BLOCK_HEADER + "\n" - if (body.startsWith(headerPrefix)) { - body = body.slice(headerPrefix.length) - } - body = body.replace(/\n]*>b\d+<\/dcp-message-id>$/, "") - return body.trim() -} - -function truncateMergedSummary(merged: string, maxLength: number): string { - if (merged.length <= maxLength) return merged - - const blocks = merged.split("\n---\n") - const headers = blocks - .map((b) => b.split("\n")[0] ?? "") - .filter((h) => h.trim().length > 0) - - const marker = "\n...\n[merged and truncated by batch cleanup]" - const budget = Math.max(0, maxLength - marker.length) - const headerJoin = headers.join("\n") - - if (headerJoin.length <= budget) { - return headerJoin + marker - } - return headerJoin.slice(0, budget) + marker -} - -export function mergeMarkedBlocks( - state: SessionState, - markedIds: number[], - maxMergedLength: number, -): MergeMarkedResult { - const sortedIds = [...new Set(markedIds)].filter( - (id) => Number.isInteger(id) && id > 0, - ).sort((a, b) => a - b) - - const sourceBlocks: CompressionBlock[] = [] - for (const id of sortedIds) { - const block = state.prune.messages.blocksById.get(id) - if (!block || !block.active) continue - if (!sourceBlocks.some((b) => b.blockId === id)) { - sourceBlocks.push(block) - } - } - - if (sourceBlocks.length < 2) { - return { mergedCount: 0, savedTokens: 0 } - } - - const messagesState = state.prune.messages - const newBlockId = allocateBlockId(state) - const newRunId = allocateRunId(state) - - const bodies = sourceBlocks.map((block) => extractSummaryBody(block.summary)) - const mergedRaw = bodies.join("\n---\n") - const mergedBody = truncateMergedSummary(mergedRaw, maxMergedLength) - const newSummary = wrapCompressedSummary(newBlockId, mergedBody) - const newSummaryTokens = countTokens(newSummary) - - const oldest = sourceBlocks[0] - const newest = sourceBlocks[sourceBlocks.length - 1] - - const effectiveMessageIds = new Set() - const effectiveToolIds = new Set() - for (const block of sourceBlocks) { - for (const id of block.effectiveMessageIds) effectiveMessageIds.add(id) - for (const id of block.effectiveToolIds) effectiveToolIds.add(id) - } - - const sourceIds = sourceBlocks.map((b) => b.blockId) - const createdAt = Date.now() - - const mergedBlock: CompressionBlock = { - blockId: newBlockId, - runId: newRunId, - active: true, - deactivatedByUser: false, - compressedTokens: 0, - summaryTokens: newSummaryTokens, - durationMs: 0, - mode: "range", - topic: "Batch merge cleanup", - batchTopic: "Batch merge cleanup", - startId: oldest.startId, - endId: newest.endId, - anchorMessageId: oldest.anchorMessageId, - compressMessageId: "", - compressCallId: undefined, - includedBlockIds: [...sourceIds], - consumedBlockIds: [...sourceIds], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: [...effectiveMessageIds], - effectiveToolIds: [...effectiveToolIds], - createdAt, - summary: newSummary, - survivedCount: 0, - generation: "old", - } - - const now = Date.now() - for (const block of sourceBlocks) { - block.active = false - block.deactivatedAt = now - block.deactivatedByBlockId = newBlockId - if (!block.parentBlockIds.includes(newBlockId)) { - block.parentBlockIds.push(newBlockId) - } - messagesState.activeBlockIds.delete(block.blockId) - const mappedId = messagesState.activeByAnchorMessageId.get(block.anchorMessageId) - if (mappedId === block.blockId) { - messagesState.activeByAnchorMessageId.delete(block.anchorMessageId) - } - } - - messagesState.blocksById.set(newBlockId, mergedBlock) - messagesState.activeBlockIds.add(newBlockId) - messagesState.activeByAnchorMessageId.set(mergedBlock.anchorMessageId, newBlockId) - - for (const messageId of effectiveMessageIds) { - const entry = messagesState.byMessageId.get(messageId) - if (!entry) continue - entry.activeBlockIds = entry.activeBlockIds.filter((id) => !sourceIds.includes(id)) - if (!entry.activeBlockIds.includes(newBlockId)) { - entry.activeBlockIds.push(newBlockId) - } - if (!entry.allBlockIds.includes(newBlockId)) { - entry.allBlockIds.push(newBlockId) - } - } - - for (const id of sourceIds) { - messagesState.markedForCleanup.delete(id) - } - - const sourceTokens = sourceBlocks.reduce( - (sum, block) => sum + (block.summaryTokens || Math.round(block.summary.length / 4)), - 0, - ) - const savedTokens = Math.max(0, sourceTokens - newSummaryTokens) - - return { mergedCount: sourceBlocks.length, savedTokens } -} - -export function runBatchCleanup( - state: SessionState, - config: PluginConfig, - logger: Logger, - messages: WithParts[], -): BatchCleanupResult { - const noop: BatchCleanupResult = { - tier: 0, - action: "none", - mergedCount: 0, - savedTokens: 0, - } - - if (!state.modelContextLimit || state.modelContextLimit <= 0) { - return noop - } - - const currentTokens = getCurrentTokenUsage(state, messages) - - // Only a hardcoded 100% force fallback remains. The mark_block mechanism and - // the multi-tier (low/high/force) batch-cleanup were retired; full GC removal - // is tracked separately. Threshold is intentionally NOT read from config. - if (currentTokens < state.modelContextLimit) { - return noop - } - - const maxMergedLength = config.gc.maxOldGenSummaryLength - const oldGenBlocks = collectActiveOldGenBlocks(state, maxMergedLength) - if (oldGenBlocks.length < 2) { - return noop - } - - const ids = oldGenBlocks.map((b) => b.blockId) - const result = mergeMarkedBlocks(state, ids, maxMergedLength) - if (result.mergedCount === 0) { - return noop - } - - logger.info("Batch cleanup force fallback (100%): merged old-gen blocks", { - mergedCount: result.mergedCount, - savedTokens: result.savedTokens, - currentTokens, - contextLimit: state.modelContextLimit, - }) - - return { - tier: 3, - action: "merge", - mergedCount: result.mergedCount, - savedTokens: result.savedTokens, - } -} diff --git a/lib/hooks.ts b/lib/hooks.ts deleted file mode 100644 index d55a6300..00000000 --- a/lib/hooks.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type { SessionState, WithParts } from "./state" -import type { Logger } from "./logger" -import type { PluginConfig } from "./config" -import { assignMessageRefs } from "./message-ids" -import { - buildPriorityMap, - buildToolIdList, - dropEmptyMessages, - injectCompressNudges, - injectMessageIds, - prune, - stripHallucinations, - stripHallucinationsFromString, - stripStaleMetadata, - syncCompressionBlocks, - computeInputBudget, -} from "./messages" -import { renderSystemPrompt, type PromptStore } from "./prompts" -import { buildProtectedToolsExtension } from "./prompts/extensions/system" -import { - applyPendingCompressionDurations, - buildCompressionTimingKey, - resolveCompressionDuration, -} from "./compress/timing" -import { filterMessages, filterMessagesInPlace } from "./messages/shape" -import { getLastUserMessage } from "./messages/query" -import { truncateLargeToolOutputs } from "./messages/truncate-tools" -import { - handleContextCommand, - handleStatsCommand, -} from "./commands" -import { type HostPermissionSnapshot } from "./host-permissions" -import { compressPermission, syncCompressPermissionState } from "./compress-permission" -import { hideConsumedCompressCalls } from "./compress/hide-consumed" -import { hideFailedCompressCalls } from "./compress/hide-failed" -import { applyMessageFilters } from "./messages/filter/apply" -import { ensureBuiltinFiltersRegistered } from "./messages/filter/builtin" -import { createSessionState, saveSessionState, syncToolCache, updatePerTurnState, type SessionStateRegistry } from "./state" -import { cacheSystemPromptTokens } from "./ui/utils" -import { sendIgnoredMessage } from "./ui/notification" -import { runBatchCleanup } from "./gc/merge" -import { getCurrentTokenUsage } from "./token-utils" - -const INTERNAL_AGENT_SIGNATURES = [ - "You are a title generator", - "You are a helpful AI assistant tasked with summarizing conversations", - "You are an anchored context summarization assistant for coding sessions", - "Summarize what was done in this conversation", -] - -// [FIX Bug 37] OpenCode built-in hidden primary-mode agents that must NOT be -// run through the message-transform pipeline. These small internal LLM -// requests (title/summary/compaction generation) carry the agent name on the -// user message's `info.agent` field. Mutating them corrupts the request and -// shared session state (e.g. countTurns runs on the wrong message set). -// Keep in sync with INTERNAL_AGENT_SIGNATURES (system-prompt layer) and the -// agent IDs defined in OpenCode's packages/core/src/plugin/agent.ts. -const INTERNAL_AGENT_NAMES = new Set(["title", "summary", "compaction"]) - -function isInternalAgentRequest(messages: WithParts[]): boolean { - const lastUserMessage = getLastUserMessage(messages) - if (!lastUserMessage) { - return false - } - const agent = (lastUserMessage.info as { agent?: unknown }).agent - return typeof agent === "string" && INTERNAL_AGENT_NAMES.has(agent) -} - -export function createSystemPromptHandler( - registry: SessionStateRegistry, - logger: Logger, - config: PluginConfig, - prompts: PromptStore, -) { - return async ( - input: { - sessionID?: string - model: { limit: { context: number; input?: number; output?: number } } - }, - output: { system: string[] }, - ) => { - // messages.transform creates the session state before this fires; if - // absent (internal-agent early-return), there is nothing to attribute. - const state = input.sessionID ? registry.get(input.sessionID) : undefined - if (state && input.model?.limit?.context) { - state.modelContextLimit = input.model.limit.context - } - - if (!state || (state.isSubAgent && !config.experimental.allowSubAgents)) { - return - } - - const systemText = output.system.join("\n") - if (INTERNAL_AGENT_SIGNATURES.some((sig) => systemText.includes(sig))) { - logger.info("Skipping DCP system prompt injection for internal agent") - return - } - - const effectivePermission = compressPermission(state, config) - - if (effectivePermission === "deny") { - return - } - - prompts.reload() - const runtimePrompts = prompts.getRuntimePrompts() - const newPrompt = renderSystemPrompt( - runtimePrompts, - buildProtectedToolsExtension(config.compress.protectedTools), - state.isSubAgent && config.experimental.allowSubAgents, - ) - if (output.system.length > 0) { - output.system[output.system.length - 1] += "\n\n" + newPrompt - } else { - output.system.push(newPrompt) - } - } -} - -export function createChatMessageTransformHandler( - client: any, - registry: SessionStateRegistry, - logger: Logger, - config: PluginConfig, - prompts: PromptStore, - hostPermissions: HostPermissionSnapshot, -) { - return async (input: {}, output: { messages: WithParts[] }) => { - const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0 - const messages = filterMessagesInPlace(output.messages) - if (messages.length !== receivedMessages) { - logger.warn("Skipping messages with unexpected shape during chat transform", { - received: receivedMessages, - usable: messages.length, - }) - } - - // [FIX Bug 37] Skip OpenCode internal agents (title/summary/compaction). - // These small hidden LLM requests must not be mutated, and resolving a - // session state for them would corrupt it (currentTurn, etc.). - if (isInternalAgentRequest(messages)) { - logger.debug("Skipping message transform for internal agent request") - return - } - - const lastUserMessage = getLastUserMessage(messages) - let state: SessionState - if (!lastUserMessage) { - // Ephemeral state: no session to resolve, but keep running - // state-independent stages (e.g. stripHallucinations). - state = createSessionState() - } else { - // [FIX #33] Per-session state: each session keeps its own SessionState, - // so interleaved sessions no longer reset each other's modelContextLimit. - state = await registry.getOrCreate( - client, - lastUserMessage.info.sessionID, - messages, - config, - ) - await updatePerTurnState(state, logger, messages) - } - - syncCompressPermissionState(state, config, hostPermissions, output.messages) - - if (state.isSubAgent && !config.experimental.allowSubAgents) { - return - } - - stripHallucinations(output.messages) - ensureBuiltinFiltersRegistered() - applyMessageFilters(output.messages, config.messageFilters, logger, { - sessionId: state.sessionId ?? "", - isSubAgent: state.isSubAgent, - modelContextLimit: state.modelContextLimit, - }) - cacheSystemPromptTokens(state, output.messages) - assignMessageRefs(state, output.messages) - const activeBlockCountBefore = state.prune.messages.activeBlockIds.size // [FIX Bug 4] - syncCompressionBlocks(state, logger, output.messages) - if (state.prune.messages.activeBlockIds.size !== activeBlockCountBefore) { // [FIX Bug 4] - saveSessionState(state, logger).catch(() => {}) // [FIX Bug 4] persist deactivations - } - syncToolCache(state, config, logger, output.messages) - buildToolIdList(state, output.messages) - const batchResult = runBatchCleanup(state, config, logger, output.messages) - if (batchResult.mergedCount > 0) { - saveSessionState(state, logger).catch(() => {}) - } - const prePruneTokens = getCurrentTokenUsage(state, output.messages) - prune(state, logger, config, output.messages) - truncateLargeToolOutputs(state, config, logger, output.messages) - hideConsumedCompressCalls(state, output.messages) - assignMessageRefs(state, output.messages) - const compressionPriorities = buildPriorityMap(config, state, output.messages) - prompts.reload() - injectCompressNudges( - state, - config, - logger, - output.messages, - prompts.getRuntimePrompts(), - compressionPriorities, - config.debug - ? (text: string) => { - logger.debug(`[ACP Debug] Nudge injected:\n${text}`) - if (state.sessionId && lastUserMessage) { - const userInfo = lastUserMessage.info as any - sendIgnoredMessage( - client, - state.sessionId, - `[ACP Debug Nudge]\n${text}`, - { - providerId: userInfo.model?.providerID, - modelId: userInfo.model?.modelID, - agent: userInfo.agent, - variant: userInfo.variant, - }, - logger, - ).catch(() => {}) - } - client.tui - .showToast({ - body: { - title: "ACP: Nudge Injected", - message: text.slice(0, 500), - variant: "info", - duration: 5000, - }, - }) - .catch(() => {}) - } - : undefined, - prePruneTokens, - ) - injectMessageIds(state, config, output.messages, compressionPriorities) - hideFailedCompressCalls(output.messages) - stripStaleMetadata(output.messages) - dropEmptyMessages(output.messages) - - if (state.sessionId) { - await logger.saveContext(state.sessionId, output.messages) - } - } -} - -export function createCommandExecuteHandler( - client: any, - registry: SessionStateRegistry, - logger: Logger, - config: PluginConfig, - workingDirectory: string, - hostPermissions: HostPermissionSnapshot, -) { - return async ( - input: { command: string; sessionID: string; arguments: string }, - output: { parts: any[] }, - ) => { - if (!config.commands.enabled) { - return - } - - if (input.command === "acp" || input.command === "dcp") { - const messagesResponse = await client.session.messages({ - path: { id: input.sessionID }, - }) - const messages = filterMessages(messagesResponse.data || messagesResponse) - - const state = await registry.getOrCreate( - client, - input.sessionID, - messages, - config, - ) - - syncCompressPermissionState(state, config, hostPermissions, messages) - - const effectivePermission = compressPermission(state, config) - if (effectivePermission === "deny") { - return - } - - const commandCtx = { - client, - state, - config, - logger, - sessionId: input.sessionID, - messages, - } - - const sub = input.arguments?.trim().toLowerCase() - if (sub === "stats" || sub === "status") { - await handleStatsCommand(commandCtx) - throw new Error("__DCP_CONTEXT_HANDLED__") - } - - await handleContextCommand(commandCtx) - throw new Error("__DCP_CONTEXT_HANDLED__") - } - } -} - -export function createTextCompleteHandler() { - return async ( - _input: { sessionID: string; messageID: string; partID: string }, - output: { text: string }, - ) => { - output.text = stripHallucinationsFromString(output.text) - } -} - -export function createEventHandler(registry: SessionStateRegistry, logger: Logger) { - return async (input: { event: any }) => { - const eventTime = - typeof input.event?.time === "number" && Number.isFinite(input.event.time) - ? input.event.time - : typeof input.event?.properties?.time === "number" && - Number.isFinite(input.event.properties.time) - ? input.event.properties.time - : undefined - - if (input.event.type !== "message.part.updated") { - return - } - - const part = input.event.properties?.part - if (part?.type !== "tool" || part.tool !== "compress") { - return - } - - // [FIX #33] The event hook carries no sessionID. compressionTiming is - // shared on the registry so record/consume use one map (a per-session map - // would let the destructive consume delete the start in the wrong - // session). The apply step iterates sessions; only the owner matches. - const timing = registry.compressionTiming - - if (part.state.status === "pending") { - if (typeof part.callID !== "string" || typeof part.messageID !== "string") { - return - } - - const startedAt = eventTime ?? Date.now() - const key = buildCompressionTimingKey(part.messageID, part.callID) - if (timing.startsByCallId.has(key)) { - return - } - timing.startsByCallId.set(key, startedAt) - logger.debug("Recorded compression start", { - messageID: part.messageID, - callID: part.callID, - startedAt, - }) - return - } - - if (part.state.status === "completed") { - if (typeof part.callID !== "string" || typeof part.messageID !== "string") { - return - } - - const key = buildCompressionTimingKey(part.messageID, part.callID) - const start = timing.startsByCallId.get(key) - timing.startsByCallId.delete(key) - const durationMs = resolveCompressionDuration(start, eventTime, part.state.time) - if (typeof durationMs !== "number") { - return - } - - timing.pendingByCallId.set(key, { - messageId: part.messageID, - callId: part.callID, - durationMs, - }) - - for (const state of registry.all()) { - const updates = applyPendingCompressionDurations(state) - if (updates > 0) { - await saveSessionState(state, logger) - logger.info("Attached compression time to blocks", { - messageID: part.messageID, - callID: part.callID, - blocks: updates, - durationMs, - }) - } - } - return - } - - if (part.state.status === "running") { - return - } - - if (typeof part.callID === "string" && typeof part.messageID === "string") { - timing.startsByCallId.delete( - buildCompressionTimingKey(part.messageID, part.callID), - ) - } - } -} diff --git a/lib/messages/filter/apply.ts b/lib/messages/filter/apply.ts deleted file mode 100644 index 10dc3fc3..00000000 --- a/lib/messages/filter/apply.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { WithParts } from "../../state" -import type { Logger } from "../../logger" -import type { MessageFilter, MessageFilterContext, MessageFiltersConfig, FilterResult } from "./types" -import { listMessageFilters } from "./registry" - -export interface ApplyResult { - partsFiltered: number - partsDropped: number - partsModified: number -} - -export function applyMessageFilters( - messages: WithParts[], - config: MessageFiltersConfig | undefined, - logger: Logger, - ctx: { sessionId: string; isSubAgent: boolean; modelContextLimit?: number }, -): ApplyResult { - if (!config?.enabled) { - return { partsFiltered: 0, partsDropped: 0, partsModified: 0 } - } - - const allFilters = listMessageFilters().filter((f) => { - const fc = config.filters?.[f.name] - return fc?.enabled !== false - }) - - if (allFilters.length === 0) { - return { partsFiltered: 0, partsDropped: 0, partsModified: 0 } - } - - const result: ApplyResult = { partsFiltered: 0, partsDropped: 0, partsModified: 0 } - const total = messages.length - - const buildCtx = (text: string, role: string, i: number): MessageFilterContext => ({ - text, - role, - sessionId: ctx.sessionId, - isSubAgent: ctx.isSubAgent, - messageIndex: i, - totalMessages: total, - modelContextLimit: ctx.modelContextLimit, - }) - - const applyDecision = ( - part: { text?: string }, - decision: FilterResult, - filterName: string, - i: number, - originalText: string, - ): string => { - result.partsFiltered++ - if (decision.action === "drop") { - part.text = "" - result.partsDropped++ - if (decision.reason) { - logger.debug("Message filter dropped text", { - filter: filterName, reason: decision.reason, messageIndex: i, originalLength: originalText.length, - }) - } - return "" - } - if (decision.action === "modify" && decision.text !== undefined) { - part.text = decision.text - result.partsModified++ - if (decision.reason) { - logger.debug("Message filter modified text", { - filter: filterName, reason: decision.reason, messageIndex: i, - originalLength: originalText.length, newLength: decision.text.length, - }) - } - return decision.text - } - return originalText - } - - // Phase 1: immediate filters (forward pass, chained) - const immediateFilters = allFilters.filter((f) => !f.keepLastOnly) - for (let i = 0; i < messages.length; i++) { - const msg = messages[i] - const role = (msg.info as { role?: string }).role ?? "unknown" - for (const part of msg.parts ?? []) { - const text = (part as { text?: string }).text - if (typeof text !== "string" || text.length === 0) continue - let current = text - const filterCtx = buildCtx(current, role, i) - filterCtx.toolName = (part as { tool?: string }).tool - for (const filter of immediateFilters) { - let decision - try { - decision = filter.filter(filterCtx) - } catch (err) { - logger.warn("Message filter threw error", { - filter: filter.name, - error: err instanceof Error ? err.message : String(err), - messageIndex: i, - }) - continue - } - if (decision.action === "keep") continue - current = applyDecision(part as { text?: string }, decision, filter.name, i, current) - filterCtx.text = current - } - } - } - - // Phase 2: keep-last-only dedup (reverse pass) - const keepLastFilters = allFilters.filter((f) => f.keepLastOnly) - for (const filter of keepLastFilters) { - const fcKeepLast = config.filters?.[filter.name]?.keepLast - const keepCount = Math.max(1, fcKeepLast ?? filter.keepLast ?? 1) - let kept = 0 - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i] - const role = (msg.info as { role?: string }).role ?? "unknown" - const parts = msg.parts ?? [] - for (let p = parts.length - 1; p >= 0; p--) { - const part = parts[p] - const text = (part as { text?: string }).text - if (typeof text !== "string" || text.length === 0) continue - const filterCtx = buildCtx(text, role, i) - filterCtx.toolName = (part as { tool?: string }).tool - let decision - try { - decision = filter.filter(filterCtx) - } catch { - continue - } - if (decision.action !== "drop" && decision.action !== "modify") continue - if (kept < keepCount) { - kept++ - } else { - applyDecision(part as { text?: string }, decision, filter.name, i, text) - } - } - } - } - - if (result.partsFiltered > 0) { - logger.info("Message filters applied", { - filtersRun: allFilters.length, - partsFiltered: result.partsFiltered, - partsDropped: result.partsDropped, - partsModified: result.partsModified, - }) - } - - return result -} diff --git a/lib/messages/filter/builtin/index.ts b/lib/messages/filter/builtin/index.ts deleted file mode 100644 index 101b6b1c..00000000 --- a/lib/messages/filter/builtin/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { MessageFilter } from "../types" -import { registerMessageFilter, getMessageFilter } from "../registry" -import { OMO_SYSTEM_REMINDER_FILTER } from "./omo-system-reminder" -import { OMO_TODO_FILTER } from "./omo-todo-continuation" -import { OMO_CONTEXT_FILTER } from "./omo-context" -import { OMO_TASK_FILTER } from "./omo-task-directive" -import { OMO_MODE_FILTER } from "./omo-mode-injection" - -const BUILTIN_FILTERS: MessageFilter[] = [ - OMO_SYSTEM_REMINDER_FILTER, - OMO_TODO_FILTER, - OMO_CONTEXT_FILTER, - OMO_TASK_FILTER, - OMO_MODE_FILTER, -] - -export function ensureBuiltinFiltersRegistered(): void { - for (const filter of BUILTIN_FILTERS) { - if (!getMessageFilter(filter.name)) { - registerMessageFilter(filter) - } - } -} diff --git a/lib/messages/filter/builtin/omo-context.ts b/lib/messages/filter/builtin/omo-context.ts deleted file mode 100644 index 6493985c..00000000 --- a/lib/messages/filter/builtin/omo-context.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MessageFilter, MessageFilterContext, FilterResult } from "../types" - -const OMO_MARKER = "" - -const OMO_CONTEXT_FILTER: MessageFilter = { - name: "omo-context", - version: "1.0.0", - description: "Keep only the latest OMO [CONTEXT] injection, drop earlier occurrences", - keepLastOnly: true, - - filter(ctx: MessageFilterContext): FilterResult { - if (ctx.role !== "user") return { action: "keep" } - if (!ctx.text.includes(OMO_MARKER)) return { action: "keep" } - const stripped = ctx.text.trimStart() - if (!stripped.startsWith("[CONTEXT]") && !stripped.startsWith("CONTEXT:")) { - return { action: "keep" } - } - return { action: "drop", reason: "OMO context injection" } - }, -} - -export { OMO_CONTEXT_FILTER } diff --git a/lib/messages/filter/builtin/omo-mode-injection.ts b/lib/messages/filter/builtin/omo-mode-injection.ts deleted file mode 100644 index 814c50f6..00000000 --- a/lib/messages/filter/builtin/omo-mode-injection.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { MessageFilter, MessageFilterContext, FilterResult } from "../types" - -/** - * OMO mode injection filter. - * - * Unlike pure-injection filters (omo-context, omo-task-directive), mode - * injections are *prepended* to the user's actual message. This filter - * strips only the injection block(s) and preserves user content. - */ -const XML_MODE_TAGS: Array<{ open: string; close: string }> = [ - { open: "", close: "" }, - { open: "", close: "" }, -] - -const BRACKET_MODE_PATTERNS = ["[search-mode]", "[analyze-mode]", "[ultrawork-mode]"] - -function stripLeadingModeInjections(text: string): string | null { - let current = text - let strippedAny = false - - for (let iter = 0; iter < 5; iter++) { - current = current.trimStart() - let matched = false - - for (const { open, close } of XML_MODE_TAGS) { - if (current.startsWith(open)) { - const closeIdx = current.indexOf(close) - if (closeIdx !== -1) { - current = current.slice(closeIdx + close.length) - } else { - current = current.slice(open.length) - } - matched = true - break - } - } - - if (!matched) { - for (const pattern of BRACKET_MODE_PATTERNS) { - if (current.startsWith(pattern)) { - current = current.slice(pattern.length) - matched = true - break - } - } - } - - if (matched) { - strippedAny = true - } else { - break - } - } - - return strippedAny ? current.trim() : null -} - -const OMO_MODE_FILTER: MessageFilter = { - name: "omo-mode-injection", - version: "1.1.0", - description: - "Strip OMO mode injection blocks (..., [search-mode], etc.) from user messages, preserving user content", - - filter(ctx: MessageFilterContext): FilterResult { - if (ctx.role !== "user") return { action: "keep" } - - const remaining = stripLeadingModeInjections(ctx.text) - if (remaining === null) return { action: "keep" } - - if (remaining.length === 0) { - return { action: "drop", reason: "OMO mode injection (no user content after stripping)" } - } - - return { - action: "modify", - text: remaining, - reason: "Stripped OMO mode injection block(s), preserved user content", - } - }, -} - -export { OMO_MODE_FILTER } diff --git a/lib/messages/filter/builtin/omo-system-reminder.ts b/lib/messages/filter/builtin/omo-system-reminder.ts deleted file mode 100644 index 78423e8a..00000000 --- a/lib/messages/filter/builtin/omo-system-reminder.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { MessageFilter, MessageFilterContext, FilterResult } from "../types" - -const SYSTEM_REMINDER_OPEN = "" -const SYSTEM_REMINDER_CLOSE = "" -const OMO_MARKER = "" - -// Pre-compiled regexes for stripping OMO system-reminder blocks. -const PAIRED_BLOCK_RE = /[\s\S]*?<\/system-reminder>\s*/g -const LONE_REMINDER_RE = /[\s\S]*?<\/system-reminder>/g -const LONE_MARKER_RE = //g - -const OMO_SYSTEM_REMINDER_FILTER: MessageFilter = { - name: "omo-system-reminder", - version: "1.3.0", - description: - "Keep recent OMO messages; for older ones, strip blocks but preserve user content", - keepLastOnly: true, - keepLast: 2, - - filter(ctx: MessageFilterContext): FilterResult { - if (ctx.role !== "user") return { action: "keep" } - if (!ctx.text.includes(SYSTEM_REMINDER_OPEN) && !ctx.text.includes(OMO_MARKER)) { - return { action: "keep" } - } - - let modified = ctx.text - let removedBlocks = 0 - - modified = modified.replace(PAIRED_BLOCK_RE, () => { - removedBlocks++ - return "" - }) - modified = modified.replace(LONE_REMINDER_RE, () => { - removedBlocks++ - return "" - }) - modified = modified.replace(LONE_MARKER_RE, "") - modified = modified.replace(/\n{3,}/g, "\n\n").trim() - - if (modified.length === 0) { - return { action: "drop", reason: `Pure OMO system-reminder (${removedBlocks} block(s), no user content)` } - } - - return { - action: "modify", - text: modified, - reason: `Stripped ${removedBlocks} OMO system-reminder block(s), preserved user content (${modified.length} chars)`, - } - }, -} - -export { OMO_SYSTEM_REMINDER_FILTER } diff --git a/lib/messages/filter/builtin/omo-task-directive.ts b/lib/messages/filter/builtin/omo-task-directive.ts deleted file mode 100644 index 0b50338a..00000000 --- a/lib/messages/filter/builtin/omo-task-directive.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MessageFilter, MessageFilterContext, FilterResult } from "../types" - -const OMO_MARKER = "" - -const OMO_TASK_FILTER: MessageFilter = { - name: "omo-task-directive", - version: "1.0.0", - description: "Keep only the latest OMO TASK directive, drop earlier occurrences", - keepLastOnly: true, - - filter(ctx: MessageFilterContext): FilterResult { - if (ctx.role !== "user") return { action: "keep" } - if (!ctx.text.includes(OMO_MARKER)) return { action: "keep" } - const stripped = ctx.text.trimStart() - if (!stripped.startsWith("TASK:") && !stripped.startsWith("## TASK")) { - return { action: "keep" } - } - return { action: "drop", reason: "OMO task directive" } - }, -} - -export { OMO_TASK_FILTER } diff --git a/lib/messages/filter/builtin/omo-todo-continuation.ts b/lib/messages/filter/builtin/omo-todo-continuation.ts deleted file mode 100644 index 79a76863..00000000 --- a/lib/messages/filter/builtin/omo-todo-continuation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { MessageFilter, MessageFilterContext, FilterResult } from "../types" - -const TODO_MARKER = "[SYSTEM DIRECTIVE" -const TODO_CONTINUATION = "TODO CONTINUATION" - -const OMO_TODO_FILTER: MessageFilter = { - name: "omo-todo-continuation", - version: "1.0.0", - description: "Keep only the latest OMO TODO CONTINUATION directive, drop earlier occurrences", - keepLastOnly: true, - - filter(ctx: MessageFilterContext): FilterResult { - if (ctx.role !== "user") return { action: "keep" } - if (!ctx.text.includes(TODO_MARKER) || !ctx.text.includes(TODO_CONTINUATION)) { - return { action: "keep" } - } - return { action: "drop", reason: "TODO CONTINUATION directive" } - }, -} - -export { OMO_TODO_FILTER } diff --git a/lib/messages/filter/index.ts b/lib/messages/filter/index.ts deleted file mode 100644 index 13f77e1e..00000000 --- a/lib/messages/filter/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { - MessageFilter, - MessageFilterContext, - FilterResult, - MessageFilterConfig, - MessageFiltersConfig, -} from "./types" -export { registerMessageFilter, getMessageFilter, listMessageFilters, clearMessageFilters } from "./registry" -export { applyMessageFilters } from "./apply" -export type { ApplyResult } from "./apply" -export { ensureBuiltinFiltersRegistered } from "./builtin" diff --git a/lib/messages/filter/registry.ts b/lib/messages/filter/registry.ts deleted file mode 100644 index 10a2724c..00000000 --- a/lib/messages/filter/registry.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { MessageFilter } from "./types" - -const registry = new Map() - -export function registerMessageFilter(filter: MessageFilter): void { - const existing = registry.get(filter.name) - if (existing && existing.version !== filter.version) { - throw new Error( - `Message filter "${filter.name}" already registered with version ${existing.version}, ` + - `cannot register version ${filter.version}. Use a different name or bump version.`, - ) - } - registry.set(filter.name, filter) -} - -export function getMessageFilter(name: string): MessageFilter | undefined { - return registry.get(name) -} - -export function listMessageFilters(): MessageFilter[] { - return Array.from(registry.values()) -} - -export function clearMessageFilters(): void { - registry.clear() -} diff --git a/lib/messages/filter/types.ts b/lib/messages/filter/types.ts deleted file mode 100644 index ad00679a..00000000 --- a/lib/messages/filter/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Message Filter — pluggable system for stripping/deduplicating third-party - * plugin injections (OMO system-reminders, background task notifications, etc.) - * from the visible context before ACP processes them. - * - * Filters run BEFORE assignMessageRefs, so filtered content never gets message - * refs and is never counted toward context usage or compression triggers. - */ - -/** - * Context passed to each registered filter for a single text part. - */ -export interface MessageFilterContext { - /** The text content of this part. */ - text: string - /** Role of the containing message ("user" | "assistant" | "system"). */ - role: string - /** Session ID (may be empty for ephemeral state). */ - sessionId: string - /** Whether this is a subagent session. */ - isSubAgent: boolean - /** 0-based index of this message in the visible messages array. */ - messageIndex: number - /** Total number of visible messages. */ - totalMessages: number - /** Tool name if this part is a tool call (e.g. "bash", "read"). */ - toolName?: string - /** Model context limit (for threshold-based filtering). */ - modelContextLimit?: number -} - -/** - * Result returned by a filter for a single text part. - */ -export interface FilterResult { - /** What to do with this text part. */ - action: "keep" | "modify" | "drop" - /** Replacement text when action is "modify". Ignored for "keep" and "drop". */ - text?: string - /** Human-readable reason for logging/auditing. */ - reason?: string -} - -/** - * A pluggable message filter. Filters are registered via {@link registerMessageFilter} - * and called by {@link applyMessageFilters} during the message transform pipeline. - * - * Filters MUST be pure and side-effect-free: they receive a snapshot of the - * text and return a decision. They MUST NOT mutate the input context. - * - * If a filter returns "drop", the text part is emptied (set to ""). If ALL - * text parts in a message become empty after filtering, the message itself - * is not removed (ACP's existing dropEmptyMessages handles that downstream). - */ -export interface MessageFilter { - /** Unique name (used in config to enable/disable). */ - name: string - /** Semver version (for breaking-change detection). */ - version: string - /** Human-readable description. */ - description: string - /** - * Evaluate whether to keep, modify, or drop this text part. - * Called once per text part per message. - */ - filter(ctx: MessageFilterContext): FilterResult - /** - * When true, applyMessageFilters keeps only the most recent N matching - * messages and drops all earlier matches. Useful for repeating directives - * (e.g., TODO CONTINUATION) where only the latest is relevant. - * The filter() function still runs per-part to identify matches; - * the dedup pass then empties earlier occurrences. - */ - keepLastOnly?: boolean - /** - * How many of the most recent matches to keep when keepLastOnly is true. - * Default: 1. Set to 2+ to preserve recent notifications (e.g., background - * task results) while still cleaning up historical accumulation. - */ - keepLast?: number -} - -/** - * Per-filter configuration: whether the filter is enabled. - */ -export type MessageFilterConfig = Record - -/** - * Top-level configuration for the message filter subsystem. - */ -export interface MessageFiltersConfig { - /** Master switch. When false, no filters run. */ - enabled: boolean - /** Per-filter enable/disable. Keys are filter names. */ - filters: MessageFilterConfig -} diff --git a/lib/messages/index.ts b/lib/messages/index.ts deleted file mode 100644 index 32b61d7c..00000000 --- a/lib/messages/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { prune } from "./prune" -export { syncCompressionBlocks } from "./sync" -export { injectCompressNudges } from "./inject/inject" -export { computeInputBudget } from "./inject/utils" -export { injectMessageIds } from "./inject/inject" -export { stripStaleMetadata } from "./reasoning-strip" -export { buildPriorityMap } from "./priority" -export { buildToolIdList, stripHallucinations, stripHallucinationsFromString, hasContent, dropEmptyMessages } from "./utils" diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts deleted file mode 100644 index a9adb5a2..00000000 --- a/lib/messages/inject/inject.ts +++ /dev/null @@ -1,796 +0,0 @@ -import type { SessionState, WithParts } from "../../state" -import type { Logger } from "../../logger" -import type { PluginConfig } from "../../config" -import type { RuntimePrompts } from "../../prompts/store" -import { formatMessageIdTag, formatTokenSize, classifyMessageType } from "../../message-ids" -import type { CompressionPriorityMap } from "../priority" -import { compressPermission } from "../../compress-permission" -import { countMessageCharacters } from "../../token-utils" -import { - getLastUserMessage, - isIgnoredUserMessage, - isProtectedUserMessage, - messageHasCompress, - messageHasCompressAttempt, -} from "../query" -import { saveSessionState } from "../../state/persistence" -import { - appendToTextPart, - appendToLastTextPart, - appendToAllToolParts, - createSyntheticTextPart, - createSyntheticUserMessage, - hasContent, -} from "../utils" -import { - addAnchor, - applyAnchoredNudges, - buildCompressibleRanges, - computeProtectedRefs, - computeShouldNudge, - countMessagesAfterIndex, - estimateContextComposition, - excludeProtectedRanges, - filterRecommendedRanges, - findLastNonIgnoredMessage, - formatCompressibleRanges, - getIterationNudgeThreshold, - getNudgeFrequency, - getModelInfo, - isContextOverLimits, - resolveAdaptiveNudgeGrowth, -} 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" - -/** - * Stable seed for the ACP dynamic guidance suffix message. - * Using a fixed seed ensures the synthetic message ID is deterministic, - * so it won't be assigned a new mNNNNN ref on each transform call. - */ -const ACP_SUFFIX_SEED = "acp-dynamic-guidance" - -/** - * Create a synthetic user message at the END of the messages array. - * All per-turn dynamic ACP content (context usage, visible IDs, nudges, etc.) - * is injected into this suffix message instead of historical user messages, - * preserving OpenAI Responses prefix cache stability. - */ -function createSuffixMessage(messages: WithParts[]): WithParts | null { - if (messages.length === 0) return null - // Use any user message as base for session/agent/model info - const base = messages.find((m) => m.info.role === "user") || messages[messages.length - 1] - const synthetic = createSyntheticUserMessage(base, "", ACP_SUFFIX_SEED) - messages.push(synthetic) - return synthetic -} - -export const injectCompressNudges = ( - state: SessionState, - config: PluginConfig, - logger: Logger, - messages: WithParts[], - prompts: RuntimePrompts, - compressionPriorities?: CompressionPriorityMap, - debugNotify?: (text: string) => void, - preCompressTokens?: number, -): void => { - if (compressPermission(state, config) === "deny") { - return - } - - const lastMessage = findLastNonIgnoredMessage(messages) - const lastAssistantMessage = messages.findLast((message) => message.info.role === "assistant") - - const { providerId, modelId } = getModelInfo(messages) - - const { overMaxLimit, overMinLimit, currentTokens, modelContextLimit } = isContextOverLimits( - config, - state, - providerId, - modelId, - messages, - ) - - const lastUserIdx = messages.findLastIndex( - (m) => m.info.role === "user" && !isIgnoredUserMessage(m), - ) - const currentTurnStart = lastUserIdx >= 0 ? lastUserIdx + 1 : 0 - const currentTurnHasCompress = messages - .slice(currentTurnStart) - .some((m) => m.info.role === "assistant" && messageHasCompressAttempt(m)) - - if (currentTurnHasCompress) { - const lastCompressMsg = messages - .slice(currentTurnStart) - .findLast((m) => m.info.role === "assistant" && messageHasCompressAttempt(m)) - const lastCompressMsgId = lastCompressMsg?.info?.id - - if (lastCompressMsgId !== state.nudges.lastProcessedCompressMessageId) { - state.nudges.lastProcessedCompressMessageId = lastCompressMsgId - - const wasNudgeTriggered = state.nudges.lastNudgeShownTokens !== undefined - - state.nudges.contextLimitAnchors.clear() - state.nudges.turnNudgeAnchors.clear() - state.nudges.iterationNudgeAnchors.clear() - state.nudges.lastNudgeShownTokens = undefined - state.nudges.lastToolOutputNudgeTokens = undefined - // Preserve tier cadence baselines instead of resetting to undefined. - // Resetting to undefined causes T2/T3 to immediately re-trigger on - // the next turn (cadence check treats undefined as "never fired"), - // creating a loop: T2 fires → compress attempted → baseline reset - // → T2 fires again. Set to currentTokens so the growthFloor gate - // applies naturally. - state.nudges.lastTier2NudgeTokens = currentTokens - state.nudges.lastTier3NudgeTokens = currentTokens - - const currentTurnHasSuccessfulCompress = messages - .slice(currentTurnStart) - .some((m) => m.info.role === "assistant" && messageHasCompress(m)) - - if (currentTurnHasSuccessfulCompress && wasNudgeTriggered && !state.nudges.compressBaselineSet) { - const baseline = state.nudges.lastPerMessageNudgeTokens - const postCompress = currentTokens - const preCompress = preCompressTokens - - if ( - baseline !== undefined && - postCompress !== undefined && - preCompress !== undefined && - preCompress > postCompress - ) { - const growth = preCompress - baseline - const compressed = preCompress - postCompress - if (growth > 0 && compressed > 0) { - const ratio = Math.min(1, compressed / growth) - const adjustment = Math.min(1, ratio * 2) - state.nudges.lastPerMessageNudgeTokens = - baseline + Math.round((postCompress - baseline) * adjustment) - } else { - state.nudges.lastPerMessageNudgeTokens = postCompress - } - } else { - state.nudges.lastPerMessageNudgeTokens = postCompress - } - state.nudges.compressBaselineSet = true - } - - state.nudges.shouldInjectThisTurn = false - saveSessionState(state, logger).catch(() => {}) - return - } - } else { - state.nudges.lastProcessedCompressMessageId = undefined - } - - state.nudges.compressBaselineSet = false - - let anchorsChanged = false - let baselineReEstablished = false - let baselineCorrected = false - - if (!overMinLimit) { - const hadTurnAnchors = state.nudges.turnNudgeAnchors.size > 0 - const hadIterationAnchors = state.nudges.iterationNudgeAnchors.size > 0 - - if (hadTurnAnchors || hadIterationAnchors) { - state.nudges.turnNudgeAnchors.clear() - state.nudges.iterationNudgeAnchors.clear() - anchorsChanged = true - } - } - - if (overMaxLimit) { - if (lastMessage) { - const interval = getNudgeFrequency(config) - const added = addAnchor( - state.nudges.contextLimitAnchors, - lastMessage.message.info.id, - lastMessage.index, - messages, - interval, - ) - if (added) { - anchorsChanged = true - } - } - } else { - if (state.nudges.contextLimitAnchors.size > 0) { - state.nudges.contextLimitAnchors.clear() - anchorsChanged = true - } - if (overMinLimit) { - const isLastMessageUser = lastMessage?.message.info.role === "user" - - if (isLastMessageUser && lastAssistantMessage) { - const previousSize = state.nudges.turnNudgeAnchors.size - state.nudges.turnNudgeAnchors.add(lastMessage.message.info.id) - state.nudges.turnNudgeAnchors.add(lastAssistantMessage.info.id) - if (state.nudges.turnNudgeAnchors.size !== previousSize) { - anchorsChanged = true - } - } - - const lastUserMessage = getLastUserMessage(messages) - if (lastUserMessage && lastMessage) { - const lastUserMessageIndex = messages.findIndex( - (message) => message.info.id === lastUserMessage.info.id, - ) - if (lastUserMessageIndex >= 0) { - const messagesSinceUser = countMessagesAfterIndex(messages, lastUserMessageIndex) - const iterationThreshold = getIterationNudgeThreshold(config) - - if ( - lastMessage.index > lastUserMessageIndex && - messagesSinceUser >= iterationThreshold - ) { - const interval = getNudgeFrequency(config) - const added = addAnchor( - state.nudges.iterationNudgeAnchors, - lastMessage.message.info.id, - lastMessage.index, - messages, - interval, - ) - - if (added) { - anchorsChanged = true - } - } - } - } - } - } - - const suffixMessage = createSuffixMessage(messages) - - - const nudgeGrowthTokens = - config.compress?.nudgeGrowthTokens ?? resolveAdaptiveNudgeGrowth(modelContextLimit) - - // ── Growth floor gate (anti-thrashing) ────────────────────────────── - // Nudge output is suppressed unless context grew by at least growthFloor - // tokens since the last nudge baseline. Prevents re-nudging every turn - // after a small compress or when anchors accumulate with negligible growth. - // - // growthFloor = max(minNudgeGrowthFloor, minNudgeGrowthRatio × nudgeGrowthTokens) - // 1M model: max(5000, 0.45×50000) = 22500 - // 100K model: max(5000, 0.45×6000) = 5000 - // - // Only bypassed at emergencyThresholdPercent (default 98%) — near-overflow - // always fires regardless of growth. - const growthFloor = Math.max( - config.compress?.minNudgeGrowthFloor ?? 5000, - (config.compress?.minNudgeGrowthRatio ?? 0.45) * nudgeGrowthTokens, - ) - const emergencyThreshold = resolveEmergencyThreshold(config, modelContextLimit) - const emergencyOverride = - emergencyThreshold !== undefined && - currentTokens !== undefined && - currentTokens >= emergencyThreshold - - if ( - currentTokens !== undefined && - state.nudges.lastPerMessageNudgeTokens !== undefined && - currentTokens < state.nudges.lastPerMessageNudgeTokens - nudgeGrowthTokens - ) { - state.nudges.lastPerMessageNudgeTokens = currentTokens - state.nudges.lastNudgeShownTokens = undefined - baselineCorrected = true - } - - const hasPendingNudge = state.nudges.lastNudgeShownTokens !== undefined - const effectiveThreshold = hasPendingNudge - ? Math.floor(nudgeGrowthTokens / 2) - : nudgeGrowthTokens - const growthReference = - state.nudges.lastNudgeShownTokens ?? state.nudges.lastPerMessageNudgeTokens - - const decision = computeShouldNudge({ - currentTokens, - modelContextLimit, - overMinLimit, - overMaxLimit, - lastNudgeTokens: growthReference, - minNudgeContextPercent: config.compress?.minNudgeContextPercent ?? 15, - nudgeGrowthTokens: effectiveThreshold, - }) - - const growthSinceBaseline = - currentTokens !== undefined && growthReference !== undefined - ? currentTokens - growthReference - : undefined - const nudgeAllowed = - emergencyOverride || - (decision.shouldNudge && - growthSinceBaseline !== undefined && - growthSinceBaseline >= growthFloor) - - const effectiveTipsVariant = emergencyOverride ? "maxLimit" : decision.tipsVariant - - if (state.nudges.lastPerMessageNudgeTokens === undefined && currentTokens !== undefined) { - // Growth is measured from the session's starting context — the system - // prompt is always present and is NOT growth. - state.nudges.lastPerMessageNudgeTokens = currentTokens - baselineReEstablished = true - } - - const composition = estimateContextComposition( - messages, - state, - config.compress.protectedTools, - config.protectedFilePatterns, - ) - - // 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) - - // 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 unprotectedCompressible = excludeProtectedRanges(contextRanges.compressible, protectedRefs) - - const recommendedRanges = filterRecommendedRanges( - unprotectedCompressible, - contextRanges.protected, - { logger }, - ) - const hasRecommendations = recommendedRanges.length > 0 - - if (config.debug && contextRanges.compressible.length > 0) { - const compressible = contextRanges.compressible - const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) - const lines = [ - `[ACP Debug] Recommendation filter:`, - ` Input: ${compressible.length} range(s), ${fmt(compressible.reduce((s, r) => s + r.tokens, 0))} tokens`, - ` Output: ${recommendedRanges.length} range(s) (last segment marked dangerous)`, - ] - logger.debug(lines.join("\n")) - } - - const allProtected = contextRanges.compressible.length === 0 && contextRanges.protected.length > 0 - const allInProtectedZone = protectedRefs.size > 0 && unprotectedCompressible.length === 0 - const nothingToCompress = allProtected || allInProtectedZone - const shouldInjectNudge = nudgeAllowed && (!nothingToCompress || emergencyOverride) - let shouldInject = shouldInjectNudge - - // Keep lastNudgeShownTokens when nothingToCompress — resetting it - // reintroduces the nudge loop (baseline wiped → stale growthReference - // → nudge fires every turn). - - // Issue #216 Defect 1: only apply anchored nudge text when there IS something - // to compress. Previously applyAnchoredNudges ran before nothingToCompress was - // computed, injecting the full nudge text (with HOW_TO_COMPRESS rules) even - // when the filter said "nothing to compress". - if (shouldInjectNudge) { - applyAnchoredNudges(state, config, messages, prompts, compressionPriorities, currentTokens, modelContextLimit, suffixMessage) - } - - if (state.nudges.lastPerMessageNudgeTokens === undefined && currentTokens !== undefined) { - // Growth is measured from the session's starting context — the system - // prompt is always present and is NOT growth. - state.nudges.lastPerMessageNudgeTokens = currentTokens - baselineReEstablished = true - } - - // ── Tier 2/3 triggers — only if T1 didn't already fire ──────────── - // 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) - - const tierChecks = [ - { triggerTier: 2 as const, targetTier: 1 as const, tokens: tierUsage.tier1Tokens, lastNudge: state.nudges.lastTier2NudgeTokens }, - { triggerTier: 3 as const, targetTier: 2 as const, tokens: tierUsage.tier2Tokens, lastNudge: state.nudges.lastTier3NudgeTokens }, - ] - - for (const tc of tierChecks) { - if (tc.tokens < nudgeGrowthTokens) continue - const cadenceMet = tc.lastNudge === undefined || - (currentTokens !== undefined && currentTokens - tc.lastNudge >= growthFloor) - if (!cadenceMet) continue - - let candidates = [...state.prune.messages.activeBlockIds] - .map((id) => state.prune.messages.blocksById.get(id)) - .filter((b): b is NonNullable => b !== undefined && b.active && (b.tier ?? 1) === tc.targetTier) - .sort((a, b) => a.blockId - b.blockId) - - // Cross-tier safety: narrow to exclude non-target active blocks by blockId. - if (candidates.length >= 2) { - const firstId = candidates[0].blockId - const lastId = candidates[candidates.length - 1].blockId - const nonTargetInIdRange = new Set( - [...state.prune.messages.activeBlockIds] - .map((id) => state.prune.messages.blocksById.get(id)) - .filter( - (b) => - b !== undefined && - b.active && - (b.tier ?? 1) !== tc.targetTier && - b.blockId > firstId && - b.blockId < lastId, - ) - .map((b) => b!.blockId), - ) - - if (nonTargetInIdRange.size > 0) { - let bestStart = 0 - let bestLen = 1 - let curStart = 0 - for (let i = 1; i < candidates.length; i++) { - const prevId = candidates[i - 1].blockId - const currId = candidates[i].blockId - let hasGap = false - for (const nid of nonTargetInIdRange) { - if (nid > prevId && nid < currId) { - hasGap = true - break - } - } - if (hasGap) { - const curLen = i - curStart - if (curLen > bestLen) { - bestLen = curLen - bestStart = curStart - } - curStart = i - } - } - const finalLen = candidates.length - curStart - if (finalLen > bestLen) { - bestLen = finalLen - bestStart = curStart - } - candidates = candidates.slice(bestStart, bestStart + bestLen) - } - } - - if (candidates.length < 2) continue - - const rules = tc.triggerTier === 2 ? TIER2_DISTILL_RULES : TIER3_CONDENSE_RULES - const candidateTokens = candidates.reduce((s, b) => s + b.summaryTokens, 0) - const firstBlock = candidates[0] - const lastBlock = candidates[candidates.length - 1] - const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) - const sourceTier = tc.triggerTier === 2 ? "Tier 1" : "Tier 2" - const action = tc.triggerTier === 2 ? "Distill" : "Condense" - - const blockList = candidates - .slice(0, 10) - .map((b) => `b${b.blockId} (age=${b.survivedCount}, ${fmt(b.summaryTokens)}tok): "${b.topic}"`) - .join("\n") - const extraCount = candidates.length > 10 ? `\n...and ${candidates.length - 10} more` : "" - - const tierText = `\n\n[Tier ${tc.triggerTier} Trigger] ${sourceTier} summaries accumulated (${fmt(candidateTokens)} tokens across ${candidates.length} blocks). ${action} them to free context.\n\nTarget blocks (oldest first):\n${blockList}${extraCount}\n\nCompress range: \`content: [{ startId: "b${firstBlock.blockId}", endId: "b${lastBlock.blockId}", summary: "..." }]\`\nMultiple entries create separate blocks: \`content: [{ startId: "b${firstBlock.blockId}", endId: "b...", summary: "..." }, { startId: "b...", endId: "b${lastBlock.blockId}", summary: "..." }]\`\n\n${rules}` - - appendToLastTextPart(suffixMessage, tierText) - shouldInject = true - if (tc.triggerTier === 2) { - state.nudges.lastTier2NudgeTokens = currentTokens - } else { - state.nudges.lastTier3NudgeTokens = currentTokens - } - break - } - } - - state.nudges.shouldInjectThisTurn = shouldInject - - let tipsText: string | null = null - - if (shouldInject) { - if (suffixMessage && 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 - const growth = - currentTokens !== undefined && - (state.nudges.lastNudgeShownTokens ?? state.nudges.lastPerMessageNudgeTokens) !== undefined - ? currentTokens - (state.nudges.lastNudgeShownTokens ?? state.nudges.lastPerMessageNudgeTokens!) - : 0 - const growthStr = growth > 0 ? ` (+${fmt(growth)} since last nudge)` : "" - - const plainTextTokens = composition.textTokens - // Soft nudges (growth/min-limit) are efficiency prompts, not overflow - // warnings — a separate, stronger alert fires at maxLimit (below). - const efficiencyNote = effectiveTipsVariant !== "maxLimit" - ? `\nThis is an efficiency nudge to compress early and keep context lean — not an overflow warning. A separate, stronger alert will appear if the context is actually full.\n\n${COMPRESS_PHILOSOPHY}` - : "" - const sysPart = composition.systemTokens > 0 - ? `${fmt(composition.systemTokens)} system (${pct(composition.systemTokens)}%) | ` - : "" - let breakdown = `${efficiencyNote}\nBreakdown: ${sysPart}${fmt(composition.toolTokens)} tool (${pct(composition.toolTokens)}%) | ${fmt(composition.summaryTokens)} summaries (${pct(composition.summaryTokens)}%) | ${fmt(composition.codeTokens)} code (${pct(composition.codeTokens)}%) | ${fmt(plainTextTokens)} text (${pct(plainTextTokens)}%)${growthStr}` - - const compressibleTokens = - composition.total - - composition.systemTokens - - composition.protectedTokens - - composition.summaryTokens - if (composition.protectedTokens > 0) { - breakdown += `\n⚠️ ${fmt(composition.protectedTokens)} tokens are protected (environment-managed tools) — not compressible. Effective compressible: ~${fmt(compressibleTokens)}.` - } - - if (recommendedRanges.length > 0) { - breakdown += `\n\n${HOW_TO_COMPRESS_RULES}\n\n${formatCompressibleRanges(recommendedRanges, contextRanges.protected)}` - breakdown += `\n💡 Compress all ranges in one call (pass multiple content entries: \`content: [{...}, {...}]\`).` - } - breakdown += `\nUse \`acp_status({scope:"uncompressed"})\` to re-fetch compressible ranges after compressing, or \`acp_status\` for compressed block details.` - - appendToLastTextPart(suffixMessage, breakdown) - } - - // maxLimit strong alert + lastNudgeShownTokens + block aging guidance - if (effectiveTipsVariant === "maxLimit") { - tipsText = - '\n\n⚠️ Context limit reached — compress now. Prioritize consumed tool outputs.\n\n' + HOW_TO_COMPRESS_RULES + '\n\n{ "topic": "...", "content": [{ "startId": "", "endId": "", "summary": "..." }] }\n\nOnly use IDs from visible messages above. Compress older work first.' - } - // Intentionally do NOT update lastPerMessageNudgeTokens here — nudges - // repeat every turn until the model actually compresses. - state.nudges.lastNudgeShownTokens = currentTokens - { - const visibleMessageIds = new Set( - messages.map((message) => message.info.id), - ) - const blockGuidance = buildCompressedBlockGuidance(state, { - currentTokens, - modelContextLimit, - includeHint: tipsText !== null, - visibleMessageIds, - }) - if (blockGuidance.trim() && suffixMessage) { - appendToLastTextPart(suffixMessage, "\n\n" + blockGuidance) - } - } - - if (tipsText && suffixMessage) { - appendToLastTextPart(suffixMessage, tipsText) - } - } - - if (suffixMessage) { - // [FIX #12] Nothing injected this turn → drop the empty synthetic user - // message. (appendToLastTextPart would no-op on "\n" anyway.) - if (hasContent(suffixMessage)) { - appendToLastTextPart(suffixMessage, "\n") - if (debugNotify) { - const text = suffixMessage.parts - .filter((p) => p.type === "text") - .map((p) => (p as any).text || "") - .join("\n") - .trim() - if (text) { - debugNotify(text) - } - } - } else { - const idx = messages.lastIndexOf(suffixMessage) - if (idx !== -1) { - messages.splice(idx, 1) - } - } - } - - // [FIX #60] Save on nudge too: a growth-triggered nudge updates the in-memory - // baseline (above) but anchorsChanged stays false when anchor sets are - // saturated, so the on-disk baseline went stale and the nudge refired every - // turn after restart. - if (anchorsChanged || nudgeAllowed || baselineReEstablished || baselineCorrected) { - saveSessionState(state, logger).catch(() => {}) - } -} - -function resolveEmergencyThreshold( - config: PluginConfig, - modelContextLimit: number | undefined, -): number | undefined { - const threshold = config.compress?.emergencyThresholdPercent - if (threshold === undefined || modelContextLimit === undefined) return undefined - if (typeof threshold === "number") return threshold - if (!threshold.endsWith("%")) return undefined - const parsedPercent = parseFloat(threshold.slice(0, -1)) - if (isNaN(parsedPercent)) return undefined - const clampedPercent = Math.max(0, Math.min(100, Math.round(parsedPercent))) - return Math.round((clampedPercent / 100) * modelContextLimit) -} - -export interface VisibleSegment { - startRef: string - endRef: string - count: number - tokens: number - hasTool: boolean -} - -function refNumber(ref: string): number { - const n = parseInt(ref.slice(1), 10) - return Number.isNaN(n) ? -1 : n -} - -/** - * Build disjoint visible-id segments from the surviving messages. - * - * Each segment is a maximal run of contiguous refs (e.g. m00003–m00007). - * Holes between segments correspond to messages already consumed by a - * compression block — those refs are NOT safe to target. Surfacing the - * segments (instead of a single `first–last` span) stops the model from - * picking a ref that lives inside a compressed hole. - */ -export function buildVisibleSegments(state: SessionState, messages: WithParts[]): VisibleSegment[] { - const refInfo = new Map() - for (const msg of messages) { - const ref = state.messageIds.byRawId.get(msg.info.id) - if (!ref) continue - let tokens = 0 - let hasTool = false - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - tokens += Math.round(((part as any).text as string).length / 4) - } else if (part.type !== "text" && part.type !== "reasoning") { - tokens += Math.round(JSON.stringify(part).length / 4) - hasTool = true - } - } - refInfo.set(ref, { tokens, hasTool }) - } - if (refInfo.size === 0) return [] - - const refs = Array.from(refInfo.keys()).sort((a, b) => refNumber(a) - refNumber(b)) - const segments: VisibleSegment[] = [] - let cur: VisibleSegment | null = null - let prevNum = -2 - for (const ref of refs) { - const num = refNumber(ref) - const info = refInfo.get(ref)! - if (cur && num === prevNum + 1) { - cur.endRef = ref - cur.count++ - cur.tokens += info.tokens - if (info.hasTool) cur.hasTool = true - } else { - if (cur) segments.push(cur) - cur = { - startRef: ref, - endRef: ref, - count: 1, - tokens: info.tokens, - hasTool: info.hasTool, - } - } - prevNum = num - } - if (cur) segments.push(cur) - return segments -} - -function formatSegment(seg: VisibleSegment): string { - return seg.startRef === seg.endRef ? seg.startRef : `${seg.startRef}–${seg.endRef}` -} - -export function formatVisibleGuidance(segments: VisibleSegment[], maxSegs: number): string { - if (segments.length === 0) return "" - const totalMsgs = segments.reduce((s, seg) => s + seg.count, 0) - const totalSegs = segments.length - const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) - - if (totalSegs <= maxSegs) { - return `[Visible: ${segments.map(formatSegment).join(", ")} (${totalMsgs} msg${totalMsgs === 1 ? "" : "s"}, ${totalSegs} segment${totalSegs === 1 ? "" : "s"})]` - } - // Keep the largest tool-bearing/high-token segments, drop the smallest, - // but preserve ascending ref order for what gets shown. - const keepSet = new Set( - [...segments] - .sort((a, b) => { - if (a.hasTool !== b.hasTool) return a.hasTool ? -1 : 1 - return b.tokens - a.tokens - }) - .slice(0, maxSegs), - ) - const shown = segments.filter((s) => keepSet.has(s)) - const omitted = segments.filter((s) => !keepSet.has(s)) - const omittedTokens = omitted.reduce((sum, s) => sum + s.tokens, 0) - const omittedMsgs = omitted.reduce((sum, s) => sum + s.count, 0) - return `[Visible (top ${shown.length} of ${totalSegs} segments, ${totalMsgs} msgs): ${shown.map(formatSegment).join(", ")} | +${omitted.length} smaller segment${omitted.length === 1 ? "" : "s"} (~${fmt(omittedTokens)} tokens, ${omittedMsgs} msg${omittedMsgs === 1 ? "" : "s"}) omitted]` -} - -function injectVisibleIdRange( - state: SessionState, - config: PluginConfig, - messages: WithParts[], - target: WithParts | null, -): void { - if (!target) return - const segments = buildVisibleSegments(state, messages) - if (segments.length === 0) return - const maxSegs = config.compress?.maxVisibleSegments ?? 50 - const rangeTag = "\n\n" + formatVisibleGuidance(segments, maxSegs) - - for (const part of target.parts) { - if (part.type === "text") { - appendToTextPart(part, rangeTag) - return - } - } - target.parts.push(createSyntheticTextPart(target, rangeTag)) -} - -export const injectMessageIds = ( - state: SessionState, - config: PluginConfig, - messages: WithParts[], - compressionPriorities?: CompressionPriorityMap, -): void => { - if (compressPermission(state, config) === "deny") { - return - } - - for (const message of messages) { - if (isIgnoredUserMessage(message)) { - continue - } - - const messageRef = state.messageIds.byRawId.get(message.info.id) - if (!messageRef) { - continue - } - - const isBlockedMessage = isProtectedUserMessage(config, message) - const priority = undefined - const msgType = classifyMessageType(message.parts) - const msgTokens = Math.round(countMessageCharacters(message) / 4) - const tag = formatMessageIdTag(isBlockedMessage ? "BLOCKED" : messageRef, { - priority: priority ?? undefined, - type: msgType, - tokens: formatTokenSize(msgTokens), - }) - - if (message.info.role === "user") { - let injected = false - for (const part of message.parts) { - if (part.type === "text") { - injected = appendToTextPart(part, tag) || injected - } - } - - if (injected) { - continue - } - - message.parts.push(createSyntheticTextPart(message, tag)) - continue - } - - if (message.info.role !== "assistant") { - continue - } - - if (!hasContent(message)) { - continue - } - - if (appendToAllToolParts(message, tag)) { - continue - } - - if (appendToLastTextPart(message, tag)) { - continue - } - - const syntheticPart = createSyntheticTextPart(message, tag) - const firstToolIndex = message.parts.findIndex((p) => p.type === "tool") - if (firstToolIndex === -1) { - message.parts.push(syntheticPart) - } else { - message.parts.splice(firstToolIndex, 0, syntheticPart) - } - } -} diff --git a/lib/messages/inject/policy/index.ts b/lib/messages/inject/policy/index.ts deleted file mode 100644 index c27ce98c..00000000 --- a/lib/messages/inject/policy/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { defaultTriggerPolicy } from "context-compress-algorithms/trigger" -import { registerTriggerPolicy } from "./registry" - -export function ensureBuiltinTriggerPolicyRegistered(): void { - registerTriggerPolicy(defaultTriggerPolicy) -} - -export type { - TipsVariant, - NudgeDecision, - NudgeDecisionInput, - CompressionTriggerPolicy, -} from "./types" -export { - registerTriggerPolicy, - getTriggerPolicy, - listTriggerPolicies, - getDefaultTriggerPolicy, - setDefaultTriggerPolicy, - clearTriggerPolicyRegistryForTests, -} from "./registry" -export { defaultTriggerPolicy } from "context-compress-algorithms/trigger" diff --git a/lib/messages/inject/policy/registry.ts b/lib/messages/inject/policy/registry.ts deleted file mode 100644 index d415ab55..00000000 --- a/lib/messages/inject/policy/registry.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { CompressionTriggerPolicy, NudgeDecision, NudgeDecisionInput, TipsVariant } from "./types" - -const registry = new Map() -let defaultPolicy: CompressionTriggerPolicy | null = null - -export function registerTriggerPolicy(policy: CompressionTriggerPolicy): void { - if (!policy.name) { - throw new Error("TriggerPolicy must have a name") - } - registry.set(policy.name, policy) - if (!defaultPolicy) { - defaultPolicy = policy - } -} - -export function getTriggerPolicy(name: string): CompressionTriggerPolicy | undefined { - return registry.get(name) -} - -export function listTriggerPolicies(): string[] { - return Array.from(registry.keys()) -} - -export function getDefaultTriggerPolicy(): CompressionTriggerPolicy | null { - return defaultPolicy -} - -export function setDefaultTriggerPolicy(name: string): boolean { - const policy = registry.get(name) - if (!policy) return false - defaultPolicy = policy - return true -} - -export function clearTriggerPolicyRegistryForTests(): void { - registry.clear() - defaultPolicy = null -} diff --git a/lib/messages/inject/policy/types.ts b/lib/messages/inject/policy/types.ts deleted file mode 100644 index 7f9d226c..00000000 --- a/lib/messages/inject/policy/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { - TipsVariant, - NudgeDecision, - NudgeDecisionInput, - CompressionTriggerPolicy, -} from "context-compress-algorithms/trigger" diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts deleted file mode 100644 index 12c98425..00000000 --- a/lib/messages/inject/utils.ts +++ /dev/null @@ -1,994 +0,0 @@ -import type { SessionState, WithParts } from "../../state" -import type { PluginConfig } from "../../config" -import { messageContainsProtectedTool } from "../../compress/protected-content" -import { isToolNameProtected, getFilePathsFromParameters, isFilePathProtected } from "../../protected-patterns" -import { - appendGuidanceToDcpTag, - buildCompressedBlockGuidance, - renderMessagePriorityGuidance, -} from "../../prompts/extensions/nudge" -import type { RuntimePrompts } from "../../prompts/store" -import type { UserMessage } from "@opencode-ai/sdk/v2" -import { - type CompressionPriorityMap, - type MessagePriority, - listPriorityRefsBeforeIndex, -} from "../priority" -import { estimateSystemPromptTokens } from "../../token-utils" -import { - appendToTextPart, - appendToLastTextPart, - createSyntheticTextPart, - hasContent, -} from "../utils" -import { getLastUserMessage, isIgnoredUserMessage, isSyntheticMessage } from "../query" -import { getCurrentTokenUsage } from "../../token-utils" -import { getActiveSummaryTokenUsage } from "../../state/utils" - -export interface LastUserModelContext { - providerId: string | undefined - modelId: string | undefined -} - -export interface LastNonIgnoredMessage { - message: WithParts - index: number -} - -interface ModelLimit { - context: number - input?: number - output?: number -} - -export function computeInputBudget(limit: ModelLimit): number | undefined { - if (!limit.context) { - return undefined - } - - return limit.input ?? Math.max(0, limit.context - (limit.output ?? 0)) -} - -export function getNudgeFrequency(config: PluginConfig): number { - return Math.max(1, Math.floor(config.compress.nudgeFrequency || 1)) -} - -export function getIterationNudgeThreshold(config: PluginConfig): number { - return Math.max(1, Math.floor(config.compress.iterationNudgeThreshold || 1)) -} - -export function findLastNonIgnoredMessage(messages: WithParts[]): LastNonIgnoredMessage | null { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (isIgnoredUserMessage(message)) { - continue - } - if (isSyntheticMessage(message)) { - continue - } - return { message, index: i } - } - - return null -} - -export function countMessagesAfterIndex(messages: WithParts[], index: number): number { - let count = 0 - - for (let i = index + 1; i < messages.length; i++) { - const message = messages[i] - if (isIgnoredUserMessage(message)) { - continue - } - count++ - } - - return count -} - -export function getModelInfo(messages: WithParts[]): LastUserModelContext { - const lastUserMessage = getLastUserMessage(messages) - if (!lastUserMessage) { - return { - providerId: undefined, - modelId: undefined, - } - } - - const userInfo = lastUserMessage.info as UserMessage - return { - providerId: userInfo.model?.providerID, - modelId: userInfo.model?.modelID, - } -} - -function resolveContextTokenLimit( - config: PluginConfig, - state: SessionState, - providerId: string | undefined, - modelId: string | undefined, - threshold: "max" | "min", -): number | undefined { - const parseLimitValue = (limit: number | `${number}%` | undefined): number | undefined => { - if (limit === undefined) { - return undefined - } - - if (typeof limit === "number") { - return limit - } - - if (!limit.endsWith("%") || state.modelContextLimit === undefined) { - return undefined - } - - const parsedPercent = parseFloat(limit.slice(0, -1)) - if (isNaN(parsedPercent)) { - return undefined - } - - const roundedPercent = Math.round(parsedPercent) - const clampedPercent = Math.max(0, Math.min(100, roundedPercent)) - return Math.round((clampedPercent / 100) * state.modelContextLimit) - } - - const modelLimits = - threshold === "max" ? config.compress.modelMaxLimits : config.compress.modelMinLimits - if (modelLimits && providerId !== undefined && modelId !== undefined) { - const providerModelId = `${providerId}/${modelId}` - const modelLimit = modelLimits[providerModelId] - if (modelLimit !== undefined) { - return parseLimitValue(modelLimit) - } - } - - const globalLimit = - threshold === "max" ? config.compress.maxContextLimit : config.compress.minContextLimit - return parseLimitValue(globalLimit) -} - -export function isContextOverLimits( - config: PluginConfig, - state: SessionState, - providerId: string | undefined, - modelId: string | undefined, - messages: WithParts[], -) { - const summaryTokenExtension = config.compress.summaryBuffer - ? getActiveSummaryTokenUsage( - state, - new Set(messages.map((m) => m.info.id)), - ) - : 0 - const resolvedMaxContextLimit = resolveContextTokenLimit( - config, - state, - providerId, - modelId, - "max", - ) - const maxContextLimit = - resolvedMaxContextLimit === undefined - ? undefined - : resolvedMaxContextLimit + summaryTokenExtension - const minContextLimit = resolveContextTokenLimit(config, state, providerId, modelId, "min") - const currentTokens = getCurrentTokenUsage(state, messages) - - let overMaxLimit = maxContextLimit === undefined ? false : currentTokens > maxContextLimit - const overMinLimit = minContextLimit === undefined ? false : currentTokens >= minContextLimit - - // [FIX Bug 20] Suppress overMax while cacheRead hasn't updated after compress - if (overMaxLimit) { - const recentCompressCount = 3 - const recentMessages = messages.slice(-recentCompressCount) - for (const msg of recentMessages) { - if (msg.info.role === "assistant" && msg.parts) { - for (const part of msg.parts) { - if (part.type === "tool" && part.tool === "compress") { - overMaxLimit = false - break - } - } - } - if (!overMaxLimit) break - } - } - - return { - overMaxLimit, - overMinLimit, - currentTokens, - modelContextLimit: state.modelContextLimit, - } -} - -export type TipsVariant = "maxLimit" | "minLimit" | "normal" - -export interface NudgeDecision { - shouldNudge: boolean - tipsVariant: TipsVariant | null -} - -import { - ensureBuiltinTriggerPolicyRegistered, - getDefaultTriggerPolicy, -} from "./policy" -ensureBuiltinTriggerPolicyRegistered() - -export function computeShouldNudge(params: { - currentTokens: number | undefined - modelContextLimit: number | undefined - overMinLimit: boolean - overMaxLimit: boolean - lastNudgeTokens: number | undefined - /** @deprecated Kept for backward compat; ignored. Cadence is growth-only now. */ - minNudgeContextPercent: number - nudgeGrowthTokens: number -}): NudgeDecision { - const policy = getDefaultTriggerPolicy() - if (!policy) { - return { shouldNudge: false, tipsVariant: null } - } - return policy.computeShouldNudge(params) -} - -export function resolveAdaptiveNudgeGrowth(modelContextLimit: number | undefined): number { - const policy = getDefaultTriggerPolicy() - if (!policy) { - return 6000 - } - return policy.resolveAdaptiveNudgeGrowth(modelContextLimit) -} - -export function addAnchor( - anchorMessageIds: Set, - anchorMessageId: string, - anchorMessageIndex: number, - messages: WithParts[], - interval: number, -): boolean { - if (anchorMessageIndex < 0) { - return false - } - - let latestAnchorMessageIndex = -1 - for (let i = messages.length - 1; i >= 0; i--) { - if (anchorMessageIds.has(messages[i].info.id)) { - latestAnchorMessageIndex = i - break - } - } - - const shouldAdd = - latestAnchorMessageIndex < 0 || anchorMessageIndex - latestAnchorMessageIndex >= interval - if (!shouldAdd) { - return false - } - - const previousSize = anchorMessageIds.size - anchorMessageIds.add(anchorMessageId) - return anchorMessageIds.size !== previousSize -} - -function injectAnchoredNudge(message: WithParts, nudgeText: string): void { - if (!nudgeText.trim()) { - return - } - - if (message.info.role === "user") { - if (appendToLastTextPart(message, nudgeText)) { - return - } - - message.parts.push(createSyntheticTextPart(message, nudgeText)) - return - } - - if (message.info.role !== "assistant") { - return - } - - if (!hasContent(message)) { - return - } - - for (const part of message.parts) { - if (part.type === "text") { - if (appendToTextPart(part, nudgeText)) { - return - } - } - } - - const syntheticPart = createSyntheticTextPart(message, nudgeText) - const firstToolIndex = message.parts.findIndex((p) => p.type === "tool") - if (firstToolIndex === -1) { - message.parts.push(syntheticPart) - } else { - message.parts.splice(firstToolIndex, 0, syntheticPart) - } -} - -function collectAnchoredMessages( - anchorMessageIds: Set, - messages: WithParts[], -): Array<{ message: WithParts; index: number }> { - const anchoredMessages: Array<{ message: WithParts; index: number }> = [] - - for (const anchorMessageId of anchorMessageIds) { - const index = messages.findIndex((message) => message.info.id === anchorMessageId) - if (index === -1) { - continue - } - - anchoredMessages.push({ - message: messages[index], - index, - }) - } - - return anchoredMessages -} - -function collectTurnNudgeAnchors( - state: SessionState, - config: PluginConfig, - messages: WithParts[], -): Set { - const turnNudgeAnchors = new Set() - const targetRole = config.compress.nudgeForce === "strong" ? "user" : "assistant" - - for (const message of messages) { - if (!state.nudges.turnNudgeAnchors.has(message.info.id)) continue - - if (message.info.role === targetRole) { - turnNudgeAnchors.add(message.info.id) - } - } - - return turnNudgeAnchors -} - -function applyRangeModeAnchoredNudge( - anchorMessageIds: Set, - messages: WithParts[], - baseNudgeText: string, - compressedBlockGuidance: string, -): void { - const nudgeText = appendGuidanceToDcpTag(baseNudgeText, compressedBlockGuidance) - if (!nudgeText.trim()) { - return - } - - for (const { message } of collectAnchoredMessages(anchorMessageIds, messages)) { - injectAnchoredNudge(message, nudgeText) - } -} - -/** - * Resolve a config threshold (number | "NN%") to a percentage value. - */ -function resolveThresholdPercent( - threshold: number | `${number}%` | undefined, - modelContextLimit: number | undefined, -): number | undefined { - if (threshold === undefined) return undefined - if (typeof threshold === "number") { - if (!modelContextLimit) return undefined - return (threshold / modelContextLimit) * 100 - } - const parsed = parseFloat(threshold) - return isNaN(parsed) ? undefined : parsed -} - -export function applyAnchoredNudges( - state: SessionState, - config: PluginConfig, - messages: WithParts[], - prompts: RuntimePrompts, - compressionPriorities?: CompressionPriorityMap, - currentTokens?: number, - modelContextLimit?: number, - suffixMessage?: WithParts | null, -): void { - const turnNudgeAnchors = collectTurnNudgeAnchors(state, config, messages) - - if (suffixMessage) { - const nudgeParts: string[] = [] - - if (state.nudges.contextLimitAnchors.size > 0) { - nudgeParts.push(prompts.contextLimitNudge) - } - if (turnNudgeAnchors.size > 0) { - nudgeParts.push(prompts.turnNudge) - } - if (state.nudges.iterationNudgeAnchors.size > 0) { - nudgeParts.push(prompts.iterationNudge) - } - - const combined = nudgeParts.join("\n\n") - if (combined.trim()) { - injectAnchoredNudge(suffixMessage, combined) - } - return - } - - applyRangeModeAnchoredNudge( - state.nudges.contextLimitAnchors, - messages, - prompts.contextLimitNudge, - "", - ) - applyRangeModeAnchoredNudge(turnNudgeAnchors, messages, prompts.turnNudge, "") - applyRangeModeAnchoredNudge( - state.nudges.iterationNudgeAnchors, - messages, - prompts.iterationNudge, - "", - ) -} - -export interface ContextComposition { - toolTokens: number - codeTokens: number - summaryTokens: number - messageTokens: number - textTokens: number - systemTokens: number - protectedTokens: number - total: number - largestRanges: { ref: string; tokens: number }[] - largestToolRanges: { ref: string; tokens: number; tool?: string }[] - largestCodeRanges: { ref: string; tokens: number }[] - largestMessageRanges: { ref: string; tokens: number }[] - toolTypeBreakdown: { tool: string; tokens: number }[] -} - -function estimateCodeTokens(text: string): number { - let codeChars = 0 - let inCode = false - for (const line of text.split("\n")) { - if (line.trim().startsWith("```")) { - inCode = !inCode - codeChars += line.length + 1 - continue - } - if (inCode) codeChars += line.length + 1 - } - return Math.round(codeChars / 4) -} - -export function estimateContextComposition( - messages: WithParts[], - state?: SessionState, - protectedTools: string[] = [], - protectedFilePatterns: string[] = [], -): ContextComposition { - let toolTokens = 0 - let codeTokens = 0 - let summaryTokens = 0 - let messageTokens = 0 - let protectedTokens = 0 - const perMessage: { ref: string; tokens: number }[] = [] - const perTool: { ref: string; tokens: number; tool?: string }[] = [] - const perCode: { ref: string; tokens: number }[] = [] - const perText: { ref: string; tokens: number }[] = [] - const toolTypeMap = new Map() - - for (const msg of messages) { - const text = (msg.parts || []) - .filter((p) => p.type === "text") - .map((p: any) => p.text || "") - .join("") - const msgId = (msg.info as any)?.id || "" - const isSummary = - msgId.startsWith("msg_dcp_summary") || - text.includes("[Compressed conversation section]") - - const isProtected = - (protectedTools.length > 0 || protectedFilePatterns.length > 0) && - messageContainsProtectedTool(msg, protectedTools, protectedFilePatterns) - - let msgTotal = 0 - let msgTool = 0 - let msgCode = 0 - let msgText = 0 - let msgToolName = "" - - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - const partText = (part as any).text as string - const tokens = Math.round(partText.length / 4) - msgTotal += tokens - if (isSummary) { - summaryTokens += tokens - } else { - messageTokens += tokens - msgText += tokens - const cTokens = estimateCodeTokens(partText) - if (cTokens > 0) { - codeTokens += cTokens - msgCode += cTokens - } - } - } else if (part.type === "tool") { - const raw = JSON.stringify(part) - const tokens = Math.round(raw.length / 4) - msgTotal += tokens - const toolName = (part as any)?.tool || "unknown" - - // Compress-as-anchor (v1.12.9+): classify summary content as - // summaryTokens, not toolTokens. The summary text lives in - // part.state.input.content[].summary. - let summaryPartTokens = 0 - if (toolName === "compress") { - const input = (part as any)?.state?.input - if (input?.content && Array.isArray(input.content)) { - for (const entry of input.content) { - if (typeof entry?.summary === "string") { - summaryPartTokens += Math.round(entry.summary.length / 4) - } - } - } - } - const toolPartTokens = Math.max(0, tokens - summaryPartTokens) - toolTokens += toolPartTokens - msgTool += toolPartTokens - summaryTokens += summaryPartTokens - toolTypeMap.set(toolName, (toolTypeMap.get(toolName) || 0) + toolPartTokens) - if (!msgToolName) msgToolName = toolName - } - } - - if (isProtected && !isSummary) { - protectedTokens += msgTotal - } - - if (!isSummary) { - const ref = state?.messageIds?.byRawId?.get(msgId) || "?" - if (msgTotal > 500) perMessage.push({ ref, tokens: msgTotal }) - if (msgTool > 500) perTool.push({ ref, tokens: msgTool, tool: msgToolName }) - if (msgCode > 300) perCode.push({ ref, tokens: msgCode }) - if (msgText > 500 && msgCode === 0) perText.push({ ref, tokens: msgText }) - } - } - - perMessage.sort((a, b) => b.tokens - a.tokens) - perTool.sort((a, b) => b.tokens - a.tokens) - perCode.sort((a, b) => b.tokens - a.tokens) - perText.sort((a, b) => b.tokens - a.tokens) - - const toolTypeBreakdown = Array.from(toolTypeMap.entries()) - .map(([tool, tokens]) => ({ tool, tokens })) - .sort((a, b) => b.tokens - a.tokens) - - const systemTokens = estimateSystemPromptTokens(messages) - - return { - toolTokens, - codeTokens, - summaryTokens, - messageTokens, - textTokens: Math.max(0, messageTokens - codeTokens), - systemTokens, - protectedTokens, - total: systemTokens + toolTokens + summaryTokens + messageTokens, - largestRanges: perMessage.slice(0, 15), - largestToolRanges: perTool.slice(0, 15), - largestCodeRanges: perCode.slice(0, 5), - largestMessageRanges: perText.slice(0, 5), - toolTypeBreakdown, - } -} - -export interface CompressibleRange { - startRef: string - endRef: string - count: number - tokens: number - toolPct: number - textPct: number - dangerous?: boolean -} - -export interface ProtectedRange { - startRef: string - endRef: string - count: number - tokens: number - tools: string[] -} - -export interface ContextRanges { - compressible: CompressibleRange[] - protected: ProtectedRange[] -} - -function refNum(ref: string): number { - const n = parseInt(ref.slice(1), 10) - return Number.isNaN(n) ? -1 : n -} - -export function buildCompressibleRanges( - messages: WithParts[], - state: SessionState, - protectedTools: string[] = [], - protectedFilePatterns: string[] = [], - protectedZoneRefs?: Set, -): ContextRanges { - const msgInfo: { - ref: string - refNum: number - tokens: number - isTool: boolean - isUser: boolean - }[] = [] - const protectedMsgInfo: { - ref: string - refNum: number - tokens: number - tools: string[] - }[] = [] - for (const msg of messages) { - if (isSyntheticMessage(msg)) continue - const ref = state.messageIds.byRawId.get(msg.info.id) - if (!ref) continue - - const rn = parseInt(ref.slice(1), 10) - - if ( - (protectedTools.length > 0 || protectedFilePatterns.length > 0) && - messageContainsProtectedTool(msg, protectedTools, protectedFilePatterns) - ) { - let tokens = 0 - const tools = new Set() - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - tokens += Math.round(((part as any).text as string).length / 4) - } else if (part.type !== "text" && part.type !== "reasoning") { - tokens += Math.round(JSON.stringify(part).length / 4) - const toolName = (part as any)?.tool - const callID = (part as any)?.callID - if (toolName && callID) { - if (isToolNameProtected(toolName, protectedTools)) { - tools.add(toolName) - } else if (protectedFilePatterns.length > 0) { - const filePaths = getFilePathsFromParameters( - toolName, - (part as any)?.state?.input, - ) - if (isFilePathProtected(filePaths, protectedFilePatterns)) { - tools.add(toolName) - } - } - } - } - } - protectedMsgInfo.push({ ref, refNum: rn, tokens, tools: [...tools] }) - continue - } - - let tokens = 0 - let isTool = false - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - tokens += Math.round(((part as any).text as string).length / 4) - } else if (part.type !== "text" && part.type !== "reasoning") { - tokens += Math.round(JSON.stringify(part).length / 4) - isTool = true - } - } - msgInfo.push({ ref, refNum: rn, tokens, isTool, isUser: msg.info.role === "user" }) - } - - const groups: CompressibleRange[] = [] - let cur: CompressibleRange | null = null - let prevRefNum = -2 - for (const info of msgInfo) { - // Split groups at the protected-zone boundary: close the current group - // before skipping protected messages, so the unprotected head survives - // as its own range instead of being swallowed by excludeProtectedRanges. - if (protectedZoneRefs?.has(info.ref)) { - if (cur) { - groups.push(cur) - cur = null - } - prevRefNum = info.refNum - continue - } - const hasGap = info.refNum > prevRefNum + 1 - if (cur && ((info.isUser && cur.count >= 3) || hasGap)) { - groups.push(cur) - cur = null - } - prevRefNum = info.refNum - if (!cur) { - cur = { - startRef: info.ref, - endRef: info.ref, - count: 1, - tokens: info.tokens, - toolPct: info.isTool ? 100 : 0, - textPct: info.isTool ? 0 : 100, - } - } else { - cur.endRef = info.ref - cur.count++ - cur.tokens += info.tokens - if (info.isTool) { - cur.toolPct = Math.round((cur.toolPct * (cur.count - 1) + 100) / cur.count) - } else { - cur.toolPct = Math.round((cur.toolPct * (cur.count - 1)) / cur.count) - } - cur.textPct = 100 - cur.toolPct - } - } - if (cur) groups.push(cur) - - const protectedGroups: ProtectedRange[] = [] - let pcur: ProtectedRange | null = null - let pPrevRefNum = -2 - for (const info of protectedMsgInfo) { - const hasGap = info.refNum > pPrevRefNum + 1 - if (pcur && hasGap) { - protectedGroups.push(pcur) - pcur = null - } - pPrevRefNum = info.refNum - if (!pcur) { - pcur = { - startRef: info.ref, - endRef: info.ref, - count: 1, - tokens: info.tokens, - tools: [...info.tools], - } - } else { - pcur.endRef = info.ref - pcur.count++ - pcur.tokens += info.tokens - for (const t of info.tools) { - if (!pcur.tools.includes(t)) pcur.tools.push(t) - } - } - } - if (pcur) protectedGroups.push(pcur) - - return { - compressible: groups.filter((g) => g.tokens > 0), - protected: protectedGroups, - } -} - -export interface RangeFilterOptions { - logger?: { debug: (msg: string, data?: any) => void } -} - -/** - * Filter compressible ranges for the recommendation list. - * - * All ranges are shown to the model — the model decides what to compress. - * The last segment is always marked `dangerous: true` (it may still be in - * active use; the model is warned in the suffix text). - * - * Issue #251: Previously this function used `growthThreshold` (5% of context - * window = 50K at 1M) as an aggregate gate — if "effective compressible" - * was below the threshold, ALL ranges were suppressed and the nudge was - * hidden. At large context windows, individual ranges rarely exceeded this - * threshold, so compression was permanently blocked. The aggregate gate - * has been removed; `minCompressRange` in `range.ts` (5000 chars) already - * prevents garbage compressions as a backstop. - */ -export function filterRecommendedRanges( - compressible: CompressibleRange[], - _protectedRanges: ProtectedRange[], - options: RangeFilterOptions, -): CompressibleRange[] { - const { logger } = options - const log = logger?.debug.bind(logger) - - if (compressible.length === 0) { - log?.("filterRecommendedRanges: no compressible ranges, returning empty") - return [] - } - - const result = compressible.map((r, i) => - i === compressible.length - 1 ? { ...r, dangerous: true } : r, - ) - - log?.("filterRecommendedRanges: passthrough (last segment marked dangerous)", { - inputRanges: compressible.length, - outputRanges: result.length, - }) - - return result -} - -interface MergedEntry { - startRef: string - endRef: string - startNum: number - endNum: number - count: number - tokens: number - toolPct: number - textPct: number - compressibleTokens: number - compressibleCount: number - protectedTokens: number - protectedCount: number - protectedTools: string[] - dangerous: boolean -} - -export function formatCompressibleRanges( - ranges: CompressibleRange[], - protectedRanges?: ProtectedRange[], -): string { - const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n)) - - if (!protectedRanges || protectedRanges.length === 0) { - if (ranges.length === 0) return "" - const lines = ranges.map((r) => { - const suffix = r.dangerous ? " ⚠️ NOT recommended unless you are certain. If you MUST compress this, pass `dangerous: true`." : "" - return ` ${r.startRef}–${r.endRef} ${r.count} msgs ${fmt(r.tokens)} [tool ${r.toolPct}% | text ${r.textPct}%]${suffix}` - }) - return `Compressible ranges (oldest first):\n${lines.join("\n")}` - } - - const entries: MergedEntry[] = [] - - for (const r of ranges) { - entries.push({ - startRef: r.startRef, - endRef: r.endRef, - startNum: refNum(r.startRef), - endNum: refNum(r.endRef), - count: r.count, - tokens: r.tokens, - toolPct: r.toolPct, - textPct: r.textPct, - compressibleTokens: r.tokens, - compressibleCount: r.count, - protectedTokens: 0, - protectedCount: 0, - protectedTools: [], - dangerous: r.dangerous ?? false, - }) - } - for (const r of protectedRanges) { - entries.push({ - startRef: r.startRef, - endRef: r.endRef, - startNum: refNum(r.startRef), - endNum: refNum(r.endRef), - count: r.count, - tokens: r.tokens, - toolPct: 0, - textPct: 0, - compressibleTokens: 0, - compressibleCount: 0, - protectedTokens: r.tokens, - protectedCount: r.count, - protectedTools: [...r.tools], - dangerous: false, - }) - } - - entries.sort((a, b) => a.startNum - b.startNum) - - const merged: MergedEntry[] = [] - for (const entry of entries) { - const last = merged[merged.length - 1] - if (last && entry.startNum <= last.endNum + 1) { - last.endRef = entry.endRef - last.endNum = Math.max(last.endNum, entry.endNum) - last.count += entry.count - last.tokens += entry.tokens - last.compressibleTokens += entry.compressibleTokens - last.compressibleCount += entry.compressibleCount - last.protectedTokens += entry.protectedTokens - last.protectedCount += entry.protectedCount - if (entry.dangerous) last.dangerous = true - for (const t of entry.protectedTools) { - if (!last.protectedTools.includes(t)) last.protectedTools.push(t) - } - } else { - merged.push({ ...entry }) - } - } - - const lines = merged.map((e) => { - const suffix = e.dangerous && e.compressibleTokens > 0 ? " ⚠️ NOT recommended unless you are certain. If you MUST compress this, pass `dangerous: true`." : "" - - if (e.protectedTokens > 0 && e.compressibleTokens === 0) { - return ` ${e.startRef}–${e.endRef} ${e.count} msgs ${fmt(e.tokens)} [PROTECTED: ${e.protectedTools.join(", ")} — not compressible]${suffix}` - } - - if (e.protectedTokens > 0 && e.compressibleTokens > 0) { - return ` ${e.startRef}–${e.endRef} ${e.count} msgs ${fmt(e.tokens)} [${fmt(e.compressibleTokens)} compressible | ${fmt(e.protectedTokens)} protected: ${e.protectedTools.join(", ")}]${suffix}` - } - - return ` ${e.startRef}–${e.endRef} ${e.count} msgs ${fmt(e.tokens)} [tool ${e.toolPct}% | text ${e.textPct}%]${suffix}` - }) - - return `Compressible ranges (oldest first):\n${lines.join("\n")}` -} - -/** - * Compute the set of protected message refs (mNNNNN) that should be excluded - * from compression recommendations. Combines two rules: - * 1. Last N messages (preserveRecentMessages, default 5) - * 2. Last N tokens expanding backward (preserveRecentTokens, default 5000) - * - * Note: preserveLastUserMessage is no longer handled here (moved to soft - * filtering in the compress pipeline — see filterLastUserMessage). The last - * user message is filtered from the compress plan instead of causing a hard - * rejection. - * - * Only considers visible, non-synthetic, non-pruned messages. - */ -export function computeProtectedRefs( - messages: WithParts[], - state: SessionState, - compress: PluginConfig["compress"], -): Set { - if (compress.lastSegmentSoftBlock === false) return new Set() - - const preserveN = compress.preserveRecentMessages ?? 5 - const preserveTokens = compress.preserveRecentTokens ?? 5000 - - const result = new Set() - - const visible: { ref: string; tokens: number; isUser: boolean }[] = [] - for (const msg of messages) { - if (isSyntheticMessage(msg)) continue - if (isIgnoredUserMessage(msg)) continue - const ref = state.messageIds.byRawId.get(msg.info.id) - if (!ref) continue - if (state.prune.messages.byMessageId.has(msg.info.id)) continue - - let tokens = 0 - for (const part of msg.parts || []) { - if (part.type === "text" && typeof (part as any).text === "string") { - tokens += Math.round(((part as any).text as string).length / 4) - } else if (part.type !== "text" && part.type !== "reasoning") { - tokens += Math.round(JSON.stringify(part).length / 4) - } - } - visible.push({ ref, tokens, isUser: msg.info.role === "user" }) - } - - if (preserveN > 0) { - for (const m of visible.slice(-preserveN)) { - result.add(m.ref) - } - } - - if (preserveTokens > 0) { - let tokenAccum = 0 - for (let i = visible.length - 1; i >= 0 && tokenAccum < preserveTokens; i--) { - result.add(visible[i]!.ref) - tokenAccum += visible[i]!.tokens - } - } - - return result -} - -/** - * Filter compressible ranges to exclude those overlapping the protected zone. - * Since the protected zone is always at the tail of the conversation, a range - * whose startRef or endRef is protected is partially or fully within the zone. - * Compressing such a range would either be rejected by the enforcement check - * or waste model effort — exclude it preemptively. - */ -export function excludeProtectedRanges( - ranges: CompressibleRange[], - protectedRefs: Set, -): CompressibleRange[] { - if (protectedRefs.size === 0) return ranges - return ranges.filter( - (r) => !protectedRefs.has(r.startRef) && !protectedRefs.has(r.endRef), - ) -} diff --git a/lib/messages/priority.ts b/lib/messages/priority.ts deleted file mode 100644 index c56f4b4b..00000000 --- a/lib/messages/priority.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { PluginConfig } from "../config" -import type { SessionState, WithParts } from "../state" - -const MEDIUM_PRIORITY_MIN_TOKENS = 500 -const HIGH_PRIORITY_MIN_TOKENS = 5000 - -export type MessagePriority = "low" | "medium" | "high" - -export interface CompressionPriorityEntry { - ref: string - tokenCount: number - priority: MessagePriority -} - -export type CompressionPriorityMap = Map - -export function buildPriorityMap( - _config: PluginConfig, - _state: SessionState, - _messages: WithParts[], -): CompressionPriorityMap { - return new Map() -} - -export function classifyMessagePriority(tokenCount: number): MessagePriority { - if (tokenCount >= HIGH_PRIORITY_MIN_TOKENS) { - return "high" - } - - if (tokenCount >= MEDIUM_PRIORITY_MIN_TOKENS) { - return "medium" - } - - return "low" -} - -export function listPriorityRefsBeforeIndex( - messages: WithParts[], - priorities: CompressionPriorityMap, - anchorIndex: number, - priority: MessagePriority, -): string[] { - const refs: string[] = [] - const seen = new Set() - const upperBound = Math.max(0, Math.min(anchorIndex, messages.length)) - - for (let index = 0; index < upperBound; index++) { - const rawMessageId = messages[index]?.info.id - if (typeof rawMessageId !== "string") { - continue - } - - const entry = priorities.get(rawMessageId) - if (!entry || entry.priority !== priority || seen.has(entry.ref)) { - continue - } - - seen.add(entry.ref) - refs.push(entry.ref) - } - - return refs -} diff --git a/lib/messages/prune.ts b/lib/messages/prune.ts deleted file mode 100644 index ed945d29..00000000 --- a/lib/messages/prune.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { SessionState, WithParts } from "../state" -import type { Logger } from "../logger" -import type { PluginConfig } from "../config" - -export const prune = ( - state: SessionState, - logger: Logger, - config: PluginConfig, - messages: WithParts[], -): void => { - filterCompressedRanges(state, messages) - stripStepMarkers(messages) -} - -const MAX_STEP_FINISH_REASON = 50 - -const stripStepMarkers = (messages: WithParts[]): void => { - for (const msg of messages) { - const parts = Array.isArray(msg.parts) ? msg.parts : [] - let changed = false - const filtered: typeof parts = [] - - for (const part of parts) { - if (part.type === "step-start") { - changed = true - continue - } - - if (part.type === "step-finish") { - const reason = (part as { reason?: unknown }).reason - if (typeof reason === "string" && reason.length > MAX_STEP_FINISH_REASON) { - const truncated = reason.slice(0, MAX_STEP_FINISH_REASON) + "..." - // Skip when already truncated: keeps `changed` false on idempotent - // re-runs so the parts array reference (and prefix cache) stays stable. - if (truncated !== reason) { - filtered.push({ ...part, reason: truncated }) - changed = true - continue - } - } - } - - filtered.push(part) - } - - if (changed) { - msg.parts = filtered - } - } -} - -const filterCompressedRanges = ( - state: SessionState, - messages: WithParts[], -): void => { - if (state.prune.messages.byMessageId.size === 0) { - return - } - - const survive: boolean[] = messages.map((msg) => { - const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id) - if (!pruneEntry || pruneEntry.activeBlockIds.length === 0) { - return true - } - return false - }) - - // [FIX preserve-first-user] zhipuai-lb (and most providers) reject requests - // with zero user-role messages (code 1214, "The messages parameter is - // illegal"), freezing the session. The first user message is the session's - // original task — it must always survive compression to guarantee API - // validity. This is simpler and more reliable than the previous - // "restore most recent pruned user" approach, which depended on the - // pruned message still being in the messages array (not guaranteed after - // OpenCode compaction). - const firstUserIdx = messages.findIndex((msg) => msg.info.role === "user") - if (firstUserIdx >= 0) { - survive[firstUserIdx] = true - } - - const result: WithParts[] = [] - for (let i = 0; i < messages.length; i++) { - if (survive[i]) { - result.push(messages[i]!) - } - } - - messages.length = 0 - messages.push(...result) -} diff --git a/lib/messages/reasoning-strip.ts b/lib/messages/reasoning-strip.ts deleted file mode 100644 index 9872e1d0..00000000 --- a/lib/messages/reasoning-strip.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { WithParts } from "../state" -import { getLastUserMessage } from "./query" - -/** - * Mirrors opencode's differentModel handling by preserving part content while - * dropping provider metadata on assistant parts that came from a different - * model/provider than the current turn's user message. - */ -export function stripStaleMetadata(messages: WithParts[]): void { - const lastUserMessage = getLastUserMessage(messages) - if (lastUserMessage?.info.role !== "user") { - return - } - - const modelID = lastUserMessage.info.model.modelID - const providerID = lastUserMessage.info.model.providerID - - messages.forEach((message) => { - if (message.info.role !== "assistant") { - return - } - - // [FIX Bug 8] Guard against undefined modelID/providerID - const msgModelID = (message.info as any).modelID - const msgProviderID = (message.info as any).providerID - if (msgModelID === modelID && msgProviderID === providerID) { - return - } - - message.parts = message.parts.map((part) => { - if (part.type !== "text" && part.type !== "tool" && part.type !== "reasoning") { - return part - } - - if (!("metadata" in part)) { - return part - } - - const { metadata: _metadata, ...rest } = part - return rest - }) - }) -} diff --git a/lib/messages/sync.ts b/lib/messages/sync.ts deleted file mode 100644 index 6bee4e55..00000000 --- a/lib/messages/sync.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { SessionState, WithParts } from "../state" -import type { Logger } from "../logger" - -function sortBlocksByCreation( - a: { createdAt: number; blockId: number }, - b: { createdAt: number; blockId: number }, -): number { - const createdAtDiff = a.createdAt - b.createdAt - if (createdAtDiff !== 0) { - return createdAtDiff - } - return a.blockId - b.blockId -} - -export const syncCompressionBlocks = ( - state: SessionState, - logger: Logger, - messages: WithParts[], -): void => { - const messagesState = state.prune.messages - if (!messagesState?.blocksById?.size) { - return - } - - const messageIds = new Set(messages.map((msg) => msg.info.id)) - const previousActiveBlockIds = new Set( - Array.from(messagesState.blocksById.values()) - .filter((block) => block.active) - .map((block) => block.blockId), - ) - - messagesState.activeBlockIds.clear() - messagesState.activeByAnchorMessageId.clear() - - const now = Date.now() - const orderedBlocks = Array.from(messagesState.blocksById.values()).sort(sortBlocksByCreation) - - // [PATCH Bug 3] Removed compressMessageId presence check. - // Blocks should remain active even if the compress tool call message was - // removed by opencode's internal compaction. The block's existence IS proof - // that compression happened. - for (const block of orderedBlocks) { - if (block.deactivatedByUser || block.deactivatedByUserDeep) { - block.active = false - if (block.deactivatedAt === undefined) { - block.deactivatedAt = now - } - block.deactivatedByBlockId = undefined - continue - } - - for (const consumedBlockId of block.consumedBlockIds) { - if (!messagesState.activeBlockIds.has(consumedBlockId)) { - continue - } - - const consumedBlock = messagesState.blocksById.get(consumedBlockId) - if (consumedBlock) { - consumedBlock.active = false - consumedBlock.deactivatedAt = now - consumedBlock.deactivatedByBlockId = block.blockId - - const mappedBlockId = messagesState.activeByAnchorMessageId.get( - consumedBlock.anchorMessageId, - ) - if (mappedBlockId === consumedBlock.blockId) { - messagesState.activeByAnchorMessageId.delete(consumedBlock.anchorMessageId) - } - } - - messagesState.activeBlockIds.delete(consumedBlockId) - } - - block.active = true - block.deactivatedAt = undefined - block.deactivatedByBlockId = undefined - messagesState.activeBlockIds.add(block.blockId) - if (messageIds.has(block.anchorMessageId)) { - messagesState.activeByAnchorMessageId.set(block.anchorMessageId, block.blockId) - } - } - - for (const entry of messagesState.byMessageId.values()) { - const allBlockIds = Array.isArray(entry.allBlockIds) - ? [...new Set(entry.allBlockIds.filter((id) => Number.isInteger(id) && id > 0))] - : [] - - entry.allBlockIds = allBlockIds - entry.activeBlockIds = allBlockIds.filter((id) => messagesState.activeBlockIds.has(id)) - } - - const nextActiveBlockIds = messagesState.activeBlockIds - let deactivatedCount = 0 - let reactivatedCount = 0 - - for (const blockId of previousActiveBlockIds) { - if (!nextActiveBlockIds.has(blockId)) { - deactivatedCount++ - } - } - for (const blockId of nextActiveBlockIds) { - if (!previousActiveBlockIds.has(blockId)) { - reactivatedCount++ - } - } - - if (deactivatedCount > 0 || reactivatedCount > 0) { - logger.info("Synced compress block state", { - deactivatedCount, - reactivatedCount, - }) - } -} diff --git a/lib/messages/truncate-tools.ts b/lib/messages/truncate-tools.ts deleted file mode 100644 index f0077fe7..00000000 --- a/lib/messages/truncate-tools.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { SessionState, WithParts } from "../state" -import type { PluginConfig } from "../config" -import { Logger } from "../logger" -import { getCurrentTokenUsage, countTokens, extractCompletedToolOutput } from "../token-utils" - -const TRUNCATION_MARKER = "[truncated for context space" -const MIN_OUTPUT_TOKENS = 1000 -const KEEP_PREFIX_CHARS = 2000 -const KEEP_SUFFIX_CHARS = 2000 -const PROTECT_RECENT_MESSAGES = 3 - -function parseGcThreshold( - threshold: number | `${number}%` | undefined, - modelContextLimit: number, -): number { - if (typeof threshold === "number") return threshold - const str = threshold ?? "100%" - const match = /^(\d+(?:\.\d+)?)%$/.exec(str) - if (match) return Math.round((Number(match[1]) / 100) * modelContextLimit) - return modelContextLimit -} - -/** - * When context reaches the GC threshold, truncate the largest visible tool outputs - * (keeping prefix + suffix) to free space. Summaries are never touched — they contain - * distilled information. Only verbose tool outputs (build logs, listings) are truncated. - */ -export function truncateLargeToolOutputs( - state: SessionState, - config: PluginConfig, - logger: Logger, - messages: WithParts[], -): void { - if (!state.modelContextLimit) return - - const currentTokens = getCurrentTokenUsage(state, messages) - if (currentTokens === 0) return - - const threshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, state.modelContextLimit) - if (currentTokens < threshold) return - - const protectedIndex = messages.length - PROTECT_RECENT_MESSAGES - - const candidates: Array<{ part: any; content: string; tokens: number }> = [] - - for (let mi = 0; mi < messages.length; mi++) { - if (mi >= protectedIndex) break - - const msg = messages[mi] - const parts = Array.isArray(msg.parts) ? msg.parts : [] - - for (let pi = 0; pi < parts.length; pi++) { - const part = parts[pi] - if (part?.type !== "tool") continue - if (part.state?.status !== "completed") continue - - const content = extractCompletedToolOutput(part) - if (content === undefined) continue - if (content === "[Old tool result content cleared]") continue - if (content.includes(TRUNCATION_MARKER)) continue - - const tokens = countTokens(content) - if (tokens < MIN_OUTPUT_TOKENS) continue - - candidates.push({ part, content, tokens }) - } - } - - if (candidates.length === 0) return - - candidates.sort((a, b) => b.tokens - a.tokens) - - const targetTokens = threshold * 0.9 - let savedTokens = 0 - let truncatedCount = 0 - - for (const { part, content, tokens } of candidates) { - if (currentTokens - savedTokens <= targetTokens) break - - if (content.length <= KEEP_PREFIX_CHARS + KEEP_SUFFIX_CHARS) continue - - const prefix = content.slice(0, KEEP_PREFIX_CHARS) - const suffix = content.slice(-KEEP_SUFFIX_CHARS) - const truncated = - prefix + - `\n\n...${TRUNCATION_MARKER} — original ~${tokens} tokens]...\n\n` + - suffix - - part.state.output = truncated - savedTokens += tokens - countTokens(truncated) - truncatedCount++ - } - - if (truncatedCount > 0) { - logger.info("Emergency tool output truncation", { - truncatedCount, - estimatedSavedTokens: Math.round(savedTokens), - currentTokens, - threshold, - }) - } -} diff --git a/lib/messages/utils.ts b/lib/messages/utils.ts deleted file mode 100644 index 8f91eb18..00000000 --- a/lib/messages/utils.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { createHash } from "node:crypto" -import type { SessionState, WithParts } from "../state" -import { isMessageCompacted } from "../state/utils" -import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2" - -const SUMMARY_ID_HASH_LENGTH = 16 - -const DCP_BLOCK_ID_TAG_REGEX = /(<(?:dcp|acp)-message-id[^>]*>)b\d+(<\/(?:dcp|acp)-message-id>)/g -// [FIX Bug 28] Regex to strip stale mNNNN refs from compressed summaries -const DCP_MESSAGE_REF_TAG_REGEX = /<(?:dcp|acp)-message-id[^>]*>m\d+<\/(?:dcp|acp)-message-id>/g -const DCP_PAIRED_TAG_REGEX = /<(?:dcp|acp)[^>]*>[\s\S]*?<\/(?:dcp|acp)[^>]*>/gi -const DCP_UNPAIRED_TAG_REGEX = /<\/?(?:dcp|acp)[^>]*>/gi - -const generateStableId = (prefix: string, seed: string): string => { - const hash = createHash("sha256").update(seed).digest("hex").slice(0, SUMMARY_ID_HASH_LENGTH) - return `${prefix}_${hash}` -} - -export const createSyntheticMessage = ( - baseMessage: WithParts, - content: string, - stableSeed?: string, - role: "user" | "assistant" = "user", -): WithParts => { - const baseInfo = baseMessage.info - const now = Date.now() - const deterministicSeed = stableSeed?.trim() || baseInfo.id - const messageId = generateStableId("msg_dcp_summary", deterministicSeed) - const partId = generateStableId("prt_dcp_summary", deterministicSeed) - - const parts = [ - { - id: partId, - sessionID: baseInfo.sessionID, - messageID: messageId, - type: "text" as const, - text: content, - synthetic: true, - }, - ] - - if (role === "assistant") { - const isAssistant = baseInfo.role === "assistant" - const assistantBase = isAssistant ? baseInfo : undefined - const userModel = !isAssistant ? (baseInfo as UserMessage).model : undefined - const info: AssistantMessage = { - id: messageId, - sessionID: baseInfo.sessionID, - role: "assistant", - time: { created: now }, - parentID: assistantBase?.parentID ?? "", - modelID: assistantBase?.modelID ?? userModel?.modelID ?? "", - providerID: assistantBase?.providerID ?? userModel?.providerID ?? "", - mode: assistantBase?.mode ?? "code", - agent: baseInfo.agent ?? "code", - path: assistantBase?.path ?? { cwd: "", root: "" }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - } - return { info, parts } - } - - const userInfo = baseInfo as UserMessage - const info: UserMessage = { - id: messageId, - sessionID: userInfo.sessionID, - role: "user", - agent: userInfo.agent, - model: userInfo.model, - time: { created: now }, - } - return { info, parts } -} - -export const createSyntheticUserMessage = ( - baseMessage: WithParts, - content: string, - stableSeed?: string, -): WithParts => createSyntheticMessage(baseMessage, content, stableSeed, "user") - -export const createSyntheticTextPart = ( - baseMessage: WithParts, - content: string, - stableSeed?: string, -) => { - const userInfo = baseMessage.info as UserMessage - const deterministicSeed = stableSeed?.trim() || userInfo.id - const partId = generateStableId("prt_dcp_text", deterministicSeed) - - return { - id: partId, - sessionID: userInfo.sessionID, - messageID: userInfo.id, - type: "text" as const, - text: content, - } -} - -type MessagePart = WithParts["parts"][number] -type ToolPart = Extract -type TextPart = Extract - -export const appendToLastTextPart = (message: WithParts, injection: string): boolean => { - const textPart = findLastTextPart(message) - if (!textPart) { - return false - } - - return appendToTextPart(textPart, injection) -} - -const findLastTextPart = (message: WithParts): TextPart | null => { - for (let i = message.parts.length - 1; i >= 0; i--) { - const part = message.parts[i] - if (part.type === "text") { - return part - } - } - - return null -} - -export const appendToTextPart = (part: TextPart, injection: string): boolean => { - if (typeof part.text !== "string") { - return false - } - - const normalizedInjection = injection.replace(/^\n+/, "") - if (!normalizedInjection.trim()) { - return false - } - if (part.text.includes(normalizedInjection)) { - return true - } - - const baseText = part.text.replace(/\n*$/, "") - part.text = baseText.length > 0 ? `${baseText}\n\n${normalizedInjection}` : normalizedInjection - return true -} - -export const appendToAllToolParts = (message: WithParts, tag: string): boolean => { - let injected = false - for (const part of message.parts) { - if (part.type === "tool") { - injected = appendToToolPart(part, tag) || injected - } - } - return injected -} - -const appendToToolPart = (part: ToolPart, tag: string): boolean => { - if (part.state?.status !== "completed" || typeof part.state.output !== "string") { - return false - } - if (part.state.output.includes(tag)) { - return true - } - - part.state.output = `${part.state.output}${tag}` - return true -} - -export const hasContent = (message: WithParts): boolean => { - return message.parts.some( - (part) => - (part.type === "text" && - typeof part.text === "string" && - part.text.trim().length > 0) || - (part.type === "tool" && - part.state?.status === "completed" && - typeof part.state.output === "string"), - ) -} - -export function buildToolIdList(state: SessionState, messages: WithParts[]): string[] { - const toolIds: string[] = [] - for (const msg of messages) { - if (isMessageCompacted(state, msg)) { - continue - } - const parts = Array.isArray(msg.parts) ? msg.parts : [] - if (parts.length > 0) { - for (const part of parts) { - if (part.type === "tool" && part.callID && part.tool) { - toolIds.push(part.callID) - } - } - } - } - state.toolIdList = toolIds - return toolIds -} - -export const replaceBlockIdsWithBlocked = (text: string): string => { - return text.replace(DCP_BLOCK_ID_TAG_REGEX, "$1BLOCKED$2") -} - -// [FIX Bug 28] Strip stale mNNNN refs from compressed summaries before injection -export const stripStaleMessageRefs = (text: string): string => { - return text.replace(DCP_MESSAGE_REF_TAG_REGEX, "") -} - -export const stripHallucinationsFromString = (text: string): string => { - return text.replace(DCP_PAIRED_TAG_REGEX, "").replace(DCP_UNPAIRED_TAG_REGEX, "") -} - -export const stripHallucinations = (messages: WithParts[]): void => { - for (const message of messages) { - for (const part of message.parts) { - if (part.type === "text" && typeof part.text === "string") { - part.text = stripHallucinationsFromString(part.text) - } - - if ( - part.type === "tool" && - part.state?.status === "completed" && - typeof part.state.output === "string" - ) { - part.state.output = stripHallucinationsFromString(part.state.output) - } - } - } -} - -// [FIX #12] Backstop: sweep empty messages of ANY role (in-place, backwards). -// A message is considered empty only when every part is a whitespace-only text -// part (or there are no parts at all). Any non-text part — a tool call regardless -// of status, reasoning, etc. — counts as meaningful content and prevents removal. -// This is deliberately more conservative than hasContent(): hasContent treats a -// non-completed/errored tool as "no content" (appropriate for suffix-fill logic), -// but here we must not drop a message that carries an errored or in-flight tool call. -// -// [FIX #20] A text part carrying `ignored: true` also counts as discardable. -// opencode strips ignored parts before the LLM call; a message whose only part -// is ignored would arrive at the provider as an empty user message and trigger -// HTTP 400 (zhipuai code 1214, isRetryable: false). Treating ignored parts as -// empty here drops those messages before they can do damage. -export const dropEmptyMessages = (messages: WithParts[]): number => { - let removed = 0 - for (let i = messages.length - 1; i >= 0; i--) { - const parts = Array.isArray(messages[i].parts) ? messages[i].parts : [] - const isEmpty = parts.every( - (part) => - part.type === "text" && - ((typeof part.text !== "string" || part.text.trim().length === 0) || - (part as { ignored?: boolean }).ignored === true), - ) - if (isEmpty) { - messages.splice(i, 1) - removed++ - } - } - return removed -} diff --git a/lib/prompts/extensions/nudge.ts b/lib/prompts/extensions/nudge.ts deleted file mode 100644 index 5e124857..00000000 --- a/lib/prompts/extensions/nudge.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { SessionState, CompressionBlock } from "../../state" -import { formatAge as formatBlockAge } from "../../ui/utils" - -export interface BlockGuidanceContext { - currentTokens?: number - modelContextLimit?: number - includeHint?: boolean - /** - * Raw message IDs currently visible in the model's context window. - * When provided, the directive nudge only suggests ranges whose anchor - * messages are still visible, preventing stale-ID and backwards-range bugs. - */ - visibleMessageIds?: Set -} - -export function buildCompressedBlockGuidance( - state: SessionState, - context?: BlockGuidanceContext, -): string { - const activeBlockIds = Array.from(state.prune.messages.activeBlockIds) - .filter((id) => Number.isInteger(id) && id > 0) - .sort((a, b) => a - b) - - const blockCount = activeBlockIds.length - - const blocksForStats = activeBlockIds - .map((id) => state.prune.messages.blocksById.get(id)) - .filter((b): b is CompressionBlock => b !== undefined && b.active) - const totalSummaryTokens = blocksForStats.reduce((s, b) => s + (b.summaryTokens ?? 0), 0) - const totalSummaryDisplay = - totalSummaryTokens >= 1000 - ? `${(totalSummaryTokens / 1000).toFixed(1)}K` - : String(totalSummaryTokens) - const lastBlock = blocksForStats.length > 0 - ? blocksForStats.reduce((latest, b) => (b.createdAt > latest.createdAt ? b : latest)) - : null - const ageStr = lastBlock ? formatBlockAge(lastBlock.createdAt) : "never" - - const lines = [ - `- Compressed blocks: ${blockCount} (${totalSummaryDisplay} summary, last ${ageStr}). Use acp_status for details.`, - ] - - if (blockCount > 50) { - const oldBlockIds = activeBlockIds.slice(0, Math.max(0, blockCount - 20)) - const allOldBlocks = oldBlockIds - .map((id) => state.prune.messages.blocksById.get(id)) - .filter((b): b is CompressionBlock => b !== undefined) - - // [Plan B] Filter to blocks whose anchor message is still visible, then - // build suggestion ranges from anchor refs (mNNNNN) instead of stored - // block startId/endId. This avoids suggesting IDs that are no longer - // visible and prevents backwards ranges (end < start). - const visibleMessageIds = context?.visibleMessageIds - const visibleOldBlocks = - visibleMessageIds === undefined - ? allOldBlocks - : allOldBlocks.filter((b) => b.anchorMessageId && visibleMessageIds.has(b.anchorMessageId)) - - if (visibleOldBlocks.length > 5) { - const blocksWithRef = visibleOldBlocks - .map((block) => { - const ref = state.messageIds.byRawId.get(block.anchorMessageId) - return ref ? { block, ref } : null - }) - .filter((x): x is { block: CompressionBlock; ref: string } => x !== null) - .sort((a, b) => a.ref.localeCompare(b.ref)) - - const totalTokens = blocksWithRef.reduce((s, x) => s + (x.block.summaryTokens ?? 0), 0) - const totalK = Math.max(1, Math.round(totalTokens / 1000)) - - const targets: string[] = [] - const chunkSize = Math.ceil(blocksWithRef.length / 3) - for (let i = 0; i < 3 && i * chunkSize < blocksWithRef.length; i++) { - const chunk = blocksWithRef.slice(i * chunkSize, (i + 1) * chunkSize) - if (chunk.length < 2) continue - // Sorted by ref above guarantees startRef <= endRef. - const startRef = chunk[0].ref - const endRef = chunk[chunk.length - 1].ref - const chunkTokens = chunk.reduce((s, x) => s + (x.block.summaryTokens ?? 0), 0) - const chunkK = Math.max(1, Math.round(chunkTokens / 1000)) - targets.push(` • compress ${startRef}→${endRef}: ${chunk.length} blocks (~${chunkK}K tokens)`) - } - - if (targets.length > 0) { - lines.push(`- 🔀 ${blocksWithRef.length} old blocks using ~${totalK}K tokens. Consolidate into ${targets.length}:`) - lines.push(...targets) - lines.push(` System auto-detects blocks in range — no need to manually list (bN) placeholders. Just write your summary normally.`) - } - } - } - - return lines.join("\n") -} - -export function renderMessagePriorityGuidance(priorityLabel: string, refs: string[]): string { - const refList = refs.length > 0 ? refs.join(", ") : "none" - - return [ - "Message priority context:", - "- Higher-priority older messages consume more context and should be compressed right away if it is safe to do so.", - `- ${priorityLabel}-priority message IDs before this point: ${refList}`, - ].join("\n") -} - -export function appendGuidanceToDcpTag(nudgeText: string, guidance: string): string { - if (!guidance.trim()) { - return nudgeText - } - - const closeTag = "" - const closeTagIndex = nudgeText.lastIndexOf(closeTag) - - if (closeTagIndex === -1) { - return nudgeText - } - - const beforeClose = nudgeText.slice(0, closeTagIndex).trimEnd() - const afterClose = nudgeText.slice(closeTagIndex) - return `${beforeClose}\n\n${guidance}\n${afterClose}` -} diff --git a/lib/prompts/extensions/tool.ts b/lib/prompts/extensions/tool.ts deleted file mode 100644 index f7bf6950..00000000 --- a/lib/prompts/extensions/tool.ts +++ /dev/null @@ -1,42 +0,0 @@ -// These format schemas are kept separate from the editable compress prompts -// so they cannot be modified via custom prompt overrides. The schemas must -// match the tool's input validation and are not safe to change independently. - -export const RANGE_FORMAT_EXTENSION = ` - -THE FORMAT OF COMPRESS - -\`\`\` -{ - topic?: string, // OPTIONAL fallback topic for entries without their own. - // Omit when every content entry specifies its own topic. - content: [ // One or more ranges to compress - { - topic?: string, // OPTIONAL per-entry topic for this range. - // Falls back to top-level topic. - // Give each entry its own topic when compressing - // unrelated ranges in one call. - startId: string, // Boundary ID at range start: mNNNNN or bN - endId: string, // Boundary ID at range end: mNNNNN or bN - summary: string // Complete technical summary replacing all content in range - } - ] -} -\`\`\` -Each entry needs a topic — either its own or the top-level fallback.` - -export const MESSAGE_FORMAT_EXTENSION = ` -THE FORMAT OF COMPRESS - -\`\`\` -{ - topic: string, // Short label (3-5 words) for the overall batch - content: [ // One or more messages to compress independently - { - messageId: string, // Raw message ID only: mNNNNN (ignore metadata attributes like priority) - topic: string, // Short label (3-5 words) for this one message summary - summary: string // Complete technical summary replacing that one message - } - ] -} -\`\`\`` diff --git a/lib/prompts/index.ts b/lib/prompts/index.ts deleted file mode 100644 index 9ae74302..00000000 --- a/lib/prompts/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { RuntimePrompts } from "./store" -export type { PromptStore, RuntimePrompts } from "./store" - -export function renderSystemPrompt( - prompts: RuntimePrompts, - protectedToolsExtension?: string, - subagent?: boolean, -): string { - const extensions: string[] = [] - - if (protectedToolsExtension) { - extensions.push(protectedToolsExtension.trim()) - } - - if (subagent) { - extensions.push(prompts.subagentExtension.trim()) - } - - // decompress extension is always included when compress is not denied - // (the caller guards on permission === "deny" before reaching renderSystemPrompt) - extensions.push(prompts.decompressExtension.trim()) - - return [prompts.system.trim(), ...extensions] - .filter(Boolean) - .join("\n\n") - .replace(/\n([ \t]*\n)+/g, "\n\n") - .trim() -} diff --git a/lib/ui/notification.ts b/lib/ui/notification.ts deleted file mode 100644 index b1651c89..00000000 --- a/lib/ui/notification.ts +++ /dev/null @@ -1,337 +0,0 @@ -import type { Logger } from "../logger" -import type { SessionState } from "../state" -import { - formatProgressBar, - formatTokenCount, -} from "./utils" -import { PluginConfig } from "../config" - -interface CompressionNotificationEntry { - blockId: number - runId: number - summary: string - summaryTokens: number -} - -const TOAST_BODY_MAX_LINES = 12 -const TOAST_SUMMARY_MAX_CHARS = 600 -const NOTIFICATION_SUMMARY_MAX_CHARS = 1500 - -function formatEntryRanges( - entries: CompressionNotificationEntry[], - state: SessionState, -): string | null { - const parts: string[] = [] - for (const entry of entries) { - const block = state.prune.messages.blocksById.get(entry.blockId) - if (!block) continue - const count = block.effectiveMessageIds?.length || 0 - if (count === 0) continue - parts.push(`b${entry.blockId}: ${count} msg${count !== 1 ? "s" : ""}`) - } - return parts.length > 0 ? parts.join(", ") : null -} - -function truncateToastBody(body: string, maxLines: number = TOAST_BODY_MAX_LINES): string { - const lines = body.split("\n") - if (lines.length <= maxLines) { - return body - } - const kept = lines.slice(0, maxLines - 1) - const remaining = lines.length - maxLines + 1 - return kept.join("\n") + `\n... and ${remaining} more` -} - -function truncateToastSummary(summary: string, maxChars: number = TOAST_SUMMARY_MAX_CHARS): string { - if (summary.length <= maxChars) { - return summary - } - return summary.slice(0, maxChars - 3) + "..." -} - -function buildCompressionSummary( - entries: CompressionNotificationEntry[], - state: SessionState, -): string { - if (entries.length === 1) { - return entries[0]?.summary ?? "" - } - - const perEntryMax = Math.floor(NOTIFICATION_SUMMARY_MAX_CHARS / entries.length) - let result = "" - let shown = 0 - for (let i = 0; i < entries.length; i++) { - const entry = entries[i] - const topic = - state.prune.messages.blocksById.get(entry.blockId)?.topic ?? "(unknown topic)" - const truncated = entry.summary.length > perEntryMax - ? entry.summary.slice(0, perEntryMax - 3) + "..." - : entry.summary - const section = `### ${topic}\n${truncated}` - if (result.length + section.length + 2 > NOTIFICATION_SUMMARY_MAX_CHARS) { - const remaining = entries.length - shown - if (remaining > 0) { - result += (result ? "\n\n" : "") + `... and ${remaining} more` - } - break - } - result += (result ? "\n\n" : "") + section - shown++ - } - return result -} - -function getCompressionLabel(entries: CompressionNotificationEntry[]): string { - const runId = entries[0]?.runId - const blockIds = entries.map((e) => `b${e.blockId}`) - if (runId === undefined) { - return "Compression" - } - - return `Compression #${runId} → ${blockIds.join(", ")}` -} - -function formatCompressionMetrics(removedTokens: number, summaryTokens: number): string { - const metrics = [`-${formatTokenCount(removedTokens, true)} removed`] - if (summaryTokens > 0) { - metrics.push(`+${formatTokenCount(summaryTokens, true)} summary`) - } - return metrics.join(", ") -} - -function formatContextTransition(tokensBefore: number, tokensAfter: number): string { - const beforeStr = formatTokenCount(tokensBefore, true) - const afterStr = formatTokenCount(tokensAfter, true) - return `Context ${beforeStr} → ${afterStr}` -} - -export async function sendCompressNotification( - client: any, - logger: Logger, - config: PluginConfig, - state: SessionState, - sessionId: string, - entries: CompressionNotificationEntry[], - batchTopic: string | undefined, - sessionMessageIds: string[], - params: any, - contextTokensBefore: number, -): Promise { - if (entries.length === 0) { - return false - } - - const logBlockIds = entries.map((e) => e.blockId) - const logTopics = entries - .map((e) => state.prune.messages.blocksById.get(e.blockId)?.topic ?? "?") - const logCompressedTokens = entries.reduce((sum, e) => { - const block = state.prune.messages.blocksById.get(e.blockId) - return sum + (block?.effectiveCompressedTokens ?? block?.compressedTokens ?? 0) - }, 0) - const logSummaryTokens = entries.reduce((sum, e) => sum + e.summaryTokens, 0) - logger.info("Compression completed", { - sessionId, - blockIds: logBlockIds, - topics: logTopics, - compressedTokens: logCompressedTokens, - summaryTokens: logSummaryTokens, - contextTokensBefore, - }) - - if (config.pruneNotification === "off") { - return false - } - - let message: string - const compressionLabel = getCompressionLabel(entries) - const summary = buildCompressionSummary(entries, state) - const summaryTokens = entries.reduce((total, entry) => total + entry.summaryTokens, 0) - const summaryTokensStr = formatTokenCount(summaryTokens) - const compressedTokens = entries.reduce((total, entry) => { - const compressionBlock = state.prune.messages.blocksById.get(entry.blockId) - if (!compressionBlock) { - logger.error("Compression block missing for notification", { - compressionId: entry.blockId, - sessionId, - }) - return total - } - - return total + compressionBlock.compressedTokens - }, 0) - - const newlyCompressedMessageIds: string[] = [] - const newlyCompressedToolIds: string[] = [] - const seenMessageIds = new Set() - const seenToolIds = new Set() - - for (const entry of entries) { - const compressionBlock = state.prune.messages.blocksById.get(entry.blockId) - if (!compressionBlock) { - continue - } - - for (const messageId of compressionBlock.directMessageIds) { - if (seenMessageIds.has(messageId)) { - continue - } - seenMessageIds.add(messageId) - newlyCompressedMessageIds.push(messageId) - } - - for (const toolId of compressionBlock.directToolIds) { - if (seenToolIds.has(toolId)) { - continue - } - seenToolIds.add(toolId) - newlyCompressedToolIds.push(toolId) - } - } - - const entryBlockTopics = entries - .map((e) => state.prune.messages.blocksById.get(e.blockId)?.topic) - .filter((t): t is string => typeof t === "string" && t.length > 0) - - const topic = - batchTopic ?? - (entries.length === 1 - ? (state.prune.messages.blocksById.get(entries[0]?.blockId ?? -1)?.topic ?? - "(unknown topic)") - : entryBlockTopics.length > 0 - ? entryBlockTopics.join(" · ") - : "(unknown topic)") - - const contextTokensAfter = Math.max( - 0, - contextTokensBefore - compressedTokens + summaryTokens, - ) - const notificationHeader = `▣ ACP | ${formatContextTransition( - contextTokensBefore, - contextTokensAfter, - )}` - - let displaySummary: string = summary - - if (config.pruneNotification === "minimal") { - message = `${notificationHeader} — ${compressionLabel}` - } else { - message = notificationHeader - - const activePrunedMessages = new Map() - for (const [messageId, entry] of state.prune.messages.byMessageId) { - if (entry.activeBlockIds.length > 0) { - activePrunedMessages.set(messageId, entry.tokenCount) - } - } - const progressBar = formatProgressBar( - sessionMessageIds, - activePrunedMessages, - newlyCompressedMessageIds, - 50, - ) - message += `\n\n${progressBar}` - message += `\n▣ ${compressionLabel} ${formatCompressionMetrics(compressedTokens, summaryTokens)}` - const rangeStr = formatEntryRanges(entries, state) - if (rangeStr) { - message += `\n→ Range: ${rangeStr}` - } - message += `\n→ Topic: ${topic}` - message += `\n→ Items: ${newlyCompressedMessageIds.length} messages` - if (newlyCompressedToolIds.length > 0) { - message += ` and ${newlyCompressedToolIds.length} tools compressed` - } else { - message += ` compressed` - } - if (config.compress.showCompression) { - if (config.pruneNotification === "detailed") { - displaySummary = summary - } else { - displaySummary = - summary.length > NOTIFICATION_SUMMARY_MAX_CHARS - ? truncateToastSummary(summary, NOTIFICATION_SUMMARY_MAX_CHARS) - : summary - } - message += `\n→ Compression (~${summaryTokensStr}): ${displaySummary}` - } - } - - // [FIX #20] Always toast. The prior `chat` branch called sendIgnoredMessage, - // whose `ignored: true` text part opencode strips before the LLM call — - // leaving an empty user message that triggers provider 400 (zhipuai code - // 1214, isRetryable: false) and freezes the session. Toast bypasses the - // message stream entirely. - // - // [DEBUG] When config.debug is on, ALSO inject into the chat session via - // sendIgnoredMessage so the notification persists in the transcript (user- - // visible, model-invisible). The dropEmptyMessages backstop in the message - // transform pipeline strips the ignored-only message before the next LLM - // call, preventing the FIX #20 400 error. Toast is still shown alongside - // for immediate popup feedback. - if (config.pruneNotificationType === "chat") { - logger.warn( - "compress.pruneNotificationType 'chat' is no longer supported (it injects an empty user message that causes provider 400 errors); falling back to toast. Set pruneNotificationType to 'toast' (or pruneNotification to 'off') to silence this warning.", - { sessionId }, - ) - } - - let toastMessage = message - toastMessage = - config.pruneNotification === "minimal" ? toastMessage : truncateToastBody(toastMessage) - - if (config.debug) { - const chatMessage = - config.pruneNotification === "minimal" ? message : truncateToastBody(message) - await sendIgnoredMessage(client, sessionId, chatMessage, params, logger) - } - - await client.tui.showToast({ - body: { - title: "ACP: Compress Notification", - message: toastMessage, - variant: "info", - duration: 5000, - }, - }) - return true -} - -export async function sendIgnoredMessage( - client: any, - sessionID: string, - text: string, - params: any, - logger: Logger, -): Promise { - const agent = params.agent || undefined - const variant = params.variant || undefined - const model = - params.providerId && params.modelId - ? { - providerID: params.providerId, - modelID: params.modelId, - } - : undefined - - try { - await client.session.prompt({ - path: { - id: sessionID, - }, - body: { - noReply: true, - agent: agent, - model: model, - variant: variant, - parts: [ - { - type: "text", - text: text, - ignored: true, - }, - ], - }, - }) - } catch (error: any) { - logger.error("Failed to send notification", { error: error.message }) - } -} diff --git a/lib/ui/utils.ts b/lib/ui/utils.ts deleted file mode 100644 index e0d89985..00000000 --- a/lib/ui/utils.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { SessionState, WithParts } from "../state" -import { countTokens } from "../token-utils" -import { isIgnoredUserMessage } from "../messages/query" - -export function formatAge(createdAt: number): string { - const elapsed = Date.now() - createdAt - if (elapsed < 60_000) return "just now" - if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago` - if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago` - return `${Math.floor(elapsed / 86_400_000)}d ago` -} - -export function formatTokenCount(tokens: number, compact?: boolean): string { - const suffix = compact ? "" : " tokens" - if (tokens >= 1000) { - return `${(tokens / 1000).toFixed(1)}K`.replace(".0K", "K") + suffix - } - return tokens.toString() + suffix -} - -export function formatProgressBar( - messageIds: string[], - prunedMessages: Map, - recentMessageIds: string[], - width: number = 50, -): string { - const ACTIVE = "█" - const PRUNED = "░" - const RECENT = "⣿" - const recentSet = new Set(recentMessageIds) - - const total = messageIds.length - if (total === 0) return `│${PRUNED.repeat(width)}│` - - const bar = new Array(width).fill(ACTIVE) - - for (let m = 0; m < total; m++) { - const msgId = messageIds[m] - const start = Math.floor((m / total) * width) - const end = Math.floor(((m + 1) / total) * width) - - if (recentSet.has(msgId)) { - for (let i = start; i < end; i++) { - bar[i] = RECENT - } - } else if (prunedMessages.has(msgId)) { - for (let i = start; i < end; i++) { - bar[i] = PRUNED - } - } - } - - return `│${bar.join("")}│` -} - -export function cacheSystemPromptTokens(state: SessionState, messages: WithParts[]): void { - let firstInputTokens = 0 - for (const msg of messages) { - if (msg.info.role !== "assistant") { - continue - } - const info = msg.info as any - const input = info?.tokens?.input || 0 - const cacheRead = info?.tokens?.cache?.read || 0 - const cacheWrite = info?.tokens?.cache?.write || 0 - if (input > 0 || cacheRead > 0 || cacheWrite > 0) { - firstInputTokens = input + cacheRead + cacheWrite - break - } - } - - if (firstInputTokens <= 0) { - state.systemPromptTokens = undefined - return - } - - let firstUserText = "" - for (const msg of messages) { - if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) { - continue - } - const parts = Array.isArray(msg.parts) ? msg.parts : [] - for (const part of parts) { - if (part.type === "text" && !(part as any).ignored) { - firstUserText += part.text - } - } - break - } - - const estimatedSystemTokens = Math.max(0, firstInputTokens - countTokens(firstUserText)) - state.systemPromptTokens = estimatedSystemTokens > 0 ? estimatedSystemTokens : undefined -} diff --git a/tests/acp-status-consumed-fix.test.ts b/tests/acp-status-consumed-fix.test.ts deleted file mode 100644 index a1bdd499..00000000 --- a/tests/acp-status-consumed-fix.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it } from "node:test" -import assert from "node:assert/strict" -import { createSessionState } from "../lib/state" -import type { WithParts, SessionState } from "../lib/state" -import { assignMessageRefs } from "../lib/message-ids" -import { buildStatusReport } from "../lib/compress/status" -import { hideConsumedCompressCalls } from "../lib/compress/hide-consumed" - -const SID = "ses-consumed-status" - -function makeMsg( - id: string, - role: "user" | "assistant", - text: string, - toolParts: unknown[] = [], -): WithParts { - const parts: unknown[] = [] - if (text) parts.push({ type: "text", text }) - for (const tp of toolParts) parts.push(tp) - return { - info: { id, role, sessionID: SID, agent: "a", time: { created: 1 } } as never, - parts, - } as WithParts -} - -function compressToolPart(callID: string, summary: string, input?: unknown): unknown { - return { - type: "tool", - callID, - tool: "compress", - state: { status: "completed", input: input ?? { content: [{ summary }] } }, - } -} - -function setupRefs(state: SessionState, messages: WithParts[]): void { - assignMessageRefs(state, messages) -} - -describe("acp_status consumed-compress fix", () => { - it("consumed compress calls not shown as PROTECTED after hideConsumedCompressCalls", () => { - const state = createSessionState() - const messages = [ - makeMsg("msg-1", "user", "hello"), - makeMsg("msg-2", "assistant", "response", [compressToolPart("c-old", "old summary text")]), - makeMsg("msg-3", "assistant", "normal text"), - makeMsg("msg-4", "assistant", "active compress", [compressToolPart("c-active", "active summary")]), - ] - setupRefs(state, messages) - - const blocksById = new Map() - blocksById.set(1, { - blockId: 1, - runId: 1, - active: false, - deactivatedByUser: false, - deactivatedByUserDeep: false, - deactivatedByBlockId: 2, - compressMessageId: "msg-2", - compressCallId: "c-old", - tier: 1, - }) - blocksById.set(2, { - blockId: 2, - runId: 2, - active: true, - deactivatedByUser: false, - deactivatedByUserDeep: false, - compressMessageId: "msg-4", - compressCallId: "c-active", - tier: 2, - }) - const activeBlockIds = new Set([2]) - state.prune = { - messages: { blocksById, activeBlockIds, byMessageId: new Map(), activeByAnchorMessageId: new Map() }, - } as never - - const beforeFix = buildStatusReport( - { state, config: { compress: { protectedTools: ["skill", "compress"] } } } as never, - messages, - { scope: "uncompressed" }, - ) - assert.ok( - beforeFix.includes("PROTECTED"), - "before fix: consumed compress should appear as PROTECTED", - ) - - hideConsumedCompressCalls(state, messages) - const afterFix = buildStatusReport( - { state, config: { compress: { protectedTools: ["skill", "compress"] } } } as never, - messages, - { scope: "uncompressed" }, - ) - assert.ok( - afterFix.includes("PROTECTED"), - "active compress should still be PROTECTED", - ) - assert.ok( - !afterFix.includes("old summary text"), - "after fix: consumed compress summary should not appear in status output", - ) - }) - - it("active compress calls still shown as PROTECTED", () => { - const state = createSessionState() - const messages = [ - makeMsg("msg-1", "user", "hello"), - makeMsg("msg-2", "assistant", "work"), - makeMsg("msg-3", "assistant", "active compress", [compressToolPart("c-active", "my active summary")]), - ] - setupRefs(state, messages) - - const blocksById = new Map() - blocksById.set(1, { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - deactivatedByUserDeep: false, - compressMessageId: "msg-3", - compressCallId: "c-active", - tier: 1, - }) - state.prune = { - messages: { blocksById, activeBlockIds: new Set([1]), byMessageId: new Map(), activeByAnchorMessageId: new Map() }, - } as never - - hideConsumedCompressCalls(state, messages) - const report = buildStatusReport( - { state, config: { compress: { protectedTools: ["skill", "compress"] } } } as never, - messages, - { scope: "uncompressed" }, - ) - assert.ok(report.includes("PROTECTED"), "active compress should still be PROTECTED") - }) -}) diff --git a/tests/acp-status.test.ts b/tests/acp-status.test.ts deleted file mode 100644 index 2fb2eec3..00000000 --- a/tests/acp-status.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { createAcpStatusTool } from "../lib/compress/status" -import type { ToolFactoryContext } from "../lib/compress/types" -import type { CompressionBlock, PrunedMessageEntry, SessionState } from "../lib/state/types" -import { singletonRegistry } from "./registry-stub" - -const SID = "session-acp-status-test" - -function makeBlock(overrides: Partial = {}): CompressionBlock { - return { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 100, - summaryTokens: 20, - durationMs: 0, - topic: "test topic", - batchTopic: "test topic", - startId: "m00001", - endId: "m00003", - anchorMessageId: "anchor-1", - compressMessageId: "comp-1", - compressCallId: undefined, - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: [], - effectiveToolIds: [], - createdAt: Date.now() - 60_000, - deactivatedAt: undefined, - deactivatedByBlockId: undefined, - summary: "a summary", - survivedCount: 0, - generation: "young", - ...overrides, - } -} - -function makeState(activeIds: number[], blocks: Map): SessionState { - return { - sessionId: SID, - isSubAgent: false, - compressPermission: "allow", - prune: { - messages: { - byMessageId: new Map(), - blocksById: blocks, - activeBlockIds: new Set(activeIds), - activeByAnchorMessageId: new Map(), - nextBlockId: 1, - nextRunId: 1, - markedForCleanup: new Set(), - }, - }, - nudges: { - contextLimitAnchors: new Set(), - turnNudgeAnchors: new Set(), - iterationNudgeAnchors: new Set(), - lastPerMessageNudgeTurn: 0, - lastPerMessageNudgeTokens: undefined, - }, - stats: { pruneTokenCounter: 0, totalPruneTokens: 0 }, - compressionTiming: {} as any, - toolParameters: new Map(), - toolIdList: [], - messageIds: { byRawId: new Map(), byRef: new Map(), nextRef: 1 }, - lastCompaction: 0, - currentTurn: 0, - modelContextLimit: undefined, - systemPromptTokens: undefined, - } -} - -function makeMockClient(messages: any[] = []): any { - return { - session: { - messages: async () => ({ data: messages }), - }, - } -} - -function makeToolContext( - activeIds: number[], - blocks: Map, - client?: any, -): ToolFactoryContext { - return { - client: client ?? {}, - registry: singletonRegistry(makeState(activeIds, blocks)), - logger: { enabled: false } as any, - config: {} as any, - prompts: { reload: () => {} } as any, - } -} - -function blocksMap(...blocks: CompressionBlock[]): Map { - const map = new Map() - for (const b of blocks) { - map.set(b.blockId, b) - } - return map -} - -async function runStatus( - activeIds: number[], - blocks: Map, - args: { scope?: string; view?: string; tool?: string; sort?: string; limit?: number } = {}, - client?: any, -): Promise { - const ctx = makeToolContext(activeIds, blocks, client) - const statusTool = createAcpStatusTool(ctx) - return statusTool.execute(args as any, { sessionID: SID } as any) -} - -test("acp_status: empty state returns no-blocks message", async () => { - const result = await runStatus([], new Map()) - assert.match(result, /No compressed blocks/) -}) - -test("acp_status: single block shows correct header with summary and original sizes", async () => { - const blocks = blocksMap(makeBlock({ blockId: 1, summaryTokens: 750, compressedTokens: 5000, topic: "My topic" })) - const result = await runStatus([1], blocks) - - assert.match(result, /COMPRESSED BLOCKS/) - assert.match(result, /b1/) - assert.match(result, /"My topic"/) -}) - -test("acp_status: plural header for multiple blocks", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, summaryTokens: 750, compressedTokens: 5000 }), - makeBlock({ blockId: 2, summaryTokens: 300, compressedTokens: 2000 }), - ) - const result = await runStatus([1, 2], blocks) - - assert.match(result, /2 active/) - assert.match(result, /1\.1K summary/) -}) - -test("acp_status: block with no topic shows (no topic)", async () => { - const blocks = blocksMap(makeBlock({ blockId: 1, topic: "", batchTopic: "" })) - const result = await runStatus([1], blocks) - - assert.match(result, /\(no topic\)/) -}) - -test("acp_status: overview shows compressed→summary size pair", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, summaryTokens: 500, compressedTokens: 800 }), - makeBlock({ blockId: 2, summaryTokens: 2000, compressedTokens: 15000 }), - ) - const result = await runStatus([1, 2], blocks) - - assert.match(result, /800→500/) - assert.match(result, /15\.0K→2\.0K/) -}) - -test("acp_status: overview shows message count for block coverage", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, effectiveMessageIds: ["a", "b", "c", "d", "e"] }), - ) - const result = await runStatus([1], blocks) - - assert.match(result, /5 msgs/) -}) - -test("acp_status: coverage shows single message count", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, effectiveMessageIds: ["a"] }), - ) - const result = await runStatus([1], blocks) - - assert.match(result, /1 msg\b/) -}) - -test("acp_status: overview includes drill-down hint", async () => { - const blocks = blocksMap(makeBlock({ blockId: 1 })) - const result = await runStatus([1], blocks) - - assert.match(result, /Tip:/) - assert.match(result, /scope/) -}) - -test("acp_status: scope=compressed shows detailed block info", async () => { - const blocks = blocksMap( - makeBlock({ - blockId: 1, - survivedCount: 3, - generation: "old", - effectiveMessageIds: ["a", "b", "c", "d"], - includedBlockIds: [2, 3], - consumedBlockIds: [2, 3], - }), - ) - const result = await runStatus([1], blocks, { scope: "compressed" }) - - assert.match(result, /COMPRESSED/) - assert.match(result, /age=3/) - assert.match(result, /old/) - assert.match(result, /eff=4/) - assert.match(result, /nested=\[b2,b3\]/) -}) - -test("acp_status: scope=compressed sort=size orders largest first", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, compressedTokens: 500, topic: "small" }), - makeBlock({ blockId: 2, compressedTokens: 50_000, topic: "large" }), - ) - const result = await runStatus([1, 2], blocks, { scope: "compressed", sort: "size" }) - - const smallPos = result.indexOf("small") - const largePos = result.indexOf("large") - assert.ok(largePos < smallPos, "largest block should appear first") - assert.match(result, /Sorted by size/) -}) - -test("acp_status: scope=compressed sort=age orders highest survivedCount first", async () => { - const blocks = blocksMap( - makeBlock({ blockId: 1, survivedCount: 1, topic: "alpha-topic" }), - makeBlock({ blockId: 2, survivedCount: 14, topic: "beta-topic" }), - ) - const result = await runStatus([1, 2], blocks, { scope: "compressed", sort: "age" }) - - const alphaPos = result.indexOf("alpha-topic") - const betaPos = result.indexOf("beta-topic") - assert.ok(betaPos < alphaPos, "highest survivedCount block should appear first") -}) - -test("acp_status: scope=compressed sort=time orders oldest createdAt first", async () => { - const now = Date.now() - const blocks = blocksMap( - makeBlock({ blockId: 1, createdAt: now - 10_000, topic: "older" }), - makeBlock({ blockId: 2, createdAt: now - 1_000, topic: "newer" }), - ) - const result = await runStatus([1, 2], blocks, { scope: "compressed", sort: "time" }) - - const olderPos = result.indexOf("older") - const newerPos = result.indexOf("newer") - assert.ok(olderPos < newerPos, "oldest block should appear first") -}) - -test("acp_status: scope=compressed limit caps shown blocks", async () => { - const blocks: CompressionBlock[] = [] - for (let i = 1; i <= 5; i++) { - blocks.push(makeBlock({ blockId: i, topic: `block-${i}` })) - } - const map = blocksMap(...blocks) - const result = await runStatus([1, 2, 3, 4, 5], map, { scope: "compressed", limit: 2 }) - - assert.match(result, /2 of 5 shown/) -}) - -test("acp_status: scope=compressed includes decompress hint", async () => { - const blocks = blocksMap(makeBlock({ blockId: 1 })) - const result = await runStatus([1], blocks, { scope: "compressed" }) - - assert.match(result, /Use decompress/) - assert.match(result, /search_context/) -}) - -test("acp_status: scope=uncompressed defaults to ranges view", async () => { - const mockMsgs = [ - { info: { id: "raw-1", role: "assistant" }, parts: [{ type: "text", text: "hello world" }] }, - ] - const mockClient = makeMockClient(mockMsgs) - const state = makeState([], new Map()) - state.messageIds.byRawId.set("raw-1", "m00001") - const ctx: ToolFactoryContext = { - client: mockClient, - registry: singletonRegistry(state), - logger: { enabled: false } as any, - config: {} as any, - prompts: { reload: () => {} } as any, - } - const statusTool = createAcpStatusTool(ctx) - const result = await statusTool.execute({ scope: "uncompressed" } as any, { sessionID: SID } as any) - - assert.match(result, /UNCOMPRESSED/) - assert.match(result, /ranges/) -}) - -test("acp_status: scope=uncompressed view=messages shows per-message listing", async () => { - const mockMsgs = [ - { info: { id: "raw-1", role: "assistant" }, parts: [{ type: "text", text: "hello world" }] }, - ] - const mockClient = makeMockClient(mockMsgs) - const state = makeState([], new Map()) - state.messageIds.byRawId.set("raw-1", "m00001") - const ctx: ToolFactoryContext = { - client: mockClient, - registry: singletonRegistry(state), - logger: { enabled: false } as any, - config: {} as any, - prompts: { reload: () => {} } as any, - } - const statusTool = createAcpStatusTool(ctx) - const result = await statusTool.execute({ scope: "uncompressed", view: "messages" } as any, { sessionID: SID } as any) - - assert.match(result, /UNCOMPRESSED/) - assert.match(result, /Sorted by/) -}) - -test("acp_status: scope=uncompressed view=messages with tool filter shows filter in header", async () => { - const mockMsgs = [ - { - info: { id: "raw-1", role: "assistant" }, - parts: [{ type: "tool", tool: "bash", state: { input: { command: "ls" } } }], - }, - ] - const mockClient = makeMockClient(mockMsgs) - const state = makeState([], new Map()) - state.messageIds.byRawId.set("raw-1", "m00001") - const ctx: ToolFactoryContext = { - client: mockClient, - registry: singletonRegistry(state), - logger: { enabled: false } as any, - config: {} as any, - prompts: { reload: () => {} } as any, - } - const statusTool = createAcpStatusTool(ctx) - const result = await statusTool.execute({ scope: "uncompressed", view: "messages", tool: "bash" } as any, { sessionID: SID } as any) - - assert.match(result, /UNCOMPRESSED — bash:/) -}) - -test("acp_status: invalid scope falls back to overview", async () => { - const blocks = blocksMap(makeBlock({ blockId: 1 })) - const result = await runStatus([1], blocks, { scope: "bogus" as any }) - - assert.match(result, /COMPRESSED BLOCKS/) - assert.match(result, /Tip:/) -}) - -test("acp_status: invalid sort falls back to size", async () => { - const blocks = blocksMap(makeBlock({ blockId: 1 })) - const result = await runStatus([1], blocks, { scope: "compressed", sort: "bogus" as any }) - - assert.match(result, /Sorted by size/) -}) diff --git a/tests/batch-compress.test.ts b/tests/batch-compress.test.ts deleted file mode 100644 index 4609f332..00000000 --- a/tests/batch-compress.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { join } from "node:path" -import { tmpdir } from "node:os" -import { mkdirSync } from "node:fs" -import { createCompressRangeTool } from "../lib/compress/range" -import { validateArgs } from "../lib/compress/range-utils" -import { createSessionState, type WithParts } from "../lib/state" -import type { PluginConfig } from "../lib/config" -import { Logger } from "../lib/logger" -import { singletonRegistry } from "./registry-stub" -import type { CompressRangeToolArgs } from "../lib/compress/types" - -const testDataHome = join(tmpdir(), `opencode-dcp-tests-${process.pid}`) -const testConfigHome = join(tmpdir(), `opencode-dcp-config-tests-${process.pid}`) - -process.env.XDG_DATA_HOME = testDataHome -process.env.XDG_CONFIG_HOME = testConfigHome - -mkdirSync(testDataHome, { recursive: true }) -mkdirSync(testConfigHome, { recursive: true }) - -function buildConfig(): PluginConfig { - return { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { - enabled: true, - protectedTools: [], - }, - experimental: { - allowSubAgents: true, - customPrompts: false, - }, - protectedFilePatterns: [], - compress: { - permission: "allow", - showCompression: false, - maxContextLimit: 150000, - minContextLimit: 50000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: [], - protectTags: false, - protectUserMessages: false, - lastSegmentSoftBlock: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, - }, - } -} - -function textPart(messageID: string, sessionID: string, id: string, text: string) { - return { - id, - messageID, - sessionID, - type: "text" as const, - text, - } -} - -function buildBatchMessages(sessionID: string): WithParts[] { - const messages: WithParts[] = [] - for (let i = 1; i <= 6; i++) { - const isUser = i % 2 === 1 - messages.push({ - info: { - id: `msg-${i}`, - role: isUser ? "user" : "assistant", - sessionID, - ...(isUser - ? { model: { providerID: "anthropic", modelID: "claude-test" } } - : {}), - time: { created: i }, - } as WithParts["info"], - parts: [textPart(`msg-${i}`, sessionID, `part-${i}`, `Message ${i} content`)], - }) - } - return messages -} - -function buildToolCtx(sessionID: string, state: ReturnType) { - return { - client: { - session: { - messages: async () => ({ data: buildBatchMessages(sessionID) }), - get: async () => ({ data: { parentID: null } }), - }, - }, - registry: singletonRegistry(state), - logger: new Logger(false), - config: buildConfig(), - prompts: { - reload() {}, - getRuntimePrompts() { - return { compressRange: "", compressMessage: "" } - }, - }, - } as any -} - - -test("validateArgs: per-entry topics, no top-level topic — valid", () => { - const args: CompressRangeToolArgs = { - content: [ - { topic: "Exploration", startId: "m00001", endId: "m00003", summary: "..." }, - { topic: "Bug Hunt", startId: "m00004", endId: "m00006", summary: "..." }, - ], - } - assert.doesNotThrow(() => validateArgs(args)) -}) - -test("validateArgs: top-level topic only, no per-entry topics — valid (backward compat)", () => { - const args: CompressRangeToolArgs = { - topic: "Shared topic", - content: [ - { startId: "m00001", endId: "m00003", summary: "..." }, - { startId: "m00004", endId: "m00006", summary: "..." }, - ], - } - assert.doesNotThrow(() => validateArgs(args)) -}) - -test("validateArgs: mixed — some entries have topic, others use fallback", () => { - const args: CompressRangeToolArgs = { - topic: "Fallback topic", - content: [ - { topic: "Specific", startId: "m00001", endId: "m00003", summary: "..." }, - { startId: "m00004", endId: "m00006", summary: "..." }, - ], - } - assert.doesNotThrow(() => validateArgs(args)) -}) - -test("validateArgs: no topic at all — entry without topic and no fallback", () => { - const args = { - content: [ - { startId: "m00001", endId: "m00003", summary: "..." }, - ], - } - assert.throws( - () => validateArgs(args as CompressRangeToolArgs), - /content\[0\] needs a topic/, - ) -}) - -test("validateArgs: one entry without topic in a no-topical batch", () => { - const args = { - content: [ - { topic: "First", startId: "m00001", endId: "m00003", summary: "..." }, - { startId: "m00004", endId: "m00006", summary: "..." }, - ], - } - assert.throws( - () => validateArgs(args as CompressRangeToolArgs), - /content\[1\] needs a topic/, - ) -}) - -test("validateArgs: empty top-level topic with entry lacking topic", () => { - const args = { - topic: " ", - content: [{ startId: "m00001", endId: "m00003", summary: "..." }], - } - assert.throws( - () => validateArgs(args as CompressRangeToolArgs), - /content\[0\] needs a topic/, - ) -}) - - -test("batch compress: each entry creates a block with its own topic", async () => { - const sessionID = `ses_batch_topics_${Date.now()}` - const state = createSessionState() - const tool = createCompressRangeTool(buildToolCtx(sessionID, state)) - - await tool.execute( - { - content: [ - { - topic: "Exploration", - startId: "m00001", - endId: "m00002", - summary: "Explored the codebase structure and module dependencies.", - }, - { - topic: "Bug Hunt", - startId: "m00003", - endId: "m00004", - summary: "Found the root cause of the compression bug.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress", - } as any, - ) - - const blocks = [...state.prune.messages.blocksById.values()] - assert.equal(blocks.length, 2, "should create 2 blocks") - assert.equal(blocks[0]!.topic, "Exploration") - assert.equal(blocks[1]!.topic, "Bug Hunt") -}) - -test("batch compress: backward compat — entries without topic use top-level", async () => { - const sessionID = `ses_batch_fallback_${Date.now()}` - const state = createSessionState() - const tool = createCompressRangeTool(buildToolCtx(sessionID, state)) - - await tool.execute( - { - topic: "Shared topic", - content: [ - { - startId: "m00001", - endId: "m00002", - summary: "First range summary.", - }, - { - startId: "m00003", - endId: "m00004", - summary: "Second range summary.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress", - } as any, - ) - - const blocks = [...state.prune.messages.blocksById.values()] - assert.equal(blocks.length, 2, "should create 2 blocks") - assert.equal(blocks[0]!.topic, "Shared topic") - assert.equal(blocks[1]!.topic, "Shared topic") -}) - -test("batch compress: mixed — entry topic overrides top-level fallback", async () => { - const sessionID = `ses_batch_mixed_${Date.now()}` - const state = createSessionState() - const tool = createCompressRangeTool(buildToolCtx(sessionID, state)) - - await tool.execute( - { - topic: "Fallback", - content: [ - { - topic: "Override", - startId: "m00001", - endId: "m00002", - summary: "First range with explicit topic.", - }, - { - startId: "m00003", - endId: "m00004", - summary: "Second range using fallback.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress", - } as any, - ) - - const blocks = [...state.prune.messages.blocksById.values()] - assert.equal(blocks.length, 2, "should create 2 blocks") - assert.equal(blocks[0]!.topic, "Override", "entry topic should override top-level") - assert.equal(blocks[1]!.topic, "Fallback", "entry without topic should use fallback") -}) - -test("batch compress: no top-level topic, all entries have own — blocks get entry topics", async () => { - const sessionID = `ses_batch_notopical_${Date.now()}` - const state = createSessionState() - const tool = createCompressRangeTool(buildToolCtx(sessionID, state)) - - await tool.execute( - { - content: [ - { - topic: "Auth", - startId: "m00001", - endId: "m00002", - summary: "Auth implementation details.", - }, - { - topic: "Deploy", - startId: "m00003", - endId: "m00004", - summary: "Deployment configuration.", - }, - { - topic: "Test", - startId: "m00005", - endId: "m00006", - summary: "Test suite results.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress", - } as any, - ) - - const blocks = [...state.prune.messages.blocksById.values()] - assert.equal(blocks.length, 3, "should create 3 blocks") - const topics = blocks.map((b) => b.topic) - assert.deepEqual(topics, ["Auth", "Deploy", "Test"]) -}) diff --git a/tests/compress-range.test.ts b/tests/compress-range.test.ts deleted file mode 100644 index cdae61ba..00000000 --- a/tests/compress-range.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { join } from "node:path" -import { tmpdir } from "node:os" -import { mkdirSync } from "node:fs" -import { createCompressRangeTool } from "../lib/compress/range" -import { createSessionState, type WithParts } from "../lib/state" -import type { PluginConfig } from "../lib/config" -import { Logger } from "../lib/logger" -import { singletonRegistry } from "./registry-stub" - -const testDataHome = join(tmpdir(), `opencode-dcp-tests-${process.pid}`) -const testConfigHome = join(tmpdir(), `opencode-dcp-config-tests-${process.pid}`) - -process.env.XDG_DATA_HOME = testDataHome -process.env.XDG_CONFIG_HOME = testConfigHome - -mkdirSync(testDataHome, { recursive: true }) -mkdirSync(testConfigHome, { recursive: true }) - -function buildConfig(): PluginConfig { - return { - enabled: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { - enabled: true, - protectedTools: [], - }, - experimental: { - allowSubAgents: true, - customPrompts: false, - }, - protectedFilePatterns: [], - compress: { - permission: "allow", - showCompression: false, - maxContextLimit: 150000, - minContextLimit: 50000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: [], - protectTags: false, - protectUserMessages: false, - lastSegmentSoftBlock: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, - }, - } -} - -function textPart(messageID: string, sessionID: string, id: string, text: string) { - return { - id, - messageID, - sessionID, - type: "text" as const, - text, - } -} - -function buildMessages(sessionID: string): WithParts[] { - return [ - { - info: { - id: "msg-subagent-prompt", - role: "user", - sessionID, - agent: "codebase-analyzer", - model: { - providerID: "anthropic", - modelID: "claude-test", - }, - time: { created: 1 }, - } as WithParts["info"], - parts: [textPart("msg-subagent-prompt", sessionID, "part-1", "Investigate the issue")], - }, - { - info: { - id: "msg-assistant-1", - role: "assistant", - sessionID, - agent: "codebase-analyzer", - time: { created: 2 }, - } as WithParts["info"], - parts: [ - textPart("msg-assistant-1", sessionID, "part-2", "I found the relevant code path"), - ], - }, - { - info: { - id: "msg-user-2", - role: "user", - sessionID, - agent: "codebase-analyzer", - model: { - providerID: "anthropic", - modelID: "claude-test", - }, - time: { created: 3 }, - } as WithParts["info"], - parts: [ - textPart("msg-user-2", sessionID, "part-3", "Please compress the initial findings"), - ], - }, - ] -} - -test("compress range rebuilds subagent message refs after session state was reset", async () => { - const sessionID = `ses_subagent_compress_${Date.now()}` - const rawMessages = buildMessages(sessionID) - const state = createSessionState() - state.sessionId = "ses_other" - state.messageIds.byRawId.set("other-message", "m00001") - state.messageIds.byRef.set("m00001", "other-message") - state.messageIds.nextRef = 2 - - const logger = new Logger(false) - const tool = createCompressRangeTool({ - client: { - session: { - messages: async () => ({ data: rawMessages }), - get: async () => ({ data: { parentID: "ses_parent" } }), - }, - }, - registry: singletonRegistry(state), - logger, - config: buildConfig(), - prompts: { - reload() {}, - getRuntimePrompts() { - return { compressRange: "", compressMessage: "" } - }, - }, - } as any) - - const result = await tool.execute( - { - topic: "Subagent race fix", - content: [ - { - startId: "m00001", - endId: "m00002", - summary: "Captured the initial investigation and follow-up request.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress", - }, - ) - - // [Bug 30 fix] Result now includes IMPORTANT continuation instruction - assert.equal(result, "Compressed 2 messages into [Compressed conversation section].\nIMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.\n💡 Tip: Use search_context('keyword') to find compressed content when you need it later.") - assert.equal(state.sessionId, sessionID) - assert.equal(state.isSubAgent, true) - assert.equal(state.messageIds.byRef.get("m00001"), "msg-assistant-1") - assert.equal(state.messageIds.byRef.get("m00002"), "msg-user-2") - assert.equal(state.prune.messages.blocksById.size, 1) -}) - -test("compress range mode appends protected prompt info", async () => { - const sessionID = `ses_range_protect_tag_${Date.now()}` - const rawMessages: WithParts[] = [ - { - info: { - id: "msg-user-1", - role: "user", - sessionID, - agent: "assistant", - model: { - providerID: "anthropic", - modelID: "claude-test", - }, - time: { created: 1 }, - } as WithParts["info"], - parts: [ - textPart( - "msg-user-1", - sessionID, - "part-user-1", - "Investigate the release. Keep the npm publish token note.", - ), - ], - }, - { - info: { - id: "msg-assistant-1", - role: "assistant", - sessionID, - agent: "assistant", - time: { created: 2 }, - } as WithParts["info"], - parts: [textPart("msg-assistant-1", sessionID, "part-assistant-1", "I checked it")], - }, - ] - - const state = createSessionState() - const logger = new Logger(false) - const config = buildConfig() - config.compress.protectTags = true - const tool = createCompressRangeTool({ - client: { - session: { - messages: async () => ({ data: rawMessages }), - get: async () => ({ data: { parentID: null } }), - }, - }, - registry: singletonRegistry(state), - logger, - config, - prompts: { - reload() {}, - getRuntimePrompts() { - return { compressRange: "", compressMessage: "" } - }, - }, - } as any) - - await tool.execute( - { - topic: "Protected range", - content: [ - { - startId: "m00001", - endId: "m00002", - summary: "Captured release investigation.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress-range-protect-tag", - }, - ) - - const block = Array.from(state.prune.messages.blocksById.values())[0] - assert.match( - block?.summary || "", - /The following protected prompt information was included in this conversation verbatim:/, - ) - assert.match(block?.summary || "", /Keep the npm publish token note\./) -}) - -test("compress range mode batches multiple ranges into one notification", async () => { - const sessionID = `ses_range_compress_batch_${Date.now()}` - const rawMessages = buildMessages(sessionID) - const state = createSessionState() - const logger = new Logger(false) - const config = buildConfig() - config.pruneNotification = "detailed" - config.pruneNotificationType = "toast" - - const toastCalls: string[] = [] - const tool = createCompressRangeTool({ - client: { - session: { - messages: async () => ({ data: rawMessages }), - get: async () => ({ data: { parentID: "ses_parent" } }), - }, - tui: { - showToast: async ({ body }: { body: { message: string } }) => { - toastCalls.push(body.message) - }, - }, - }, - registry: singletonRegistry(state), - logger, - config, - prompts: { - reload() {}, - getRuntimePrompts() { - return { compressRange: "", compressMessage: "" } - }, - }, - } as any) - - const result = await tool.execute( - { - topic: "Batch stale notes", - content: [ - { - startId: "m00001", - endId: "m00001", - summary: "Captured the initial assistant investigation.", - }, - { - startId: "m00002", - endId: "m00002", - summary: "Captured the follow-up user request.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress-range-batch", - }, - ) - - // [Bug 30 fix] Result now includes IMPORTANT continuation instruction - assert.equal(result, "Compressed 2 messages into [Compressed conversation section].\nIMPORTANT: This was an automatic context compression. You MUST continue your previous task exactly where you left off. Do NOT ask the user what to do next.\n💡 Tip: Use search_context('keyword') to find compressed content when you need it later.") - assert.equal(state.prune.messages.blocksById.size, 2) - assert.equal(toastCalls.length, 1) - assert.match(toastCalls[0] || "", /▣ ACP \| Context [^|]+→[^|]+/) - assert.match(toastCalls[0] || "", /Compression #1/) - assert.match(toastCalls[0] || "", /▣ Compression #1 → b\d.*removed, \+.*summary/) - assert.match(toastCalls[0] || "", /Topic: Batch stale notes/) - assert.match(toastCalls[0] || "", /Items: 2 messages/) -}) - -test("compress range mode rejects overlapping batched ranges", async () => { - const sessionID = `ses_range_compress_overlap_${Date.now()}` - const rawMessages = buildMessages(sessionID) - const state = createSessionState() - const logger = new Logger(false) - const tool = createCompressRangeTool({ - client: { - session: { - messages: async () => ({ data: rawMessages }), - get: async () => ({ data: { parentID: "ses_parent" } }), - }, - }, - registry: singletonRegistry(state), - logger, - config: buildConfig(), - prompts: { - reload() {}, - getRuntimePrompts() { - return { compressRange: "", compressMessage: "" } - }, - }, - } as any) - - await assert.rejects( - tool.execute( - { - topic: "Overlapping ranges", - content: [ - { - startId: "m00001", - endId: "m00002", - summary: "Captured the initial investigation and follow-up request.", - }, - { - startId: "m00002", - endId: "m00002", - summary: "Captured the follow-up request again.", - }, - ], - }, - { - ask: async () => {}, - metadata: () => {}, - sessionID, - messageID: "msg-compress-range-overlap", - }, - ), - /Overlapping ranges cannot be compressed in the same batch/, - ) - - assert.equal(state.prune.messages.blocksById.size, 0) -}) diff --git a/tests/compress-rollback.test.ts b/tests/compress-rollback.test.ts deleted file mode 100644 index 4e989b3a..00000000 --- a/tests/compress-rollback.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { createSessionState } from "../lib/state" -import { snapshotCompressionState, restoreCompressionState } from "../lib/compress/pipeline" -import { applyCompressionState, allocateRunId, allocateBlockId } from "../lib/compress/state" -import type { SelectionResolution, CompressionStateInput } from "../lib/compress/types" -import type { GCConfig } from "../lib/config" - -const defaultGcConfig: GCConfig = { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", -} - -function makeSelection(messageIds: string[], toolIds: string[] = []): SelectionResolution { - const messageTokenById = new Map() - for (const id of messageIds) { - messageTokenById.set(id, 100) - } - return { - messageIds, - toolIds, - messageTokenById, - requiredBlockIds: [], - startReference: { kind: "message", rawIndex: 0 }, - endReference: { kind: "message", rawIndex: 0 }, - } -} - -function makeCompressionInput(runId: number, blockId: number): CompressionStateInput { - return { - topic: "test", - batchTopic: "test", - startId: "m001", - endId: "m005", - runId, - compressMessageId: "compress-msg-1", - compressCallId: undefined, - summaryTokens: 50, - } -} - -test("snapshotCompressionState captures prune.messages and stats", () => { - const state = createSessionState() - state.prune.messages.nextBlockId = 5 - state.prune.messages.nextRunId = 3 - state.stats.pruneTokenCounter = 42 - state.stats.totalPruneTokens = 500 - - const snapshot = snapshotCompressionState(state) - - state.prune.messages.nextBlockId = 99 - state.stats.totalPruneTokens = 999 - - assert.equal(snapshot.messages.nextBlockId, 5, "snapshot preserves original value") - assert.equal(snapshot.messages.nextRunId, 3) - assert.equal(snapshot.stats.pruneTokenCounter, 42) - assert.equal(snapshot.stats.totalPruneTokens, 500) -}) - -test("restoreCompressionState fully restores state after mutations", () => { - const state = createSessionState() - state.stats.totalPruneTokens = 100 - - const selection = makeSelection(["msg-1", "msg-2", "msg-3"]) - const snapshot = snapshotCompressionState(state) - - const runId = allocateRunId(state) - const blockId = allocateBlockId(state) - applyCompressionState( - state, - makeCompressionInput(runId, blockId), - selection, - "anchor-1", - blockId, - "[Compressed conversation section]\ntest summary\n\n", - [], - defaultGcConfig, - ) - - assert.ok(state.prune.messages.blocksById.has(blockId), "block created after apply") - assert.ok(state.prune.messages.byMessageId.has("msg-1"), "byMessageId populated") - assert.notEqual( - state.prune.messages.nextBlockId, - snapshot.messages.nextBlockId, - "nextBlockId mutated", - ) - - restoreCompressionState(state, snapshot) - - assert.equal(state.prune.messages.blocksById.size, 0, "blocksById cleared after restore") - assert.equal(state.prune.messages.byMessageId.size, 0, "byMessageId cleared after restore") - assert.equal( - state.prune.messages.activeBlockIds.size, - 0, - "activeBlockIds cleared after restore", - ) - assert.equal(state.prune.messages.nextBlockId, 1, "nextBlockId restored to initial") - assert.equal(state.prune.messages.nextRunId, 1, "nextRunId restored to initial") - assert.equal(state.stats.totalPruneTokens, 100, "stats restored") -}) - -test("snapshot is independent — mutating original does not affect snapshot", () => { - const state = createSessionState() - const snapshot = snapshotCompressionState(state) - - state.prune.messages.byMessageId.set("test-id", { - tokenCount: 999, - allBlockIds: [42], - activeBlockIds: [42], - }) - state.prune.messages.blocksById.set(42, {} as any) - state.prune.messages.activeBlockIds.add(42) - - assert.equal(snapshot.messages.byMessageId.size, 0, "snapshot byMessageId unaffected") - assert.equal(snapshot.messages.blocksById.size, 0, "snapshot blocksById unaffected") - assert.equal(snapshot.messages.activeBlockIds.size, 0, "snapshot activeBlockIds unaffected") -}) - -test("restoreCompressionState creates independent Maps/Sets (no shared references)", () => { - const state = createSessionState() - state.prune.messages.byMessageId.set("msg-1", { - tokenCount: 100, - allBlockIds: [1], - activeBlockIds: [1], - }) - - const snapshot = snapshotCompressionState(state) - restoreCompressionState(state, snapshot) - - state.prune.messages.byMessageId.set("msg-2", { - tokenCount: 200, - allBlockIds: [2], - activeBlockIds: [2], - }) - - assert.equal( - snapshot.messages.byMessageId.size, - 1, - "snapshot unaffected by post-restore mutation", - ) - assert.ok(!snapshot.messages.byMessageId.has("msg-2"), "snapshot has no msg-2") -}) diff --git a/tests/compression-targets.test.ts b/tests/compression-targets.test.ts deleted file mode 100644 index 77027af1..00000000 --- a/tests/compression-targets.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { getActiveCompressionTargets } from "../lib/commands/compression-targets" -import { createSessionState, type CompressionBlock } from "../lib/state" - -function buildBlock( - blockId: number, - runId: number, - mode: "range" | "message", - durationMs: number, -): CompressionBlock { - return { - blockId, - runId, - active: true, - deactivatedByUser: false, - compressedTokens: 10, - summaryTokens: 5, - durationMs, - mode, - topic: `topic-${blockId}`, - batchTopic: mode === "message" ? `batch-${runId}` : `topic-${blockId}`, - startId: `m${blockId}`, - endId: `m${blockId}`, - anchorMessageId: `msg-${blockId}`, - compressMessageId: `origin-${runId}`, - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [`msg-${blockId}`], - directToolIds: [], - effectiveMessageIds: [`msg-${blockId}`], - effectiveToolIds: [], - createdAt: blockId, - summary: `summary-${blockId}`, - } -} - -test("active compression targets count a grouped message run once", () => { - const state = createSessionState() - const first = buildBlock(1, 10, "message", 225) - const second = buildBlock(2, 10, "message", 225) - const third = buildBlock(3, 11, "range", 80) - - state.prune.messages.blocksById.set(1, first) - state.prune.messages.blocksById.set(2, second) - state.prune.messages.blocksById.set(3, third) - state.prune.messages.activeBlockIds.add(1) - state.prune.messages.activeBlockIds.add(2) - state.prune.messages.activeBlockIds.add(3) - - const targets = getActiveCompressionTargets(state.prune.messages) - const totalDurationMs = targets.reduce((total, target) => total + target.durationMs, 0) - - assert.equal(targets.length, 2) - assert.equal(totalDurationMs, 305) -}) - -test("inactive grouped message runs no longer contribute compression time", () => { - const state = createSessionState() - const first = buildBlock(1, 10, "message", 225) - const second = buildBlock(2, 10, "message", 225) - const third = buildBlock(3, 11, "range", 80) - - first.active = false - second.active = false - - state.prune.messages.blocksById.set(1, first) - state.prune.messages.blocksById.set(2, second) - state.prune.messages.blocksById.set(3, third) - state.prune.messages.activeBlockIds.add(3) - - const targets = getActiveCompressionTargets(state.prune.messages) - const totalDurationMs = targets.reduce((total, target) => total + target.durationMs, 0) - - assert.equal(targets.length, 1) - assert.equal(totalDurationMs, 80) -}) diff --git a/tests/decompress-logic.test.ts b/tests/decompress-logic.test.ts deleted file mode 100644 index b8e13466..00000000 --- a/tests/decompress-logic.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { - parseBlockIdArg, - resolveDecompressMode, - findActiveParentBlockId, - findActiveAncestorBlockId, - findActiveBlocksOverlappingMessages, - snapshotActiveMessages, - deactivateCompressionTarget, - computeRestoredMessages, - computeReactivatedBlockIds, - buildRestoredContentPreview, -} from "../lib/compress/decompress-logic" -import type { CompressionBlock, PruneMessagesState, WithParts } from "../lib/state/types" -import type { CompressionTarget } from "../lib/commands/compression-targets" - -// --- Factory helpers --- - -function makeBlock(overrides: Partial = {}): CompressionBlock { - return { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 100, - summaryTokens: 20, - durationMs: 0, - topic: "test", - batchTopic: "test", - startId: "m00001", - endId: "m00003", - anchorMessageId: "anchor-1", - compressMessageId: "comp-1", - compressCallId: undefined, - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: ["msg-a", "msg-b"], - effectiveToolIds: [], - createdAt: 1000, - deactivatedAt: undefined, - deactivatedByBlockId: undefined, - summary: "A summary.", - survivedCount: 0, - generation: "young", - ...overrides, - } -} - -function makeMessagesState(overrides: Partial = {}): PruneMessagesState { - return { - byMessageId: new Map(), - blocksById: new Map(), - activeBlockIds: new Set(), - activeByAnchorMessageId: new Map(), - nextBlockId: 1, - nextRunId: 1, - ...overrides, - } -} - -function makeTarget(overrides: Partial = {}): CompressionTarget { - return { - displayId: 1, - runId: 1, - topic: "test topic", - compressedTokens: 100, - durationMs: 50, - grouped: false, - blocks: [makeBlock()], - ...overrides, - } -} - -// --- parseBlockIdArg --- - -test("parseBlockIdArg returns block ID for 'b1' format", () => { - assert.equal(parseBlockIdArg("b1"), 1) -}) - -test("parseBlockIdArg returns block ID for bare number '5'", () => { - assert.equal(parseBlockIdArg("5"), 5) -}) - -test("parseBlockIdArg returns null for invalid 'abc'", () => { - assert.equal(parseBlockIdArg("abc"), null) -}) - -test("parseBlockIdArg returns null for '0'", () => { - assert.equal(parseBlockIdArg("0"), null) -}) - -test("parseBlockIdArg returns null for empty string", () => { - assert.equal(parseBlockIdArg(""), null) -}) - -test("parseBlockIdArg returns null for 'b-1'", () => { - assert.equal(parseBlockIdArg("b-1"), null) -}) - -test("parseBlockIdArg returns null for 'b0'", () => { - assert.equal(parseBlockIdArg("b0"), null) -}) - -test("parseBlockIdArg is case insensitive: 'B3' returns 3", () => { - assert.equal(parseBlockIdArg("B3"), 3) -}) - -test("parseBlockIdArg trims whitespace", () => { - assert.equal(parseBlockIdArg(" b7 "), 7) -}) - -test("parseBlockIdArg returns null for negative number '-1'", () => { - assert.equal(parseBlockIdArg("-1"), null) -}) - -// --- findActiveParentBlockId --- - -test("findActiveParentBlockId returns null when block has no parents", () => { - const ms = makeMessagesState() - const block = makeBlock({ parentBlockIds: [] }) - assert.equal(findActiveParentBlockId(ms, block), null) -}) - -test("findActiveParentBlockId returns null when all parents are inactive", () => { - const parent = makeBlock({ blockId: 2, active: false }) - const ms = makeMessagesState({ blocksById: new Map([[2, parent]]) }) - const block = makeBlock({ parentBlockIds: [2] }) - assert.equal(findActiveParentBlockId(ms, block), null) -}) - -test("findActiveParentBlockId returns active parent block ID", () => { - const parent = makeBlock({ blockId: 2, active: true }) - const ms = makeMessagesState({ blocksById: new Map([[2, parent]]) }) - const block = makeBlock({ parentBlockIds: [2] }) - assert.equal(findActiveParentBlockId(ms, block), 2) -}) - -test("findActiveParentBlockId handles deep ancestor chains (grandparent)", () => { - const grandparent = makeBlock({ blockId: 10, active: true }) - const parent = makeBlock({ blockId: 5, active: false, parentBlockIds: [10] }) - const ms = makeMessagesState({ - blocksById: new Map([ - [5, parent], - [10, grandparent], - ]), - }) - const block = makeBlock({ parentBlockIds: [5] }) - assert.equal(findActiveParentBlockId(ms, block), 10) -}) - -test("findActiveParentBlockId handles cycles safely", () => { - const blockA = makeBlock({ blockId: 1, active: false, parentBlockIds: [2] }) - const blockB = makeBlock({ blockId: 2, active: false, parentBlockIds: [1] }) - const ms = makeMessagesState({ - blocksById: new Map([ - [1, blockA], - [2, blockB], - ]), - }) - assert.equal(findActiveParentBlockId(ms, blockA), null) -}) - -test("findActiveParentBlockId returns null for missing parent", () => { - const ms = makeMessagesState({ blocksById: new Map() }) - const block = makeBlock({ parentBlockIds: [99] }) - assert.equal(findActiveParentBlockId(ms, block), null) -}) - -// --- findActiveAncestorBlockId --- - -test("findActiveAncestorBlockId returns null when no blocks have active ancestors", () => { - const parent = makeBlock({ blockId: 2, active: false }) - const block = makeBlock({ parentBlockIds: [2] }) - const ms = makeMessagesState({ blocksById: new Map([[2, parent]]) }) - const target = makeTarget({ blocks: [block] }) - assert.equal(findActiveAncestorBlockId(ms, target), null) -}) - -test("findActiveAncestorBlockId returns active ancestor from any block in target", () => { - const activeParent = makeBlock({ blockId: 10, active: true }) - const block = makeBlock({ parentBlockIds: [10] }) - const ms = makeMessagesState({ blocksById: new Map([[10, activeParent]]) }) - const target = makeTarget({ blocks: [block] }) - assert.equal(findActiveAncestorBlockId(ms, target), 10) -}) - -// --- snapshotActiveMessages --- - -test("snapshotActiveMessages returns empty map when no active messages", () => { - const ms = makeMessagesState() - const result = snapshotActiveMessages(ms) - assert.equal(result.size, 0) -}) - -test("snapshotActiveMessages returns map of messageId to tokenCount for active messages", () => { - const ms = makeMessagesState({ - byMessageId: new Map([ - ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [1] }], - ["msg-b", { tokenCount: 30, allBlockIds: [2], activeBlockIds: [] }], - ]), - }) - const result = snapshotActiveMessages(ms) - assert.equal(result.size, 1) - assert.equal(result.get("msg-a"), 50) - assert.ok(!result.has("msg-b")) -}) - -// --- deactivateCompressionTarget --- - -test("deactivateCompressionTarget sets block.active = false", () => { - const block = makeBlock({ blockId: 1, active: true }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const target = makeTarget({ blocks: [block] }) - deactivateCompressionTarget(ms, target) - assert.equal(block.active, false) -}) - -test("deactivateCompressionTarget sets block.deactivatedByUser = true", () => { - const block = makeBlock({ blockId: 1, deactivatedByUser: false }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const target = makeTarget({ blocks: [block] }) - deactivateCompressionTarget(ms, target) - assert.equal(block.deactivatedByUser, true) -}) - -test("deactivateCompressionTarget sets block.deactivatedAt to a number", () => { - const block = makeBlock({ blockId: 1 }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const target = makeTarget({ blocks: [block] }) - const before = Date.now() - deactivateCompressionTarget(ms, target) - assert.ok(typeof block.deactivatedAt === "number") - assert.ok(block.deactivatedAt! >= before) -}) - -test("deactivateCompressionTarget clears block.deactivatedByBlockId", () => { - const block = makeBlock({ blockId: 1, deactivatedByBlockId: 99 }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const target = makeTarget({ blocks: [block] }) - deactivateCompressionTarget(ms, target) - assert.equal(block.deactivatedByBlockId, undefined) -}) - -test("deactivateCompressionTarget default (one-level-up) does NOT mark consumed blocks", () => { - const consumedBlock = makeBlock({ blockId: 2, deactivatedByUser: false }) - const block = makeBlock({ blockId: 1, consumedBlockIds: [2] }) - const ms = makeMessagesState({ - blocksById: new Map([ - [1, block], - [2, consumedBlock], - ]), - }) - const target = makeTarget({ blocks: [block] }) - deactivateCompressionTarget(ms, target) - assert.equal(block.deactivatedByUser, true) - assert.equal(consumedBlock.deactivatedByUser, false) -}) - -test("deactivateCompressionTarget full:true marks consumed blocks deactivatedByUserDeep", () => { - const consumedBlock = makeBlock({ blockId: 2, deactivatedByUser: false }) - const block = makeBlock({ blockId: 1, consumedBlockIds: [2] }) - const ms = makeMessagesState({ - blocksById: new Map([ - [1, block], - [2, consumedBlock], - ]), - }) - const target = makeTarget({ blocks: [block] }) - deactivateCompressionTarget(ms, target, { full: true }) - assert.equal(block.deactivatedByUser, true) - assert.equal(consumedBlock.deactivatedByUserDeep, true) -}) - -test("deactivateCompressionTarget handles target with multiple blocks", () => { - const block1 = makeBlock({ blockId: 1, active: true, deactivatedByUser: false }) - const block2 = makeBlock({ blockId: 2, active: true, deactivatedByUser: false }) - const ms = makeMessagesState({ - blocksById: new Map([ - [1, block1], - [2, block2], - ]), - }) - const target = makeTarget({ blocks: [block1, block2] }) - deactivateCompressionTarget(ms, target) - assert.equal(block1.active, false) - assert.equal(block2.active, false) - assert.equal(block1.deactivatedByUser, true) - assert.equal(block2.deactivatedByUser, true) -}) - -// --- computeRestoredMessages --- - -test("computeRestoredMessages returns zero when no messages restored", () => { - const ms = makeMessagesState({ - byMessageId: new Map([ - ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [1] }], - ]), - }) - const before = new Map([["msg-a", 50]]) - const result = computeRestoredMessages(ms, before) - assert.equal(result.restoredMessageCount, 0) - assert.equal(result.restoredTokens, 0) -}) - -test("computeRestoredMessages counts messages that went from active to inactive", () => { - const ms = makeMessagesState({ - byMessageId: new Map([ - ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], - ["msg-b", { tokenCount: 30, allBlockIds: [2], activeBlockIds: [] }], - ]), - }) - const before = new Map([ - ["msg-a", 50], - ["msg-b", 30], - ]) - const result = computeRestoredMessages(ms, before) - assert.equal(result.restoredMessageCount, 2) - assert.equal(result.restoredTokens, 80) -}) - -test("computeRestoredMessages handles messages removed from byMessageId entirely", () => { - const ms = makeMessagesState({ byMessageId: new Map() }) - const before = new Map([["msg-gone", 40]]) - const result = computeRestoredMessages(ms, before) - assert.equal(result.restoredMessageCount, 1) - assert.equal(result.restoredTokens, 40) -}) - -// --- computeReactivatedBlockIds --- - -test("computeReactivatedBlockIds returns empty array when no blocks reactivated", () => { - const ms = makeMessagesState({ activeBlockIds: new Set([1, 2]) }) - const before = new Set([1, 2]) - const result = computeReactivatedBlockIds(ms, before) - assert.deepEqual(result, []) -}) - -test("computeReactivatedBlockIds returns sorted list of newly reactivated block IDs", () => { - const ms = makeMessagesState({ activeBlockIds: new Set([1, 3, 5]) }) - const before = new Set([1]) - const result = computeReactivatedBlockIds(ms, before) - assert.deepEqual(result, [3, 5]) -}) - -// --- buildRestoredContentPreview --- - -test("buildRestoredContentPreview returns empty string when no messages restored", () => { - const ms = makeMessagesState({ - byMessageId: new Map([ - ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [1] }], - ]), - }) - const before = new Map([["msg-a", 50]]) - const messages: WithParts[] = [ - { info: { id: "msg-a", role: "user" } as any, parts: [{ text: "hello" }] as any }, - ] - assert.equal(buildRestoredContentPreview(messages, before, ms), "") -}) - -test("buildRestoredContentPreview returns preview with role and truncated content", () => { - const ms = makeMessagesState({ - byMessageId: new Map([ - ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], - ]), - }) - const before = new Map([["msg-a", 50]]) - const messages: WithParts[] = [ - { info: { id: "msg-a", role: "user" } as any, parts: [{ text: "Hello world" }] as any }, - ] - const result = buildRestoredContentPreview(messages, before, ms) - assert.ok(result.includes("[user]")) - assert.ok(result.includes("Hello world")) -}) - -test("buildRestoredContentPreview truncates individual messages at ~200 chars", () => { - const longText = "A".repeat(300) - const ms = makeMessagesState({ - byMessageId: new Map([ - ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], - ]), - }) - const before = new Map([["msg-a", 50]]) - const messages: WithParts[] = [ - { info: { id: "msg-a", role: "assistant" } as any, parts: [{ text: longText }] as any }, - ] - const result = buildRestoredContentPreview(messages, before, ms) - // The line should contain "[assistant]" and the truncated text (200 chars + "...") - const line = result.split("\n")[0] - assert.ok(line.length < 250) - assert.ok(line.includes("...")) -}) - -test("buildRestoredContentPreview caps total output at approximately 2000 chars", () => { - const ms = makeMessagesState({ - byMessageId: new Map(), - }) - const before = new Map() - const messages: WithParts[] = [] - - // Create 20 messages, each with 200 chars - for (let i = 0; i < 20; i++) { - const id = `msg-${i}` - ms.byMessageId.set(id, { tokenCount: 10, allBlockIds: [1], activeBlockIds: [] }) - before.set(id, 10) - messages.push({ - info: { id, role: "assistant" } as any, - parts: [{ text: "B".repeat(200) }] as any, - }) - } - - const result = buildRestoredContentPreview(messages, before, ms) - assert.ok(result.length < 2200, `Expected ~2000 chars, got ${result.length}`) -}) - -test("buildRestoredContentPreview handles messages with no parts", () => { - const ms = makeMessagesState({ - byMessageId: new Map([ - ["msg-a", { tokenCount: 50, allBlockIds: [1], activeBlockIds: [] }], - ]), - }) - const before = new Map([["msg-a", 50]]) - const messages: WithParts[] = [ - { info: { id: "msg-a", role: "user" } as any, parts: [] as any }, - ] - const result = buildRestoredContentPreview(messages, before, ms) - assert.ok(result.includes("[user]")) -}) - -// --- findActiveBlocksOverlappingMessages --- - -test("findActiveBlocksOverlappingMessages returns empty array for empty message set", () => { - const block = makeBlock({ blockId: 1, effectiveMessageIds: ["msg-a"] }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - assert.deepEqual(findActiveBlocksOverlappingMessages(ms, new Set()), []) -}) - -test("findActiveBlocksOverlappingMessages returns empty array when no blocks exist", () => { - const ms = makeMessagesState({ blocksById: new Map() }) - assert.deepEqual(findActiveBlocksOverlappingMessages(ms, new Set(["msg-a"])), []) -}) - -test("findActiveBlocksOverlappingMessages matches active block with overlapping effective message", () => { - const block = makeBlock({ blockId: 1, effectiveMessageIds: ["msg-a", "msg-b"] }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const result = findActiveBlocksOverlappingMessages(ms, new Set(["msg-b"])) - assert.equal(result.length, 1) - assert.equal(result[0].blockId, 1) -}) - -test("findActiveBlocksOverlappingMessages skips inactive blocks", () => { - const block = makeBlock({ blockId: 1, active: false, effectiveMessageIds: ["msg-a"] }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - assert.deepEqual(findActiveBlocksOverlappingMessages(ms, new Set(["msg-a"])), []) -}) - -test("findActiveBlocksOverlappingMessages handles partial overlap (matches whole block)", () => { - const block = makeBlock({ blockId: 1, effectiveMessageIds: ["msg-a", "msg-b", "msg-c"] }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const result = findActiveBlocksOverlappingMessages(ms, new Set(["msg-b"])) - assert.equal(result.length, 1) - assert.equal(result[0].blockId, 1) -}) - -test("findActiveBlocksOverlappingMessages returns multiple matched blocks sorted by blockId", () => { - const block3 = makeBlock({ blockId: 3, effectiveMessageIds: ["msg-c"] }) - const block1 = makeBlock({ blockId: 1, effectiveMessageIds: ["msg-a"] }) - const block2 = makeBlock({ blockId: 2, effectiveMessageIds: ["msg-b"] }) - const ms = makeMessagesState({ - blocksById: new Map([ - [3, block3], - [1, block1], - [2, block2], - ]), - }) - const result = findActiveBlocksOverlappingMessages(ms, new Set(["msg-a", "msg-b", "msg-c"])) - assert.deepEqual(result.map((b) => b.blockId), [1, 2, 3]) -}) - -test("findActiveBlocksOverlappingMessages dedupes when block matches multiple messages in set", () => { - const block = makeBlock({ blockId: 1, effectiveMessageIds: ["msg-a", "msg-b", "msg-c"] }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const result = findActiveBlocksOverlappingMessages(ms, new Set(["msg-a", "msg-b", "msg-c"])) - assert.equal(result.length, 1) -}) - -test("findActiveBlocksOverlappingMessages returns no match when message IDs disjoint", () => { - const block = makeBlock({ blockId: 1, effectiveMessageIds: ["msg-a", "msg-b"] }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - const result = findActiveBlocksOverlappingMessages(ms, new Set(["msg-x", "msg-y"])) - assert.deepEqual(result, []) -}) - -test("findActiveBlocksOverlappingMessages treats undefined effectiveMessageIds as empty", () => { - const block = makeBlock({ blockId: 1, effectiveMessageIds: undefined as unknown as string[] }) - const ms = makeMessagesState({ blocksById: new Map([[1, block]]) }) - assert.deepEqual(findActiveBlocksOverlappingMessages(ms, new Set(["msg-a"])), []) -}) - -test("findActiveBlocksOverlappingMessages handles nested blocks (child effective ⊇ ancestor)", () => { - const ancestor = makeBlock({ - blockId: 1, - effectiveMessageIds: ["msg-a", "msg-b"], - }) - const child = makeBlock({ - blockId: 2, - effectiveMessageIds: ["msg-a", "msg-b", "msg-c"], - parentBlockIds: [1], - }) - const ms = makeMessagesState({ - blocksById: new Map([ - [1, ancestor], - [2, child], - ]), - }) - const result = findActiveBlocksOverlappingMessages(ms, new Set(["msg-c"])) - assert.deepEqual(result.map((b) => b.blockId), [2]) -}) - -test("findActiveBlocksOverlappingMessages returns both ancestor and child when range covers ancestor messages", () => { - const ancestor = makeBlock({ - blockId: 1, - effectiveMessageIds: ["msg-a"], - }) - const child = makeBlock({ - blockId: 2, - effectiveMessageIds: ["msg-a", "msg-c"], - parentBlockIds: [1], - }) - const ms = makeMessagesState({ - blocksById: new Map([ - [1, ancestor], - [2, child], - ]), - }) - const result = findActiveBlocksOverlappingMessages(ms, new Set(["msg-a"])) - assert.deepEqual(result.map((b) => b.blockId), [1, 2]) -}) - -// --- resolveDecompressMode dispatch tests --- - -test("resolveDecompressMode: blockId only → block mode", () => { - const result = resolveDecompressMode({ blockId: "b3" }) - assert.equal(result.ok, true) - if (result.ok) assert.equal(result.mode, "block") -}) - -test("resolveDecompressMode: startId + endId → range mode", () => { - const result = resolveDecompressMode({ startId: "m001", endId: "m005" }) - assert.equal(result.ok, true) - if (result.ok) assert.equal(result.mode, "range") -}) - -test("resolveDecompressMode: mixed blockId + startId → error", () => { - const result = resolveDecompressMode({ blockId: "b3", startId: "m001", endId: "m005" }) - assert.equal(result.ok, false) - if (!result.ok) assert.match(result.error, /Cannot specify both/) -}) - -test("resolveDecompressMode: mixed blockId + endId only → error", () => { - const result = resolveDecompressMode({ blockId: "b3", endId: "m005" }) - assert.equal(result.ok, false) - if (!result.ok) assert.match(result.error, /Cannot specify both/) -}) - -test("resolveDecompressMode: only startId (missing endId) → error", () => { - const result = resolveDecompressMode({ startId: "m001" }) - assert.equal(result.ok, false) - if (!result.ok) assert.match(result.error, /Must specify either/) -}) - -test("resolveDecompressMode: only endId (missing startId) → error", () => { - const result = resolveDecompressMode({ endId: "m005" }) - assert.equal(result.ok, false) - if (!result.ok) assert.match(result.error, /Must specify either/) -}) - -test("resolveDecompressMode: no args → error", () => { - const result = resolveDecompressMode({}) - assert.equal(result.ok, false) - if (!result.ok) assert.match(result.error, /Must specify either/) -}) - -test("resolveDecompressMode: empty string blockId → error (treated as missing)", () => { - const result = resolveDecompressMode({ blockId: " " }) - assert.equal(result.ok, false) - if (!result.ok) assert.match(result.error, /Must specify either/) -}) - -test("resolveDecompressMode: empty string startId/endId → error (treated as missing)", () => { - const result = resolveDecompressMode({ startId: "", endId: "" }) - assert.equal(result.ok, false) - if (!result.ok) assert.match(result.error, /Must specify either/) -}) diff --git a/tests/drop-empty-messages.test.ts b/tests/drop-empty-messages.test.ts deleted file mode 100644 index 835add31..00000000 --- a/tests/drop-empty-messages.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { dropEmptyMessages } from "../lib/messages/utils" -import type { WithParts } from "../lib/state" - -const sessionID = "ses_drop_empty" - -function buildUserMessage(parts: WithParts["parts"], id = "msg-user"): WithParts { - return { - info: { - id, - role: "user", - sessionID, - agent: "assistant", - model: { providerID: "anthropic", modelID: "claude-test" }, - time: { created: 1 }, - } as WithParts["info"], - parts, - } -} - -function buildAssistantMessage(parts: WithParts["parts"], id = "msg-assistant"): WithParts { - return { - info: { - id, - role: "assistant", - sessionID, - agent: "assistant", - time: { created: 1 }, - } as WithParts["info"], - parts, - } -} - -test("removes user message with no parts", () => { - const messages = [buildUserMessage([])] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 1) - assert.equal(messages.length, 0) -}) - -test("removes user message with empty text part", () => { - const messages = [buildUserMessage([{ type: "text", text: "" }])] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 1) - assert.equal(messages.length, 0) -}) - -test("removes user message with whitespace-only text", () => { - const messages = [buildUserMessage([{ type: "text", text: " \n\t " }])] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 1) - assert.equal(messages.length, 0) -}) - -test("preserves user message with text content", () => { - const msg = buildUserMessage([{ type: "text", text: "hello world" }]) - const messages = [msg] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 1) - assert.equal(messages[0], msg) -}) - -test("preserves user message with completed tool output", () => { - const msg = buildUserMessage([ - { - type: "tool", - tool: "bash", - state: { status: "completed", output: "result" }, - }, - ]) - const messages = [msg] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 1) -}) - -test("removes empty user message but keeps completed tool message", () => { - const emptyUser = buildUserMessage([{ type: "text", text: "" }], "msg-empty") - const toolUser = buildUserMessage( - [{ type: "tool", tool: "bash", state: { status: "completed", output: "data" } }], - "msg-tool", - ) - const messages = [emptyUser, toolUser] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 1) - assert.equal(messages.length, 1) - assert.equal(messages[0].info.id, "msg-tool") -}) - -test("removes empty assistant messages", () => { - const messages = [buildAssistantMessage([]), buildAssistantMessage([{ type: "text", text: "" }])] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 2) - assert.equal(messages.length, 0) -}) - -test("removes empty messages of both roles, keeps non-empty in order", () => { - const m1 = buildUserMessage([{ type: "text", text: "real content" }], "msg-1") - const m2 = buildUserMessage([{ type: "text", text: "" }], "msg-2") - const m3 = buildAssistantMessage([{ type: "text", text: "" }], "msg-3") - const m4 = buildUserMessage([{ type: "text", text: " " }], "msg-4") - const m5 = buildUserMessage([{ type: "text", text: "more content" }], "msg-5") - const messages = [m1, m2, m3, m4, m5] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 3) - assert.equal(messages.length, 2) - assert.equal(messages[0].info.id, "msg-1") - assert.equal(messages[1].info.id, "msg-5") -}) - -test("preserves assistant message with errored tool call", () => { - const msg = buildAssistantMessage( - [{ type: "tool", tool: "bash", state: { status: "error", output: "boom" } }], - "msg-err", - ) - const messages = [msg] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 1) - assert.equal(messages[0].info.id, "msg-err") -}) - -test("preserves assistant message with pending tool call", () => { - const msg = buildAssistantMessage( - [{ type: "tool", tool: "bash", state: { status: "pending" } }], - "msg-pending", - ) - const messages = [msg] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 1) - assert.equal(messages[0].info.id, "msg-pending") -}) - -test("empty array returns 0", () => { - const messages: WithParts[] = [] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 0) -}) - -test("all non-empty messages returns 0", () => { - const messages = [ - buildUserMessage([{ type: "text", text: "a" }]), - buildUserMessage([{ type: "text", text: "b" }]), - ] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 2) -}) - -// [FIX #20] Regression coverage for the empty-user-message freeze. -// ACP's `sendIgnoredMessage` injects a user-role message whose only part is -// `{ type: "text", text: , ignored: true }`. opencode strips -// ignored parts before the LLM call, leaving an empty user message that -// triggers zhipuai-lb HTTP 400 (code 1214, isRetryable: false). These tests -// pin dropEmptyMessages to remove such messages before they reach the provider. -test("removes user message whose only part is ignored text", () => { - const messages = [ - buildUserMessage([ - { type: "text", text: "▣ ACP | Context 80K → 60K", ignored: true } as any, - ]), - ] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 1) - assert.equal(messages.length, 0) -}) - -test("preserves user message that mixes ignored text with real content", () => { - const kept = buildUserMessage( - [ - { type: "text", text: "▣ ACP | Context 80K → 60K", ignored: true } as any, - { type: "text", text: "user asked to refactor the auth module" }, - ], - "msg-mixed", - ) - const messages = [kept] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 1) - assert.equal(messages[0].info.id, "msg-mixed") -}) - -test("removes user message with ignored text plus whitespace-only text", () => { - const messages = [ - buildUserMessage( - [ - { type: "text", text: "▣ ACP | done", ignored: true } as any, - { type: "text", text: " " }, - ], - "msg-ignored-plus-ws", - ), - ] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 1) - assert.equal(messages.length, 0) -}) - -test("preserves user message with ignored text and an errored tool call", () => { - const kept = buildUserMessage( - [ - { type: "text", text: "ignored notification", ignored: true } as any, - { type: "tool", tool: "bash", state: { status: "error", output: "boom" } }, - ], - "msg-ignored-plus-error", - ) - const messages = [kept] - const removed = dropEmptyMessages(messages) - assert.equal(removed, 0) - assert.equal(messages.length, 1) - assert.equal(messages[0].info.id, "msg-ignored-plus-error") -}) diff --git a/tests/e2e-blocks-nudges.test.ts b/tests/e2e-blocks-nudges.test.ts deleted file mode 100644 index 056edc9f..00000000 --- a/tests/e2e-blocks-nudges.test.ts +++ /dev/null @@ -1,638 +0,0 @@ -/** - * E2E tests for nudge injection, block lifecycle, and multi-session scenarios. - * - * Tests exercise `createChatMessageTransformHandler` with focus on: - * - Compression nudge injection based on context usage - * - Compression block deactivation and aging - * - Tool error pruning - * - Session switching - * - Message ID injection into tool parts - */ - -import assert from "node:assert/strict" -import test from "node:test" -import type { PluginConfig } from "../lib/config" -import { createChatMessageTransformHandler } from "../lib/hooks" -import { Logger } from "../lib/logger" -import { createSessionState, type WithParts, type SessionState } from "../lib/state" -import { createTestRegistry } from "./registry-stub" -import { isSyntheticMessage } from "../lib/messages/query" -import { mkdtempSync, rmSync } from "node:fs" -import { join } from "node:path" -import { tmpdir } from "node:os" - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -const SID_A = "session-nudge-a" -const SID_B = "session-nudge-b" - -function buildConfig(overrides: Partial = {}): PluginConfig { - const base: PluginConfig = { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { enabled: true, protectedTools: [] }, - experimental: { allowSubAgents: false, customPrompts: false }, - protectedFilePatterns: [], - compress: { - mode: "message", - permission: "allow", - showCompression: false, - summaryBuffer: true, - maxContextLimit: 150000, - minContextLimit: 50000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: ["task"], - protectTags: false, - protectUserMessages: false, - preserveRecentMessages: 0, - preserveRecentTokens: 0, - preserveLastUserMessage: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, - }, - } - return { ...base, ...overrides } -} - -function makeUserMessage(id: string, text: string, sessionId: string = SID_A): WithParts { - return { - info: { - id, - sessionID: sessionId, - role: "user", - agent: "assistant", - time: { created: Date.now() }, - model: { providerID: "test-provider", modelID: "test-model" }, - } as WithParts["info"], - parts: [{ type: "text", text, id: `${id}-p1`, sessionID: sessionId, messageID: id }], - } -} - -function makeAssistantMessage( - id: string, - text: string, - extraParts: any[] = [], - sessionId: string = SID_A, - tokenOverrides: { input?: number; output?: number } = {}, -): WithParts { - return { - info: { - id, - sessionID: sessionId, - role: "assistant", - agent: "assistant", - parentID: "parent-placeholder", - modelID: "test-model", - providerID: "test-provider", - mode: "normal", - path: { cwd: "/", root: "/" }, - summary: false, - cost: 0, - tokens: { - input: tokenOverrides.input ?? 100, - output: tokenOverrides.output ?? 50, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - time: { created: Date.now() }, - } as WithParts["info"], - parts: [ - { type: "step-start", id: `${id}-ss`, sessionID: sessionId, messageID: id }, - { type: "text", text, id: `${id}-p1`, sessionID: sessionId, messageID: id }, - ...extraParts, - ], - } -} - -function makeToolPart( - callID: string, - tool: string, - status: "completed" | "error" | "running" = "completed", - output: string = "tool output", - input: any = {}, - sessionId: string = SID_A, - messageId: string = "", -): any { - return { - type: "tool", - tool, - callID, - id: `part-${callID}`, - sessionID: sessionId, - messageID: messageId, - state: { status, output, input, error: status === "error" ? "error msg" : undefined }, - } -} - -function createMockClient() { - return { - session: { - get: async () => ({ data: { parentID: null } }), - }, - } -} - -function createMockPrompts() { - return { - reload() {}, - getRuntimePrompts() { - return { - system: "ACP system", - compressRange: "compress range", - compressMessage: "compress message", - contextLimitNudge: "nudge", - turnNudge: "turn nudge", - iterationNudge: "iteration nudge", - manualExtension: "", - subagentExtension: "", - } - }, - } -} - -function setupPipeline( - sessionId: string = SID_A, - configOverrides: Partial = {}, - stateOverrides: Partial = {}, -) { - const tempDir = mkdtempSync(join(tmpdir(), "acp-e2e2-")) - process.env.XDG_DATA_HOME = tempDir - process.env.XDG_CONFIG_HOME = tempDir - - const state = createSessionState() - state.sessionId = sessionId - Object.assign(state, stateOverrides) - - const config = buildConfig(configOverrides) - const logger = new Logger(false) - const handler = createChatMessageTransformHandler( - createMockClient(), - createTestRegistry(state), - logger, - config, - createMockPrompts(), - { global: undefined, agents: {} }, - ) - - return { state, logger, config, handler, tempDir } -} - -// ─── Test: Nudge injection when context is near limits ────────────────────── - -test("nudge injection: nudge breakdown injected when modelContextLimit is set", async () => { - const { state, handler } = setupPipeline(SID_A, {}, { - modelContextLimit: 200000, - }) - state.nudges.lastPerMessageNudgeTokens = 0 - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi", [ - makeToolPart("c1", "bash", "completed", "x".repeat(120_000)), - ], SID_A, { input: 100000, output: 50000 }), - makeUserMessage("u2", "Tell me more"), - ], - } - - await handler({}, output) - - const suffixMessage = output.messages.find((m: WithParts) => isSyntheticMessage(m)) - assert.ok(suffixMessage, "suffix message should be created") - const textParts = suffixMessage!.parts.filter((p: any) => p.type === "text") - const combinedText = textParts.map((p: any) => p.text).join("") - assert.ok(combinedText.includes("Breakdown:"), "should inject breakdown") - assert.ok(!combinedText.match(/\d+%\s*full/i), "should NOT inject context fill percentage") -}) - -// ─── Test: No nudge when permission is denied ─────────────────────────────── - -test("nudge injection: no context usage tag when permission is denied", async () => { - const { state, handler } = setupPipeline(SID_A, { - compress: { - ...buildConfig().compress, - permission: "deny", - }, - }, { - modelContextLimit: 200000, - }) - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi", [], SID_A, { input: 100000, output: 50000 }), - makeUserMessage("u2", "Tell me more"), - ], - } - - await handler({}, output) - - const lastUser = output.messages.find((m: WithParts) => m.info.id === "u2") - assert.ok(lastUser) - const textParts = lastUser!.parts.filter((p: any) => p.type === "text" && !p.synthetic) - const originalText = textParts.map((p: any) => p.text).join("") - assert.ok(!originalText.includes("Breakdown:"), "should NOT inject nudge with deny") -}) - -// ─── Test: Age-based deactivation removed (memory-loss fix) ──────────────── - -test("block aging: old blocks are NOT deactivated even with modelContextLimit set (age-based GC disabled)", async () => { - const { state, handler } = setupPipeline(SID_A, { - gc: { ...buildConfig().gc, maxBlockAge: 2 }, - }, { - modelContextLimit: 200000, - }) - - const blockId = 1 - const originalSummary = "Compressed summary text that should be preserved" - state.prune.messages.blocksById.set(blockId, { - blockId, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 500, - summaryTokens: 50, - durationMs: 0, - mode: "message", - topic: "test", - batchTopic: "test", - startId: "m00001", - endId: "m00002", - anchorMessageId: "u2", - compressMessageId: "msg-comp", - compressCallId: "call-comp", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: ["u1"], - directToolIds: [], - effectiveMessageIds: ["u1"], - effectiveToolIds: [], - createdAt: Date.now() - 1000, - summary: originalSummary, - survivedCount: 10, - generation: "old", - }) - state.prune.messages.activeBlockIds.add(blockId) - state.prune.messages.activeByAnchorMessageId.set("u2", blockId) - state.prune.messages.byMessageId.set("u1", { - tokenCount: 200, allBlockIds: [blockId], activeBlockIds: [blockId], - }) - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi", [], SID_A, { input: 50000, output: 20000 }), - makeUserMessage("u2", "Next"), - makeAssistantMessage("a2", "Response"), - ], - } - - await handler({}, output) - - const block = state.prune.messages.blocksById.get(blockId) - assert.equal(block?.active, true, "block must remain active — age-based deactivation was removed") - assert.equal(block?.summary, originalSummary, "summary must be unchanged — no truncation below 100% context") -}) - -// ─── Test: Oversized-block override removed (memory-loss fix) ────────────── - -test("oversized block: summary > 6000 chars is NOT truncated below 100% context", async () => { - const { state, handler } = setupPipeline(SID_A, {}, { - modelContextLimit: 200000, - }) - - const blockId = 1 - const largeSummary = "# Large Summary\n" + "x".repeat(9000) - state.prune.messages.blocksById.set(blockId, { - blockId, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 5000, - summaryTokens: 2500, - durationMs: 0, - mode: "message", - topic: "large-test", - batchTopic: "large-test", - startId: "m00001", - endId: "m00002", - anchorMessageId: "u2", - compressMessageId: "msg-comp", - compressCallId: "call-comp", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: ["u1"], - directToolIds: [], - effectiveMessageIds: ["u1"], - effectiveToolIds: [], - createdAt: Date.now() - 1000, - summary: largeSummary, - survivedCount: 0, - generation: "young", - }) - state.prune.messages.activeBlockIds.add(blockId) - state.prune.messages.activeByAnchorMessageId.set("u2", blockId) - state.prune.messages.byMessageId.set("u1", { - tokenCount: 200, allBlockIds: [blockId], activeBlockIds: [blockId], - }) - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi", [], SID_A, { input: 50000, output: 20000 }), - makeUserMessage("u2", "Next"), - makeAssistantMessage("a2", "Response"), - ], - } - - await handler({}, output) - - const block = state.prune.messages.blocksById.get(blockId) - assert.equal(block?.active, true, "block must remain active") - assert.equal( - block?.summary, - largeSummary, - "oversized summary (>6000 chars) must be preserved below 100% context — oversized override removed", - ) - assert.ok( - !block?.summary.includes("[GC truncated]"), - "summary must not contain GC truncation marker", - ) -}) - -// ─── Test: Session switch resets state ────────────────────────────────────── - -test("session switch: each session keeps its own state (no cross-session reset)", async () => { - const { state, handler } = setupPipeline(SID_A) - - // First call with session A - const output1 = { - messages: [ - makeUserMessage("u1a", "Hello A", SID_A), - makeAssistantMessage("a1a", "Hi A", [], SID_A), - ], - } - await handler({}, output1) - - assert.equal(state.sessionId, SID_A) - assert.equal(state.messageIds.byRawId.get("u1a"), "m00001") - assert.equal(state.messageIds.byRawId.get("a1a"), "m00002") - - // Second call with a DIFFERENT session (session B). - // Per-session state (#33): session B resolves its own state; session A is - // left untouched instead of being reset by the shared singleton. - const output2 = { - messages: [ - makeUserMessage("u1b", "Hello B", SID_B), - makeAssistantMessage("a1b", "Hi B", [], SID_B), - ], - } - await handler({}, output2) - - // Session A's state is preserved, not reset by session B's activity - assert.equal(state.sessionId, SID_A) - assert.equal(state.messageIds.byRawId.get("u1a"), "m00001", "session A IDs preserved") - assert.equal(state.messageIds.byRawId.get("a1a"), "m00002") -}) - -// ─── Test: Message IDs injected into tool parts ───────────────────────────── - -test("message ID injection: IDs are appended to tool parts", async () => { - const { state, handler } = setupPipeline() - - const toolPart = makeToolPart( - "call-1", "read", "completed", "file contents", - { path: "/test.txt" }, SID_A, "a1", - ) - const output = { - messages: [ - makeUserMessage("u1", "Read file"), - makeAssistantMessage("a1", "Here is the file", [toolPart]), - ], - } - - await handler({}, output) - - const assistantMsg = output.messages.find((m: WithParts) => m.info.id === "a1") - assert.ok(assistantMsg) - - const tool = assistantMsg!.parts.find((p: any) => p.type === "tool") - assert.ok(tool) - - const toolOutput = (tool as any).state.output as string - assert.ok( - toolOutput.includes("dcp-message-id"), - "tool output should contain message ID tag", - ) - assert.ok( - toolOutput.includes("m00002"), - "tool output should contain the m00002 ref", - ) -}) - -// ─── Test: Visible ID range injection ─────────────────────────────────────── - -test("compressible ranges injected into suffix message when shouldNudge fires", async () => { - const { state, handler } = setupPipeline(SID_A, {}, { - modelContextLimit: 200000, - }) - // Simulate post-baseline state so growth-gating can fire (not first turn). - state.nudges.lastPerMessageNudgeTokens = 0 - - const output = { - messages: [ - makeUserMessage("u1", "First"), - makeAssistantMessage("a1", "Response 1", [ - makeToolPart("c1", "bash", "completed", "x".repeat(50_000)), - ], SID_A, { input: 100000, output: 50000 }), - makeUserMessage("u2", "Second"), - makeAssistantMessage("a2", "Response 2", [], SID_A, { input: 100000, output: 50000 }), - makeUserMessage("u3", "Third"), - ], - } - - await handler({}, output) - - const suffixMessage = output.messages.find((m: WithParts) => isSyntheticMessage(m)) - assert.ok(suffixMessage, "suffix message should be created") - const textParts = suffixMessage!.parts.filter((p: any) => p.type === "text") - const combinedText = textParts.map((p: any) => p.text).join("") - assert.ok( - combinedText.includes("Compressible ranges"), - "should inject compressible ranges section", - ) - assert.ok( - /msgs?/.test(combinedText), - "compressible ranges should mention message counts", - ) - assert.ok( - !combinedText.includes("[Visible:"), - "should NOT inject visible segments tag (removed)", - ) -}) - -// ─── Test: Block consumed by newer block ──────────────────────────────────── - -test("block consumption: newer block deactivates consumed blocks", async () => { - const { state, handler } = setupPipeline() - - // Old block - const oldBlockId = 1 - state.prune.messages.blocksById.set(oldBlockId, { - blockId: oldBlockId, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 500, - summaryTokens: 50, - durationMs: 0, - mode: "message", - topic: "old", - batchTopic: "old", - startId: "m00001", - endId: "m00002", - anchorMessageId: "u1", - compressMessageId: "msg-comp1", - compressCallId: "call-comp1", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: ["u1"], - directToolIds: [], - effectiveMessageIds: ["u1"], - effectiveToolIds: [], - createdAt: Date.now() - 2000, - summary: "Old summary", - survivedCount: 0, - generation: "old", - }) - state.prune.messages.activeBlockIds.add(oldBlockId) - state.prune.messages.activeByAnchorMessageId.set("u1", oldBlockId) - state.prune.messages.byMessageId.set("u1", { - tokenCount: 200, allBlockIds: [oldBlockId], activeBlockIds: [oldBlockId], - }) - - // New block that consumes the old one - const newBlockId = 2 - state.prune.messages.blocksById.set(newBlockId, { - blockId: newBlockId, - runId: 2, - active: true, - deactivatedByUser: false, - compressedTokens: 1000, - summaryTokens: 100, - durationMs: 0, - mode: "message", - topic: "new", - batchTopic: "new", - startId: "m00003", - endId: "m00004", - anchorMessageId: "u3", - compressMessageId: "msg-comp2", - compressCallId: "call-comp2", - includedBlockIds: [], - consumedBlockIds: [oldBlockId], - parentBlockIds: [], - directMessageIds: ["u2"], - directToolIds: [], - effectiveMessageIds: ["u2"], - effectiveToolIds: [], - createdAt: Date.now() - 1000, - summary: "New summary covering old content", - survivedCount: 0, - generation: "young", - }) - state.prune.messages.activeBlockIds.add(newBlockId) - state.prune.messages.activeByAnchorMessageId.set("u3", newBlockId) - state.prune.messages.byMessageId.set("u2", { - tokenCount: 300, allBlockIds: [newBlockId], activeBlockIds: [newBlockId], - }) - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi"), - makeUserMessage("u2", "Next"), - makeAssistantMessage("a2", "Response"), - makeUserMessage("u3", "More"), - makeAssistantMessage("a3", "Done"), - ], - } - - await handler({}, output) - - assert.equal( - state.prune.messages.blocksById.get(oldBlockId)?.active, - false, - "old block should be deactivated because it's consumed by the new block", - ) - assert.equal( - state.prune.messages.blocksById.get(newBlockId)?.active, - true, - "new block should remain active", - ) -}) - -// ─── Test: Multiple pipeline runs accumulate IDs correctly ────────────────── - -test("ID accumulation: sequential runs never produce duplicate refs", async () => { - const { state, handler } = setupPipeline() - - for (let round = 0; round < 5; round++) { - const prefix = `r${round}_` - const output = { - messages: [ - makeUserMessage(`${prefix}u1`, `Round ${round} question`), - makeAssistantMessage(`${prefix}a1`, `Round ${round} answer`), - ], - } - await handler({}, output) - } - - const allRefs = Array.from(state.messageIds.byRawId.values()) - assert.equal(allRefs.length, 10, "should have 10 message refs (5 rounds × 2)") - assert.equal(new Set(allRefs).size, 10, "all refs should be unique") - - assert.equal(state.messageIds.nextRef, 11) - assert.equal(state.messageIds.byRawId.get("r4_u1"), "m00009") - assert.equal(state.messageIds.byRawId.get("r4_a1"), "m00010") -}) - -// ─── Test: Mixed valid and invalid messages ───────────────────────────────── - -test("mixed messages: only valid messages survive, IDs assigned to survivors", async () => { - const { state, handler } = setupPipeline() - - const output = { - messages: [ - makeUserMessage("u1", "Valid"), - { role: "user", parts: [] } as any, - makeAssistantMessage("a1", "Valid response"), - { garbage: true } as any, - makeUserMessage("u2", "Also valid"), - ], - } - - await handler({}, output) - - assert.equal(output.messages.length, 3, "3 valid messages (empty suffix dropped, issue #12)") - const ids = output.messages.filter((m: WithParts) => !isSyntheticMessage(m)).map((m: WithParts) => m.info.id) - assert.deepEqual(ids, ["u1", "a1", "u2"]) - - assert.equal(state.messageIds.byRawId.get("u1"), "m00001") - assert.equal(state.messageIds.byRawId.get("a1"), "m00002") - assert.equal(state.messageIds.byRawId.get("u2"), "m00003") -}) diff --git a/tests/e2e-message-transform.test.ts b/tests/e2e-message-transform.test.ts deleted file mode 100644 index 58f7009a..00000000 --- a/tests/e2e-message-transform.test.ts +++ /dev/null @@ -1,856 +0,0 @@ -/** - * E2E tests for the full chat message transform pipeline. - * - * These tests exercise `createChatMessageTransformHandler` end-to-end, - * calling it with realistic mock data and verifying that output messages - * are transformed correctly through the sequential pipeline stages: - * - * filterMessagesInPlace → checkSession → syncCompressPermission → - * stripHallucinations → cacheSystemPromptTokens → assignMessageRefs → - * syncCompressionBlocks → syncToolCache → buildToolIdList → - * runBatchCleanup → prune → truncateLargeToolOutputs → - * assignMessageRefs (reassign) → buildPriorityMap → injectCompressNudges → injectMessageIds → - * applyPendingManualTrigger → stripStaleMetadata → logger.saveContext - */ - -import assert from "node:assert/strict" -import test, { beforeEach } from "node:test" -import type { PluginConfig } from "../lib/config" -import { createChatMessageTransformHandler } from "../lib/hooks" -import { Logger } from "../lib/logger" -import { createSessionState, saveSessionState, type WithParts, type SessionState } from "../lib/state" -import { isSyntheticMessage } from "../lib/messages/query" -import { mkdtempSync, rmSync } from "node:fs" -import { join } from "node:path" -import { tmpdir } from "node:os" -import { createTestRegistry } from "./registry-stub" - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -const SID = "session-e2e-1" - -function buildConfig(overrides: Partial = {}): PluginConfig { - const base: PluginConfig = { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { enabled: true, protectedTools: [] }, - experimental: { allowSubAgents: false, customPrompts: false }, - protectedFilePatterns: [], - compress: { - mode: "message", - permission: "allow", - showCompression: false, - summaryBuffer: true, - maxContextLimit: 150000, - minContextLimit: 50000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: ["task"], - protectTags: false, - protectUserMessages: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, - }, - } - return { ...base, ...overrides } -} - -let msgCounter = 0 -function nextMsgId(): string { - return `msg-e2e-${++msgCounter}` -} - -function makeUserMessage( - id: string, - text: string, - sessionId: string = SID, - agent: string = "assistant", -): WithParts { - return { - info: { - id, - sessionID: sessionId, - role: "user", - agent, - time: { created: Date.now() }, - model: { providerID: "test-provider", modelID: "test-model" }, - } as WithParts["info"], - parts: [{ type: "text", text, id: `${id}-p1`, sessionID: sessionId, messageID: id }], - } -} - -function makeAssistantMessage( - id: string, - text: string, - extraParts: any[] = [], - sessionId: string = SID, -): WithParts { - return { - info: { - id, - sessionID: sessionId, - role: "assistant", - agent: "assistant", - parentID: "parent-placeholder", - modelID: "test-model", - providerID: "test-provider", - mode: "normal", - path: { cwd: "/", root: "/" }, - summary: false, - cost: 0, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: Date.now() }, - } as WithParts["info"], - parts: [ - { type: "step-start", id: `${id}-ss`, sessionID: sessionId, messageID: id }, - { type: "text", text, id: `${id}-p1`, sessionID: sessionId, messageID: id }, - ...extraParts, - ], - } -} - -function makeToolPart( - callID: string, - tool: string, - status: "completed" | "error" | "running" = "completed", - output: string = "tool output", - input: any = {}, -): any { - return { - type: "tool", - tool, - callID, - id: `part-${callID}`, - sessionID: SID, - messageID: "msg-tool-host", - state: { status, output, input, error: status === "error" ? "error msg" : undefined }, - } -} - -function createMockClient() { - return { - session: { - get: async () => ({ data: { parentID: null } }), - }, - } -} - -function createMockPrompts() { - return { - reload() {}, - getRuntimePrompts() { - return { - system: "ACP system", - compressRange: "compress range", - compressMessage: "compress message", - contextLimitNudge: "nudge", - turnNudge: "turn nudge", - iterationNudge: "iteration nudge", - manualExtension: "", - subagentExtension: "", - } - }, - } -} - -function setupPipeline(stateOverrides: Partial = {}) { - const tempDir = mkdtempSync(join(tmpdir(), "acp-e2e-")) - process.env.XDG_DATA_HOME = tempDir - process.env.XDG_CONFIG_HOME = tempDir - - const state = createSessionState() - state.sessionId = SID - Object.assign(state, stateOverrides) - - const logger = new Logger(false) - const config = buildConfig() - const client = createMockClient() - const prompts = createMockPrompts() - const hostPermissions = { global: undefined, agents: {} } - - const handler = createChatMessageTransformHandler( - client, - createTestRegistry(state), - logger, - config, - prompts, - hostPermissions, - ) - - return { state, logger, config, handler, tempDir } -} - -beforeEach(() => { - msgCounter = 0 -}) - -// ─── Test: Basic pipeline run ─────────────────────────────────────────────── - -test("basic pipeline: assigns message IDs and preserves all messages", async () => { - const { state, handler } = setupPipeline() - - const messages: WithParts[] = [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi there"), - makeUserMessage("u2", "How are you?"), - makeAssistantMessage("a2", "I'm fine"), - makeUserMessage("u3", "Good"), - ] - - const output = { messages } - - await handler({}, output) - - // All 5 real messages survive; empty suffix message is dropped (issue #12) - assert.equal(output.messages.length, 5) - - // Message IDs should be assigned (suffix message excluded from ref assignment) - assert.equal(state.messageIds.byRawId.get("u1"), "m00001") - assert.equal(state.messageIds.byRawId.get("a1"), "m00002") - assert.equal(state.messageIds.byRawId.get("u2"), "m00003") - assert.equal(state.messageIds.byRawId.get("a2"), "m00004") - assert.equal(state.messageIds.byRawId.get("u3"), "m00005") - - // Reverse mapping should exist - assert.equal(state.messageIds.byRef.get("m00001"), "u1") - assert.equal(state.messageIds.byRef.get("m00005"), "u3") -}) - -// ─── Test: Message IDs are stable across multiple pipeline runs ────────────── - -test("message IDs remain stable across sequential pipeline calls", async () => { - const { state, handler } = setupPipeline() - - // First call with 2 messages - const output1 = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi"), - ], - } - await handler({}, output1) - - assert.equal(state.messageIds.byRawId.get("u1"), "m00001") - assert.equal(state.messageIds.byRawId.get("a1"), "m00002") - assert.equal(state.messageIds.nextRef, 3) - - // Second call adds new messages; existing IDs should remain stable - const output2 = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi"), - makeUserMessage("u2", "How are you?"), - makeAssistantMessage("a2", "I'm fine"), - ], - } - await handler({}, output2) - - // Old IDs stable - assert.equal(state.messageIds.byRawId.get("u1"), "m00001") - assert.equal(state.messageIds.byRawId.get("a1"), "m00002") - // New IDs assigned - assert.equal(state.messageIds.byRawId.get("u2"), "m00003") - assert.equal(state.messageIds.byRawId.get("a2"), "m00004") - assert.equal(state.messageIds.nextRef, 5) -}) - -// ─── Test: Invalid messages are filtered out ──────────────────────────────── - -test("filterMessagesInPlace: removes messages without valid info", async () => { - const { state, handler } = setupPipeline() - - const output = { - messages: [ - { role: "user", parts: [{ type: "text", text: "no info" }] }, // no .info → filtered - makeUserMessage("u1", "Valid"), - makeAssistantMessage("a1", "Response"), - ] as WithParts[], - } - - await handler({}, output) - - // Only 2 valid messages survive; empty suffix message is dropped (issue #12) - assert.equal(output.messages.length, 2) - const realMessages = output.messages.filter((m: WithParts) => !isSyntheticMessage(m)) - assert.equal(realMessages[0].info.id, "u1") - assert.equal(realMessages[1].info.id, "a1") -}) - -// ─── Test: Hallucinated tags are stripped ──────────────────────────────────── - -test("stripHallucinations: removes hallucinated DCP tags from message text", async () => { - const { state, handler } = setupPipeline() - - const output = { - messages: [ - makeAssistantMessage("a1", "Here is info secret and more"), - ], - } - - await handler({}, output) - - const textPart = output.messages[0].parts.find((p: any) => p.type === "text") - assert.ok(textPart) - const text = (textPart as any).text as string - assert.ok(!text.includes(""), "hallucinated tags should be stripped") - assert.ok(text.includes("Here is info"), "non-hallucinated text preserved") -}) - -// ─── Test: Compression blocks are synced and pruned ────────────────────────── - -test("compression blocks: compressed messages are replaced with summaries", async () => { - const { state, handler } = setupPipeline() - - // Pre-populate a compression block that covers messages u1-a1 - const blockId = 1 - state.prune.messages.blocksById.set(blockId, { - blockId, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 500, - summaryTokens: 50, - durationMs: 0, - mode: "message", - topic: "test topic", - batchTopic: "test topic", - startId: "m00001", - endId: "m00002", - anchorMessageId: "u2", // summary injected at this anchor - compressMessageId: "msg-compress", - compressCallId: "call-compress", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: ["u1", "a1"], - directToolIds: [], - effectiveMessageIds: ["u1", "a1"], - effectiveToolIds: [], - createdAt: Date.now() - 1000, - summary: "Previous conversation about greetings", - survivedCount: 0, - generation: "old", - }) - state.prune.messages.activeBlockIds.add(blockId) - state.prune.messages.activeByAnchorMessageId.set("u2", blockId) - - // Mark u1 and a1 as compressed by this block - state.prune.messages.byMessageId.set("u1", { - tokenCount: 200, - allBlockIds: [blockId], - activeBlockIds: [blockId], - }) - state.prune.messages.byMessageId.set("a1", { - tokenCount: 300, - allBlockIds: [blockId], - activeBlockIds: [blockId], - }) - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi there"), - makeUserMessage("u2", "How are you?"), - makeAssistantMessage("a2", "I'm fine"), - ], - } - - await handler({}, output) - - const remainingIds = output.messages.map((m: any) => m.info.id) - - assert.ok(remainingIds.includes("u1"), "u1 (first user) is force-preserved even when compressed") - assert.ok(!remainingIds.includes("a1"), "a1 should be pruned") - - assert.ok(remainingIds.includes("u2"), "u2 should survive") - assert.ok(remainingIds.includes("a2"), "a2 should survive") - - const hasRecap = output.messages.some( - (m: any) => - m.parts.some( - (p: any) => p.type === "tool" && p.tool === "acp_context_recap", - ), - ) - assert.ok(!hasRecap, "no synthetic recap should be injected (compress-as-anchor)") - - const u2Msg = output.messages.find((m: any) => m.info.id === "u2") - assert.ok(u2Msg, "u2 should survive") - const u2Text = u2Msg!.parts - .filter((p: any) => p.type === "text") - .map((p: any) => p.text) - .join("") - assert.ok( - !u2Text.includes("Previous conversation about greetings"), - "summary should NOT be merged into u2 text", - ) - assert.ok( - u2Text.includes("How are you?"), - "u2's original text should be preserved unchanged", - ) -}) - -// ─── Test: Regression — no consecutive user messages after compression ────── - -test("compression summary: never produces two consecutive user turns (Bug 36)", async () => { - const { state, handler } = setupPipeline() - - const blockId = 1 - state.prune.messages.blocksById.set(blockId, { - blockId, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 500, - summaryTokens: 50, - durationMs: 0, - mode: "message", - topic: "early work", - batchTopic: "early work", - startId: "m00001", - endId: "m00002", - anchorMessageId: "u1", - compressMessageId: "msg-compress", - compressCallId: "call-compress", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: ["u1", "a1"], - directToolIds: [], - effectiveMessageIds: ["u1", "a1"], - effectiveToolIds: [], - createdAt: Date.now() - 1000, - summary: "The assistant explained the plan and the user acknowledged it.", - survivedCount: 0, - generation: "old", - }) - state.prune.messages.activeBlockIds.add(blockId) - state.prune.messages.activeByAnchorMessageId.set("u1", blockId) - state.prune.messages.byMessageId.set("u1", { - tokenCount: 200, - allBlockIds: [blockId], - activeBlockIds: [blockId], - }) - state.prune.messages.byMessageId.set("a1", { - tokenCount: 300, - allBlockIds: [blockId], - activeBlockIds: [blockId], - }) - - const output = { - messages: [ - makeUserMessage("u1", "What's the plan?"), - makeAssistantMessage("a1", "Here is the plan."), - makeUserMessage("u2", "Sounds good, continue."), - makeAssistantMessage("a2", "Working on it."), - ], - } - - await handler({}, output) - - const lastIdx = output.messages.length - 1 - const historical = output.messages.filter( - (m: any, idx: number) => !(idx === lastIdx && isSyntheticMessage(m)), - ) - - // With preserve-first-user, u1 (the first user message) is always - // force-preserved even when it falls in a compression range. When a - // later user message (u2) also survives, u1 and u2 become adjacent. - // This is an accepted trade-off: zero user messages is a hard API - // rejection (zhipuai-lb code 1214), while adjacent user messages are - // accepted by virtually all providers. The no-adjacent-users invariant - // still holds for all NON-first-user pairs. - for (let i = 1; i < historical.length; i++) { - const prev = historical[i - 1]! - const curr = historical[i]! - const bothUser = prev.info.role === "user" && curr.info.role === "user" - const isForcePreservedFirstUser = i === 1 && prev.info.id === "u1" - assert.ok( - !bothUser || isForcePreservedFirstUser, - `unexpected adjacent user turns at index ${i - 1}/${i} (ids ${prev.info.id}, ${curr.info.id})`, - ) - } - - const u1 = historical.find((m: WithParts) => m.info.id === "u1") - assert.ok(u1, "u1 (first user) should be force-preserved even when compressed") - - const u2 = historical.find((m: WithParts) => m.info.id === "u2") - assert.ok(u2, "u2 should survive") - const u2Text = u2!.parts - .filter((p) => p.type === "text") - .map((p) => (p as any).text) - .join("") - assert.ok(!u2Text.includes("The assistant explained the plan"), "summary should NOT be merged into u2") - assert.ok(u2Text.includes("Sounds good, continue."), "u2 original text preserved") - - const hasRecap = historical.some( - (m: any) => - m.parts.some( - (p: any) => p.type === "tool" && p.tool === "acp_context_recap", - ), - ) - assert.ok(!hasRecap, "no synthetic recap should be injected (compress-as-anchor)") -}) - -// ─── Test: Fallback — standalone summary when no following user turn (Bug 36) ── - -test("compression summary: emits standalone summary when range is last (no user to merge into)", async () => { - const { state, handler } = setupPipeline() - - const blockId = 2 - state.prune.messages.blocksById.set(blockId, { - blockId, - runId: 2, - active: true, - deactivatedByUser: false, - compressedTokens: 500, - summaryTokens: 50, - durationMs: 0, - mode: "message", - topic: "closing work", - batchTopic: "closing work", - startId: "m00003", - endId: "m00004", - anchorMessageId: "u2", - compressMessageId: "msg-compress", - compressCallId: "call-compress", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: ["u2", "a2"], - directToolIds: [], - effectiveMessageIds: ["u2", "a2"], - effectiveToolIds: [], - createdAt: Date.now() - 1000, - summary: "Final wrap-up of the task.", - survivedCount: 0, - generation: "old", - }) - state.prune.messages.activeBlockIds.add(blockId) - state.prune.messages.activeByAnchorMessageId.set("u2", blockId) - state.prune.messages.byMessageId.set("u2", { - tokenCount: 200, - allBlockIds: [blockId], - activeBlockIds: [blockId], - }) - state.prune.messages.byMessageId.set("a2", { - tokenCount: 300, - allBlockIds: [blockId], - activeBlockIds: [blockId], - }) - - const output = { - messages: [ - makeUserMessage("u1", "Start here"), - makeAssistantMessage("a1", "Working"), - makeUserMessage("u2", "Almost done"), - makeAssistantMessage("a2", "Finished"), - ], - } - - await handler({}, output) - - const remainingIds = output.messages.map((m: any) => m.info.id) - assert.ok(!remainingIds.includes("u2"), "u2 (covered by block) should be pruned") - assert.ok(!remainingIds.includes("a2"), "a2 (covered by block) should be pruned") - - const hasRecap = output.messages.some( - (m: any) => - m.parts.some( - (p: any) => p.type === "tool" && p.tool === "acp_context_recap", - ), - ) - assert.ok(!hasRecap, "no synthetic recap should be injected (compress-as-anchor)") - - const lastIdx = output.messages.length - 1 - const checkMessages = output.messages.filter( - (m: any, idx: number) => !(idx === lastIdx && isSyntheticMessage(m)), - ) - for (let i = 1; i < checkMessages.length; i++) { - const prev = checkMessages[i - 1]! - const curr = checkMessages[i]! - assert.ok( - !(prev.info.role === "user" && curr.info.role === "user"), - `unexpected adjacent user turns at ${i - 1}/${i}`, - ) - } -}) - -// ─── Test: Message IDs after pruning + reassignment ───────────────────────── - -test("message IDs remain consistent after compression and pruning", async () => { - const { state, handler } = setupPipeline() - - const blockId = 1 - state.prune.messages.blocksById.set(blockId, { - blockId, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 500, - summaryTokens: 50, - durationMs: 0, - mode: "message", - topic: "early chat", - batchTopic: "early chat", - startId: "m00001", - endId: "m00002", - anchorMessageId: "u3", - compressMessageId: "msg-comp", - compressCallId: "call-comp", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: ["u1", "a1"], - directToolIds: [], - effectiveMessageIds: ["u1", "a1"], - effectiveToolIds: [], - createdAt: Date.now() - 1000, - summary: "Summary of early messages", - survivedCount: 0, - generation: "old", - }) - state.prune.messages.activeBlockIds.add(blockId) - state.prune.messages.activeByAnchorMessageId.set("u3", blockId) - state.prune.messages.byMessageId.set("u1", { - tokenCount: 200, allBlockIds: [blockId], activeBlockIds: [blockId], - }) - state.prune.messages.byMessageId.set("a1", { - tokenCount: 300, allBlockIds: [blockId], activeBlockIds: [blockId], - }) - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Hi"), - makeUserMessage("u2", "How are you?"), - makeAssistantMessage("a2", "Good"), - makeUserMessage("u3", "What's up?"), - makeAssistantMessage("a3", "Nothing much"), - ], - } - - await handler({}, output) - - assert.ok(state.messageIds.byRawId.has("u2"), "u2 should have an ID") - assert.ok(state.messageIds.byRawId.has("a2"), "a2 should have an ID") - assert.ok(state.messageIds.byRawId.has("u3"), "u3 should have an ID") - assert.ok(state.messageIds.byRawId.has("a3"), "a3 should have an ID") - - const allRefs = Array.from(state.messageIds.byRawId.values()) - assert.equal(new Set(allRefs).size, allRefs.length, "no duplicate message refs") - - const outputIds = output.messages.map((m: any) => m.info.id) - assert.ok(outputIds.includes("u1"), "u1 (first user) is force-preserved even when compressed") - assert.ok(!outputIds.includes("a1"), "a1 should be pruned from output") - assert.ok(outputIds.includes("u2"), "u2 should survive") - assert.ok(outputIds.includes("a2"), "a2 should survive") -}) - -// ─── Test: Manual trigger applied to last user message ─────────────────────── - -// ─── Test: Sub-agent messages are skipped ──────────────────────────────────── - -test("sub-agent messages: pipeline returns early for sub-agent sessions", async () => { - const { state, handler } = setupPipeline() - state.isSubAgent = true - // Note: config has experimental.allowSubAgents = false - - const output = { - messages: [ - makeUserMessage("u1", "Hello"), - makeAssistantMessage("a1", "Should not be processed"), - ], - } - - await handler({}, output) - - // Sub-agent early return: message IDs should NOT be assigned - // (the pipeline returns after syncCompressPermissionState check) - assert.equal(state.messageIds.byRawId.has("u1"), false) - assert.equal(state.messageIds.byRawId.has("a1"), false) -}) - -// ─── Test: Deny permission still processes filterMessages + stripHallucinations ─ - -test("deny permission: still filters messages and strips hallucinations", async () => { - const config = buildConfig() - config.compress.permission = "deny" - - const state = createSessionState() - state.sessionId = SID - const logger = new Logger(false) - const handler = createChatMessageTransformHandler( - createMockClient(), - createTestRegistry(state), - logger, - config, - createMockPrompts(), - { global: undefined, agents: {} }, - ) - - const output = { - messages: [ - makeAssistantMessage("a1", "Hello secret world"), - { role: "user", parts: [] }, // invalid - no .info - ] as WithParts[], - } - - await handler({}, output) - - // Invalid message filtered out - assert.equal(output.messages.length, 1) - assert.equal(output.messages[0].info.id, "a1") - - // Hallucination stripped even with deny - const textPart = output.messages[0].parts.find((p: any) => p.type === "text") - assert.equal((textPart as any).text, "Hello world") -}) - -// ─── Test: State persistence survives round-trip ───────────────────────────── - -test("state persistence: session state survives save/load round-trip", async () => { - const { state, tempDir } = setupPipeline() - - state.messageIds.byRawId.set("u1", "m00001") - state.messageIds.byRawId.set("a1", "m00002") - state.messageIds.byRef.set("m00001", "u1") - state.messageIds.byRef.set("m00002", "a1") - state.messageIds.nextRef = 3 - state.stats.totalPruneTokens = 5000 - - const logger = new Logger(false) - await saveSessionState(state, logger) - - const { loadSessionState } = await import("../lib/state/persistence") - const loaded = await loadSessionState(SID, logger) - - assert.ok(loaded, "state file should be loadable") - assert.equal(loaded!.messageIds?.byRawId?.["u1"], "m00001") - assert.equal(loaded!.messageIds?.byRawId?.["a1"], "m00002") - assert.equal(loaded!.messageIds?.nextRef, 3) - assert.equal(loaded!.stats.totalPruneTokens, 5000) - - rmSync(tempDir, { recursive: true, force: true }) -}) - -// ─── Test: Internal agent requests are skipped (Bug 37) ────────────────────── - -test("title agent request: pipeline is skipped and messages are not mutated", async () => { - const { state, handler } = setupPipeline() - - // Seed state as if a normal conversation already happened, so we can detect - // corruption of currentTurn / messageIds by the title request. - const seedMessages: WithParts[] = [ - makeUserMessage("seed-u1", "Hello", SID, "build"), - makeAssistantMessage("seed-a1", "Hi there"), - makeUserMessage("seed-u2", "Second message", SID, "build"), - ] - await handler({}, { messages: seedMessages }) - - const turnBefore = state.currentTurn - const nextRefBefore = state.messageIds.nextRef - const byRawIdSizeBefore = state.messageIds.byRawId.size - - // Now simulate OpenCode's internal title-generation request. The user message - // carries agent: "title". This must NOT be mutated. - const titleMessages: WithParts[] = [ - makeUserMessage("title-u1", "Generate a title for this conversation", SID, "title"), - ] - const originalText = (titleMessages[0].parts[0] as { text: string }).text - await handler({}, { messages: titleMessages }) - - // Messages returned unchanged (no mNNNN injection, no suffix, no pruning) - assert.equal(titleMessages.length, 1) - assert.equal(titleMessages[0].info.id, "title-u1") - assert.equal((titleMessages[0].parts[0] as { text: string }).text, originalText) - - // State NOT corrupted by the internal request - assert.equal(state.currentTurn, turnBefore, "currentTurn must not change for title request") - assert.equal( - state.messageIds.nextRef, - nextRefBefore, - "nextRef must not advance for title request", - ) - assert.equal( - state.messageIds.byRawId.size, - byRawIdSizeBefore, - "messageIds map must not grow for title request", - ) - assert.ok( - !state.messageIds.byRawId.has("title-u1"), - "title request user message must not get a ref", - ) -}) - -test("summary and compaction agent requests are skipped", async () => { - const { state, handler } = setupPipeline() - - // Seed normal conversation state - await handler({}, { - messages: [ - makeUserMessage("seed-u1", "Hello", SID, "build"), - makeAssistantMessage("seed-a1", "Hi"), - ], - }) - - const nextRefBefore = state.messageIds.nextRef - - for (const internalAgent of ["summary", "compaction"]) { - const internalMessages: WithParts[] = [ - makeUserMessage( - `${internalAgent}-u1`, - `Internal ${internalAgent} request`, - SID, - internalAgent, - ), - ] - const originalText = (internalMessages[0].parts[0] as { text: string }).text - await handler({}, { messages: internalMessages }) - - // Messages untouched - assert.equal(internalMessages.length, 1, `${internalAgent}: message count unchanged`) - assert.equal( - (internalMessages[0].parts[0] as { text: string }).text, - originalText, - `${internalAgent}: text must not be mutated`, - ) - // No ref assigned - assert.ok( - !state.messageIds.byRawId.has(`${internalAgent}-u1`), - `${internalAgent}: must not get a ref`, - ) - } - - // State unchanged across both internal requests - assert.equal(state.messageIds.nextRef, nextRefBefore) -}) - -test("normal agent request (build) is still fully processed", async () => { - const { state, handler } = setupPipeline() - - const messages: WithParts[] = [ - makeUserMessage("u1", "Hello", SID, "build"), - makeAssistantMessage("a1", "Hi there"), - ] - - await handler({}, { messages }) - - // Normal processing: refs assigned, suffix message appended - assert.ok(state.messageIds.byRawId.has("u1"), "build: u1 should get a ref") - assert.ok(state.messageIds.byRawId.has("a1"), "build: a1 should get a ref") - assert.ok(state.messageIds.nextRef >= 3, "build: nextRef should advance") - assert.ok( - messages.length >= 2, - "build: messages should be processed (suffix may be appended)", - ) -}) diff --git a/tests/e2e-tier-compression.test.ts b/tests/e2e-tier-compression.test.ts deleted file mode 100644 index 46e2658c..00000000 --- a/tests/e2e-tier-compression.test.ts +++ /dev/null @@ -1,1165 +0,0 @@ -/** - * E2E tests for multi-tier compression triggers. - * - * Tests simulate the scenario where many T1 compression blocks accumulate - * over dozens of turns, reaching the threshold for T2 distillation (and - * eventually T3 condensation). In production this takes 10+ days; here we - * pre-populate the state with realistic block data to test the trigger - * logic without waiting. - * - * Key behaviors tested: - * - T2 trigger fires INDEPENDENTLY when T1 summaries reach nudgeGrowthTokens - * - T2 trigger does NOT fire when T1 summaries are below threshold - * - T3 trigger fires when T2 summaries reach nudgeGrowthTokens - * - T2 trigger fires even when T1 nudge is active (independent, not fallback) - * - T2 trigger respects cadence (growthFloor) - */ - -import assert from "node:assert/strict" -import test from "node:test" -import type { PluginConfig } from "../lib/config" -import { createChatMessageTransformHandler } from "../lib/hooks" -import { Logger } from "../lib/logger" -import { createSessionState, type WithParts, type SessionState } from "../lib/state" -import { createTestRegistry } from "./registry-stub" -import { isSyntheticMessage } from "../lib/messages/query" -import { mkdtempSync, rmSync } from "node:fs" -import { join } from "node:path" -import { tmpdir } from "node:os" -import type { CompressionBlock } from "../lib/state/types" - -const SID = "session-tier-test" - -function buildConfig(overrides: Partial = {}): PluginConfig { - const base: PluginConfig = { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { enabled: true, protectedTools: [] }, - experimental: { allowSubAgents: false, customPrompts: false }, - protectedFilePatterns: [], - compress: { - mode: "message", - permission: "allow", - showCompression: false, - summaryBuffer: true, - maxContextLimit: 60000, - minContextLimit: 40000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: ["task"], - protectTags: false, - protectUserMessages: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - }, - } - return { ...base, ...overrides } -} - -function makeUserMessage(id: string, text: string): WithParts { - return { - info: { - id, - sessionID: SID, - role: "user", - agent: "assistant", - time: { created: Date.now() }, - model: { providerID: "test-provider", modelID: "test-model" }, - } as WithParts["info"], - parts: [{ type: "text", text, id: `${id}-p1`, sessionID: SID, messageID: id }], - } -} - -function makeAssistantMessage(id: string, text: string, extraParts?: WithParts["parts"]): WithParts { - const baseParts: WithParts["parts"] = [ - { type: "step-start", id: `${id}-ss`, sessionID: SID, messageID: id }, - { type: "text", text, id: `${id}-p1`, sessionID: SID, messageID: id }, - ] - return { - info: { - id, - sessionID: SID, - role: "assistant", - agent: "assistant", - parentID: "parent-placeholder", - modelID: "test-model", - providerID: "test-provider", - mode: "normal", - path: { cwd: "/", root: "/" }, - summary: false, - cost: 0, - tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: Date.now() }, - } as WithParts["info"], - parts: extraParts ? [...baseParts, ...extraParts] : baseParts, - } -} - -function makeCompressionBlock( - blockId: number, - summaryTokens: number, - topic: string, - tier: 1 | 2 = 1, - survivedCount: number = 10, - anchorMessageId: string = "u1", -): CompressionBlock { - return { - blockId, - runId: blockId, - active: true, - deactivatedByUser: false, - compressedTokens: summaryTokens * 60, - summaryTokens, - durationMs: 5000, - topic, - batchTopic: topic, - startId: `m${String(blockId * 10).padStart(5, "0")}`, - endId: `m${String(blockId * 10 + 5).padStart(5, "0")}`, - anchorMessageId, - compressMessageId: `msg-comp-${blockId}`, - compressCallId: `call-comp-${blockId}`, - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [`msg-${blockId}`], - directToolIds: [], - effectiveMessageIds: [`msg-${blockId}`], - effectiveToolIds: [], - createdAt: Date.now() - blockId * 60000, - summary: `[Compressed conversation section]\n## ${topic}\nSummary content for testing (${summaryTokens} tokens).`, - survivedCount, - generation: survivedCount >= 5 ? "old" : "young", - tier, - } -} - -function createMockClient() { - return { session: { get: async () => ({ data: { parentID: null } }) } } -} - -function createMockPrompts() { - return { - reload() {}, - getRuntimePrompts() { - return { - system: "ACP system", - compressRange: "compress range", - compressMessage: "compress message", - contextLimitNudge: "nudge", - turnNudge: "turn nudge", - iterationNudge: "iteration nudge", - manualExtension: "", - subagentExtension: "", - } - }, - } -} - -function setupPipeline( - configOverrides: Partial = {}, - stateOverrides: Partial = {}, -) { - const tempDir = mkdtempSync(join(tmpdir(), "acp-tier-e2e-")) - process.env.XDG_DATA_HOME = tempDir - process.env.XDG_CONFIG_HOME = tempDir - - const state = createSessionState() - state.sessionId = SID - Object.assign(state, stateOverrides) - - const config = buildConfig(configOverrides) - const logger = new Logger(false) - const handler = createChatMessageTransformHandler( - createMockClient(), - createTestRegistry(state), - logger, - config, - createMockPrompts(), - { global: undefined, agents: {} }, - ) - - return { state, logger, config, handler, tempDir } -} - -function getSuffixText(output: { messages: WithParts[] }): string { - const suffix = output.messages.find((m: WithParts) => isSyntheticMessage(m)) - if (!suffix) return "" - return suffix.parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text || "") - .join("\n") -} - -function populateBlocks( - state: SessionState, - blocks: CompressionBlock[], -) { - for (const block of blocks) { - state.prune.messages.blocksById.set(block.blockId, block) - state.prune.messages.activeBlockIds.add(block.blockId) - } -} - -// ─── T2 Trigger: fires when T1 summaries reach threshold ──────────────────── - -test("T2 trigger: fires when T1 summaries exceed nudgeGrowthTokens", async () => { - // nudgeGrowthTokens for 1M model = ~50K. We set it explicitly to 50000. - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // Pre-populate 10 T1 blocks totaling 60K tokens (>50K threshold) - const blocks: CompressionBlock[] = [] - for (let i = 1; i <= 10; i++) { - blocks.push(makeCompressionBlock(i, 6000, `T1 block ${i}`, 1, 15)) - } - populateBlocks(state, blocks) - - // Allow tier nudge (lastTier2NudgeTokens undefined = first time) - state.nudges.lastPerMessageNudgeTokens = 100000 - - const output = { - messages: [ - makeUserMessage("u1", "Continue work"), - makeAssistantMessage("a1", "Working on it"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - assert.ok( - suffixText.includes("[Tier 2 Trigger]"), - `T2 trigger should fire when T1 summaries (60K) exceed threshold (50K). Got suffix:\n${suffixText.slice(0, 500)}`, - ) - assert.ok( - suffixText.includes("Distill"), - "T2 trigger should say 'Distill'", - ) -}) - -// ─── T2 Trigger: does NOT fire when below threshold ───────────────────────── - -test("T2 trigger: does NOT fire when T1 summaries below nudgeGrowthTokens", async () => { - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // Only 20K of T1 summaries (< 50K threshold) - const blocks: CompressionBlock[] = [] - for (let i = 1; i <= 4; i++) { - blocks.push(makeCompressionBlock(i, 5000, `T1 block ${i}`, 1, 15)) - } - populateBlocks(state, blocks) - - state.nudges.lastPerMessageNudgeTokens = 100000 - - const output = { - messages: [ - makeUserMessage("u1", "Continue"), - makeAssistantMessage("a1", "OK"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - assert.ok( - !suffixText.includes("[Tier 2 Trigger]"), - "T2 trigger should NOT fire when T1 summaries (20K) < threshold (50K)", - ) -}) - -// ─── T2 Trigger: fires when T1 summaries exceed threshold (T1 priority) ───── - -test("T2 trigger: fires when T1 summaries exceed threshold even with large context", async () => { - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - maxContextLimit: 100000, - minContextLimit: 80000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // T1 summaries at 60K (triggers T2) - const blocks: CompressionBlock[] = [] - for (let i = 1; i <= 10; i++) { - blocks.push(makeCompressionBlock(i, 6000, `T1 block ${i}`, 1, 15)) - } - populateBlocks(state, blocks) - - state.nudges.lastPerMessageNudgeTokens = 0 - - const output = { - messages: [ - makeUserMessage("u1", "Big context now"), - makeAssistantMessage("a1", "x".repeat(100000), [ - { - type: "tool", - tool: "bash", - callID: "c1", - id: "p1", - sessionID: SID, - messageID: "a1", - state: { - status: "completed", - output: "x".repeat(200000), - input: {}, - }, - }, - ]), - makeUserMessage("u2", "Next"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - assert.ok( - suffixText.includes("[Tier 2 Trigger]"), - "T2 trigger should fire when T1 summaries (60K) exceed threshold (50K)", - ) -}) - -// ─── T2 Trigger: respects cadence (growthFloor) ───────────────────────────── - -test("T2 trigger: suppressed by cadence when lastTier2NudgeTokens too recent", async () => { - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // T1 summaries at 60K (> threshold) - const blocks: CompressionBlock[] = [] - for (let i = 1; i <= 10; i++) { - blocks.push(makeCompressionBlock(i, 6000, `T1 block ${i}`, 1, 15)) - } - populateBlocks(state, blocks) - - // Current context at 200K, but last T2 nudge was at 195K - // growthFloor = max(5000, 0.45 * 50000) = 22500 - // Growth since last = 200K - 195K = 5K < 22500 → cadence NOT met - state.nudges.lastTier2NudgeTokens = 195000 - state.nudges.lastPerMessageNudgeTokens = 100000 - - const output = { - messages: [ - makeUserMessage("u1", "Continue"), - makeAssistantMessage("a1", "OK"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - assert.ok( - !suffixText.includes("[Tier 2 Trigger]"), - "T2 trigger should be suppressed by cadence (growth < growthFloor)", - ) -}) - -// ─── T3 Trigger: fires when T2 summaries reach threshold ──────────────────── - -test("T3 trigger: fires when T2 summaries exceed nudgeGrowthTokens", async () => { - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // T2 summaries at 60K (> threshold), but T1 summaries below threshold - // (so T2 doesn't fire, only T3) - const t2Blocks: CompressionBlock[] = [] - for (let i = 1; i <= 6; i++) { - t2Blocks.push(makeCompressionBlock(i, 10000, `T2 block ${i}`, 2, 20)) - } - populateBlocks(state, t2Blocks) - - state.nudges.lastPerMessageNudgeTokens = 100000 - - const output = { - messages: [ - makeUserMessage("u1", "Continue"), - makeAssistantMessage("a1", "OK"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - assert.ok( - suffixText.includes("[Tier 3 Trigger]"), - `T3 trigger should fire when T2 summaries (60K) exceed threshold (50K). Got:\n${suffixText.slice(0, 500)}`, - ) - assert.ok( - suffixText.includes("Condense"), - "T3 trigger should say 'Condense'", - ) -}) - -// ─── T2 > T3 Priority: when both would trigger, T2 wins ───────────────────── - -test("T2 > T3 priority: only T2 fires when both T1 and T2 summaries exceed threshold", async () => { - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // Both T1 (60K) and T2 (60K) exceed threshold - const blocks: CompressionBlock[] = [] - for (let i = 1; i <= 6; i++) { - blocks.push(makeCompressionBlock(i, 10000, `T1 block ${i}`, 1, 15)) - } - for (let i = 101; i <= 106; i++) { - blocks.push(makeCompressionBlock(i, 10000, `T2 block ${i}`, 2, 20)) - } - populateBlocks(state, blocks) - - state.nudges.lastPerMessageNudgeTokens = 100000 - - const output = { - messages: [ - makeUserMessage("u1", "Continue"), - makeAssistantMessage("a1", "OK"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - assert.ok( - suffixText.includes("[Tier 2 Trigger]"), - "T2 should take priority when both T1 and T2 summaries exceed threshold", - ) - assert.ok( - !suffixText.includes("[Tier 3 Trigger]"), - "T3 should NOT fire when T2 fires (only one per turn)", - ) -}) - -// ─── T2 Trigger: generates correct compress range (b→b) ────────────────────── - -test("T2 trigger: nudge text contains b→b compress range for oldest blocks", async () => { - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // Create blocks with varying ages (survivedCount) - const blocks: CompressionBlock[] = [ - makeCompressionBlock(5, 12000, "Oldest block", 1, 25), - makeCompressionBlock(8, 11000, "Old block", 1, 20), - makeCompressionBlock(12, 10000, "Medium block", 1, 15), - makeCompressionBlock(15, 9000, "Newer block", 1, 10), - makeCompressionBlock(20, 8000, "Newest block", 1, 5), - ] - populateBlocks(state, blocks) - - state.nudges.lastPerMessageNudgeTokens = 100000 - - const output = { - messages: [ - makeUserMessage("u1", "Continue"), - makeAssistantMessage("a1", "OK"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - // Should contain block range from oldest to newest - assert.ok( - suffixText.includes('startId: "b5"'), - "Should start from oldest block b5", - ) - assert.ok( - suffixText.includes('endId: "b20"'), - "Should end at newest block b20", - ) - // Should list all 5 blocks - assert.ok(suffixText.includes("b5"), "Should list b5") - assert.ok(suffixText.includes("b8"), "Should list b8") - assert.ok(suffixText.includes("b12"), "Should list b12") - assert.ok(suffixText.includes("b15"), "Should list b15") - assert.ok(suffixText.includes("b20"), "Should list b20") -}) - -// ─── getTierTokenUsage: correct token counting by tier ────────────────────── - -test("getTierTokenUsage: correctly sums tokens by tier", async () => { - const { getTierTokenUsage } = await import("../lib/state/utils") - const { state } = setupPipeline() - - populateBlocks(state, [ - makeCompressionBlock(1, 5000, "T1-a", 1), - makeCompressionBlock(2, 3000, "T1-b", 1), - makeCompressionBlock(3, 8000, "T2-a", 2), - makeCompressionBlock(4, 2000, "T2-b", 2), - ]) - - // Mark one as inactive - const b4 = state.prune.messages.blocksById.get(4)! - b4.active = false - state.prune.messages.activeBlockIds.delete(4) - - const usage = getTierTokenUsage(state) - assert.equal(usage.tier1Tokens, 8000, "T1 = 5000 + 3000") - assert.equal(usage.tier2Tokens, 8000, "T2 = 8000 (b4 inactive, excluded)") - assert.equal(usage.tier3Tokens, 0) -}) - -// ─── Untiered blocks default to tier 1 ────────────────────────────────────── - -test("getTierTokenUsage: blocks without tier field default to tier 1", async () => { - const { getTierTokenUsage } = await import("../lib/state/utils") - const { state } = setupPipeline() - - const block = makeCompressionBlock(1, 5000, "Legacy block") - block.tier = undefined - state.prune.messages.blocksById.set(1, block) - state.prune.messages.activeBlockIds.add(1) - - const usage = getTierTokenUsage(state) - assert.equal(usage.tier1Tokens, 5000, "Untiered block should count as tier 1") - assert.equal(usage.tier2Tokens, 0) -}) - -// ─── Cross-tier safety: nudge narrows range to exclude non-target blocks ── - -test("T2 trigger: narrows range when non-target (T2) block sits between T1 candidates", async () => { - const { state, handler } = setupPipeline({ - compress: { - ...buildConfig().compress!, - nudgeGrowthTokens: 50000, - }, - }, { - modelContextLimit: 1_000_000, - }) - - // T1 candidates: b5, b6, b7 (before T2) and b12, b13 (after T2) - // T2 block b10 sits between them — should narrow to one contiguous group - const blocks: CompressionBlock[] = [ - makeCompressionBlock(5, 12000, "T1-a", 1, 25), - makeCompressionBlock(6, 12000, "T1-b", 1, 25), - makeCompressionBlock(7, 12000, "T1-c", 1, 25), - makeCompressionBlock(10, 5000, "T2 block", 2, 30), - makeCompressionBlock(12, 12000, "T1-d", 1, 20), - makeCompressionBlock(13, 12000, "T1-e", 1, 20), - ] - populateBlocks(state, blocks) - - state.nudges.lastPerMessageNudgeTokens = 100000 - - const output = { - messages: [ - makeUserMessage("u1", "Continue"), - makeAssistantMessage("a1", "OK"), - ], - } - - await handler({}, output) - - const suffixText = getSuffixText(output) - // T2 trigger should fire (T1 summaries exceed 50K) - assert.ok(suffixText.includes("[Tier 2 Trigger]"), "T2 trigger should fire") - - // The range should be narrowed — either b5→b7 or b12→b13, NOT b5→b13 - // (which would include the T2 block b10) - assert.ok( - !suffixText.includes('endId: "b13"'), - "Should NOT suggest range ending at b13 (would include T2 b10)", - ) - assert.ok( - suffixText.includes('startId: "b5"') && suffixText.includes('endId: "b7"'), - "Should narrow to first contiguous group b5→b7", - ) -}) - -// ─── Cross-tier safety: applyCompressionState uses minConsumedTier ───────── - -test("applyCompressionState: mixed-tier consumption produces minTier+1, not maxTier+1", async () => { - const { applyCompressionState } = await import("../lib/compress/state") - const { state } = setupPipeline() - - // Pre-populate a T2 block that would be "accidentally" consumed - const t2Block = makeCompressionBlock(10, 3000, "T2 block", 2, 20) - const t1BlockA = makeCompressionBlock(5, 3000, "T1-a", 1, 15) - const t1BlockB = makeCompressionBlock(12, 3000, "T1-b", 1, 15) - - populateBlocks(state, [t1BlockA, t2Block, t1BlockB]) - - // Simulate a compression that "consumes" all three (as search.ts would - // if their anchors fell in range) - const selection = { - startReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 5 }, - endReference: { kind: "compressed-block" as const, rawIndex: 2, blockId: 12 }, - messageIds: ["msg-5", "msg-10", "msg-12"], - toolIds: [] as string[], - messageTokenById: new Map([ - ["msg-5", 500], - ["msg-10", 300], - ["msg-12", 500], - ]), - } - - applyCompressionState( - state, - { - runId: 100, - topic: "T2 compression", - batchTopic: "T2 compression", - startId: "b5", - endId: "b12", - summaryTokens: 800, - summary: "distilled summary", - compressMessageId: "msg-comp-100", - }, - selection, - "msg-anchor-100", - 100, - "distilled summary", - [5, 10, 12], // consumedBlockIds: T1(5), T2(10), T1(12) - ) - - const newBlock = state.prune.messages.blocksById.get(100)! - assert.equal(newBlock.tier, 2, "Output tier should be minTier+1=2, not maxTier+1=3") - - // T1 blocks should be deactivated; T2 block should stay active - const b5 = state.prune.messages.blocksById.get(5)! - const b10 = state.prune.messages.blocksById.get(10)! - const b12 = state.prune.messages.blocksById.get(12)! - assert.equal(b5.active, false, "T1 block b5 should be deactivated") - assert.equal(b12.active, false, "T1 block b12 should be deactivated") - assert.equal(b10.active, true, "T2 block b10 should remain active (not consumed)") - - // consumedBlockIds should only include target-tier blocks - assert.deepEqual( - newBlock.consumedBlockIds.sort((a, b) => a - b), - [5, 12], - "consumedBlockIds should exclude non-target T2 block", - ) -}) - -// ─── effectiveCompressedTokens: T2+ blocks track full coverage ───────────── - -test("applyCompressionState: T2 block gets effectiveCompressedTokens = consumed T1 tokens", async () => { - const { applyCompressionState } = await import("../lib/compress/state") - const { state } = setupPipeline() - - // T1 blocks with known compressedTokens - const t1a = makeCompressionBlock(1, 1000, "T1-a", 1, 10) - t1a.compressedTokens = 60000 - t1a.effectiveCompressedTokens = 60000 - const t1b = makeCompressionBlock(2, 1000, "T1-b", 1, 10) - t1b.compressedTokens = 40000 - t1b.effectiveCompressedTokens = 40000 - - populateBlocks(state, [t1a, t1b]) - - const selection = { - startReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 1 }, - endReference: { kind: "compressed-block" as const, rawIndex: 1, blockId: 2 }, - messageIds: [], - toolIds: [], - messageTokenById: new Map(), - } - - applyCompressionState( - state, - { - runId: 10, - topic: "T2 distillation", - batchTopic: "T2 distillation", - startId: "b1", - endId: "b2", - summaryTokens: 2000, - summary: "distilled", - compressMessageId: "msg-comp-10", - }, - selection, - "msg-anchor-10", - 10, - "distilled", - [1, 2], - ) - - const t2Block = state.prune.messages.blocksById.get(10)! - assert.equal(t2Block.tier, 2) - assert.equal(t2Block.compressedTokens, 0, "T2 direct compressedTokens should be 0") - assert.equal( - t2Block.effectiveCompressedTokens, - 100000, - "effectiveCompressedTokens should be 60000+40000=100000", - ) - - assert.equal( - state.stats.totalPruneTokens, - 0, - "totalPruneTokens uses direct compressedTokens (0 for T2 — raw tokens counted at T1 creation)", - ) -}) - -// ─── effectiveCompressedTokens: T1 blocks get effectiveCompressedTokens = compressedTokens ── - -test("applyCompressionState: T1 block gets effectiveCompressedTokens = compressedTokens", async () => { - const { applyCompressionState } = await import("../lib/compress/state") - const { state } = setupPipeline() - - const selection = { - messageIds: ["m1", "m2", "m3"], - toolIds: [], - messageTokenById: new Map([ - ["m1", 500], - ["m2", 300], - ["m3", 200], - ]), - } - - applyCompressionState( - state, - { - runId: 1, - topic: "T1 compression", - batchTopic: "T1 compression", - startId: "m00001", - endId: "m00003", - summaryTokens: 100, - summary: "summary", - compressMessageId: "msg-comp-1", - }, - selection, - "msg-anchor-1", - 1, - "summary", - [], // no consumed blocks → T1 - ) - - const t1Block = state.prune.messages.blocksById.get(1)! - assert.equal(t1Block.tier, 1) - assert.equal(t1Block.compressedTokens, 1000, "compressedTokens from direct messages") - assert.equal( - t1Block.effectiveCompressedTokens, - 1000, - "T1 effectiveCompressedTokens should equal compressedTokens", - ) -}) - -test("tier-aware decompress: default restores one level up (T2→T1)", async () => { - const tmpDir = mkdtempSync(join(tmpdir(), "acp-tier-decomp-")) - const registry = createTestRegistry(tmpDir) - const logger = new Logger({ level: "error" }) - const config = buildConfig() - - const t1 = makeCompressionBlock(1, 1000, "T1 work", 1, 10, "u1") - const t2 = makeCompressionBlock(2, 100, "T2 distill", 2, 10, "u2") - t2.consumedBlockIds = [1] - t2.directMessageIds = ["msg-comp-1"] - t2.effectiveMessageIds = [...t1.effectiveMessageIds, "msg-comp-1"] - t1.active = false - - const state = createSessionState(SID, "test-model", 1_000_000) - state.prune.messages.blocksById.set(1, t1) - state.prune.messages.blocksById.set(2, t2) - state.prune.messages.activeBlockIds.add(2) - - const messages: WithParts[] = [ - makeUserMessage("u1", "original user message"), - makeAssistantMessage("u2", "t2 compress call"), - ] - - const { deactivateCompressionTarget } = await import("../lib/compress/decompress-logic") - const { syncCompressionBlocks } = await import("../lib/messages/sync") - - const target = { displayId: 2, blocks: [t2] } - deactivateCompressionTarget(state.prune.messages, target) - - syncCompressionBlocks(state, logger, messages) - - assert.equal(t2.active, false, "T2 should be inactive after decompress") - assert.equal(t1.active, true, "T1 should be reactivated by sync (one level up)") - assert.equal(t1.deactivatedByUser, false, "T1 should not be deactivatedByUser") - - rmSync(tmpDir, { recursive: true, force: true }) -}) - -test("tier-aware decompress: full:true restores to original (T2→raw)", async () => { - const tmpDir = mkdtempSync(join(tmpdir(), "acp-tier-decomp-full-")) - const registry = createTestRegistry(tmpDir) - const logger = new Logger({ level: "error" }) - const config = buildConfig() - - const t1 = makeCompressionBlock(1, 1000, "T1 work", 1, 10, "u1") - const t2 = makeCompressionBlock(2, 100, "T2 distill", 2, 10, "u2") - t2.consumedBlockIds = [1] - t2.directMessageIds = ["msg-comp-1"] - t2.effectiveMessageIds = [...t1.effectiveMessageIds, "msg-comp-1"] - t1.active = false - - const state = createSessionState(SID, "test-model", 1_000_000) - state.prune.messages.blocksById.set(1, t1) - state.prune.messages.blocksById.set(2, t2) - state.prune.messages.activeBlockIds.add(2) - - const messages: WithParts[] = [ - makeUserMessage("u1", "original user message"), - makeAssistantMessage("u2", "t2 compress call"), - ] - - const { deactivateCompressionTarget } = await import("../lib/compress/decompress-logic") - const { syncCompressionBlocks } = await import("../lib/messages/sync") - - const target = { displayId: 2, blocks: [t2] } - deactivateCompressionTarget(state.prune.messages, target, { full: true }) - - syncCompressionBlocks(state, logger, messages) - - assert.equal(t2.active, false, "T2 should be inactive after decompress") - assert.equal(t1.active, false, "T1 should stay inactive (full decompress to original)") - assert.equal(t1.deactivatedByUserDeep, true, "T1 marked deactivatedByUserDeep for full mode") - - rmSync(tmpDir, { recursive: true, force: true }) -}) - -function extractMsgText(msg: WithParts): string { - const texts: string[] = [] - for (const part of msg.parts) { - if (typeof part === "object" && part !== null && "text" in part && typeof part.text === "string") { - texts.push(part.text) - } - } - return texts.join(" ") -} - -function cloneMessages(msgs: WithParts[]): WithParts[] { - return msgs.map((m) => ({ ...m, parts: [...m.parts], info: { ...m.info } })) -} - -function registerMessages(state: SessionState, msgs: WithParts[]): void { - for (const msg of msgs) { - state.prune.messages.byMessageId.set(msg.info.id, { - tokenCount: 100, - allBlockIds: [], - activeBlockIds: [], - }) - } -} - -test("E2E round-trip: compress → decompress → content identical", async () => { - const { state, logger, config } = setupPipeline() - const { applyCompressionState } = await import("../lib/compress/state") - const { deactivateCompressionTarget } = await import("../lib/compress/decompress-logic") - const { syncCompressionBlocks } = await import("../lib/messages/sync") - const { prune } = await import("../lib/messages/prune") - - const messages = [ - makeUserMessage("u1", "Fix the login bug in auth.ts"), - makeAssistantMessage("a1", "I will investigate the login bug."), - makeAssistantMessage("a2", "Found the issue in auth.ts line 42: missing null check."), - makeAssistantMessage("a3", "Fixed. The bug was a missing null check on the token."), - ] - registerMessages(state, messages) - - const originalTexts = messages.map(extractMsgText) - - applyCompressionState(state, { - runId: 1, topic: "Bug fix", batchTopic: "Bug fix", - startId: "a1", endId: "a3", summaryTokens: 200, - summary: "Investigated and fixed login bug", - compressMessageId: "msg-comp-1", - }, { - messageIds: ["a1", "a2", "a3"], toolIds: [], - messageTokenById: new Map([["a1", 100], ["a2", 100], ["a3", 100]]), - }, "msg-comp-1", 1, "Bug fix", []) - - const afterCompress = cloneMessages(messages) - prune(state, logger as any, config, afterCompress) - assert.equal(afterCompress.length, 1, "Only user message visible after compression") - assert.equal(afterCompress[0].info.id, "u1") - - const block = state.prune.messages.blocksById.get(1)! - deactivateCompressionTarget(state.prune.messages, { displayId: 1, blocks: [block] }) - syncCompressionBlocks(state, logger as any, messages) - - const afterDecompress = cloneMessages(messages) - prune(state, logger as any, config, afterDecompress) - assert.equal(afterDecompress.length, 4, "All 4 messages visible after decompress") - - const restoredTexts = afterDecompress.map(extractMsgText) - assert.deepEqual(restoredTexts, originalTexts, - "Content must be identical after compress → decompress round-trip") - - rmSync(state.sessionId, { recursive: true, force: true }) -}) - -test("E2E round-trip: decompress → recompress → no redundancy", async () => { - const { state, logger, config } = setupPipeline() - const { applyCompressionState } = await import("../lib/compress/state") - const { deactivateCompressionTarget } = await import("../lib/compress/decompress-logic") - const { syncCompressionBlocks } = await import("../lib/messages/sync") - const { prune } = await import("../lib/messages/prune") - - const messages = [ - makeUserMessage("u1", "Task description"), - makeAssistantMessage("a1", "Step 1 content"), - makeAssistantMessage("a2", "Step 2 content"), - ] - registerMessages(state, messages) - - applyCompressionState(state, { - runId: 1, topic: "Work", batchTopic: "Work", - startId: "a1", endId: "a2", summaryTokens: 100, - summary: "Did step 1 and 2", - compressMessageId: "msg-comp-1", - }, { - messageIds: ["a1", "a2"], toolIds: [], - messageTokenById: new Map([["a1", 100], ["a2", 100]]), - }, "msg-comp-1", 1, "Work", []) - - const block1 = state.prune.messages.blocksById.get(1)! - assert.equal(block1.active, true) - - deactivateCompressionTarget(state.prune.messages, { displayId: 1, blocks: [block1] }) - syncCompressionBlocks(state, logger as any, messages) - - assert.equal(block1.active, false, "Block 1 deactivated after decompress") - - const activeBeforeRecompress = new Set(state.prune.messages.activeBlockIds) - assert.equal(activeBeforeRecompress.size, 0, "No active blocks after decompress") - - const afterDecompress = cloneMessages(messages) - prune(state, logger as any, config, afterDecompress) - assert.equal(afterDecompress.length, 3, "All messages visible after decompress") - - applyCompressionState(state, { - runId: 2, topic: "Work v2", batchTopic: "Work v2", - startId: "a1", endId: "a2", summaryTokens: 100, - summary: "Did step 1 and 2 (recompressed)", - compressMessageId: "msg-comp-2", - }, { - messageIds: ["a1", "a2"], toolIds: [], - messageTokenById: new Map([["a1", 100], ["a2", 100]]), - }, "msg-comp-2", 2, "Work v2", []) - - const block2 = state.prune.messages.blocksById.get(2)! - assert.equal(block2.active, true, "New block 2 is active") - assert.equal(block1.active, false, "Old block 1 stays inactive") - - const activeAfterRecompress = Array.from(state.prune.messages.activeBlockIds) - assert.equal(activeAfterRecompress.length, 1, "Exactly 1 active block (no redundancy)") - assert.ok(activeAfterRecompress.includes(2), "Active block is block 2") - - const entry = state.prune.messages.byMessageId.get("a1")! - assert.equal( - entry.activeBlockIds.length, 1, - "Message a1 has exactly 1 active block (no duplicate coverage)", - ) - assert.equal(entry.activeBlockIds[0], 2, "Active block for a1 is block 2") - - const afterRecompress = cloneMessages(messages) - prune(state, logger as any, config, afterRecompress) - assert.equal(afterRecompress.length, 1, "Only user message visible after recompress") - assert.equal(afterRecompress[0].info.id, "u1") - - rmSync(state.sessionId, { recursive: true, force: true }) -}) - -test("E2E T3 decompress: default restores T2 summaries", async () => { - const { state, logger } = setupPipeline() - const { applyCompressionState } = await import("../lib/compress/state") - const { deactivateCompressionTarget } = await import("../lib/compress/decompress-logic") - const { syncCompressionBlocks } = await import("../lib/messages/sync") - - const messages = [ - makeUserMessage("u1", "Task"), - makeAssistantMessage("a1", "Content 1"), - makeAssistantMessage("a2", "Content 2"), - makeAssistantMessage("a3", "T1 compress anchor 1"), - makeAssistantMessage("a4", "T1 compress anchor 2"), - makeAssistantMessage("a5", "T2 compress anchor"), - ] - registerMessages(state, messages) - - applyCompressionState(state, { - runId: 1, topic: "T1-a", batchTopic: "T1-a", - startId: "a1", endId: "a1", summaryTokens: 500, - summary: "T1 summary A", - compressMessageId: "a3", - }, { - messageIds: ["a1"], toolIds: [], - messageTokenById: new Map([["a1", 30000]]), - }, "a3", 1, "T1-a", []) - - applyCompressionState(state, { - runId: 2, topic: "T1-b", batchTopic: "T1-b", - startId: "a2", endId: "a2", summaryTokens: 500, - summary: "T1 summary B", - compressMessageId: "a4", - }, { - messageIds: ["a2"], toolIds: [], - messageTokenById: new Map([["a2", 30000]]), - }, "a4", 2, "T1-b", []) - - applyCompressionState(state, { - runId: 3, topic: "T2 distill", batchTopic: "T2 distill", - startId: "b1", endId: "b2", summaryTokens: 200, - summary: "T2 distilled summary", - compressMessageId: "a5", - }, { - startReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 1 }, - endReference: { kind: "compressed-block" as const, rawIndex: 1, blockId: 2 }, - messageIds: [], toolIds: [], - messageTokenById: new Map(), - }, "a5", 3, "T2 distill", [1, 2]) - - const t1a = state.prune.messages.blocksById.get(1)! - const t1b = state.prune.messages.blocksById.get(2)! - const t2 = state.prune.messages.blocksById.get(3)! - assert.equal(t1a.tier, 1) - assert.equal(t1b.tier, 1) - assert.equal(t2.tier, 2) - - const messagesT2Anchor = [ - makeAssistantMessage("a6", "T3 compress anchor"), - ] - registerMessages(state, messagesT2Anchor) - - applyCompressionState(state, { - runId: 4, topic: "T3 condense", batchTopic: "T3 condense", - startId: "b3", endId: "b3", summaryTokens: 100, - summary: "T3 condensed", - compressMessageId: "a6", - }, { - startReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 3 }, - endReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 3 }, - messageIds: [], toolIds: [], - messageTokenById: new Map(), - }, "a6", 4, "T3 condense", [3]) - - const t3 = state.prune.messages.blocksById.get(4)! - assert.equal(t3.tier, 3, "Block 4 should be T3") - assert.equal(t2.active, false, "T2 consumed by T3") - assert.equal(t1a.active, false, "T1a consumed by T2") - - deactivateCompressionTarget( - state.prune.messages, - { displayId: 4, blocks: [t3] }, - ) - syncCompressionBlocks(state, logger as any, [...messages, ...messagesT2Anchor]) - - assert.equal(t3.active, false, "T3 inactive after decompress") - assert.equal(t2.active, true, "T2 reactivated (one level up)") - assert.equal(t1a.active, false, "T1a stays inactive (consumed by T2)") - assert.equal(t1b.active, false, "T1b stays inactive (consumed by T2)") - - rmSync(state.sessionId, { recursive: true, force: true }) -}) - -test("E2E T3 decompress: full:true recursively deactivates to raw", async () => { - const { state, logger } = setupPipeline() - const { applyCompressionState } = await import("../lib/compress/state") - const { deactivateCompressionTarget } = await import("../lib/compress/decompress-logic") - const { syncCompressionBlocks } = await import("../lib/messages/sync") - - const messages = [ - makeUserMessage("u1", "Task"), - makeAssistantMessage("a1", "Raw content 1"), - makeAssistantMessage("a2", "Raw content 2"), - makeAssistantMessage("a3", "T1 anchor 1"), - makeAssistantMessage("a4", "T1 anchor 2"), - makeAssistantMessage("a5", "T2 anchor"), - makeAssistantMessage("a6", "T3 anchor"), - ] - registerMessages(state, messages) - - applyCompressionState(state, { - runId: 1, topic: "T1-a", batchTopic: "T1-a", - startId: "a1", endId: "a1", summaryTokens: 500, - summary: "T1 A", compressMessageId: "a3", - }, { messageIds: ["a1"], toolIds: [], messageTokenById: new Map([["a1", 30000]]) }, - "a3", 1, "T1-a", []) - - applyCompressionState(state, { - runId: 2, topic: "T1-b", batchTopic: "T1-b", - startId: "a2", endId: "a2", summaryTokens: 500, - summary: "T1 B", compressMessageId: "a4", - }, { messageIds: ["a2"], toolIds: [], messageTokenById: new Map([["a2", 30000]]) }, - "a4", 2, "T1-b", []) - - applyCompressionState(state, { - runId: 3, topic: "T2", batchTopic: "T2", - startId: "b1", endId: "b2", summaryTokens: 200, - summary: "T2 summary", compressMessageId: "a5", - }, { startReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 1 }, - endReference: { kind: "compressed-block" as const, rawIndex: 1, blockId: 2 }, - messageIds: [], toolIds: [], messageTokenById: new Map() }, - "a5", 3, "T2", [1, 2]) - - applyCompressionState(state, { - runId: 4, topic: "T3", batchTopic: "T3", - startId: "b3", endId: "b3", summaryTokens: 100, - summary: "T3 summary", compressMessageId: "a6", - }, { startReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 3 }, - endReference: { kind: "compressed-block" as const, rawIndex: 0, blockId: 3 }, - messageIds: [], toolIds: [], messageTokenById: new Map() }, - "a6", 4, "T3", [3]) - - const t1a = state.prune.messages.blocksById.get(1)! - const t1b = state.prune.messages.blocksById.get(2)! - const t2 = state.prune.messages.blocksById.get(3)! - const t3 = state.prune.messages.blocksById.get(4)! - - deactivateCompressionTarget( - state.prune.messages, - { displayId: 4, blocks: [t3] }, - { full: true }, - ) - syncCompressionBlocks(state, logger as any, messages) - - assert.equal(t3.active, false, "T3 inactive") - assert.equal(t3.deactivatedByUser, true) - assert.equal(t2.active, false, "T2 stays inactive (full recursive)") - assert.equal(t2.deactivatedByUserDeep, true, "T2 marked by recursive full:true") - assert.equal(t1a.active, false, "T1a stays inactive (full recursive)") - assert.equal(t1a.deactivatedByUserDeep, true, "T1a marked by recursive full:true") - assert.equal(t1b.active, false, "T1b stays inactive") - assert.equal(t1b.deactivatedByUserDeep, true, "T1b marked by recursive full:true") - - rmSync(state.sessionId, { recursive: true, force: true }) -}) diff --git a/tests/e2e-tier-simulation.test.ts b/tests/e2e-tier-simulation.test.ts deleted file mode 100644 index 3caadb91..00000000 --- a/tests/e2e-tier-simulation.test.ts +++ /dev/null @@ -1,551 +0,0 @@ -/** - * Large-scale E2E simulation for multi-tier compression strategy. - * - * Simulates sessions with realistic context growth, compression, and tier - * escalation. Verifies the unified trigger loop produces correct, stable - * behavior over a long session lifetime. - * - * Key properties verified: - * - T1 fires when context exceeds limit, creating blocks - * - T2 fires when T1 summaries accumulate past threshold - * - T3 fires when T2 summaries accumulate past threshold - * - T1 priority: T2/T3 don't fire on the same turn as T1 - * - Independent cadence: T2 firing doesn't reset T3 counter - * - No phantom triggers: tiers only fire when their specific input is ready - * - System reaches steady state: context oscillates around limit, not runaway - */ - -import assert from "node:assert/strict" -import test from "node:test" -import type { PluginConfig } from "../lib/config" -import { createChatMessageTransformHandler } from "../lib/hooks" -import { Logger } from "../lib/logger" -import { createSessionState, type WithParts, type SessionState } from "../lib/state" -import { createTestRegistry } from "./registry-stub" -import { isSyntheticMessage } from "../lib/messages/query" -import { mkdtempSync, rmSync } from "node:fs" -import { join } from "node:path" -import { tmpdir } from "node:os" -import type { CompressionBlock } from "../lib/state/types" - -const SID = "session-tier-sim" - -function buildConfig(overrides: Partial = {}): PluginConfig { - const base: PluginConfig = { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { enabled: true, protectedTools: [] }, - experimental: { allowSubAgents: false, customPrompts: false }, - protectedFilePatterns: [], - compress: { - mode: "message", - permission: "allow", - showCompression: false, - summaryBuffer: true, - maxContextLimit: 50000, - minContextLimit: 40000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: ["task"], - protectTags: false, - protectUserMessages: false, - nudgeGrowthTokens: 10000, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - }, - } - return { ...base, ...overrides } -} - -function makeUserMessage(id: string, text: string): WithParts { - return { - info: { - id, - sessionID: SID, - role: "user", - agent: "assistant", - time: { created: Date.now() }, - model: { providerID: "test-provider", modelID: "test-model" }, - } as WithParts["info"], - parts: [{ type: "text", text, id: `${id}-p1`, sessionID: SID, messageID: id }], - } -} - -function makeAssistantMessage(id: string, text: string, inputTokens: number, extraParts?: WithParts["parts"]): WithParts { - const baseParts: WithParts["parts"] = [ - { type: "step-start", id: `${id}-ss`, sessionID: SID, messageID: id }, - { type: "text", text, id: `${id}-p1`, sessionID: SID, messageID: id }, - ] - return { - info: { - id, - sessionID: SID, - role: "assistant", - agent: "assistant", - parentID: "parent-placeholder", - modelID: "test-model", - providerID: "test-provider", - mode: "normal", - path: { cwd: "/", root: "/" }, - summary: false, - cost: 0, - tokens: { input: inputTokens, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: Date.now() }, - } as WithParts["info"], - parts: extraParts ? [...baseParts, ...extraParts] : baseParts, - } -} - -function createMockClient() { - return { session: { get: async () => ({ data: { parentID: null } }) } } -} - -function createMockPrompts() { - return { - reload() {}, - getRuntimePrompts() { - return { - system: "ACP system", - compressRange: "compress range", - compressMessage: "compress message", - contextLimitNudge: "nudge", - turnNudge: "turn nudge", - iterationNudge: "iteration nudge", - manualExtension: "", - subagentExtension: "", - } - }, - } -} - -interface SimEvent { - turn: number - contextTokens: number - type: "T1" | "T2" | "T3" | "none" - activeT1: number - activeT2: number - t1Tokens: number - t2Tokens: number -} - -function getSuffixText(output: { messages: WithParts[] }): string { - const suffix = output.messages.find((m: WithParts) => isSyntheticMessage(m)) - if (!suffix) return "" - return suffix.parts - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text || "") - .join("\n") -} - -function makeBlock( - blockId: number, - tier: 1 | 2 | 3, - summaryTokens: number, - compressedTokens: number, - topic: string, - anchorMessageId: string, - consumedBlockIds: number[] = [], -): CompressionBlock { - return { - blockId, - runId: blockId, - active: true, - deactivatedByUser: false, - compressedTokens, - effectiveCompressedTokens: compressedTokens, - summaryTokens, - durationMs: 5000, - topic, - batchTopic: topic, - startId: `m${String(blockId * 10).padStart(5, "0")}`, - endId: `m${String(blockId * 10 + 5).padStart(5, "0")}`, - anchorMessageId, - compressMessageId: `msg-comp-${blockId}`, - compressCallId: `call-comp-${blockId}`, - includedBlockIds: [], - consumedBlockIds, - parentBlockIds: [], - directMessageIds: consumedBlockIds.length > 0 ? [] : [`msg-${blockId}`], - directToolIds: [], - effectiveMessageIds: [`msg-${blockId}`], - effectiveToolIds: [], - createdAt: Date.now() - blockId * 60000, - summary: `[Compressed conversation section]\n## ${topic}\nSummary (${summaryTokens} tok).`, - survivedCount: 10, - generation: "old", - tier, - } -} - -function getActiveCounts(state: SessionState) { - let t1 = 0, t2 = 0, t3 = 0, t1Tok = 0, t2Tok = 0 - for (const id of state.prune.messages.activeBlockIds) { - const b = state.prune.messages.blocksById.get(id) - if (!b || !b.active) continue - const tier = b.tier ?? 1 - if (tier === 1) { t1++; t1Tok += b.summaryTokens } - else if (tier === 2) { t2++; t2Tok += b.summaryTokens } - else t3++ - } - return { t1, t2, t3, t1Tok, t2Tok } -} - -function setupSim() { - const tempDir = mkdtempSync(join(tmpdir(), "acp-tier-sim-")) - process.env.XDG_DATA_HOME = tempDir - process.env.XDG_CONFIG_HOME = tempDir - - const state = createSessionState() - state.sessionId = SID - state.modelContextLimit = 200_000 - - const config = buildConfig() - const logger = new Logger(false) - const handler = createChatMessageTransformHandler( - createMockClient(), - createTestRegistry(state), - logger, - config, - createMockPrompts(), - { global: undefined, agents: {} }, - ) - - return { state, logger, config, handler, tempDir } -} - -const ANCHOR = "u0" - -function detectTrigger(state: SessionState, suffix: string): "T1" | "T2" | "T3" | "none" { - if (suffix.includes("[Tier 2 Trigger]")) return "T2" - if (suffix.includes("[Tier 3 Trigger]")) return "T3" - if (state.nudges.shouldInjectThisTurn) return "T1" - return "none" -} - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 1: 150-turn session — T1 → T2 escalation -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 1: 30-turn session — T1 fires, blocks accumulate, T2 escalates", async () => { - const { state, handler, tempDir } = setupSim() - try { - const T1_RATIO = 45 - const T2_RATIO = 10 - let nextBlockId = 1 - const events: SimEvent[] = [] - const t1Ids: number[] = [] - const conversation: WithParts[] = [] - - for (let i = 0; i < 3; i++) { - conversation.push(makeUserMessage(`seed-u${i}`, "x".repeat(3000))) - conversation.push(makeAssistantMessage(`seed-a${i}`, "y".repeat(3000), 15000 + i * 5000)) - } - - for (let turn = 0; turn < 30; turn++) { - const u = makeUserMessage(`u${turn + 100}`, "x".repeat(2000)) - const a = makeAssistantMessage(`a${turn + 100}`, "y".repeat(2000), 0) - conversation.push(u, a) - - const lastInfo = conversation[conversation.length - 1].info as any - lastInfo.tokens = { - input: 25000 + turn * 4000, - output: 200, - reasoning: 0, - cache: { read: 0, write: 0 }, - } - - const output = { messages: [...conversation] } - await handler({}, output) - const suffix = getSuffixText(output) - const counts = getActiveCounts(state) - - let type: SimEvent["type"] = "none" - - if (suffix.includes("[Tier 2 Trigger]")) { - type = "T2" - const consumed = [...t1Ids] - const t1Sum = consumed.reduce((s, id) => s + (state.prune.messages.blocksById.get(id)?.summaryTokens ?? 0), 0) - const t2Tok = Math.ceil(t1Sum / T2_RATIO) - const t2Id = nextBlockId++ - state.prune.messages.blocksById.set(t2Id, makeBlock(t2Id, 2, t2Tok, t1Sum, `T2`, ANCHOR, consumed)) - state.prune.messages.activeBlockIds.add(t2Id) - for (const id of consumed) { - const b = state.prune.messages.blocksById.get(id) - if (b) { b.active = false; state.prune.messages.activeBlockIds.delete(id) } - } - t1Ids.length = 0 - } else if (detectTrigger(state, suffix) === "T1") { - type = "T1" - const compressed = 20000 - const t1Tok = Math.ceil(compressed / T1_RATIO) - const t1Id = nextBlockId++ - state.prune.messages.blocksById.set(t1Id, makeBlock(t1Id, 1, t1Tok, compressed, `T1-${turn}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(t1Id) - t1Ids.push(t1Id) - } - - events.push({ turn, contextTokens: lastInfo.tokens.input, type, activeT1: counts.t1, activeT2: counts.t2, t1Tokens: counts.t1Tok, t2Tokens: counts.t2Tok }) - } - - const triggered = events.filter((e) => e.type !== "none") - assert.ok(triggered.length >= 1, - `At least 1 trigger expected. Events: ${JSON.stringify(triggered.slice(0, 10).map(e => ({ t: e.turn, ty: e.type, ctx: e.contextTokens })))}`) - - for (const e of events) { - assert.ok( - e.type === "none" || e.type === "T1" || e.type === "T2" || e.type === "T3", - `Turn ${e.turn}: unexpected trigger type "${e.type}"`, - ) - } - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 2: 25 T1 blocks — T2 fires, T3 waits -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 2: 25 T1 blocks pre-populated — T2 fires, T3 does not", async () => { - const { state, handler, tempDir } = setupSim() - try { - for (let i = 1; i <= 25; i++) { - state.prune.messages.blocksById.set(i, makeBlock(i, 1, 500, 22500, `T1-${i}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(i) - } - state.nudges.lastPerMessageNudgeTokens = 40000 - - const output = { messages: [makeUserMessage(ANCHOR, "Go"), makeAssistantMessage("a0", "OK", 45000)] } - await handler({}, output) - const suffix = getSuffixText(output) - - assert.ok(suffix.includes("[Tier 2 Trigger]"), `T2 should fire (12.5K > 10K). Got: ${suffix.substring(0, 200)}`) - assert.ok(!suffix.includes("[Tier 3 Trigger]"), "T3 should NOT fire") - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 3: both T2+T3 thresholds met — T2 priority -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 3: both T2+T3 ready — T2 fires first (priority)", async () => { - const { state, handler, tempDir } = setupSim() - try { - for (let i = 1; i <= 25; i++) { - state.prune.messages.blocksById.set(i, makeBlock(i, 1, 500, 22500, `T1-${i}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(i) - } - for (let i = 26; i <= 37; i++) { - state.prune.messages.blocksById.set(i, makeBlock(i, 2, 900, 9000, `T2-${i}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(i) - } - state.nudges.lastPerMessageNudgeTokens = 40000 - - const output = { messages: [makeUserMessage(ANCHOR, "Go"), makeAssistantMessage("a0", "OK", 45000)] } - await handler({}, output) - const suffix = getSuffixText(output) - - assert.ok(suffix.includes("[Tier 2 Trigger]"), "T2 should fire (priority)") - assert.ok(!suffix.includes("[Tier 3 Trigger]"), "T3 should NOT fire same turn") - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 4: Independent cadence — T2 blocked, T3 fires -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 4: T2 cadence blocked → T3 fires (independent counters)", async () => { - const { state, handler, tempDir } = setupSim() - try { - // No T1 blocks — all already consumed by previous T2 - // 12 T2 blocks at 900 tok = 10.8K (> 10K → T3 ready) - for (let i = 1; i <= 12; i++) { - state.prune.messages.blocksById.set(i, makeBlock(i, 2, 900, 9000, `T2-${i}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(i) - } - - // T2 just fired recently — cadence NOT met - // growthFloor = max(5000, 0.45 * 10000) = 5000 - // ctx = 46000, lastTier2 = 44000, growth = 2000 < 5000 - state.nudges.lastPerMessageNudgeTokens = 42000 - state.nudges.lastNudgeShownTokens = 44000 - state.nudges.lastTier2NudgeTokens = 44000 - state.nudges.lastTier3NudgeTokens = undefined - - const output = { messages: [makeUserMessage(ANCHOR, "Go"), makeAssistantMessage("a0", "OK", 46000)] } - await handler({}, output) - const suffix = getSuffixText(output) - - assert.ok(suffix.includes("[Tier 3 Trigger]"), - `T3 should fire (T2 cadence blocked, T3 open). Got: "${suffix.substring(0, 300)}"`) - assert.ok(!suffix.includes("[Tier 2 Trigger]"), - "T2 should NOT fire (cadence blocked)") - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 5: Empty state — no tier nudges -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 5: empty state — no tier nudges fire", async () => { - const { state, handler, tempDir } = setupSim() - try { - state.nudges.lastPerMessageNudgeTokens = 10000 - - const output = { messages: [makeUserMessage(ANCHOR, "Hi"), makeAssistantMessage("a0", "Hello", 15000)] } - await handler({}, output) - const suffix = getSuffixText(output) - - assert.ok(!suffix.includes("[Tier 2 Trigger]"), "T2 should not fire") - assert.ok(!suffix.includes("[Tier 3 Trigger]"), "T3 should not fire") - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 6: Cadence blocks refire within growthFloor -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 6: T2 cadence blocks refire within growthFloor window", async () => { - const { state, handler, tempDir } = setupSim() - try { - for (let i = 1; i <= 25; i++) { - state.prune.messages.blocksById.set(i, makeBlock(i, 1, 500, 22500, `T1-${i}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(i) - } - // growthFloor = max(5000, 0.45 * 10000) = 5000 - state.nudges.lastPerMessageNudgeTokens = 35000 - state.nudges.lastTier2NudgeTokens = 43000 - - const output = { messages: [makeUserMessage(ANCHOR, "Go"), makeAssistantMessage("a0", "OK", 46000)] } - await handler({}, output) - const suffix = getSuffixText(output) - - assert.ok(!suffix.includes("[Tier 2 Trigger]"), - `T2 should NOT refire (3K < 5K floor). Got: ${suffix.substring(0, 200)}`) - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 7: 300-turn session — steady state, no runaway -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 7: 30-turn session — no crashes, state consistent", async () => { - const { state, handler, tempDir } = setupSim() - try { - const T1_RATIO = 45 - let nextId = 1 - const conversation: WithParts[] = [] - - for (let i = 0; i < 5; i++) { - conversation.push(makeUserMessage(`seed-u${i}`, "x".repeat(2000))) - conversation.push(makeAssistantMessage(`seed-a${i}`, "y".repeat(2000), 15000 + i * 3000)) - } - - for (let turn = 0; turn < 30; turn++) { - const u = makeUserMessage(`u${turn + 200}`, "x".repeat(1500)) - const a = makeAssistantMessage(`a${turn + 200}`, "y".repeat(1500), 15000 + turn * 3000) - conversation.push(u, a) - - const output = { messages: [...conversation] } - await handler({}, output) - const suffix = getSuffixText(output) - - if (suffix.includes("[Tier 2 Trigger]")) { - const consumed: number[] = [] - let t1Sum = 0 - for (const id of [...state.prune.messages.activeBlockIds]) { - const b = state.prune.messages.blocksById.get(id) - if (b?.active && (b.tier ?? 1) === 1) { consumed.push(id); t1Sum += b.summaryTokens } - } - if (consumed.length >= 2) { - const t2Id = nextId++ - state.prune.messages.blocksById.set(t2Id, makeBlock(t2Id, 2, Math.ceil(t1Sum / 10), t1Sum, `T2-${turn}`, ANCHOR, consumed)) - state.prune.messages.activeBlockIds.add(t2Id) - for (const id of consumed) { - const b = state.prune.messages.blocksById.get(id) - if (b) { b.active = false; state.prune.messages.activeBlockIds.delete(id) } - } - } - } else if (detectTrigger(state, suffix) === "T1") { - const compressed = 20000 - const t1Id = nextId++ - state.prune.messages.blocksById.set(t1Id, makeBlock(t1Id, 1, Math.ceil(compressed / T1_RATIO), compressed, `T1-${turn}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(t1Id) - } - } - - for (const id of state.prune.messages.activeBlockIds) { - const b = state.prune.messages.blocksById.get(id) - assert.ok(b?.active, `Block ${id} in activeBlockIds but not active`) - } - - for (const [, b] of state.prune.messages.blocksById) { - for (const cid of b.consumedBlockIds) { - const consumed = state.prune.messages.blocksById.get(cid) - if (consumed) assert.ok(!consumed.active, `Block ${cid} consumed but still active`) - } - } - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) - -// ═══════════════════════════════════════════════════════════════════════════ -// SIM 8: Cross-tier safety — T2 range excludes T2 blocks (contiguous T1) -// ═══════════════════════════════════════════════════════════════════════════ - -test("SIM 8: T2 range excludes non-T1 blocks via cross-tier narrowing", async () => { - const { state, handler, tempDir } = setupSim() - try { - // T1 blocks at IDs 1-15, T2 blocks at IDs 20-25 (separated by gap) - // T1 tokens: 15 * 800 = 12K (> 10K threshold) - for (let i = 1; i <= 15; i++) { - state.prune.messages.blocksById.set(i, makeBlock(i, 1, 800, 24000, `T1-${i}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(i) - } - for (let i = 20; i <= 25; i++) { - state.prune.messages.blocksById.set(i, makeBlock(i, 2, 900, 9000, `T2-${i}`, ANCHOR)) - state.prune.messages.activeBlockIds.add(i) - } - state.nudges.lastPerMessageNudgeTokens = 30000 - - const output = { messages: [makeUserMessage(ANCHOR, "Go"), makeAssistantMessage("a0", "OK", 45000)] } - await handler({}, output) - const suffix = getSuffixText(output) - - assert.ok(suffix.includes("[Tier 2 Trigger]"), `T2 should fire. Got: ${suffix.substring(0, 200)}`) - - const rangeMatch = suffix.match(/startId: "b(\d+)".*endId: "b(\d+)"/) - assert.ok(rangeMatch, "Should include compress range") - const startId = parseInt(rangeMatch[1]) - const endId = parseInt(rangeMatch[2]) - - for (let id = startId; id <= endId; id++) { - const b = state.prune.messages.blocksById.get(id) - if (b?.active) { - assert.equal(b.tier ?? 1, 1, `Block ${id} in T2 range is tier ${b.tier}, should be 1`) - } - } - } finally { - rmSync(tempDir, { recursive: true, force: true }) - } -}) diff --git a/tests/gc-merge.test.ts b/tests/gc-merge.test.ts deleted file mode 100644 index 02612e55..00000000 --- a/tests/gc-merge.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { mergeMarkedBlocks, runBatchCleanup } from "../lib/gc/merge" -import { createSessionState } from "../lib/state" -import { wrapCompressedSummary } from "../lib/compress/state" -import { Logger } from "../lib/logger" -import type { - CompressionBlock, - PrunedMessageEntry, - SessionState, - WithParts, -} from "../lib/state/types" -import type { GCConfig, PluginConfig } from "../lib/config" - -function makeBlock(overrides: Partial = {}): CompressionBlock { - return { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 1000, - summaryTokens: 100, - durationMs: 0, - topic: "test", - batchTopic: "test", - startId: "m0", - endId: "m5", - anchorMessageId: "anchor-1", - compressMessageId: "comp-1", - compressCallId: undefined, - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: [], - effectiveToolIds: [], - createdAt: 1000, - deactivatedAt: undefined, - deactivatedByBlockId: undefined, - summary: "A short summary.", - survivedCount: 5, - generation: "old", - ...overrides, - } -} - -interface MakeStateOptions { - modelContextLimit?: number - marked?: number[] -} - -function makeState(blocks: CompressionBlock[], opts: MakeStateOptions = {}): SessionState { - const state = createSessionState() - state.modelContextLimit = opts.modelContextLimit - - let maxId = 0 - for (const block of blocks) { - state.prune.messages.blocksById.set(block.blockId, block) - if (block.active) { - state.prune.messages.activeBlockIds.add(block.blockId) - if (block.anchorMessageId) { - state.prune.messages.activeByAnchorMessageId.set(block.anchorMessageId, block.blockId) - } - } - if (block.blockId > maxId) maxId = block.blockId - } - state.prune.messages.nextBlockId = Math.max(state.prune.messages.nextBlockId, maxId + 1) - state.prune.messages.nextRunId = Math.max(state.prune.messages.nextRunId, maxId + 1) - - for (const id of opts.marked ?? []) { - state.prune.messages.markedForCleanup.add(id) - } - - return state -} - -function registerMessage( - state: SessionState, - messageId: string, - blockIds: number[], - tokenCount = 100, -): PrunedMessageEntry { - const entry: PrunedMessageEntry = { - tokenCount, - allBlockIds: [...blockIds], - activeBlockIds: [...blockIds], - } - state.prune.messages.byMessageId.set(messageId, entry) - return entry -} - -function buildConfig(gcOverrides: Partial = {}): PluginConfig { - return { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { enabled: true, protectedTools: [] }, - experimental: { allowSubAgents: false, customPrompts: false }, - protectedFilePatterns: [], - compress: { - permission: "allow", - showCompression: false, - summaryBuffer: true, - maxContextLimit: 150000, - minContextLimit: 50000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: [], - protectTags: false, - protectUserMessages: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - batchCleanup: { - lowThreshold: "55%", - highThreshold: "75%", - forceThreshold: "90%", - }, - ...gcOverrides, - }, - } -} - -function makeAssistantMessage(id: string, totalTokens: number, sessionId = "s1"): WithParts { - return { - info: { - id, - sessionID: sessionId, - role: "assistant", - time: { created: Date.now() }, - parentID: "parent-1", - modelID: "test-model", - providerID: "test-provider", - mode: "normal", - agent: "code", - path: { cwd: "/", root: "/" }, - cost: 0, - tokens: { - input: 0, - output: totalTokens, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - }, - parts: [ - { type: "text", text: "ok", id: `${id}-p1`, sessionID: sessionId, messageID: id }, - ], - } -} - -test("mergeMarkedBlocks: merges 2 blocks → creates new block, deactivates sources, updates indexes", () => { - const block1 = makeBlock({ - blockId: 1, - runId: 1, - anchorMessageId: "anchor-1", - summary: wrapCompressedSummary(1, "Body of block one"), - summaryTokens: 50, - effectiveMessageIds: ["m1", "m2"], - effectiveToolIds: ["t1"], - }) - const block2 = makeBlock({ - blockId: 2, - runId: 2, - anchorMessageId: "anchor-2", - summary: wrapCompressedSummary(2, "Body of block two"), - summaryTokens: 60, - effectiveMessageIds: ["m3"], - effectiveToolIds: ["t2"], - }) - const state = makeState([block1, block2], { marked: [1, 2] }) - registerMessage(state, "m1", [1]) - registerMessage(state, "m2", [1]) - registerMessage(state, "m3", [2]) - - const newId = state.prune.messages.nextBlockId - const result = mergeMarkedBlocks(state, [1, 2], 3000) - - assert.equal(result.mergedCount, 2) - assert.ok(result.savedTokens >= 0) - - const merged = state.prune.messages.blocksById.get(newId) - assert.ok(merged, "merged block created") - assert.equal(merged!.active, true) - assert.equal(merged!.generation, "old") - assert.ok(state.prune.messages.activeBlockIds.has(newId)) - - assert.equal(block1.active, false) - assert.equal(block2.active, false) - assert.equal(block1.deactivatedByBlockId, newId) - assert.equal(block2.deactivatedByBlockId, newId) - assert.equal(state.prune.messages.activeBlockIds.has(1), false) - assert.equal(state.prune.messages.activeBlockIds.has(2), false) - - assert.ok(merged!.summary.includes("Body of block one")) - assert.ok(merged!.summary.includes("Body of block two")) - - assert.deepEqual( - [...merged!.effectiveMessageIds].sort(), - ["m1", "m2", "m3"], - ) - assert.deepEqual( - [...merged!.effectiveToolIds].sort(), - ["t1", "t2"], - ) - - const entryM1 = state.prune.messages.byMessageId.get("m1")! - assert.ok(!entryM1.activeBlockIds.includes(1)) - assert.ok(entryM1.activeBlockIds.includes(newId)) - assert.ok(entryM1.allBlockIds.includes(newId)) - - assert.equal( - state.prune.messages.activeByAnchorMessageId.get("anchor-1"), - newId, - ) - assert.equal(state.prune.messages.activeByAnchorMessageId.has("anchor-2"), false) - - assert.equal(state.prune.messages.markedForCleanup.size, 0) -}) - -test("mergeMarkedBlocks: merges 3 blocks with overlapping effectiveMessageIds → union correct", () => { - const block1 = makeBlock({ - blockId: 1, - anchorMessageId: "a1", - summary: wrapCompressedSummary(1, "block one body"), - effectiveMessageIds: ["m1", "m2"], - }) - const block2 = makeBlock({ - blockId: 2, - runId: 2, - anchorMessageId: "a2", - summary: wrapCompressedSummary(2, "block two body"), - effectiveMessageIds: ["m2", "m3"], - }) - const block3 = makeBlock({ - blockId: 3, - runId: 3, - anchorMessageId: "a3", - summary: wrapCompressedSummary(3, "block three body"), - effectiveMessageIds: ["m3", "m4"], - }) - const state = makeState([block1, block2, block3]) - - const newId = state.prune.messages.nextBlockId - const result = mergeMarkedBlocks(state, [3, 1, 2], 3000) - - assert.equal(result.mergedCount, 3) - const merged = state.prune.messages.blocksById.get(newId)! - assert.equal(merged.effectiveMessageIds.length, 4) - for (const id of ["m1", "m2", "m3", "m4"]) { - assert.ok(merged.effectiveMessageIds.includes(id), `union should include ${id}`) - } -}) - -test("mergeMarkedBlocks: single block (< 2) → noop", () => { - const block1 = makeBlock({ blockId: 1 }) - const state = makeState([block1]) - const result = mergeMarkedBlocks(state, [1], 3000) - assert.equal(result.mergedCount, 0) - assert.equal(result.savedTokens, 0) - assert.equal(block1.active, true) -}) - -test("mergeMarkedBlocks: empty array → noop", () => { - const block1 = makeBlock({ blockId: 1 }) - const block2 = makeBlock({ blockId: 2, runId: 2 }) - const state = makeState([block1, block2]) - const result = mergeMarkedBlocks(state, [], 3000) - assert.equal(result.mergedCount, 0) - assert.equal(result.savedTokens, 0) -}) - -test("mergeMarkedBlocks: inactive block in input → filtered out", () => { - const block1 = makeBlock({ blockId: 1, active: true }) - const block2 = makeBlock({ blockId: 2, runId: 2, active: false }) - const state = makeState([block1, block2]) - const result = mergeMarkedBlocks(state, [1, 2], 3000) - assert.equal(result.mergedCount, 0) - assert.equal(result.savedTokens, 0) - assert.equal(block1.active, true) -}) - -test("mergeMarkedBlocks: markedForCleanup only clears merged IDs (not all)", () => { - const block1 = makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }) - const block2 = makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }) - const block3 = makeBlock({ blockId: 3, runId: 3, anchorMessageId: "a3", summary: wrapCompressedSummary(3, "three") }) - const state = makeState([block1, block2, block3], { marked: [1, 2, 3] }) - - mergeMarkedBlocks(state, [1, 2], 3000) - - assert.equal(state.prune.messages.markedForCleanup.has(1), false) - assert.equal(state.prune.messages.markedForCleanup.has(2), false) - assert.equal(state.prune.messages.markedForCleanup.has(3), true) - assert.equal(state.prune.messages.markedForCleanup.size, 1) -}) - -test("mergeMarkedBlocks: new merged block has generation old and survivedCount 0", () => { - const block1 = makeBlock({ - blockId: 1, - anchorMessageId: "a1", - summary: wrapCompressedSummary(1, "one"), - survivedCount: 9, - generation: "old", - }) - const block2 = makeBlock({ - blockId: 2, - runId: 2, - anchorMessageId: "a2", - summary: wrapCompressedSummary(2, "two"), - survivedCount: 7, - generation: "old", - }) - const state = makeState([block1, block2]) - - const newId = state.prune.messages.nextBlockId - mergeMarkedBlocks(state, [1, 2], 3000) - - const merged = state.prune.messages.blocksById.get(newId)! - assert.equal(merged.generation, "old") - assert.equal(merged.survivedCount, 0) -}) - -test("mergeMarkedBlocks: reports saved tokens as reduction from source summaries", () => { - const longBody = "x".repeat(4000) - const block1 = makeBlock({ - blockId: 1, - anchorMessageId: "a1", - summary: wrapCompressedSummary(1, longBody), - summaryTokens: 1000, - }) - const block2 = makeBlock({ - blockId: 2, - runId: 2, - anchorMessageId: "a2", - summary: wrapCompressedSummary(2, longBody), - summaryTokens: 1000, - }) - const state = makeState([block1, block2]) - - const result = mergeMarkedBlocks(state, [1, 2], 3000) - assert.equal(result.mergedCount, 2) - assert.ok(result.savedTokens > 0, "truncation should free tokens") -}) - -const logger = new Logger(false) - -// ===================================================================== -// runBatchCleanup — hardcoded 100% force fallback only. -// The mark_block mechanism and the multi-tier (low/high/force) batch -// cleanup were retired; only a single last-resort merge at 100% remains. -// ===================================================================== - -test("runBatchCleanup: below 100% (95%) → noop tier 0", () => { - const blocks = [ - makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one"), generation: "old" }), - makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two"), generation: "old" }), - ] - const state = makeState(blocks, { modelContextLimit: 1000 }) - const messages: WithParts[] = [makeAssistantMessage("a1", 950)] - - const result = runBatchCleanup(state, buildConfig(), logger, messages) - assert.equal(result.tier, 0) - assert.equal(result.action, "none") - assert.equal(result.mergedCount, 0) - assert.equal(state.prune.messages.activeBlockIds.size, 2) -}) - -test("runBatchCleanup: at 100% with >= 2 old-gen blocks → tier 3 force merge", () => { - const blocks = [ - makeBlock({ - blockId: 1, - anchorMessageId: "a1", - summary: wrapCompressedSummary(1, "one"), - generation: "old", - }), - makeBlock({ - blockId: 2, - runId: 2, - anchorMessageId: "a2", - summary: wrapCompressedSummary(2, "two"), - generation: "old", - }), - ] - const state = makeState(blocks, { modelContextLimit: 1000 }) - const messages: WithParts[] = [makeAssistantMessage("a1", 1000)] - - const result = runBatchCleanup(state, buildConfig(), logger, messages) - assert.equal(result.tier, 3) - assert.equal(result.action, "merge") - assert.equal(result.mergedCount, 2) - assert.equal(state.prune.messages.activeBlockIds.size, 1) -}) - -test("runBatchCleanup: at 100% with < 2 old-gen blocks → noop", () => { - const blocks = [ - makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one"), generation: "old" }), - ] - const state = makeState(blocks, { modelContextLimit: 1000 }) - const messages: WithParts[] = [makeAssistantMessage("a1", 1000)] - - const result = runBatchCleanup(state, buildConfig(), logger, messages) - assert.equal(result.tier, 0) - assert.equal(result.action, "none") - assert.equal(result.mergedCount, 0) - assert.equal(state.prune.messages.activeBlockIds.size, 1) -}) - -test("runBatchCleanup: modelContextLimit undefined → noop", () => { - const blocks = [ - makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one") }), - makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two") }), - ] - const state = makeState(blocks, { modelContextLimit: undefined }) - const messages: WithParts[] = [makeAssistantMessage("a1", 999999)] - - const result = runBatchCleanup(state, buildConfig(), logger, messages) - assert.equal(result.tier, 0) - assert.equal(result.action, "none") - assert.equal(result.mergedCount, 0) -}) - -test("runBatchCleanup: mark tiers removed — marked blocks below 100% → noop (no nudge, no merge)", () => { - const blocks = [ - makeBlock({ blockId: 1, anchorMessageId: "a1", summary: wrapCompressedSummary(1, "one"), generation: "old" }), - makeBlock({ blockId: 2, runId: 2, anchorMessageId: "a2", summary: wrapCompressedSummary(2, "two"), generation: "old" }), - makeBlock({ blockId: 3, runId: 3, anchorMessageId: "a3", summary: wrapCompressedSummary(3, "three"), generation: "old" }), - ] - // Legacy marks that would previously have triggered tier 1/2 — now ignored. - const state = makeState(blocks, { modelContextLimit: 1000, marked: [1, 2, 3] }) - const messages: WithParts[] = [makeAssistantMessage("a1", 800)] - - const result = runBatchCleanup(state, buildConfig(), logger, messages) - assert.equal(result.tier, 0, "no nudge/merge below 100% even with marks") - assert.equal(result.action, "none") - assert.equal(result.mergedCount, 0) - assert.ok(!result.nudgeText, "no nudge text — mark_block nudge is retired") - assert.equal(state.prune.messages.activeBlockIds.size, 3) -}) diff --git a/tests/hide-consumed.test.ts b/tests/hide-consumed.test.ts deleted file mode 100644 index 5301a639..00000000 --- a/tests/hide-consumed.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -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 type { CompressionBlock } from "../lib/state/types" - -function makeBlock(overrides: Partial & { blockId: number }): CompressionBlock { - return { - blockId: overrides.blockId, - runId: overrides.runId ?? 1, - displayId: overrides.displayId ?? `b${overrides.blockId}`, - active: overrides.active ?? true, - tier: overrides.tier ?? 1, - topic: overrides.topic ?? "test", - summary: overrides.summary ?? "test summary", - compressMessageId: overrides.compressMessageId ?? "", - compressCallId: overrides.compressCallId ?? "", - directMessageIds: overrides.directMessageIds ?? [], - effectiveMessageIds: overrides.effectiveMessageIds ?? [], - generation: overrides.generation ?? "young", - survivedCount: overrides.survivedCount ?? 0, - createdAt: overrides.createdAt ?? Date.now(), - compressedTokens: overrides.compressedTokens ?? 100, - summaryLength: overrides.summaryLength ?? 20, - deactivatedByBlockId: overrides.deactivatedByBlockId, - deactivatedByUser: overrides.deactivatedByUser, - deactivatedByUserDeep: overrides.deactivatedByUserDeep, - consumedBlockIds: overrides.consumedBlockIds, - ...overrides, - } as CompressionBlock -} - -function makeState(blocks: CompressionBlock[]): Pick { - const blocksById = new Map() - for (const b of blocks) blocksById.set(b.blockId, b) - return { - prune: { - messages: { - blocksById, - byMessageId: new Map(), - activeBlockIds: blocks.filter((b) => b.active).map((b) => b.blockId), - }, - }, - } as any -} - -describe("hideConsumedCompressCalls", () => { - it("hides consumed T1 compress call when T2 consumes it (previous turn)", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [ - { type: "text", text: "Compressing" }, - { type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }, - ], - }, - { - info: { id: "msg-t2-compress", role: "assistant" } as any, - parts: [{ type: "tool", tool: "compress", callID: "call-t4", state: { status: "completed" } }], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - const t1Msg = messages.find((m) => m.info.id === "msg-t1-compress")! - assert.equal( - t1Msg.parts.filter((p: any) => p.type === "tool" && p.tool === "compress").length, - 0, - ) - }) - - it("hides consumed T1 compress call even when it is AFTER lastUserIdx (same-turn T1+T2)", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [{ type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }], - }, - { - info: { id: "msg-t2-compress", role: "assistant" } as any, - parts: [{ type: "tool", tool: "compress", callID: "call-t4", state: { status: "completed" } }], - }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1, "consumed T1 compress call should be hidden") - assert.equal( - messages.find((m) => m.info.id === "msg-t1-compress"), - undefined, - "T1-only-compress message should be entirely removed", - ) - assert.ok( - messages.find((m) => m.info.id === "msg-t2-compress"), - "T2 compress call (active block) should survive", - ) - }) - - it("does NOT hide active T1 compress call", () => { - const b1 = makeBlock({ - blockId: 1, - active: true, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - - const state = makeState([b1]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [{ type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 0) - assert.ok(messages.find((m) => m.info.id === "msg-t1-compress")) - }) - - it("preserves non-compress parts when hiding consumed compress call", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [ - { type: "text", text: "Let me compress" }, - { type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }, - { type: "tool", tool: "bash", state: { status: "completed" } }, - ], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - const t1Msg = messages.find((m) => m.info.id === "msg-t1-compress")! - assert.equal(t1Msg.parts.length, 2, "text and bash parts should survive") - assert.equal( - t1Msg.parts.filter((p: any) => p.type === "tool" && p.tool === "compress").length, - 0, - ) - }) - - it("splices message when only reasoning + step-finish remain after compress removal", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [ - { type: "reasoning", text: "I need to compress the early messages..." }, - { type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }, - { type: "step-finish", reason: "stop" }, - ], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - assert.equal( - messages.find((m) => m.info.id === "msg-t1-compress"), - undefined, - "structural-only orphan should be spliced entirely", - ) - }) - - it("splices message when only reasoning remains after compress removal", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [ - { type: "reasoning", text: "Analyzing context usage..." }, - { type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }, - ], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - assert.equal( - messages.find((m) => m.info.id === "msg-t1-compress"), - undefined, - "reasoning-only orphan should be spliced entirely", - ) - }) - - it("preserves message when text accompanies reasoning after compress removal", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [ - { type: "reasoning", text: "I need to compress..." }, - { type: "text", text: "Compressing early messages" }, - { type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }, - { type: "step-finish", reason: "stop" }, - ], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - const t1Msg = messages.find((m) => m.info.id === "msg-t1-compress")! - assert.ok(t1Msg, "message with text should survive") - assert.equal(t1Msg.parts.length, 3, "reasoning + text + step-finish remain") - assert.equal( - t1Msg.parts.filter((p: any) => p.type === "tool" && p.tool === "compress").length, - 0, - ) - }) - - it("preserves message when non-compress tool accompanies structural parts", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [ - { type: "reasoning", text: "I need to compress..." }, - { type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }, - { type: "tool", tool: "bash", state: { status: "completed" } }, - { type: "step-finish", reason: "stop" }, - ], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - const t1Msg = messages.find((m) => m.info.id === "msg-t1-compress")! - assert.ok(t1Msg, "message with bash tool should survive") - assert.equal( - t1Msg.parts.filter((p: any) => p.type === "tool" && p.tool === "compress").length, - 0, - ) - }) - - it("splices message when only step-start + step-finish remain after compress removal", () => { - const b1 = makeBlock({ - blockId: 1, - active: false, - deactivatedByBlockId: 4, - compressMessageId: "msg-t1-compress", - compressCallId: "call-t1", - tier: 1, - }) - const b4 = makeBlock({ - blockId: 4, - active: true, - compressMessageId: "msg-t2-compress", - compressCallId: "call-t4", - tier: 2, - }) - - const state = makeState([b1, b4]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-t1-compress", role: "assistant" } as any, - parts: [ - { type: "step-start" }, - { type: "tool", tool: "compress", callID: "call-t1", state: { status: "completed" } }, - { type: "step-finish", reason: "stop" }, - ], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - assert.equal( - messages.find((m) => m.info.id === "msg-t1-compress"), - undefined, - "step-start + step-finish orphan should be spliced", - ) - }) - - it("keeps last 2 orphaned (failed) compress calls, hides older ones", () => { - const b1 = makeBlock({ - blockId: 1, - active: true, - compressMessageId: "msg-good-compress", - compressCallId: "call-good", - tier: 1, - }) - - const state = makeState([b1]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - { - info: { id: "msg-fail-1", role: "assistant" } as any, - parts: [ - { type: "text", text: "Trying compress..." }, - { type: "tool", tool: "compress", callID: "call-fail-1", state: { status: "error" } }, - ], - }, - { - info: { id: "msg-fail-2", role: "assistant" } as any, - parts: [ - { type: "text", text: "Retry..." }, - { type: "tool", tool: "compress", callID: "call-fail-2", state: { status: "error" } }, - ], - }, - { - info: { id: "msg-fail-3", role: "assistant" } as any, - parts: [ - { type: "text", text: "Retry..." }, - { type: "tool", tool: "compress", callID: "call-fail-3", state: { status: "error" } }, - ], - }, - { - info: { id: "msg-good-compress", role: "assistant" } as any, - parts: [{ type: "tool", tool: "compress", callID: "call-good", state: { status: "completed" } }], - }, - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 1) - const fail1Msg = messages.find((m) => m.info.id === "msg-fail-1")! - assert.ok(fail1Msg, "message with text survives") - assert.equal( - fail1Msg.parts.filter((p: any) => p.type === "tool" && p.tool === "compress").length, - 0, - "oldest orphaned compress part removed", - ) - assert.ok(messages.find((m) => m.info.id === "msg-fail-2"), "2nd-last orphaned kept") - assert.ok(messages.find((m) => m.info.id === "msg-fail-3"), "last orphaned kept") - assert.ok(messages.find((m) => m.info.id === "msg-good-compress"), "active block kept") - }) - - it("hides all orphaned compress calls beyond the last 2", () => { - const state = makeState([]) - const messages: WithParts[] = [ - { info: { id: "msg-user-1", role: "user" } as any, parts: [{ type: "text", text: "Hi" }] }, - ...Array.from({ length: 5 }, (_, i) => ({ - info: { id: `msg-fail-${i}`, role: "assistant" } as any, - parts: [ - { type: "tool", tool: "compress", callID: `call-fail-${i}`, state: { status: "error" } }, - ], - })), - { info: { id: "msg-user-2", role: "user" } as any, parts: [{ type: "text", text: "Next" }] }, - ] - - const hidden = hideConsumedCompressCalls(state as SessionState, messages) - - assert.equal(hidden, 3, "5 orphaned - 2 kept = 3 hidden") - assert.equal(messages.length, 4, "user + 2 kept + user = 4 messages") - }) -}) diff --git a/tests/hide-failed.test.ts b/tests/hide-failed.test.ts deleted file mode 100644 index 52a30f0d..00000000 --- a/tests/hide-failed.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { hideFailedCompressCalls } from "../lib/compress/hide-failed" -import type { WithParts } from "../lib/state" - -function makeCompressPart(status: "completed" | "error", callID: string, output?: string) { - return { - type: "tool" as const, - callID, - tool: "compress", - state: { - status, - input: { content: [{ startId: "m00001", endId: "m00010", summary: "test" }] }, - output: output ?? (status === "error" ? "Error: bad boundaries" : "compressed"), - }, - } -} - -function makeOtherToolPart(status: "completed" | "error") { - return { - type: "tool" as const, - callID: "call-other", - tool: "bash", - state: { - status, - input: { command: "ls" }, - output: status === "error" ? "Error: not found" : "file.txt", - }, - } -} - -function makeTextPart(text: string) { - return { type: "text" as const, text } -} - -function makeMessage(id: string, role: "user" | "assistant", parts: any[]): WithParts { - return { - info: { id, role, sessionID: "ses-test", time: { created: 1 } } as any, - parts, - } -} - -test("hideFailedCompressCalls: keeps single failed compress call (most recent)", () => { - const messages = [ - makeMessage("msg-1", "user", [makeTextPart("hello")]), - makeMessage("msg-2", "assistant", [ - makeTextPart("Let me compress"), - makeCompressPart("error", "call-1"), - ]), - makeMessage("msg-3", "user", [makeTextPart("ok")]), - ] - - const hidden = hideFailedCompressCalls(messages) - - assert.equal(hidden, 0) - assert.equal(messages.length, 3) - assert.equal(messages[1]!.parts.length, 2) -}) - -test("hideFailedCompressCalls: keeps single failed compress-only message (most recent)", () => { - const messages = [ - makeMessage("msg-1", "user", [makeTextPart("hello")]), - makeMessage("msg-2", "assistant", [makeCompressPart("error", "call-1")]), - makeMessage("msg-3", "user", [makeTextPart("ok")]), - ] - - const hidden = hideFailedCompressCalls(messages) - - assert.equal(hidden, 0) - assert.equal(messages.length, 3) - assert.equal(messages[1]!.info.id, "msg-2") -}) - -test("hideFailedCompressCalls: does NOT remove successful compress calls", () => { - const messages = [ - makeMessage("msg-1", "user", [makeTextPart("hello")]), - makeMessage("msg-2", "assistant", [ - makeCompressPart("completed", "call-1"), - ]), - makeMessage("msg-3", "user", [makeTextPart("ok")]), - ] - - const hidden = hideFailedCompressCalls(messages) - - assert.equal(hidden, 0) - assert.equal(messages.length, 3) - assert.equal(messages[1]!.parts.length, 1) -}) - -test("hideFailedCompressCalls: does NOT remove failed non-compress tool calls", () => { - const messages = [ - makeMessage("msg-1", "user", [makeTextPart("hello")]), - makeMessage("msg-2", "assistant", [ - makeOtherToolPart("error"), - ]), - makeMessage("msg-3", "user", [makeTextPart("ok")]), - ] - - const hidden = hideFailedCompressCalls(messages) - - assert.equal(hidden, 0) - assert.equal(messages.length, 3) - assert.equal(messages[1]!.parts.length, 1) -}) - -test("hideFailedCompressCalls: keeps most recent failure, removes older ones", () => { - const messages = [ - makeMessage("msg-1", "user", [makeTextPart("hello")]), - makeMessage("msg-2", "assistant", [ - makeCompressPart("error", "call-1"), - ]), - makeMessage("msg-3", "user", [makeTextPart("retry")]), - makeMessage("msg-4", "assistant", [ - makeCompressPart("error", "call-2"), - ]), - makeMessage("msg-5", "assistant", [ - makeCompressPart("completed", "call-3"), - ]), - ] - - const hidden = hideFailedCompressCalls(messages) - - assert.equal(hidden, 1) - assert.equal(messages.length, 4) - assert.equal(messages[0]!.info.id, "msg-1") - assert.equal(messages[1]!.info.id, "msg-3") - assert.equal(messages[2]!.info.id, "msg-4") - assert.equal(messages[2]!.parts[0]!.state.status, "error") - assert.equal(messages[3]!.info.id, "msg-5") - assert.equal(messages[3]!.parts[0]!.state.status, "completed") -}) - -test("hideFailedCompressCalls: handles empty messages array", () => { - const hidden = hideFailedCompressCalls([]) - assert.equal(hidden, 0) -}) - -test("hideFailedCompressCalls: handles messages with no parts", () => { - const messages = [ - makeMessage("msg-1", "user", []), - ] - const hidden = hideFailedCompressCalls(messages) - assert.equal(hidden, 0) -}) - -test("hideFailedCompressCalls: splices orphan when only structural parts remain after failure removal", () => { - const messages = [ - makeMessage("msg-1", "user", [makeTextPart("hello")]), - makeMessage("msg-2", "assistant", [ - { type: "reasoning", text: "thinking..." }, - makeCompressPart("error", "call-1"), - { type: "step-finish", reason: "stop" }, - ]), - makeMessage("msg-3", "user", [makeTextPart("retry")]), - makeMessage("msg-4", "assistant", [ - makeCompressPart("error", "call-2"), - ]), - ] - - const hidden = hideFailedCompressCalls(messages) - - assert.equal(hidden, 1, "older failed compress removed, most recent kept") - assert.equal( - messages.find((m) => m.info.id === "msg-2"), - undefined, - "structural-only orphan msg-2 should be spliced", - ) - assert.ok(messages.find((m) => m.info.id === "msg-4"), "msg-4 (most recent failure) survives") -}) diff --git a/tests/hooks-permission.test.ts b/tests/hooks-permission.test.ts deleted file mode 100644 index d4cc4319..00000000 --- a/tests/hooks-permission.test.ts +++ /dev/null @@ -1,690 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import type { PluginConfig } from "../lib/config" -import { - createChatMessageTransformHandler, - createCommandExecuteHandler, - createEventHandler, - createTextCompleteHandler, -} from "../lib/hooks" -import { Logger } from "../lib/logger" -import { - createSessionState, - ensureSessionInitialized, - saveSessionState, - type WithParts, -} from "../lib/state" -import { createTestRegistry } from "./registry-stub" - -function buildConfig(permission: "allow" | "ask" | "deny" = "allow"): PluginConfig { - return { - enabled: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { - enabled: true, - protectedTools: [], - }, - experimental: { - allowSubAgents: false, - customPrompts: false, - }, - protectedFilePatterns: [], - compress: { - mode: "message", - permission, - showCompression: false, - maxContextLimit: 150000, - minContextLimit: 50000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: ["task"], - protectTags: false, - protectUserMessages: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, - }, - } -} - -function buildMessage(id: string, role: "user" | "assistant", text: string): WithParts { - return { - info: { - id, - role, - sessionID: "session-1", - agent: "assistant", - time: { created: 1 }, - } as WithParts["info"], - parts: [ - { - id: `${id}-part`, - messageID: id, - sessionID: "session-1", - type: "text", - text, - }, - ], - } -} - -test("chat message transform strips hallucinated tags even when compress is denied", async () => { - const state = createSessionState() - const logger = new Logger(false) - const config = buildConfig("deny") - const handler = createChatMessageTransformHandler( - { session: { get: async () => ({}) } } as any, - createTestRegistry(state), - logger, - config, - { - reload() {}, - getRuntimePrompts() { - return {} as any - }, - } as any, - { global: undefined, agents: {} }, - ) - const output = { - messages: [buildMessage("assistant-1", "assistant", "alpha beta omega")], - } - - await handler({}, output) - - assert.equal(output.messages[0]?.parts[0]?.type, "text") - assert.equal((output.messages[0]?.parts[0] as any).text, "alpha omega") -}) - -test("chat message transform drops messages without info instead of crashing", async () => { - const state = createSessionState() - const logger = new Logger(false) - const config = buildConfig("deny") - const handler = createChatMessageTransformHandler( - { session: { get: async () => ({}) } } as any, - createTestRegistry(state), - logger, - config, - { - reload() {}, - getRuntimePrompts() { - return {} as any - }, - } as any, - { global: undefined, agents: {} }, - ) - const output = { - messages: [ - { - role: "user", - time: 1, - parts: [ - { - type: "text", - text: "Carica le skill di laravel", - }, - ], - } as any, - ], - } - - await handler({}, output as any) - - assert.equal(state.sessionId, null) - assert.equal(output.messages.length, 0) -}) - -test("command execute exits after effective permission resolves to deny", async () => { - let sessionMessagesCalls = 0 - const output = { parts: [] as any[] } - const handler = createCommandExecuteHandler( - { - session: { - messages: async () => { - sessionMessagesCalls += 1 - return { data: [] } - }, - }, - } as any, - createTestRegistry(createSessionState()), - new Logger(false), - buildConfig("deny"), - "/tmp", - { global: undefined, agents: {} }, - ) - - await handler({ command: "dcp", sessionID: "session-1", arguments: "context" }, output) - - assert.equal(sessionMessagesCalls, 1) - assert.deepEqual(output.parts, []) -}) - -test("text complete strips hallucinated metadata tags", async () => { - const output = { text: "alpha beta omega" } - const handler = createTextCompleteHandler() - - await handler({ sessionID: "session-1", messageID: "message-1", partID: "part-1" }, output) - - assert.equal(output.text, "alpha omega") -}) - -test("event hook attaches durations to matching blocks by message and call id", async () => { - const state = createSessionState() - state.sessionId = "session-1" - const handler = createEventHandler(createTestRegistry(state), new Logger(false)) - const originalNow = Date.now - Date.now = () => 100 - - try { - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-2", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "running", - input: {}, - time: { start: 325 }, - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-2", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "running", - input: {}, - time: { start: 410 }, - }, - }, - }, - }, - }) - state.prune.messages.blocksById.set(1, { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 0, - summaryTokens: 0, - durationMs: 0, - mode: "message", - topic: "one", - batchTopic: "one", - startId: "m00001", - endId: "m00001", - anchorMessageId: "msg-a", - compressMessageId: "message-1", - compressCallId: "call-1", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: ["msg-a"], - effectiveToolIds: [], - createdAt: 1, - summary: "a", - }) - state.prune.messages.blocksById.set(2, { - blockId: 2, - runId: 2, - active: true, - deactivatedByUser: false, - compressedTokens: 0, - summaryTokens: 0, - durationMs: 0, - mode: "message", - topic: "two", - batchTopic: "two", - startId: "m00002", - endId: "m00002", - anchorMessageId: "msg-b", - compressMessageId: "message-1", - compressCallId: "call-2", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: ["msg-b"], - effectiveToolIds: [], - createdAt: 2, - summary: "b", - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-2", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "completed", - input: {}, - output: "done", - title: "", - metadata: {}, - time: { start: 410, end: 500 }, - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-1", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "completed", - input: {}, - output: "done", - title: "", - metadata: {}, - time: { start: 325, end: 500 }, - }, - }, - }, - }, - }) - } finally { - Date.now = originalNow - } - - assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 225) - assert.equal(state.prune.messages.blocksById.get(2)?.durationMs, 310) -}) - -test("event hook falls back to completed runtime when running duration missing", async () => { - const state = createSessionState() - state.sessionId = "session-1" - const handler = createEventHandler(createTestRegistry(state), new Logger(false)) - - state.prune.messages.blocksById.set(1, { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 0, - summaryTokens: 0, - durationMs: 0, - mode: "message", - topic: "one", - batchTopic: "one", - startId: "m00001", - endId: "m00001", - anchorMessageId: "msg-a", - compressMessageId: "message-1", - compressCallId: "call-3", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: ["msg-a"], - effectiveToolIds: [], - createdAt: 1, - summary: "a", - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "call-3", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "completed", - input: {}, - output: "done", - title: "", - metadata: {}, - time: { start: 500, end: 940 }, - }, - }, - }, - }, - }) - - assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 440) -}) - -test("event hook queues duration updates until the matching session is loaded", async () => { - const logger = new Logger(false) - const targetSessionId = `session-target-${process.pid}-${Date.now()}` - const otherSessionId = `session-other-${process.pid}-${Date.now()}` - const persistedState = createSessionState() - persistedState.sessionId = targetSessionId - persistedState.prune.messages.blocksById.set(1, { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 0, - summaryTokens: 0, - durationMs: 0, - mode: "message", - topic: "one", - batchTopic: "one", - startId: "m00001", - endId: "m00001", - anchorMessageId: "msg-a", - compressMessageId: "message-1", - compressCallId: "call-remote", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: ["msg-a"], - effectiveToolIds: [], - createdAt: 1, - summary: "a", - }) - await saveSessionState(persistedState, logger) - - const liveState = createSessionState() - liveState.sessionId = otherSessionId - const handler = createEventHandler(createTestRegistry(liveState), logger) - - await handler({ - event: { - type: "message.part.updated", - properties: { - sessionID: targetSessionId, - part: { - type: "tool", - tool: "compress", - callID: "call-remote", - messageID: "message-1", - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - time: 100, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - sessionID: targetSessionId, - part: { - type: "tool", - tool: "compress", - callID: "call-remote", - messageID: "message-1", - state: { - status: "completed", - input: {}, - output: "done", - title: "", - metadata: {}, - time: { start: 350, end: 500 }, - }, - }, - }, - }, - }) - - assert.equal(liveState.compressionTiming.pendingByCallId.has("message-1:call-remote"), true) - assert.equal(liveState.compressionTiming.startsByCallId.has("message-1:call-remote"), false) - - await ensureSessionInitialized( - { - session: { - get: async () => ({ data: { parentID: null } }), - }, - } as any, - liveState, - targetSessionId, - logger, - [ - { - info: { - id: "msg-user-1", - role: "user", - sessionID: targetSessionId, - agent: "assistant", - time: { created: 1 }, - } as WithParts["info"], - parts: [], - }, - ], - false, - ) - - assert.equal(liveState.prune.messages.blocksById.get(1)?.durationMs, 250) - assert.equal(liveState.compressionTiming.pendingByCallId.has("message-1:call-remote"), false) -}) - -test("event hook keeps same call id distinct across message ids", async () => { - const state = createSessionState() - state.sessionId = "session-1" - const handler = createEventHandler(createTestRegistry(state), new Logger(false)) - - state.prune.messages.blocksById.set(1, { - blockId: 1, - runId: 1, - active: true, - deactivatedByUser: false, - compressedTokens: 0, - summaryTokens: 0, - durationMs: 0, - mode: "message", - topic: "one", - batchTopic: "one", - startId: "m00001", - endId: "m00001", - anchorMessageId: "msg-a", - compressMessageId: "message-1", - compressCallId: "shared-call", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: ["msg-a"], - effectiveToolIds: [], - createdAt: 1, - summary: "a", - }) - state.prune.messages.blocksById.set(2, { - blockId: 2, - runId: 2, - active: true, - deactivatedByUser: false, - compressedTokens: 0, - summaryTokens: 0, - durationMs: 0, - mode: "message", - topic: "two", - batchTopic: "two", - startId: "m00002", - endId: "m00002", - anchorMessageId: "msg-b", - compressMessageId: "message-2", - compressCallId: "shared-call", - includedBlockIds: [], - consumedBlockIds: [], - parentBlockIds: [], - directMessageIds: [], - directToolIds: [], - effectiveMessageIds: ["msg-b"], - effectiveToolIds: [], - createdAt: 2, - summary: "b", - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "shared-call", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - time: 100, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "shared-call", - messageID: "message-2", - sessionID: "session-1", - state: { - status: "pending", - input: {}, - raw: "", - }, - }, - }, - time: 200, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "shared-call", - messageID: "message-2", - sessionID: "session-1", - state: { - status: "completed", - input: {}, - output: "done", - title: "", - metadata: {}, - time: { start: 350, end: 500 }, - }, - }, - }, - }, - }) - - await handler({ - event: { - type: "message.part.updated", - properties: { - part: { - type: "tool", - tool: "compress", - callID: "shared-call", - messageID: "message-1", - sessionID: "session-1", - state: { - status: "completed", - input: {}, - output: "done", - title: "", - metadata: {}, - time: { start: 450, end: 700 }, - }, - }, - }, - }, - }) - - assert.equal(state.prune.messages.blocksById.get(1)?.durationMs, 350) - assert.equal(state.prune.messages.blocksById.get(2)?.durationMs, 150) -}) diff --git a/tests/inject-utils-pure.test.ts b/tests/inject-utils-pure.test.ts deleted file mode 100644 index 2ff7a30b..00000000 --- a/tests/inject-utils-pure.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { computeShouldNudge, resolveAdaptiveNudgeGrowth, estimateContextComposition } from "../lib/messages/inject/utils" -import { estimateSystemPromptTokens } from "../lib/token-utils" -import { countTokens } from "../lib/token-utils" -import type { WithParts } from "../lib/state" - -const baseParams = { - currentTokens: 20_000, - modelContextLimit: 100_000, - overMinLimit: false, - overMaxLimit: false, - lastNudgeTokens: 20_000 as number | undefined, - minNudgeContextPercent: 15, - nudgeGrowthTokens: 6000, -} - -test("first observed turn (lastNudgeTokens === undefined) never nudges — baseline establishment", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 50_000, - modelContextLimit: 100_000, - lastNudgeTokens: undefined, - overMaxLimit: true, - }) - assert.equal(d.shouldNudge, false) - assert.equal(d.tipsVariant, null) -}) - -test("first turn does not nudge even at high context or over max", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 90_000, - modelContextLimit: 100_000, - lastNudgeTokens: undefined, - overMaxLimit: true, - overMinLimit: true, - }) - assert.equal(d.shouldNudge, false) -}) - -test("currentTokens undefined returns no-nudge", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: undefined, - lastNudgeTokens: 20_000, - }) - assert.equal(d.shouldNudge, false) - assert.equal(d.tipsVariant, null) -}) - -test("does not re-nudge before growth step reached", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 24_000, - lastNudgeTokens: 20_000, - }) - assert.equal(d.shouldNudge, false) -}) - -test("re-nudges after growth step reached (no contextPct floor)", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 26_500, - lastNudgeTokens: 20_000, - }) - assert.equal(d.shouldNudge, true) - assert.equal(d.tipsVariant, "normal") -}) - -test("nudge fires at very low contextPct once growth step is met (no 15% floor)", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 7_000, - modelContextLimit: 1_000_000, - lastNudgeTokens: 0, - nudgeGrowthTokens: 6_000, - }) - assert.equal(d.shouldNudge, true) - assert.equal(d.tipsVariant, "normal") -}) - -test("overMaxLimit bypasses frequency gating", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 26_000, - lastNudgeTokens: 25_000, - overMaxLimit: true, - }) - assert.equal(d.shouldNudge, true) - assert.equal(d.tipsVariant, "maxLimit") -}) - -test("overMaxLimit nudges regardless of contextPct (legacy floor removed)", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 10_000, - modelContextLimit: 100_000, - lastNudgeTokens: 10_000, - overMaxLimit: true, - }) - assert.equal(d.shouldNudge, true) - assert.equal(d.tipsVariant, "maxLimit") -}) - -test("overMinLimit produces minLimit variant", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 30_000, - lastNudgeTokens: 0, - overMinLimit: true, - }) - assert.equal(d.shouldNudge, true) - assert.equal(d.tipsVariant, "minLimit") -}) - -test("overMaxLimit takes precedence over overMinLimit", () => { - const d = computeShouldNudge({ - ...baseParams, - currentTokens: 90_000, - lastNudgeTokens: 0, - overMinLimit: true, - overMaxLimit: true, - }) - assert.equal(d.shouldNudge, true) - assert.equal(d.tipsVariant, "maxLimit") -}) - -test("minNudgeContextPercent param is ignored (legacy, kept for backward compat)", () => { - const withFloor = computeShouldNudge({ - ...baseParams, - currentTokens: 7_000, - modelContextLimit: 100_000, - lastNudgeTokens: 0, - minNudgeContextPercent: 15, - }) - const withoutFloor = computeShouldNudge({ - ...baseParams, - currentTokens: 7_000, - modelContextLimit: 100_000, - lastNudgeTokens: 0, - minNudgeContextPercent: 0, - }) - assert.equal(withFloor.shouldNudge, withoutFloor.shouldNudge) -}) - -test("custom nudgeGrowthTokens respected", () => { - const tight = computeShouldNudge({ - ...baseParams, - currentTokens: 22_000, - lastNudgeTokens: 20_000, - nudgeGrowthTokens: 1000, - }) - assert.equal(tight.shouldNudge, true) - - const loose = computeShouldNudge({ - ...baseParams, - currentTokens: 22_000, - lastNudgeTokens: 20_000, - nudgeGrowthTokens: 5000, - }) - assert.equal(loose.shouldNudge, false) -}) - -test("regression: post-compress lastNudgeTokens=currentTokens prevents immediate re-nudge", () => { - const postCompressTokens = 250_000 - - const immediate = computeShouldNudge({ - ...baseParams, - currentTokens: postCompressTokens + 3_000, - lastNudgeTokens: postCompressTokens, - nudgeGrowthTokens: 50_000, - }) - assert.equal(immediate.shouldNudge, false) - - const afterGrowth = computeShouldNudge({ - ...baseParams, - currentTokens: postCompressTokens + 55_000, - lastNudgeTokens: postCompressTokens, - nudgeGrowthTokens: 50_000, - }) - assert.equal(afterGrowth.shouldNudge, true) -}) - -test("resolveAdaptiveNudgeGrowth: undefined limit returns floor", () => { - assert.equal(resolveAdaptiveNudgeGrowth(undefined), 6000) -}) - -test("resolveAdaptiveNudgeGrowth: zero/negative limit returns floor", () => { - assert.equal(resolveAdaptiveNudgeGrowth(0), 6000) - assert.equal(resolveAdaptiveNudgeGrowth(-100), 6000) -}) - -test("resolveAdaptiveNudgeGrowth: tiny context floored at 6K", () => { - assert.equal(resolveAdaptiveNudgeGrowth(10_000), 6000) - assert.equal(resolveAdaptiveNudgeGrowth(50_000), 6000) - assert.equal(resolveAdaptiveNudgeGrowth(100_000), 6000) -}) - -test("resolveAdaptiveNudgeGrowth: 128K mainstream model", () => { - assert.equal(resolveAdaptiveNudgeGrowth(128_000), 6400) -}) - -test("resolveAdaptiveNudgeGrowth: 200K → 10K", () => { - assert.equal(resolveAdaptiveNudgeGrowth(200_000), 10_000) -}) - -test("resolveAdaptiveNudgeGrowth: 1M → 50K (5% exact)", () => { - assert.equal(resolveAdaptiveNudgeGrowth(1_000_000), 50_000) -}) - -test("resolveAdaptiveNudgeGrowth: multi-million capped at 50K", () => { - assert.equal(resolveAdaptiveNudgeGrowth(2_000_000), 50_000) - assert.equal(resolveAdaptiveNudgeGrowth(10_000_000), 50_000) -}) - -function mkText(id: string, text: string): WithParts { - return { info: { id } as any, parts: [{ type: "text", text, id: `${id}-p`, sessionID: "s", messageID: id }] as any } -} - -function mkTool(id: string, raw: string): WithParts { - return { info: { id } as any, parts: [{ type: "tool", tool: raw } as any] } -} - -function mkSummary(id: string, text: string): WithParts { - return { info: { id: `msg_dcp_summary_${id}` } as any, parts: [{ type: "text", text: `[Compressed conversation section]\n${text}` }] as any } -} - -function mkAssistantWithTokens(input: number, cacheRead = 0, cacheWrite = 0): WithParts { - return { - info: { id: "a1", role: "assistant", tokens: { input, output: 100, cache: { read: cacheRead, write: cacheWrite } } } as any, - parts: [{ type: "text", text: "ok", id: "a1-p", sessionID: "s", messageID: "a1" }] as any, - } -} - -test("estimateSystemPromptTokens: assistant input minus first user text", () => { - const userText = "Hello, please help me with a task." - const input = 10_000 - const messages = [ - { info: { id: "u1", role: "user" } as any, parts: [{ type: "text", text: userText }] as any }, - mkAssistantWithTokens(input), - ] - const sys = estimateSystemPromptTokens(messages) - assert.ok(sys > 0, "system tokens should be positive") - assert.equal(sys, input - countTokens(userText)) -}) - -test("estimateSystemPromptTokens: returns 0 when no assistant token data", () => { - const messages = [mkText("u1", "hello")] - assert.equal(estimateSystemPromptTokens(messages), 0) -}) - -test("estimateSystemPromptTokens: handles cache.read and cache.write", () => { - const messages = [ - { info: { id: "u1", role: "user" } as any, parts: [{ type: "text", text: "hi" }] as any }, - mkAssistantWithTokens(5000, 3000, 2000), - ] - const sys = estimateSystemPromptTokens(messages) - assert.ok(sys > 0) - assert.equal(sys, 10_000 - countTokens("hi")) -}) - -test("estimateContextComposition: systemTokens from assistant data included in total", () => { - const msgs = [ - { info: { id: "u1", role: "user" } as any, parts: [{ type: "text", text: "hello" }] as any }, - mkAssistantWithTokens(8000), - mkText("m1", "x".repeat(400)), - ] - const c = estimateContextComposition(msgs) - assert.ok(c.systemTokens > 0, "system tokens should be computed from assistant data") - assert.equal(c.total, c.systemTokens + c.toolTokens + c.summaryTokens + c.messageTokens) -}) - -test("estimateContextComposition: empty messages returns zeros", () => { - const c = estimateContextComposition([]) - assert.equal(c.toolTokens, 0) - assert.equal(c.codeTokens, 0) - assert.equal(c.summaryTokens, 0) - assert.equal(c.messageTokens, 0) - assert.equal(c.total, 0) - assert.deepEqual(c.largestRanges, []) -}) - -test("estimateContextComposition: pure text message counted in messageTokens", () => { - const msg = mkText("m1", "x".repeat(400)) - const c = estimateContextComposition([msg]) - assert.equal(c.toolTokens, 0) - assert.equal(c.codeTokens, 0) - assert.equal(c.summaryTokens, 0) - assert.equal(c.messageTokens, 100) - assert.equal(c.total, 100) -}) - -test("estimateContextComposition: tool part counted in toolTokens", () => { - const msg = mkTool("m1", '{"x":"y"}') - const c = estimateContextComposition([msg]) - const expectedTool = Math.round(JSON.stringify({ type: "tool", tool: '{"x":"y"}' }).length / 4) - assert.equal(c.toolTokens, expectedTool) - assert.equal(c.messageTokens, 0) - assert.equal(c.total, c.toolTokens) -}) - -test("estimateContextComposition: summary message counted in summaryTokens not messageTokens", () => { - const summaryText = "x".repeat(400) - const msg = mkSummary("b0", summaryText) - const c = estimateContextComposition([msg]) - const expectedSummary = Math.round(("[Compressed conversation section]\n" + summaryText).length / 4) - assert.equal(c.summaryTokens, expectedSummary) - assert.equal(c.messageTokens, 0) - assert.equal(c.total, c.summaryTokens) -}) - -test("estimateContextComposition: code blocks counted in codeTokens (subset of messageTokens)", () => { - const code = "```\nconst x = 1\nconst y = 2\n```" - const msg = mkText("m1", code) - const c = estimateContextComposition([msg]) - assert.ok(c.codeTokens > 0, "code tokens should be detected") - assert.ok(c.messageTokens >= c.codeTokens, "messageTokens includes code") - assert.equal(c.total, c.messageTokens) -}) - -test("estimateContextComposition: total = system + tool + summary + message (no assistant data → system=0)", () => { - const msgs = [ - mkText("m1", "hello world"), - mkTool("m2", '{"a":1}'), - mkSummary("b0", "recap text"), - mkText("m3", "```\ncode\n```"), - ] - const c = estimateContextComposition(msgs) - assert.equal(c.total, c.toolTokens + c.summaryTokens + c.messageTokens) -}) - -test("estimateContextComposition: largestRanges excludes summaries", () => { - const msgs = [ - mkText("m1", "x".repeat(2400)), - mkSummary("b0", "y".repeat(4000)), - ] - const c = estimateContextComposition(msgs) - assert.equal(c.largestRanges.length, 1) - assert.equal(c.largestRanges[0].ref, "?") -}) - -test("estimateContextComposition: largestToolRanges separate from largestCodeRanges", () => { - const codeMsg = mkText("m1", "```\n" + "x".repeat(2400) + "\n```") - const toolMsg = mkTool("m2", '{"big":"' + "x".repeat(2400) + '"}') - const c = estimateContextComposition([codeMsg, toolMsg]) - assert.ok(c.largestToolRanges.length >= 1) - assert.ok(c.largestCodeRanges.length >= 1) -}) - -test("estimateContextComposition: largestMessageRanges excludes messages with code", () => { - const codeMsg = mkText("m1", "```\ncode\n```") - const textMsg = mkText("m2", "x".repeat(2400)) - const c = estimateContextComposition([codeMsg, textMsg]) - assert.equal(c.largestMessageRanges.length, 1) - assert.equal(c.largestMessageRanges[0].ref, "?") -}) - -test("estimateContextComposition: resolves ref from state.messageIds.byRawId", () => { - const msg = mkText("raw-id-1", "x".repeat(2400)) - const state = { messageIds: { byRawId: new Map([["raw-id-1", "m00001"]]), byRef: new Map(), nextRef: 2 } } as any - const c = estimateContextComposition([msg], state) - assert.equal(c.largestRanges[0].ref, "m00001") -}) - -function mkCompress(id: string, summary: string): WithParts { - return { - info: { id } as any, - parts: [ - { - type: "tool", - tool: "compress", - callID: "call-1", - state: { - status: "completed", - input: { topic: "test topic", content: [{ startId: "m001", endId: "m002", summary }] }, - }, - } as any, - ] as any, - } -} - -test("estimateContextComposition: compress tool summary counted in summaryTokens not toolTokens", () => { - const summaryText = "x".repeat(4000) - const msg = mkCompress("m1", summaryText) - const c = estimateContextComposition([msg]) - const expectedSummary = Math.round(summaryText.length / 4) - assert.ok(c.summaryTokens >= expectedSummary, `summaryTokens ${c.summaryTokens} should include summary text (${expectedSummary})`) - assert.ok(c.toolTokens < expectedSummary, `toolTokens ${c.toolTokens} should be less than summary text`) - assert.equal(c.total, c.toolTokens + c.summaryTokens + c.messageTokens) -}) - -test("estimateContextComposition: compress tool structural overhead counted in toolTokens", () => { - const summaryText = "x".repeat(4000) - const msg = mkCompress("m1", summaryText) - const c = estimateContextComposition([msg]) - assert.ok(c.toolTokens > 0, "compress tool structural overhead should be counted as toolTokens") - const toolBreakdown = c.toolTypeBreakdown.find((t) => t.tool === "compress") - assert.ok(toolBreakdown, "compress should appear in toolTypeBreakdown") - assert.ok(toolBreakdown!.tokens > 0, "compress breakdown should have tokens") -}) - -test("estimateContextComposition: compress with multiple content entries sums all summaries", () => { - const summary1 = "a".repeat(2000) - const summary2 = "b".repeat(3000) - const msg = { - info: { id: "m1" } as any, - parts: [ - { - type: "tool", - tool: "compress", - callID: "call-1", - state: { - status: "completed", - input: { - topic: "multi", - content: [ - { startId: "m001", endId: "m002", summary: summary1 }, - { startId: "m003", endId: "m004", summary: summary2 }, - ], - }, - }, - } as any, - ] as any, - } as WithParts - const c = estimateContextComposition([msg]) - const expectedSummary = Math.round((summary1.length + summary2.length) / 4) - assert.ok(c.summaryTokens >= expectedSummary, `summaryTokens ${c.summaryTokens} should include both summaries (${expectedSummary})`) -}) - -test("estimateContextComposition: compress tool without state.input falls back to toolTokens", () => { - const msg = { - info: { id: "m1" } as any, - parts: [{ type: "tool", tool: "compress", callID: "call-1" } as any] as any, - } as WithParts - const c = estimateContextComposition([msg]) - assert.equal(c.summaryTokens, 0) - assert.ok(c.toolTokens > 0, "compress without input should be all toolTokens") - assert.equal(c.total, c.toolTokens) -}) - -test("estimateContextComposition: non-compress tools unaffected by summary classification", () => { - const msg = mkTool("m1", '{"data":"' + "x".repeat(4000) + '"}') - const c = estimateContextComposition([msg]) - assert.equal(c.summaryTokens, 0, "non-compress tools should not produce summaryTokens") - const expectedTool = Math.round(JSON.stringify({ type: "tool", tool: { data: "x".repeat(4000) } }).length / 4) - assert.ok(c.toolTokens > 0) - assert.equal(c.total, c.toolTokens) -}) diff --git a/tests/inject.test.ts b/tests/inject.test.ts deleted file mode 100644 index 8d0c7d7d..00000000 --- a/tests/inject.test.ts +++ /dev/null @@ -1,1661 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import * as fs from "fs/promises" -import { existsSync } from "fs" -import { join } from "path" -import { homedir } from "os" -import type { PluginConfig } from "../lib/config" -import { Logger } from "../lib/logger" -import { injectMessageIds, injectCompressNudges } from "../lib/messages/inject/inject" -import { createSyntheticUserMessage } from "../lib/messages/utils" -import { createSessionState, ensureSessionInitialized, type WithParts } from "../lib/state" -import { saveSessionState, loadSessionState } from "../lib/state/persistence" -import { formatMessageIdTag } from "../lib/message-ids" - -function buildConfig(mode: "message" | "range" = "range"): PluginConfig { - return { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { enabled: true, protectedTools: [] }, - experimental: { allowSubAgents: false, customPrompts: false }, - protectedFilePatterns: [], - compress: { - mode, permission: "allow", showCompression: false, summaryBuffer: true, - maxContextLimit: 150000, minContextLimit: 50000, - nudgeFrequency: 5, iterationNudgeThreshold: 15, nudgeForce: "soft", - protectedTools: [], protectTags: false, protectUserMessages: false, - minNudgeContextPercent: 15, maxSummaryLengthHard: 10000, - minCompressRange: 5000, minNudgeGrowthRatio: 0.45, - minNudgeGrowthFloor: 5000, emergencyThresholdPercent: "98%", - maxVisibleSegments: 50, keepEmbedMaxChars: 2000, - preserveRecentMessages: 0, preserveRecentTokens: 0, preserveLastUserMessage: false, - }, - gc: { algorithm: "truncate", promotionThreshold: 5, maxBlockAge: 15, maxOldGenSummaryLength: 3000, majorGcThresholdPercent: "100%", batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" } }, - } -} - -const SID = "ses-inject-test" - -const STORAGE_DIR = join( - process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"), - "opencode", - "storage", - "plugin", - "acp", -) -const PERSIST_SESSION = "test-inject-nudge-persist" - -async function cleanupPersistSession(): Promise { - const filePath = join(STORAGE_DIR, `${PERSIST_SESSION}.json`) - if (existsSync(filePath)) { - await fs.unlink(filePath) - } -} - -function textPart(msgId: string, text: string) { - return { id: `${msgId}-p`, messageID: msgId, sessionID: SID, type: "text" as const, text } -} - -function userMsg(id: string, text: string): WithParts { - return { - info: { id, role: "user", sessionID: SID, agent: "a", time: { created: 1 } } as WithParts["info"], - parts: [textPart(id, text)], - } -} - -function assistantMsg(id: string, text: string, toolParts?: any[]): WithParts { - const parts = [...(toolParts ?? []), textPart(id, text)] - return { - info: { id, role: "assistant", sessionID: SID, agent: "a", time: { created: 2 } } as WithParts["info"], - parts, - } -} - -function toolPart(callID: string, output: string) { - return { - id: `${callID}-part`, messageID: "msg", sessionID: SID, - type: "tool" as const, tool: "bash", callID, - state: { status: "completed" as const, input: {}, output }, - } -} - -function compressToolPart(callID: string, output: string) { - return { - id: `${callID}-part`, messageID: "msg", sessionID: SID, - type: "tool" as const, tool: "compress", callID, - state: { status: "completed" as const, input: {}, output }, - } -} - -function assistantMsgWithTokens( - id: string, - text: string, - tokens: { input: number; output: number }, - toolParts?: any[], -): WithParts { - const parts = [...(toolParts ?? []), textPart(id, text)] - return { - info: { - id, role: "assistant", sessionID: SID, agent: "a", time: { created: 2 }, - tokens, - } as WithParts["info"], - parts, - } -} - -const logger = new Logger(false) - -test("injectMessageIds tags user messages with ref", () => { - const state = createSessionState() - state.messageIds.byRawId.set("u1", "m00001") - const messages = [userMsg("u1", "hello")] - injectMessageIds(state, buildConfig(), messages) - const text = messages[0]!.parts[0] as any - assert.ok(text.text.includes("m00001"), "user message should have m00001 ref") -}) - -test("injectMessageIds tags assistant tool outputs with ref", () => { - const state = createSessionState() - state.messageIds.byRawId.set("a1", "m00002") - const messages = [assistantMsg("a1", "response", [toolPart("call-1", "tool output")])] - injectMessageIds(state, buildConfig(), messages) - const tool = messages[0]!.parts.find((p: any) => p.type === "tool") as any - assert.ok(tool.state.output.includes("m00002"), "tool output should have m00002 ref") -}) - -test("injectMessageIds skips messages without refs", () => { - const state = createSessionState() - const messages = [userMsg("u1", "no ref assigned")] - injectMessageIds(state, buildConfig(), messages) - const text = messages[0]!.parts[0] as any - assert.ok(!text.text.includes("m0"), "message without ref should not be tagged") -}) - -test("injectMessageIds adds tag to assistant text when no tool parts exist", () => { - const state = createSessionState() - state.messageIds.byRawId.set("a1", "m00003") - const messages = [assistantMsg("a1", "just text, no tools")] - injectMessageIds(state, buildConfig(), messages) - const textPartResult = messages[0]!.parts.find((p: any) => p.type === "text") as any - assert.ok(textPartResult.text.includes("m00003"), "assistant text should have ref when no tools") -}) - -test("injectCompressNudges does nothing when permission is deny", () => { - const state = createSessionState() - const config = buildConfig() - config.compress.permission = "deny" - const messages = [userMsg("u1", "hello")] - const originalLength = messages.length - injectCompressNudges(state, config, logger, messages, {} as any) - assert.equal(messages.length, originalLength, "no messages should be added when permission denied") -}) - -test("injectCompressNudges clears anchors when compress tool is detected", () => { - const state = createSessionState() - state.nudges.contextLimitAnchors.add("anchor-1") - state.nudges.turnNudgeAnchors.add("anchor-2") - state.nudges.iterationNudgeAnchors.add("anchor-3") - const messages: WithParts[] = [ - userMsg("u1", "hello"), - { - info: { id: "a1", role: "assistant", sessionID: SID, agent: "a", time: { created: 2 } } as WithParts["info"], - parts: [{ - id: "a1-tool", messageID: "a1", sessionID: SID, - type: "tool", tool: "compress", callID: "compress-1", - state: { status: "completed", input: {}, output: "done" }, - }], - }, - ] - injectCompressNudges(state, buildConfig(), logger, messages, {} as any) - assert.equal(state.nudges.contextLimitAnchors.size, 0, "contextLimitAnchors should be cleared") - assert.equal(state.nudges.turnNudgeAnchors.size, 0, "turnNudgeAnchors should be cleared") - assert.equal(state.nudges.iterationNudgeAnchors.size, 0, "iterationNudgeAnchors should be cleared") -}) - -test("stale compress from previous turn does NOT clobber baseline (restart fix)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 240_000 - const config = buildConfig() - config.compress.maxContextLimit = 300_000 - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - userMsg("u2", "next question"), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - assert.notEqual( - state.nudges.lastPerMessageNudgeTokens, - undefined, - "stale compress must not reset baseline to undefined", - ) - assert.equal( - state.nudges.contextLimitAnchors.size, - 0, - "anchors must not be cleared by stale compress", - ) -}) - -test("compress in current turn sets baseline to compress-calling assistant's currentTokens", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 200_000 - state.nudges.lastNudgeShownTokens = 200_000 - state.nudges.contextLimitAnchors.add("anchor-1") - const config = buildConfig() - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 250_000, - "current-turn compress sets baseline to compress-calling assistant's currentTokens (input+output)", - ) - assert.equal(state.nudges.compressBaselineSet, true, "lock must be set to prevent leak from continuation work") - assert.equal(state.nudges.contextLimitAnchors.size, 0, "anchors must be cleared") -}) - -test("compress followed by continuation assistant sets baseline to continuation tokens (issue #23)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 200_000 - state.nudges.lastNudgeShownTokens = 200_000 - state.nudges.contextLimitAnchors.add("anchor-1") - const config = buildConfig() - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "compressing", { input: 200_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - assistantMsgWithTokens("a2", "now continuing the task", { input: 150_000, output: 1_000 }), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 151_000, - "compress detected in current turn — baseline set to latest assistant currentTokens", - ) - assert.equal(state.nudges.contextLimitAnchors.size, 0, "anchors must be cleared") -}) - -test("formatMessageIdTag produces dcp-message-id tag", () => { - const tag = formatMessageIdTag("m00001") - assert.ok(tag.includes("m00001")) - assert.ok(tag.includes("dcp-message-id")) -}) - -// OpenCode's SessionPrompt.ensureTitle treats a user message as "real" only when -// NOT all of its parts are synthetic (opencode prompt.ts: -// m.info.role === "user" && !m.parts.every(p => "synthetic" in p && p.synthetic) -// ) and bails out unless the conversation contains EXACTLY one real user message. -// ACP's compress-nudge suffix message is created via createSyntheticUserMessage and -// pushed as a second user message; if it counted as real, title generation would -// never be scheduled. This test locks the contract: the suffix message must be -// all-synthetic so ensureTitle still sees exactly one real user message. -const isOpenCodeRealUserMessage = (m: WithParts): boolean => - m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && (p as { synthetic?: unknown }).synthetic === true) - -test("createSyntheticUserMessage produces an all-synthetic user message that ensureTitle does not count as real", () => { - const base = userMsg("u1", "hello") - const synthetic = createSyntheticUserMessage(base, "") - - assert.ok( - synthetic.parts.every((p) => "synthetic" in p && (p as { synthetic?: unknown }).synthetic === true), - "every part of a createSyntheticUserMessage result must carry synthetic:true", - ) - assert.equal(isOpenCodeRealUserMessage(synthetic), false, "synthetic user message must NOT be a 'real' user message") - assert.equal(isOpenCodeRealUserMessage(base), true, "a plain user message must still be 'real'") - - const conversation = [base, synthetic] - assert.equal( - conversation.filter(isOpenCodeRealUserMessage).length, - 1, - "after ACP injects its suffix message the conversation must still have exactly one real user message (ensureTitle precondition)", - ) -}) - -test("injectCompressNudges: after compress, baseline set to compress-calling assistant's currentTokens", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastNudgeShownTokens = 200_000 - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 550_000 - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 250_000, - "baseline set to compress-calling assistant's currentTokens (input+output) — prevents leak from continuation work", - ) - assert.equal(state.nudges.compressBaselineSet, true, "lock must be set") -}) - -test("injectCompressNudges: post-compress baseline then small growth does NOT re-nudge", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 550_000 - - // Turn 1: compress detected → baseline set to 250K (200K input + 50K output) - state.nudges.lastNudgeShownTokens = 200_000 - const turn1: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, turn1, {} as any) - assert.equal(state.nudges.lastPerMessageNudgeTokens, 250_000) - - // Turn 2: small growth (253K - 250K = 3K < 50K threshold) → no nudge - const turn2: WithParts[] = [ - userMsg("u2", "next"), - assistantMsgWithTokens("a2", "response", { input: 247_000, output: 6_000 }), - ] - injectCompressNudges(state, config, logger, turn2, {} as any) - - assert.equal( - state.nudges.shouldInjectThisTurn, - false, - "3K growth from compress baseline — should NOT nudge", - ) -}) - -test("injectCompressNudges: post-compress baseline then large growth DOES nudge", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 550_000 - - // Turn 1: compress → baseline set to 250K - state.nudges.lastNudgeShownTokens = 200_000 - const turn1: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, turn1, {} as any) - - // Turn 2: 55K growth (305K - 250K) >= 50K threshold → nudge fires - const turn2: WithParts[] = [ - userMsg("u2", "next"), - assistantMsgWithTokens("a2", "baseline", { input: 250_000, output: 55_000 }), - ] - injectCompressNudges(state, config, logger, turn2, {} as any) - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "55K growth from compress baseline (250K→305K, >50K threshold) — should nudge", - ) - assert.equal(state.nudges.lastPerMessageNudgeTokens, 250_000, "baseline NOT updated after nudge — only compress resets") -}) - -test("nudge threshold halves after first nudge without compress (issue #23)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 100_000 - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 200_000 - - const messages1: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 100_000, output: 50_000 }), - ] - injectCompressNudges(state, config, logger, messages1, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, true, "50K growth >= 50K threshold → first nudge") - assert.equal(state.nudges.lastNudgeShownTokens, 150_000, "lastNudgeShownTokens set to currentTokens") - - const messages2: WithParts[] = [ - userMsg("u2", "more"), - assistantMsgWithTokens("a2", "work", { input: 160_000, output: 5_000 }), - ] - injectCompressNudges(state, config, logger, messages2, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "15K growth from lastShown < 25K (halved) → no nudge") - - const messages3: WithParts[] = [ - userMsg("u3", "more"), - assistantMsgWithTokens("a3", "work", { input: 170_000, output: 5_000 }), - ] - injectCompressNudges(state, config, logger, messages3, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, true, "25K growth from lastShown >= 25K (halved) → nudge fires") - assert.equal(state.nudges.lastNudgeShownTokens, 175_000) -}) - -test("voluntary compress (no nudge shown) does NOT reset baseline", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 50_000 - // lastNudgeShownTokens is undefined — no nudge was shown - const config = buildConfig() - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 80_000, output: 10_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 50_000, - "voluntary compress does NOT reset baseline — growth tracking continues from original baseline", - ) - assert.equal(state.nudges.compressBaselineSet, false, "lock NOT set for voluntary compress") -}) - -test("baseline initialized to currentTokens on first transform", () => { - // Baseline = currentTokens. The system prompt is always present and is - // NOT "growth" — measuring from currentTokens means the first nudge fires - // at ~currentTokens + nudgeGrowthTokens, not at nudgeGrowthTokens absolute. - const state = createSessionState() - state.modelContextLimit = 1_000_000 - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - undefined, - "fresh state has undefined baseline — no growth tracking yet", - ) - - const config = buildConfig() - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - // Turn 1: first message transform. currentTokens = 55K (input 50K + output 5K). - // Baseline is undefined → gets initialized to currentTokens. No nudge fires. - const messages1: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "work", { input: 50_000, output: 5_000 }), - ] - injectCompressNudges(state, config, logger, messages1, {} as any) - - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 55_000, - "baseline initialized to currentTokens — growth measured from starting context, not from 0", - ) - assert.equal( - state.nudges.shouldInjectThisTurn, - false, - "first turn never nudges — baseline establishment only", - ) - - // Turn 2: context grew slightly to 58K. Growth = 58K - 55K = 3K < 50K threshold. - // Nudge MUST NOT fire — only 3K of real growth, not 50K. - const messages2: WithParts[] = [ - userMsg("u2", "more"), - assistantMsgWithTokens("a2", "work", { input: 53_000, output: 5_000 }), - ] - injectCompressNudges(state, config, logger, messages2, {} as any) - - assert.equal( - state.nudges.shouldInjectThisTurn, - false, - "3K growth < 50K threshold → nudge correctly suppressed", - ) -}) - -test("nudge threshold restores to full after compress (issue #23)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 100_000 - state.nudges.lastNudgeShownTokens = 150_000 - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 200_000 - - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 100_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - assert.equal(state.nudges.lastNudgeShownTokens, undefined, "compress resets lastNudgeShownTokens") - assert.equal(state.nudges.lastPerMessageNudgeTokens, 150_000, "compress sets baseline to post-compression currentTokens") -}) - -test("injectCompressNudges persists new nudge baseline to disk when a growth nudge fires without anchor changes (#60)", async () => { - await cleanupPersistSession() - - // Seed disk with a stale baseline, as left by a prior session before restart. - const seed = createSessionState() - seed.sessionId = PERSIST_SESSION - seed.nudges.lastPerMessageNudgeTokens = 200_000 - await saveSessionState(seed, logger) - - // Simulate the post-restart in-memory state: stale baseline loaded back. - const state = createSessionState() - state.sessionId = PERSIST_SESSION - state.modelContextLimit = 1_000_000 - const loaded = await loadSessionState(PERSIST_SESSION, logger) - state.nudges.lastPerMessageNudgeTokens = loaded!.nudges.lastPerMessageNudgeTokens - - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 200_000 - - // Last message is an assistant turn → turnNudgeAnchors block skipped (isLastMessageUser=false); - // only one message after the user → iterationNudgeAnchors skipped (< iterationNudgeThreshold); - // no tool parts → toolOutput reminder skipped. So anchorsChanged stays false. - // Growth = 255K - 200K = 55K >= 50K adaptive threshold → shouldNudge=true. - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "response", { input: 200_000, output: 55_000 }), - ] - - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal(state.nudges.shouldInjectThisTurn, true, "growth nudge should fire (55K >= 50K adaptive)") - assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "baseline NOT updated after nudge — nudges repeat until compress") - - // saveSessionState is fire-and-forget inside injectCompressNudges (.catch(()=>{})); flush before reload. - await new Promise((resolve) => setTimeout(resolve, 50)) - - const reloaded = await loadSessionState(PERSIST_SESSION, logger) - assert.ok(reloaded, "state must be persisted when a nudge fires") - assert.equal( - reloaded!.nudges.lastPerMessageNudgeTokens, - 200_000, - "baseline unchanged on disk — nudges repeat every turn until model actually compresses", - ) - await cleanupPersistSession() -}) - -test("E2E: nudge survives compress → restart → growth (issue #23)", async () => { - await cleanupPersistSession() - - const state = createSessionState() - state.sessionId = PERSIST_SESSION - state.modelContextLimit = 1_000_000 - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 200_000 - - // Turn 1: model calls compress → baseline set to 250K (200K+50K) - state.nudges.lastNudgeShownTokens = 200_000 - const turn1: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 50_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, turn1, {} as any) - assert.equal(state.nudges.lastPerMessageNudgeTokens, 250_000, "compress sets baseline to post-compression tokens") - - // Simulate restart: load from disk - await new Promise((resolve) => setTimeout(resolve, 50)) - const loaded1 = await loadSessionState(PERSIST_SESSION, logger) - assert.equal(loaded1!.nudges.lastPerMessageNudgeTokens, 250_000, "on-disk baseline must be 250K after compress") - - const state2 = createSessionState() - state2.sessionId = PERSIST_SESSION - state2.modelContextLimit = 1_000_000 - state2.nudges.lastPerMessageNudgeTokens = loaded1!.nudges.lastPerMessageNudgeTokens - state2.nudges.compressBaselineSet = loaded1!.nudges.compressBaselineSet ?? false - - // Turn 2: post-compress turn, context dropped to 155K — baseline correction adjusts - const turn2: WithParts[] = [ - userMsg("u2", "next"), - assistantMsgWithTokens("a2", "response", { input: 150_000, output: 5_000 }), - ] - injectCompressNudges(state2, config, logger, turn2, {} as any) - // 155K < 250K - 50K = 200K → baseline corrected to 155K - assert.equal(state2.nudges.lastPerMessageNudgeTokens, 155_000, "baseline corrected down to actual post-compression level") - - // Simulate restart AGAIN: baseline must persist - await new Promise((resolve) => setTimeout(resolve, 50)) - const loaded2 = await loadSessionState(PERSIST_SESSION, logger) - assert.equal( - loaded2!.nudges.lastPerMessageNudgeTokens, - 155_000, - "corrected baseline MUST persist to disk", - ) - - // Turn 3: load persisted baseline, then grow past threshold → nudge MUST fire - const state3 = createSessionState() - state3.sessionId = PERSIST_SESSION - state3.modelContextLimit = 1_000_000 - state3.nudges.lastPerMessageNudgeTokens = loaded2!.nudges.lastPerMessageNudgeTokens - - const turn3: WithParts[] = [ - userMsg("u3", "more work"), - assistantMsgWithTokens("a3", "result", { input: 200_000, output: 10_000 }), - ] - injectCompressNudges(state3, config, logger, turn3, {} as any) - assert.equal( - state3.nudges.shouldInjectThisTurn, - true, - "55K growth past corrected baseline (155K→210K, >50K threshold) — nudge MUST fire", - ) - - await cleanupPersistSession() -}) - -test("E2E: nudge recommendation content includes composition breakdown and compress guidance (issue #23)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 200_000 - const config = buildConfig() - config.compress.maxContextLimit = 800_000 - config.compress.minContextLimit = 200_000 - - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 55_000 }, [ - toolPart("c1", "x".repeat(40_000)), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal(state.nudges.shouldInjectThisTurn, true, "should nudge (55K growth >= 50K threshold)") - - const injected = suffixText(messages) - assert.ok(injected.includes("Breakdown:"), "nudge must include composition breakdown") - assert.ok(injected.includes("tool"), "breakdown must show tool category") - assert.ok( - injected.includes("acp_status") || injected.includes("compress") || injected.includes("review"), - "nudge must include compress guidance", - ) -}) - -test("growth floor: nudge suppressed when growth below floor (issue #27 anti-thrashing)", () => { - // 1M model: growthFloor = max(5000, 0.45×50000) = 22500 - // Growth of 5K < 22500 → no nudge output at all - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 205_000 - state.messageIds.byRawId.set("u1", "m00001") - state.messageIds.byRawId.set("a1", "m00002") - state.messageIds.byRawId.set("u2", "m00003") - - const config = buildConfig() - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 10_000 }, [ - toolPart("c1", "x".repeat(40_000)), - ]), - userMsg("u2", "next"), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal(state.nudges.shouldInjectThisTurn, false, "5K growth < 22500 floor → nudge suppressed") - assert.ok(state.nudges.turnNudgeAnchors.size > 0, "anchors still accumulate") - - const injected = suffixText(messages) - assert.ok(!injected.includes("Breakdown:"), "no breakdown when growth below floor") - assert.ok(!injected.includes("Compressible ranges"), "no ranges when growth below floor") - assert.ok(!injected.includes("Context limit reached"), "no strong alert when growth below floor") - assert.equal(state.nudges.lastNudgeShownTokens, undefined, "lastNudgeShownTokens not updated") -}) - -test("growth floor: nudge fires when growth meets nudgeGrowthTokens (not just growthFloor)", () => { - // 1M model: nudgeGrowthTokens = 50000, growthFloor = max(5000, 0.45×50000) = 22500 - // Growth of 25K >= growthFloor (22500) but < nudgeGrowthTokens (50000) → suppressed - // Growth of 55K >= nudgeGrowthTokens (50000) AND >= growthFloor (22500) → fires - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 200_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 - - // 25K growth: below nudgeGrowthTokens → suppressed - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 25_000 }, [ - toolPart("c1", "x".repeat(40_000)), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal(state.nudges.shouldInjectThisTurn, false, "25K growth < 50K nudgeGrowthTokens → nudge suppressed") - - // 55K growth: above nudgeGrowthTokens AND above growthFloor → fires - const state2 = createSessionState() - state2.modelContextLimit = 1_000_000 - state2.nudges.lastPerMessageNudgeTokens = 200_000 - state2.messageIds.byRawId.set("u1", "m00001") - state2.messageIds.byRawId.set("a1", "m00002") - state2.messageIds.byRawId.set("a2", "m00003") - - const messages2: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "work", { input: 200_000, output: 30_000 }, [ - toolPart("c1", "x".repeat(320_000)), - ]), - assistantMsgWithTokens("a2", "done", { input: 200_000, output: 50_000 }, [ - toolPart("c2", "x".repeat(320_000)), - ]), - ] - injectCompressNudges(state2, config, logger, messages2, {} as any) - - assert.equal(state2.nudges.shouldInjectThisTurn, true, "55K growth >= 50K nudgeGrowthTokens → nudge fires") - - const injected = suffixText(messages2) - assert.ok(injected.includes("Breakdown:"), "breakdown shown when growth meets threshold") - assert.ok(injected.includes("Compressible ranges"), "ranges shown when growth meets threshold") -}) - -test("growth floor: 98% emergency override fires regardless of growth", () => { - // Context at 98%+ but growth is 0 → emergency override fires - 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 → 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", - ) -}) - -test("nudge fires when small ranges exist — Issue #251: no floor suppression at large context", () => { - // 1M model: growthThreshold=50K - // Growth of 55K > 50K threshold → nudgeAllowed = true - // Tool output is 80K chars (~20K tokens) — before #251 this was < 100K floor → suppressed - // After #251: filterRecommendedRanges never suppresses → range shown → nudge fires - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 200_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: 200_000, output: 55_000 }, [ - toolPart("c1", "x".repeat(80_000)), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "55K growth + 20K tool output → range recommended → nudge fires (Issue #251 fix)", - ) - - const injected = suffixText(messages) - assert.ok(injected.includes("Breakdown:"), "breakdown shown when range recommended") - assert.ok(!injected.includes("Context limit reached"), "no emergency alert — not at max limit") -}) - -test("nudge suppressed when all content is protected (nothing to compress)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 200_000 - state.messageIds.byRawId.set("a1", "m00001") - - const config = buildConfig() - config.compress.protectedTools = ["skill"] - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - const messages: WithParts[] = [ - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 55_000 }, [ - { - id: "skill-part", messageID: "a1", sessionID: SID, - type: "tool" as const, tool: "skill", callID: "skill-call", - state: { status: "completed" as const, input: {}, output: "x".repeat(80_000) }, - }, - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal( - state.nudges.shouldInjectThisTurn, - false, - "55K growth triggers nudgeAllowed but ALL tool output is protected (skill) → nothing to compress → nudge suppressed", - ) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 200_000, - "baseline PRESERVED — not advanced to currentTokens on nothingToCompress", - ) -}) - -test("emergency override fires even when all content is protected", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 980_000 - state.nudges.lastNudgeShownTokens = 980_000 - state.messageIds.byRawId.set("a1", "m00001") - - const config = buildConfig() - config.compress.protectedTools = ["skill"] - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - const messages: WithParts[] = [ - assistantMsgWithTokens("a1", "done", { input: 970_000, output: 10_000 }, [ - { - id: "skill-part", messageID: "a1", sessionID: SID, - type: "tool" as const, tool: "skill", callID: "skill-call", - state: { status: "completed" as const, input: {}, output: "x".repeat(40_000) }, - }, - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "98% emergency override fires even when all content is protected", - ) -}) - -test("baseline preserved when nudge suppressed — growth accumulates (all protected)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.messageIds.byRawId.set("a1", "m00001") - - const config = buildConfig() - config.compress.protectedTools = ["skill"] - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - const turn1: WithParts[] = [ - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 55_000 }, [ - { - id: "skill-part", messageID: "a1", sessionID: SID, - type: "tool" as const, tool: "skill", callID: "skill-call", - state: { status: "completed" as const, input: {}, output: "x".repeat(80_000) }, - }, - ]), - ] - state.nudges.lastPerMessageNudgeTokens = 200_000 - injectCompressNudges(state, config, logger, turn1, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "55K growth but all protected → suppressed") - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 200_000, - "baseline preserved — not advanced on nothingToCompress", - ) - - state.messageIds.byRawId.set("a2", "m00002") - const turn2: WithParts[] = [ - assistantMsgWithTokens("a2", "response", { input: 253_000, output: 7_000 }, [ - { - id: "skill-part2", messageID: "a2", sessionID: SID, - type: "tool" as const, tool: "skill", callID: "skill-call2", - state: { status: "completed" as const, input: {}, output: "x".repeat(10_000) }, - }, - ]), - ] - injectCompressNudges(state, config, logger, turn2, {} as any) - assert.equal( - state.nudges.shouldInjectThisTurn, - false, - "still all protected → suppressed (growth 60K from preserved baseline)", - ) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 200_000, - "baseline still preserved — growth accumulates until compressible content exists", - ) -}) - -test("baseline preserved when nudge fires for small compressible — Issue #251", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_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 - - state.nudges.lastPerMessageNudgeTokens = 200_000 - const turn1: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 55_000 }, [ - toolPart("c1", "x".repeat(80_000)), - ]), - ] - injectCompressNudges(state, config, logger, turn1, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, true, "55K growth + 20K compressible → nudge fires (Issue #251)") - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 200_000, - "baseline preserved on nudge fire — only advances after actual compression (inject.ts:537)", - ) -}) - -test("pending nudge preserved when all-protected — no loop", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.messageIds.byRawId.set("a1", "m00001") - - const config = buildConfig() - config.compress.protectedTools = ["skill"] - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - state.nudges.lastPerMessageNudgeTokens = 200_000 - state.nudges.lastNudgeShownTokens = 200_000 - const turn1: WithParts[] = [ - assistantMsgWithTokens("a1", "done", { input: 225_000, output: 30_000 }, [ - { - id: "skill-part", messageID: "a1", sessionID: SID, - type: "tool" as const, tool: "skill", callID: "skill-call", - state: { status: "completed" as const, input: {}, output: "x".repeat(80_000) }, - }, - ]), - ] - injectCompressNudges(state, config, logger, turn1, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "nudge suppressed — all protected") - assert.equal( - state.nudges.lastNudgeShownTokens, - 200_000, - "pending nudge baseline preserved — prevents loop (stale fallback → huge growth → re-fire)", - ) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 200_000, - "baseline preserved — growth accumulates for next turn", - ) -}) - -test("multi-turn: all-protected does not loop (lastNudgeShownTokens stable)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.messageIds.byRawId.set("a1", "m00001") - state.messageIds.byRawId.set("a2", "m00002") - state.messageIds.byRawId.set("a3", "m00003") - - const config = buildConfig() - config.compress.protectedTools = ["skill"] - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - state.nudges.lastPerMessageNudgeTokens = 200_000 - - const protectedTurn = (id: string, inputTokens: number) => - assistantMsgWithTokens(id, "work", { input: inputTokens, output: 30_000 }, [ - { - id: `${id}-part`, messageID: id, sessionID: SID, - type: "tool" as const, tool: "skill", callID: `${id}-call`, - state: { status: "completed" as const, input: {}, output: "x".repeat(80_000) }, - }, - ]) - - // Turn 1: nudge suppressed (all protected) - injectCompressNudges(state, config, logger, [protectedTurn("a1", 225_000)], {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: all protected, nudge suppressed") - assert.equal(state.nudges.lastNudgeShownTokens, undefined, "turn 1: no nudge shown yet") - - state.nudges.lastNudgeShownTokens = 225_000 - - // Turn 2: growth continues, still all-protected - injectCompressNudges(state, config, logger, [protectedTurn("a2", 230_000)], {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 2: still all protected") - assert.equal( - state.nudges.lastNudgeShownTokens, - 225_000, - "turn 2: baseline preserved — NOT reset (prevents loop)", - ) - - // Turn 3: more growth, still all-protected - injectCompressNudges(state, config, logger, [protectedTurn("a3", 240_000)], {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 3: still all protected") - assert.equal( - state.nudges.lastNudgeShownTokens, - 225_000, - "turn 3: baseline still preserved — no loop", - ) -}) - -test("voluntary compress after suppression does not trigger proportional baseline adjustment", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.messageIds.byRawId.set("a1", "m00001") - - const config = buildConfig() - config.compress.protectedTools = ["skill"] - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - state.nudges.lastPerMessageNudgeTokens = 200_000 - const turn1: WithParts[] = [ - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 55_000 }, [ - { - id: "skill-part", messageID: "a1", sessionID: SID, - type: "tool" as const, tool: "skill", callID: "skill-call", - state: { status: "completed" as const, input: {}, output: "x".repeat(80_000) }, - }, - ]), - ] - injectCompressNudges(state, config, logger, turn1, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: all protected → suppressed") - assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 1: baseline preserved") - assert.equal(state.nudges.lastNudgeShownTokens, undefined, "turn 1: pending nudge cleared") - - state.messageIds.byRawId.set("a2", "m00002") - const turn2: WithParts[] = [ - assistantMsgWithTokens("a2", "compressed", { input: 253_000, output: 2_000 }, [ - compressToolPart("c1", "compressed"), - ]), - ] - injectCompressNudges(state, config, logger, turn2, {} as any) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 200_000, - "turn 2: voluntary compress (wasNudgeTriggered=false) keeps suppression baseline — no proportional adjustment", - ) - assert.equal(state.nudges.compressBaselineSet, false, "lock not set for voluntary compress") -}) - -test("emergency override fires even when filter has no recommendations", () => { - // Context at 98%+ with small tool output (< floor) → emergency bypasses filter - 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% emergency override fires even when filter has no recommendations", - ) - - const injected = suffixText(messages) - assert.ok(injected.includes("Breakdown:"), "breakdown shown at emergency even without recommendations") - assert.ok( - injected.includes("Context limit reached — compress now"), - "strong maxLimit alert at emergency", - ) -}) - -test("growth floor: 5000 floor on small-context models", () => { - // 100K model: nudgeGrowthTokens = max(6000, 100K×5%) = 6000 - // growthFloor = max(5000, 0.45×6000) = max(5000, 2700) = 5000 - // Growth of 4K < 5000 → suppressed. Growth of 6K >= 5000 → fires. - const state = createSessionState() - state.modelContextLimit = 100_000 - state.nudges.lastPerMessageNudgeTokens = 20_000 - state.messageIds.byRawId.set("u1", "m00001") - state.messageIds.byRawId.set("a1", "m00002") - - const config = buildConfig() - config.compress.maxContextLimit = 60_000 - config.compress.minContextLimit = 20_000 - - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 20_000, output: 4_000 }, [ - toolPart("c1", "x".repeat(8_000)), - ]), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal(state.nudges.shouldInjectThisTurn, false, "4K growth < 5000 floor on 100K model") - - // Now with 6K growth → should fire - const state2 = createSessionState() - state2.modelContextLimit = 100_000 - state2.nudges.lastPerMessageNudgeTokens = 20_000 - state2.messageIds.byRawId.set("u1", "m00001") - state2.messageIds.byRawId.set("a1", "m00002") - - const messages2: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 20_000, output: 6_000 }, [ - toolPart("c1", "x".repeat(60_000)), - ]), - ] - injectCompressNudges(state2, config, logger, messages2, {} as any) - - assert.equal(state2.nudges.shouldInjectThisTurn, true, "6K growth >= 5000 floor on 100K model") -}) - -test("growth floor: applyAnchoredNudges output suppressed when growth below floor (Oracle MEDIUM #2)", () => { - // Verify that applyAnchoredNudges is gated by nudgeAllowed — not just the - // breakdown block. If someone un-gates applyAnchoredNudges, anchored nudge - // prompt text would leak into the suffix every turn. - const TURN_NUDGE_MARKER = "TURN_NUDGE_TEST_MARKER" - - const makePrompts = () => - ({ - system: "", - compressRange: "", - compressMessage: "", - contextLimitNudge: "CTX_LIMIT_MARKER", - turnNudge: TURN_NUDGE_MARKER, - iterationNudge: "ITER_NUDGE_MARKER", - manualExtension: "", - subagentExtension: "", - decompressExtension: "", - }) as any - - // --- Suppressed: growth below floor --- - const state1 = createSessionState() - state1.modelContextLimit = 1_000_000 - state1.nudges.lastPerMessageNudgeTokens = 205_000 - state1.messageIds.byRawId.set("u1", "m00001") - state1.messageIds.byRawId.set("a1", "m00002") - state1.messageIds.byRawId.set("u2", "m00003") - - const config = buildConfig() - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - - const messages1: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 10_000 }, [ - toolPart("c1", "x".repeat(40_000)), - ]), - userMsg("u2", "next"), - ] - injectCompressNudges(state1, config, logger, messages1, makePrompts()) - - assert.equal(state1.nudges.shouldInjectThisTurn, false) - const text1 = suffixText(messages1) - assert.ok( - !text1.includes(TURN_NUDGE_MARKER), - "anchored turn nudge text must NOT appear when nudgeAllowed is false", - ) - - // --- Fires: growth meets nudgeGrowthTokens → anchored nudge text SHOULD appear --- - const state2 = createSessionState() - state2.modelContextLimit = 1_000_000 - state2.nudges.lastPerMessageNudgeTokens = 200_000 - state2.messageIds.byRawId.set("u1", "m00001") - state2.messageIds.byRawId.set("a1", "m00002") - state2.messageIds.byRawId.set("u2", "m00003") - - const messages2: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 200_000, output: 55_000 }, [ - toolPart("c1", "x".repeat(620_000)), - ]), - userMsg("u2", "next"), - ] - injectCompressNudges(state2, config, logger, messages2, makePrompts()) - - assert.equal(state2.nudges.shouldInjectThisTurn, true) - const text2 = suffixText(messages2) - assert.ok( - text2.includes(TURN_NUDGE_MARKER), - "anchored turn nudge text MUST appear when nudgeAllowed is true", - ) -}) - -test("stale contextLimitAnchors cleared when context drops below maxLimit without compress (issue #27)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 50_000 - state.nudges.contextLimitAnchors.add("stale-anchor-1") - - const config = buildConfig() - config.compress.maxContextLimit = 200_000 - config.compress.minContextLimit = 50_000 - - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 90_000, output: 10_000 }), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal( - state.nudges.contextLimitAnchors.size, - 0, - "stale contextLimitAnchors must be cleared when context drops below maxLimit", - ) -}) - -test("stale contextLimitAnchors cleared even when context below minLimit (Oracle L1)", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 10_000 - state.nudges.contextLimitAnchors.add("stale-anchor-1") - - const config = buildConfig() - config.compress.maxContextLimit = 200_000 - config.compress.minContextLimit = 50_000 - - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 20_000, output: 10_000 }), - ] - injectCompressNudges(state, config, logger, messages, {} as any) - - assert.equal( - state.nudges.contextLimitAnchors.size, - 0, - "stale contextLimitAnchors must be cleared even when context is below minLimit", - ) -}) - -test("stale contextLimitAnchors: contextLimitNudge NOT injected when context below limit (issue #27)", () => { - const CTX_LIMIT_MARKER = "CTX_LIMIT_MARKER" - const TURN_NUDGE_MARKER = "TURN_NUDGE_MARKER" - - const makePrompts = () => - ({ - system: "", - compressRange: "", - compressMessage: "", - contextLimitNudge: CTX_LIMIT_MARKER, - turnNudge: TURN_NUDGE_MARKER, - iterationNudge: "ITER_NUDGE_MARKER", - manualExtension: "", - subagentExtension: "", - decompressExtension: "", - }) as any - - const state = createSessionState() - state.modelContextLimit = 1_000_000 - state.nudges.lastPerMessageNudgeTokens = 50_000 - state.nudges.contextLimitAnchors.add("stale-anchor-1") - state.messageIds.byRawId.set("u1", "m00001") - state.messageIds.byRawId.set("a1", "m00002") - state.messageIds.byRawId.set("u2", "m00003") - - const config = buildConfig() - config.compress.maxContextLimit = 200_000 - config.compress.minContextLimit = 50_000 - - const messages: WithParts[] = [ - userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 90_000, output: 10_000 }, [ - toolPart("c1", "x".repeat(620_000)), - ]), - userMsg("u2", "next"), - ] - injectCompressNudges(state, config, logger, messages, makePrompts()) - - assert.equal(state.nudges.shouldInjectThisTurn, true, "nudge fires (50K growth >= 22500 floor)") - assert.equal(state.nudges.contextLimitAnchors.size, 0, "stale contextLimitAnchors cleared") - - const injected = suffixText(messages) - assert.ok( - !injected.includes(CTX_LIMIT_MARKER), - "context limit nudge must NOT appear when context below maxLimit", - ) - assert.ok( - injected.includes(TURN_NUDGE_MARKER), - "turn nudge SHOULD appear (overMinLimit + nudgeAllowed)", - ) -}) -// Reminder threshold scales with context (via nudgeGrowthTokens); on a 1M model -// it is 50K, not the old hardcoded 5000. Tool chars ≈ JSON.stringify(part).length/4. - -function suffixText(messages: WithParts[]): string { - return messages - .map((m) => m.parts.map((p: any) => (typeof p.text === "string" ? p.text : "")).join("")) - .join("") -} - -// --- modelContextLimit persistence (issue #18) --- -// modelContextLimit must survive restart so adaptive thresholds (nudgeGrowthTokens, -// toolOutputThreshold) don't fall to the 6000 floor on the first turn after reload. - -const PERSIST_MODEL_LIMIT = "test-modelcontextlimit-persist" - -async function cleanupModelLimitSession(): Promise { - const filePath = join(STORAGE_DIR, `${PERSIST_MODEL_LIMIT}.json`) - if (existsSync(filePath)) { - await fs.unlink(filePath) - } -} - -test("modelContextLimit persists across save/load round-trip (#18)", async () => { - const state = createSessionState() - state.sessionId = PERSIST_MODEL_LIMIT - state.modelContextLimit = 1_000_000 - await cleanupModelLimitSession() - - await saveSessionState(state, logger) - - const loaded = await loadSessionState(PERSIST_MODEL_LIMIT, logger) - assert.ok(loaded, "state file must exist after save") - assert.equal(loaded!.modelContextLimit, 1_000_000, "modelContextLimit must survive round-trip") - await cleanupModelLimitSession() -}) - -test("ensureSessionInitialized restores persisted modelContextLimit after restart (#18)", async () => { - const seed = createSessionState() - seed.sessionId = PERSIST_MODEL_LIMIT - seed.modelContextLimit = 1_000_000 - await cleanupModelLimitSession() - await saveSessionState(seed, logger) - - const fresh = createSessionState() - assert.equal(fresh.modelContextLimit, undefined, "fresh state starts without modelContextLimit") - await ensureSessionInitialized(null, fresh, PERSIST_MODEL_LIMIT, logger, [], false) - - assert.equal( - fresh.modelContextLimit, - 1_000_000, - "persisted modelContextLimit must be restored so adaptive thresholds use the real limit, not the 6K floor", - ) - await cleanupModelLimitSession() -}) - -test("E2E growth: baseline preserved through nothingToCompress, nudge fires when content exits protected zone", () => { - const state = createSessionState() - state.modelContextLimit = 1_000_000 - - const config = buildConfig() - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - config.compress.protectedTools = ["skill"] - config.compress.preserveRecentMessages = 20 - config.compress.preserveRecentTokens = 0 - config.compress.preserveLastUserMessage = false - - function buildMessages(n: number, toolOutputSize: number = 200_000): WithParts[] { - const msgs: WithParts[] = [] - for (let i = 1; i <= n; i++) { - const uid = `u${i}` - const aid = `a${i}` - if (!state.messageIds.byRawId.has(uid)) state.messageIds.byRawId.set(uid, `m${String(i * 2 - 1).padStart(5, "0")}`) - if (!state.messageIds.byRawId.has(aid)) state.messageIds.byRawId.set(aid, `m${String(i * 2).padStart(5, "0")}`) - msgs.push(userMsg(uid, `task ${i}`)) - msgs.push(assistantMsgWithTokens(aid, `result ${i}`, { input: 200_000, output: 80_000 }, [ - toolPart(`tp${i}`, "x".repeat(toolOutputSize)), - ])) - } - return msgs - } - - state.nudges.lastPerMessageNudgeTokens = 200_000 - - const turn1 = buildMessages(5) - injectCompressNudges(state, config, logger, turn1, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: 5 msgs, all within 20-msg protection → suppressed") - assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 1: baseline PRESERVED") - - const turn2 = buildMessages(10) - injectCompressNudges(state, config, logger, turn2, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 2: 10 msgs, still within 20-msg protection → suppressed") - assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 2: baseline STILL PRESERVED — growth accumulating") - - const turn3 = buildMessages(25) - injectCompressNudges(state, config, logger, turn3, {} as any) - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "turn 3: 50 msgs, first 30 outside 20-msg protection, large outputs → nudge FIRES", - ) - assert.equal( - state.nudges.lastPerMessageNudgeTokens, - 200_000, - "turn 3: baseline still at original — growth accumulated correctly, not eaten by old bug", - ) -}) - -test("E2E autonomous: nudge re-fires after compress in same turn (Issue #176)", () => { - const state = createSessionState() - state.sessionId = "test-issue-176" - state.modelContextLimit = 1_000_000 - - const config = buildConfig() - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - config.compress.protectedTools = ["skill"] - config.compress.preserveRecentMessages = 5 - config.compress.preserveRecentTokens = 0 - config.compress.preserveLastUserMessage = false - - // Autonomous session: single user message (like an agentic task) - state.messageIds.byRawId.set("u1", "m00001") - - state.nudges.lastPerMessageNudgeTokens = 200_000 - - const phase1: WithParts[] = [userMsg("u1", "do the task")] - for (let i = 0; i < 20; i++) { - const id = `a_p1_${i}` - const ref = `m${String(i + 2).padStart(5, "0")}` - state.messageIds.byRawId.set(id, ref) - phase1.push(assistantMsgWithTokens(id, "work", { input: 300_000, output: 100_000 }, [ - toolPart(`tp_p1_${i}`, "x".repeat(50_000)), - ])) - } - injectCompressNudges(state, config, logger, phase1, {} as any) - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "phase 1: first nudge should fire — enough growth and compressible content", - ) - assert.notEqual( - state.nudges.lastNudgeShownTokens, - undefined, - "phase 1: lastNudgeShownTokens should be set", - ) - - const compressId1 = "a_compress_1" - state.messageIds.byRawId.set(compressId1, "m09001") - const phase2 = [...phase1, assistantMsg(compressId1, "compressed", [ - compressToolPart("compress-1", "compression result"), - ])] - injectCompressNudges(state, config, logger, phase2, {} as any, undefined, undefined, 400_000) - assert.equal( - state.nudges.lastNudgeShownTokens, - undefined, - "phase 2: anchors cleared after compress detected", - ) - assert.equal( - state.nudges.compressBaselineSet, - true, - "phase 2: baseline should be adjusted after successful compress", - ) - - // Phase 3: More work accumulates past the same growth threshold - // In the buggy code, currentTurnHasCompress is STILL true (same compress msg in - // turn), so the function ALWAYS returns early — nudge NEVER re-fires. - // After fix: the already-processed compress is detected, function falls through - // to normal evaluation, and the new nudge fires. - const phase3 = [...phase2] - for (let i = 0; i < 20; i++) { - const id = `a_p3_${i}` - const ref = `m${String(i + 22).padStart(5, "0")}` - state.messageIds.byRawId.set(id, ref) - phase3.push(assistantMsgWithTokens(id, "more work", { input: 350_000, output: 120_000 }, [ - toolPart(`tp_p3_${i}`, "x".repeat(50_000)), - ])) - } - injectCompressNudges(state, config, logger, phase3, {} as any) - - // THE BUG: shouldInjectThisTurn should be true but is false/stale - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "phase 3: nudge SHOULD re-fire after sufficient growth post-compress (Issue #176)", - ) - assert.notEqual( - state.nudges.lastNudgeShownTokens, - undefined, - "phase 3: lastNudgeShownTokens should be set again — nudge actually injected", - ) -}) - -test("E2E autonomous: second compress also gets processed (Issue #176 multi-compress)", () => { - const state = createSessionState() - state.sessionId = "test-issue-176-multi" - state.modelContextLimit = 1_000_000 - - const config = buildConfig() - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - config.compress.protectedTools = ["skill"] - config.compress.preserveRecentMessages = 5 - config.compress.preserveRecentTokens = 0 - config.compress.preserveLastUserMessage = false - - state.messageIds.byRawId.set("u1", "m00001") - - function mkAssistants(prefix: string, count: number, startRef: number, input: number = 300_000): WithParts[] { - const msgs: WithParts[] = [] - for (let i = 0; i < count; i++) { - const id = `a_${prefix}_${i}` - const ref = `m${String(startRef + i).padStart(5, "0")}` - state.messageIds.byRawId.set(id, ref) - msgs.push(assistantMsgWithTokens(id, "work", { input, output: 100_000 }, [ - toolPart(`tp_${prefix}_${i}`, "x".repeat(50_000)), - ])) - } - return msgs - } - - function mkCompress(id: string, ref: string, callId: string): WithParts { - state.messageIds.byRawId.set(id, ref) - return assistantMsg(id, "compressed", [ - compressToolPart(callId, "compression result"), - ]) - } - - state.nudges.lastPerMessageNudgeTokens = 200_000 - - const phase1: WithParts[] = [userMsg("u1", "do the task"), ...mkAssistants("p1", 20, 2)] - injectCompressNudges(state, config, logger, phase1, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, true, "phase 1: first nudge fires") - - const phase2 = [...phase1, mkCompress("a_compress_1", "m09001", "compress-1")] - injectCompressNudges(state, config, logger, phase2, {} as any, undefined, undefined, 400_000) - assert.equal(state.nudges.lastNudgeShownTokens, undefined, "phase 2: first compress processed") - - const phase3 = [...phase2, ...mkAssistants("p3", 20, 100, 350_000)] - injectCompressNudges(state, config, logger, phase3, {} as any) - assert.equal(state.nudges.shouldInjectThisTurn, true, "phase 3: second nudge fires") - assert.notEqual(state.nudges.lastNudgeShownTokens, undefined, "phase 3: nudge injected") - - const phase4 = [...phase3, mkCompress("a_compress_2", "m09002", "compress-2")] - injectCompressNudges(state, config, logger, phase4, {} as any, undefined, undefined, 450_000) - assert.equal(state.nudges.lastNudgeShownTokens, undefined, "phase 4: second compress processed") - assert.equal(state.nudges.compressBaselineSet, true, "phase 4: second baseline adjustment") - - const phase5 = [...phase4, ...mkAssistants("p5", 20, 200, 400_000)] - injectCompressNudges(state, config, logger, phase5, {} as any) - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "phase 5: third nudge fires after second compress — no permanent stuck state", - ) -}) - -test("T2 cadence: does NOT immediately re-fire after compress attempt (T2 loop bug)", () => { - const state = createSessionState() - state.sessionId = "test-t2-cadence" - state.modelContextLimit = 1_000_000 - - const config = buildConfig() - config.compress.maxContextLimit = 500_000 - config.compress.minContextLimit = 200_000 - config.compress.nudgeGrowthTokens = 10_000 - config.compress.minNudgeGrowthFloor = 5_000 - config.compress.minNudgeGrowthRatio = 0.01 - config.compress.preserveRecentMessages = 0 - config.compress.preserveRecentTokens = 0 - config.compress.preserveLastUserMessage = false - - // Seed T1 blocks so tier1Tokens >= nudgeGrowthTokens - for (let i = 0; i < 5; i++) { - const blockId = i + 1 - state.prune.messages.blocksById.set(blockId, { - blockId, - runId: i + 1, - active: true, - tier: 1, - generation: "young", - survivedCount: 1, - directMessageIds: [], - effectiveMessageIds: [], - consumedBlockIds: [], - parentBlockIds: [], - summary: "T1 summary ".repeat(200), - summaryTokens: 5_000, - topic: `T1 block ${i}`, - createdAt: Date.now(), - }) - state.prune.messages.activeBlockIds.add(blockId) - } - - state.messageIds.byRawId.set("u1", "m00001") - - function mkAssistant(id: string, ref: string, inputTokens: number): WithParts { - state.messageIds.byRawId.set(id, ref) - return assistantMsgWithTokens(id, "work", { input: inputTokens, output: 50_000 }, [ - toolPart(`${id}-tool`, "x".repeat(10_000)), - ]) - } - - // Phase 1: T1 won't fire (baseline very high → negative growth), - // but T2 should fire because tier1Tokens = 25K >= nudgeGrowthTokens - state.nudges.lastPerMessageNudgeTokens = 500_000 - - const phase1: WithParts[] = [ - userMsg("u1", "do the task"), - mkAssistant("a1", "m00002", 300_000), - ] - injectCompressNudges(state, config, logger, phase1, {} as any) - - assert.equal( - state.nudges.shouldInjectThisTurn, - true, - "phase 1: T2 should fire (tier1 blocks accumulated, T1 suppressed by high baseline)", - ) - assert.notEqual( - state.nudges.lastTier2NudgeTokens, - undefined, - "phase 1: lastTier2NudgeTokens should be set after T2 fires", - ) - - // Phase 2: Compress attempt appears in the turn. - // The compress-processing block runs, resetting tier cadence baselines. - // BEFORE FIX: lastTier2NudgeTokens = undefined → T2 re-fires next turn - // AFTER FIX: lastTier2NudgeTokens = currentTokens → growthFloor gate applies - const phase2 = [...phase1] - const compressId = "a_compress_1" - const compressRef = "m09001" - state.messageIds.byRawId.set(compressId, compressRef) - phase2.push(assistantMsg(compressId, "compressed", [ - compressToolPart("compress-1", "compression result"), - ])) - - injectCompressNudges(state, config, logger, phase2, {} as any, undefined, undefined, 310_000) - - assert.notEqual( - state.nudges.lastTier2NudgeTokens, - undefined, - "phase 2: lastTier2NudgeTokens must NOT be undefined after compress (was the bug)", - ) - - // Phase 3: Next turn — no new compress, small growth (< growthFloor). - // T2 should NOT fire because growth < growthFloor. - // BEFORE FIX: lastTier2NudgeTokens was reset to undefined → cadence always met → T2 fires. - // AFTER FIX: lastTier2NudgeTokens = currentTokens → growthFloor gate blocks re-fire. - const phase3 = [...phase2, mkAssistant("a3", "m00003", 301_000)] - injectCompressNudges(state, config, logger, phase3, {} as any) - - assert.notEqual( - state.nudges.lastTier2NudgeTokens, - undefined, - "phase 3: lastTier2NudgeTokens should still be defined (not reset to undefined)", - ) - assert.equal( - state.nudges.shouldInjectThisTurn, - false, - "phase 3: T2 should NOT re-fire with growth < growthFloor (the T2 loop bug)", - ) -}) - diff --git a/tests/input-budget.test.ts b/tests/input-budget.test.ts deleted file mode 100644 index 58521aab..00000000 --- a/tests/input-budget.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { computeInputBudget } from "../lib/messages/inject/utils" - -test("computeInputBudget uses limit.input when defined (split-budget OpenAI models)", () => { - // gpt-5.4-mini, gpt-5.5: 400K context, 272K input, 128K output - assert.equal(computeInputBudget({ context: 400000, input: 272000, output: 128000 }), 272000) - // gpt-5.4: 1.05M context, 922K input, 128K output - assert.equal(computeInputBudget({ context: 1050000, input: 922000, output: 128000 }), 922000) -}) - -test("computeInputBudget subtracts output from context when limit.input is undefined (shared-pool models)", () => { - // claude-opus-4-7: 1M context, 128K output, no explicit input limit - assert.equal(computeInputBudget({ context: 1000000, output: 128000 }), 872000) - // claude-haiku-4-5: 200K context, 64K output - assert.equal(computeInputBudget({ context: 200000, output: 64000 }), 136000) - // gpt-4o: 128K context, 16384 output - assert.equal(computeInputBudget({ context: 128000, output: 16384 }), 111616) -}) - -test("computeInputBudget treats missing output as 0", () => { - assert.equal(computeInputBudget({ context: 200000 }), 200000) -}) - -test("computeInputBudget returns undefined when context is unknown", () => { - assert.equal(computeInputBudget({ context: 0, input: 100, output: 50 }), undefined) -}) - -test("computeInputBudget never returns negative when output exceeds context", () => { - assert.equal(computeInputBudget({ context: 100, output: 200 }), 0) -}) - -test("computeInputBudget prefers explicit input over the context-minus-output fallback", () => { - // If both `input` and `output` are present, `input` wins regardless of what - // `context - output` would compute to. Defensive against models where the - // numbers don't satisfy `input + output = context`. - assert.equal(computeInputBudget({ context: 1000, input: 500, output: 200 }), 500) -}) diff --git a/tests/keep-markers.test.ts b/tests/keep-markers.test.ts deleted file mode 100644 index 623f6ffb..00000000 --- a/tests/keep-markers.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" -import { resolveKeepMarkers } from "../lib/compress/keep-markers" -import { buildCompressibleRanges, formatCompressibleRanges } from "../lib/messages/inject/utils" -import { createSessionState, type WithParts } from "../lib/state" -import type { PluginConfig } from "../lib/config" - -function buildConfig(): PluginConfig { - return { - enabled: true, - autoUpdate: true, - debug: false, - pruneNotification: "off", - pruneNotificationType: "chat", - commands: { enabled: true, protectedTools: [] }, - experimental: { allowSubAgents: false, customPrompts: false }, - protectedFilePatterns: [], - compress: { - permission: "allow", - showCompression: false, - summaryBuffer: true, - maxContextLimit: 150000, - minContextLimit: 50000, - nudgeFrequency: 5, - iterationNudgeThreshold: 15, - nudgeForce: "soft", - protectedTools: [], - protectTags: false, - protectUserMessages: false, - }, - gc: { - algorithm: "truncate", - promotionThreshold: 5, - maxBlockAge: 15, - maxOldGenSummaryLength: 3000, - majorGcThresholdPercent: "100%", - batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, - }, - } -} - -function mkMsg(id: string, role: "user" | "assistant", parts: any[]): WithParts { - return { - info: { id, role, sessionID: "s", agent: "a", time: { created: 1 } } as any, - parts, - } -} - -function textPart(id: string, text: string) { - return { id: `${id}-p`, messageID: id, sessionID: "s", type: "text" as const, text } -} - -function toolPart(callID: string, tool: string, output: string, input: any = {}) { - return { - id: `p-${callID}`, - messageID: "m", - sessionID: "s", - type: "tool" as const, - tool, - callID, - state: { status: "completed" as const, output, input }, - } -} - -test("resolveKeepMarkers: expands [[KEEP:mNNNNN]] with formatted content", () => { - const state = createSessionState() - state.messageIds.byRawId.set("msg1", "m00001") - state.messageIds.byRawId.set("msg2", "m00002") - const config = buildConfig() - const messages: WithParts[] = [ - mkMsg("msg1", "assistant", [toolPart("c1", "bash", "test output")]), - mkMsg("msg2", "assistant", [textPart("msg2", "important text")]), - ] - const summary = "Did work. [[KEEP:m00001]] Then more. [[KEEP:m00002]]" - const result = resolveKeepMarkers(summary, messages, state, config) - - assert.equal(result.expandedCount, 2) - assert.ok(result.summary.includes("test output"), "KEEP must expand bash output") - assert.ok(result.summary.includes("important text"), "KEEP must expand text content") - assert.ok(result.summary.includes("[m00001:"), "KEEP must label with ref") - assert.ok(result.summary.includes("[m00002:"), "KEEP must label with ref") -}) - -test("resolveKeepMarkers: converts [[REF:mNNNNN|desc]] to compact link", () => { - const state = createSessionState() - state.messageIds.byRawId.set("msg1", "m00001") - const config = buildConfig() - const messages: WithParts[] = [mkMsg("msg1", "assistant", [toolPart("c1", "bash", "result")])] - const summary = "See [[REF:m00001|test results]] for details." - const result = resolveKeepMarkers(summary, messages, state, config) - - assert.equal(result.refCount, 1) - assert.ok(result.summary.includes("[→ m00001: test results]"), "REF must become compact link") - assert.ok(!result.summary.includes("$ undefined"), "REF must NOT expand the bash command") -}) - -test("resolveKeepMarkers: leaves unresolved markers intact", () => { - const state = createSessionState() - const config = buildConfig() - const messages: WithParts[] = [] - const summary = "[[KEEP:m99999]] and [[REF:m99998|missing]]" - const result = resolveKeepMarkers(summary, messages, state, config) - - assert.equal(result.expandedCount, 0) - assert.equal(result.refCount, 0) - assert.deepEqual(result.unresolvedRefs, ["m99999", "m99998"]) - assert.ok(summary.includes("[[KEEP:m99999]]"), "unresolved KEEP stays as-is") -}) - -test("resolveKeepMarkers: truncates long content to keepEmbedMaxChars", () => { - const state = createSessionState() - state.messageIds.byRawId.set("msg1", "m00001") - const config = buildConfig() - config.compress.keepEmbedMaxChars = 100 - const longOutput = "x".repeat(500) - const messages: WithParts[] = [mkMsg("msg1", "assistant", [toolPart("c1", "bash", longOutput)])] - const summary = "[[KEEP:m00001]]" - const result = resolveKeepMarkers(summary, messages, state, config) - - assert.ok(result.summary.includes("[truncated"), "must indicate truncation") - assert.ok(/chars total/.test(result.summary), "must show original length") -}) - -test("resolveKeepMarkers: formats bash as $ command + output", () => { - const state = createSessionState() - state.messageIds.byRawId.set("msg1", "m00001") - const config = buildConfig() - const messages: WithParts[] = [ - mkMsg("msg1", "assistant", [toolPart("c1", "bash", "pass", { command: "npm test" })]), - ] - const result = resolveKeepMarkers("[[KEEP:m00001]]", messages, state, config) - assert.ok(result.summary.includes("$ npm test"), "bash must show command with $ prefix") - assert.ok(result.summary.includes("pass"), "bash must show output") -}) - -test("buildCompressibleRanges: groups by conversation turns", () => { - const state = createSessionState() - for (let i = 1; i <= 10; i++) { - state.messageIds.byRawId.set(`msg${i}`, `m${String(i).padStart(5, "0")}`) - } - const messages: WithParts[] = [ - mkMsg("msg1", "user", [textPart("msg1", "hello")]), - mkMsg("msg2", "assistant", [ - textPart("msg2", "hi"), - toolPart("c1", "bash", "x".repeat(100)), - ]), - mkMsg("msg3", "assistant", [textPart("msg3", "done")]), - mkMsg("msg4", "user", [textPart("msg4", "next")]), - mkMsg("msg5", "assistant", [ - textPart("msg5", "result"), - toolPart("c2", "bash", "y".repeat(100)), - ]), - mkMsg("msg6", "assistant", [textPart("msg6", "finished")]), - ] - const result = buildCompressibleRanges(messages, state) - const ranges = result.compressible - assert.ok(ranges.length >= 1, "should produce at least 1 range") - assert.ok(ranges[0].count >= 3, "first range should contain multiple messages") - assert.ok(ranges[0].tokens > 0, "range should have positive token count") -}) - -test("formatCompressibleRanges: produces formatted output", () => { - const state = createSessionState() - state.messageIds.byRawId.set("msg1", "m00001") - state.messageIds.byRawId.set("msg2", "m00002") - state.messageIds.byRawId.set("msg3", "m00003") - const messages: WithParts[] = [ - mkMsg("msg1", "user", [textPart("msg1", "hello")]), - mkMsg("msg2", "assistant", [ - textPart("msg2", "response"), - toolPart("c1", "bash", "x".repeat(200)), - ]), - mkMsg("msg3", "assistant", [textPart("msg3", "done")]), - ] - const result = buildCompressibleRanges(messages, state) - const formatted = formatCompressibleRanges(result.compressible) - assert.ok(formatted.includes("Compressible ranges"), "must have header") - assert.ok(formatted.includes("m00001"), "must show start ref") -}) diff --git a/tests/message-filter.test.ts b/tests/message-filter.test.ts deleted file mode 100644 index f2b72203..00000000 --- a/tests/message-filter.test.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { describe, it, beforeEach, afterEach } from "node:test" -import assert from "node:assert/strict" -import { - registerMessageFilter, - getMessageFilter, - listMessageFilters, - clearMessageFilters, -} from "../lib/messages/filter/registry" -import type { MessageFilter, MessageFilterContext } from "../lib/messages/filter/types" -import type { WithParts } from "../lib/state" -import { applyMessageFilters } from "../lib/messages/filter/apply" -import { OMO_SYSTEM_REMINDER_FILTER } from "../lib/messages/filter/builtin/omo-system-reminder" -import { OMO_TODO_FILTER } from "../lib/messages/filter/builtin/omo-todo-continuation" -import { OMO_CONTEXT_FILTER } from "../lib/messages/filter/builtin/omo-context" -import { OMO_MODE_FILTER } from "../lib/messages/filter/builtin/omo-mode-injection" -import { OMO_TASK_FILTER } from "../lib/messages/filter/builtin/omo-task-directive" -import { ensureBuiltinFiltersRegistered } from "../lib/messages/filter/builtin" - -type MockLogger = { debug: (...a: any[]) => void; info: (...a: any[]) => void; warn: (...a: any[]) => void } - -function makeLogger(): MockLogger { - return { debug: () => {}, info: () => {}, warn: () => {} } -} - -function makeCtx(overrides: Partial): MessageFilterContext { - return { - text: "", - role: "user", - sessionId: "ses_test", - isSubAgent: false, - messageIndex: 0, - totalMessages: 1, - ...overrides, - } -} - -describe("Message Filter Registry", () => { - beforeEach(() => clearMessageFilters()) - - it("registers and retrieves a filter", () => { - const filter: MessageFilter = { - name: "test-filter", - version: "1.0.0", - description: "test", - filter: () => ({ action: "keep" }), - } - registerMessageFilter(filter) - assert.equal(getMessageFilter("test-filter"), filter) - assert.equal(listMessageFilters().length, 1) - }) - - it("allows re-registering same name + same version", () => { - const filter: MessageFilter = { - name: "test-filter", - version: "1.0.0", - description: "test", - filter: () => ({ action: "keep" }), - } - registerMessageFilter(filter) - registerMessageFilter(filter) - assert.equal(listMessageFilters().length, 1) - }) - - it("throws on re-registering same name + different version", () => { - registerMessageFilter({ - name: "test-filter", - version: "1.0.0", - description: "v1", - filter: () => ({ action: "keep" }), - }) - assert.throws( - () => - registerMessageFilter({ - name: "test-filter", - version: "2.0.0", - description: "v2", - filter: () => ({ action: "keep" }), - }), - /version/i, - ) - }) -}) - -describe("applyMessageFilters", () => { - beforeEach(() => clearMessageFilters()) - - it("returns zero stats when config disabled", () => { - const logger = makeLogger() - const result = applyMessageFilters([], { enabled: false, filters: {} }, logger, { - sessionId: "s", - isSubAgent: false, - }) - assert.deepEqual(result, { partsFiltered: 0, partsDropped: 0, partsModified: 0 }) - }) - - it("returns zero stats when no filters registered", () => { - const logger = makeLogger() - const messages = [ - { info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }, - ] as any - const result = applyMessageFilters( - messages, - { enabled: true, filters: {} }, - logger, - { sessionId: "s", isSubAgent: false }, - ) - assert.deepEqual(result, { partsFiltered: 0, partsDropped: 0, partsModified: 0 }) - }) - - it("drops text parts when filter returns drop", () => { - registerMessageFilter({ - name: "dropper", - version: "1.0.0", - description: "drops all", - filter: () => ({ action: "drop" }), - }) - const logger = makeLogger() - const messages = [ - { info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }, - ] as any - const result = applyMessageFilters( - messages, - { enabled: true, filters: { dropper: { enabled: true } } }, - logger, - { sessionId: "s", isSubAgent: false }, - ) - assert.equal(result.partsDropped, 1) - assert.equal(messages[0].parts[0].text, "") - }) - - it("modifies text parts when filter returns modify", () => { - registerMessageFilter({ - name: "modifier", - version: "1.0.0", - description: "modifies", - filter: (ctx) => ({ action: "modify", text: "MODIFIED:" + ctx.text.slice(0, 5) }), - }) - const logger = makeLogger() - const messages = [ - { info: { role: "user" }, parts: [{ type: "text", text: "hello world" }] }, - ] as any - const result = applyMessageFilters( - messages, - { enabled: true, filters: { modifier: { enabled: true } } }, - logger, - { sessionId: "s", isSubAgent: false }, - ) - assert.equal(result.partsModified, 1) - assert.equal(messages[0].parts[0].text, "MODIFIED:hello") - }) - - it("skips disabled filters", () => { - registerMessageFilter({ - name: "dropper", - version: "1.0.0", - description: "drops all", - filter: () => ({ action: "drop" }), - }) - const logger = makeLogger() - const messages = [ - { info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }, - ] as any - const result = applyMessageFilters( - messages, - { enabled: true, filters: { dropper: { enabled: false } } }, - logger, - { sessionId: "s", isSubAgent: false }, - ) - assert.equal(result.partsFiltered, 0) - assert.equal(messages[0].parts[0].text, "hello") - }) - - it("catches filter errors without crashing", () => { - registerMessageFilter({ - name: "crasher", - version: "1.0.0", - description: "always throws", - filter: () => { - throw new Error("boom") - }, - }) - const logger = makeLogger() - const messages = [ - { info: { role: "user" }, parts: [{ type: "text", text: "hello" }] }, - ] as any - const result = applyMessageFilters( - messages, - { enabled: true, filters: { crasher: { enabled: true } } }, - logger, - { sessionId: "s", isSubAgent: false }, - ) - assert.equal(result.partsFiltered, 0) - assert.equal(messages[0].parts[0].text, "hello") - }) - - it("skips non-text and empty parts", () => { - registerMessageFilter({ - name: "dropper", - version: "1.0.0", - description: "drops all", - filter: () => ({ action: "drop" }), - }) - const logger = makeLogger() - const messages = [ - { - info: { role: "user" }, - parts: [ - { type: "tool", tool: "bash" }, - { type: "text", text: "" }, - { type: "text", text: "keep me" }, - ], - }, - ] as any - const result = applyMessageFilters( - messages, - { enabled: true, filters: { dropper: { enabled: true } } }, - logger, - { sessionId: "s", isSubAgent: false }, - ) - assert.equal(result.partsDropped, 1) - }) -}) - -describe("OMO system-reminder filter", () => { - beforeEach(() => clearMessageFilters()) - const filter = OMO_SYSTEM_REMINDER_FILTER - - it("has keepLastOnly and keepLast=2", () => { - assert.equal(filter.keepLastOnly, true) - assert.equal(filter.keepLast, 2) - }) - - it("matches user message with system-reminder block", () => { - const text = `\n[BG COMPLETE]\n\n` - const result = filter.filter(makeCtx({ text, role: "user" })) - assert.equal(result.action, "drop") - }) - - it("matches user message with lone OMO marker, preserves user content", () => { - const text = `Real content.\n` - const result = filter.filter(makeCtx({ text, role: "user" })) - assert.equal(result.action, "modify") - assert.equal(result.text, "Real content.") - }) - - it("does not match assistant messages", () => { - const text = "foo" - const result = filter.filter(makeCtx({ text, role: "assistant" })) - assert.equal(result.action, "keep") - }) - - it("does not match plain user messages", () => { - const result = filter.filter(makeCtx({ text: "hello world" })) - assert.equal(result.action, "keep") - }) - - it("keeps last 2, drops older ones (issue #267)", () => { - registerMessageFilter(OMO_SYSTEM_REMINDER_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "old #1" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "real user message" }] }, - { info: { id: "m3", role: "user", time: 3 } as any, parts: [{ type: "text", text: "recent #1 [BACKGROUND TASK COMPLETED]" }] }, - { info: { id: "m4", role: "user", time: 4 } as any, parts: [{ type: "text", text: "recent #2 [BACKGROUND TASK FAILED]" }] }, - ] - const config = { enabled: true, filters: { "omo-system-reminder": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "", "oldest system-reminder dropped") - assert.equal((messages[1].parts[0] as any).text, "real user message", "normal message unaffected") - assert.ok((messages[2].parts[0] as any).text.includes("BACKGROUND TASK COMPLETED"), "2nd-most-recent kept") - assert.ok((messages[3].parts[0] as any).text.includes("BACKGROUND TASK FAILED"), "most-recent kept") - }) - - it("single occurrence: no dedup needed", () => { - registerMessageFilter(OMO_SYSTEM_REMINDER_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "only one" }] }, - ] - const config = { enabled: true, filters: { "omo-system-reminder": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "only one") - }) - - it("exactly 2 occurrences: both kept", () => { - registerMessageFilter(OMO_SYSTEM_REMINDER_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "first" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "second" }] }, - ] - const config = { enabled: true, filters: { "omo-system-reminder": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.ok((messages[0].parts[0] as any).text.includes("first"), "first kept") - assert.ok((messages[1].parts[0] as any).text.includes("second"), "second kept") - }) - - it("preserves user content when stripping older system-reminder messages", () => { - registerMessageFilter(OMO_SYSTEM_REMINDER_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[BG DONE] old task\n\nFix the login bug please" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "[BG DONE] task 2\n\nThanks for the help" }] }, - { info: { id: "m3", role: "user", time: 3 } as any, parts: [{ type: "text", text: "[BG DONE] task 3" }] }, - { info: { id: "m4", role: "user", time: 4 } as any, parts: [{ type: "text", text: "[BG DONE] task 4" }] }, - ] - const config = { enabled: true, filters: { "omo-system-reminder": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "Fix the login bug please", "oldest: user content preserved, blocks stripped") - assert.equal((messages[1].parts[0] as any).text, "Thanks for the help", "2nd oldest: user content preserved, blocks stripped") - assert.ok((messages[2].parts[0] as any).text.includes("task 3"), "2nd-most-recent kept as-is") - assert.ok((messages[3].parts[0] as any).text.includes("task 4"), "most-recent kept as-is") - }) -}) - -describe("ensureBuiltinFiltersRegistered", () => { - beforeEach(() => clearMessageFilters()) - - it("registers the OMO filter", () => { - ensureBuiltinFiltersRegistered() - assert.ok(getMessageFilter("omo-system-reminder")) - assert.equal(getMessageFilter("omo-system-reminder")!.version, "1.3.0") - }) - - it("is idempotent", () => { - ensureBuiltinFiltersRegistered() - ensureBuiltinFiltersRegistered() - assert.equal(listMessageFilters().length, 5) - }) -}) - -describe("filter chaining", () => { - beforeEach(() => clearMessageFilters()) - - it("filter B sees modified text from filter A", () => { - const uppercase: MessageFilter = { - name: "uppercase", - version: "1.0.0", - description: "test", - filter(ctx) { - return { action: "modify", text: ctx.text.toUpperCase() } - }, - } - const detectUpper: MessageFilter = { - name: "detect-upper", - version: "1.0.0", - description: "test", - filter(ctx) { - if (ctx.text === ctx.text.toUpperCase() && ctx.text.length > 0) { - return { action: "drop", reason: "all uppercase detected" } - } - return { action: "keep" } - }, - } - registerMessageFilter(uppercase) - registerMessageFilter(detectUpper) - - const messages: WithParts[] = [ - { - info: { id: "msg-1", role: "user", time: Date.now() } as any, - parts: [{ type: "text", text: "hello world" }], - }, - ] - const config = { enabled: true, filters: { uppercase: { enabled: true }, "detect-upper": { enabled: true } } } - const stats = applyMessageFilters(messages, config, makeLogger(), { - sessionId: "ses-test", - isSubAgent: false, - }) - assert.equal(stats.partsDropped, 1) - assert.equal(stats.partsFiltered, 2) - assert.equal(stats.partsModified, 1) - }) -}) - -describe("keep-last-only dedup", () => { - beforeEach(() => clearMessageFilters()) - - it("keeps last TODO CONTINUATION, drops earlier ones", () => { - registerMessageFilter(OMO_TODO_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nWork on task A" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "real user message" }] }, - { info: { id: "m3", role: "user", time: 3 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nWork on task B" }] }, - ] - const config = { enabled: true, filters: { "omo-todo-continuation": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "") - assert.equal((messages[1].parts[0] as any).text, "real user message") - assert.equal((messages[2].parts[0] as any).text, "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nWork on task B") - }) - - it("keeps last [CONTEXT], drops earlier ones", () => { - registerMessageFilter(OMO_CONTEXT_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[CONTEXT] Old context\n" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "[CONTEXT] New context\n" }] }, - ] - const config = { enabled: true, filters: { "omo-context": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "") - assert.equal((messages[1].parts[0] as any).text, "[CONTEXT] New context\n") - }) - - it("configurable keepLast override keeps N most recent", () => { - registerMessageFilter(OMO_TODO_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nOld" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nMid" }] }, - { info: { id: "m3", role: "user", time: 3 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nNew" }] }, - { info: { id: "m4", role: "user", time: 4 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nNewest" }] }, - ] - const config = { enabled: true, filters: { "omo-todo-continuation": { enabled: true, keepLast: 3 } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "") - assert.equal((messages[1].parts[0] as any).text, "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nMid") - assert.equal((messages[2].parts[0] as any).text, "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nNew") - assert.equal((messages[3].parts[0] as any).text, "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nNewest") - }) - - it("handles single occurrence (no dedup needed)", () => { - registerMessageFilter(OMO_TODO_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nOnly one" }] }, - ] - const config = { enabled: true, filters: { "omo-todo-continuation": { enabled: true } } } - const stats = applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nOnly one") - assert.equal(stats.partsDropped, 0) - }) - - it("last matching message is not the last message in array", () => { - registerMessageFilter(OMO_TODO_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nEarlier" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nLatest" }] }, - { info: { id: "m3", role: "user", time: 3 } as any, parts: [{ type: "text", text: "real user message after" }] }, - ] - const config = { enabled: true, filters: { "omo-todo-continuation": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "") - assert.equal((messages[1].parts[0] as any).text, "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nLatest") - assert.equal((messages[2].parts[0] as any).text, "real user message after") - }) - - it("multiple keepLastOnly filters run independently", () => { - registerMessageFilter(OMO_TODO_FILTER) - registerMessageFilter(OMO_CONTEXT_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nOld todo" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "[CONTEXT] Old\n" }] }, - { info: { id: "m3", role: "user", time: 3 } as any, parts: [{ type: "text", text: "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nNew todo" }] }, - { info: { id: "m4", role: "user", time: 4 } as any, parts: [{ type: "text", text: "[CONTEXT] New\n" }] }, - ] - const config = { enabled: true, filters: { "omo-todo-continuation": { enabled: true }, "omo-context": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "") - assert.equal((messages[1].parts[0] as any).text, "") - assert.equal((messages[2].parts[0] as any).text, "[SYSTEM DIRECTIVE: TODO CONTINUATION]\nNew todo") - assert.equal((messages[3].parts[0] as any).text, "[CONTEXT] New\n") - }) - - it("TASK directive keepLastOnly with OMO marker", () => { - registerMessageFilter(OMO_TASK_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "TASK: Write config.py\n" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "TASK: Write tests\n" }] }, - ] - const config = { enabled: true, filters: { "omo-task-directive": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "") - assert.equal((messages[1].parts[0] as any).text, "TASK: Write tests\n") - }) - - it("does not match TASK without OMO marker (avoids false positive)", () => { - registerMessageFilter(OMO_TASK_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "TASK: Do something" }] }, - ] - const config = { enabled: true, filters: { "omo-task-directive": { enabled: true } } } - const stats = applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "TASK: Do something") - assert.equal(stats.partsFiltered, 0) - }) - - it("mode injection strips tag, preserves user content", () => { - registerMessageFilter(OMO_MODE_FILTER) - const messages: WithParts[] = [ - { info: { id: "m1", role: "user", time: 1 } as any, parts: [{ type: "text", text: "[search-mode]\nSearch for X" }] }, - { info: { id: "m2", role: "user", time: 2 } as any, parts: [{ type: "text", text: "[analyze-mode]\nAnalyze Y" }] }, - ] - const config = { enabled: true, filters: { "omo-mode-injection": { enabled: true } } } - applyMessageFilters(messages, config, makeLogger(), { sessionId: "s", isSubAgent: false }) - assert.equal((messages[0].parts[0] as any).text, "Search for X") - assert.equal((messages[1].parts[0] as any).text, "Analyze Y") - }) -}) - -describe("omo-mode-injection filter (v1.1.0)", () => { - const filter = OMO_MODE_FILTER - - it("keeps normal user messages without mode tags", () => { - const result = filter.filter(makeCtx({ text: "Fix the bug in auth.ts" })) - assert.equal(result.action, "keep") - }) - - it("keeps assistant messages even with mode tags", () => { - const result = filter.filter(makeCtx({ text: "", role: "assistant" })) - assert.equal(result.action, "keep") - }) - - it("strips ultrawork-mode XML block, preserves user content", () => { - const text = `\n\nMode instructions here.\n\n\n\nFix the bug in auth.ts` - const result = filter.filter(makeCtx({ text })) - assert.equal(result.action, "modify") - assert.equal(result.text, "Fix the bug in auth.ts") - }) - - it("strips bracket mode pattern, preserves user content", () => { - const text = `[search-mode]\nFind all uses of deprecated API` - const result = filter.filter(makeCtx({ text })) - assert.equal(result.action, "modify") - assert.equal(result.text, "Find all uses of deprecated API") - }) - - it("strips stacked hyperplan + ultrawork injections", () => { - const text = `\n\n\nInstructions.\n\n\n\n\n\nDo the actual work` - const result = filter.filter(makeCtx({ text })) - assert.equal(result.action, "modify") - assert.equal(result.text, "Do the actual work") - }) - - it("drops pure mode injection with no user content", () => { - const text = `\n\nInstructions only, no user message.\n\n` - const result = filter.filter(makeCtx({ text })) - assert.equal(result.action, "drop") - }) - - it("drops pure bracket mode with no user content", () => { - const result = filter.filter(makeCtx({ text: "[ultrawork-mode]" })) - assert.equal(result.action, "drop") - }) - - it("handles unclosed XML tag gracefully (strips opening tag only)", () => { - const text = `\nThis is the user content without closing tag` - const result = filter.filter(makeCtx({ text })) - assert.equal(result.action, "modify") - assert.equal(result.text, "This is the user content without closing tag") - }) - - it("preserves user content with angle brackets that are not mode tags", () => { - const text = `Use