From 6178a7667572efc2df050ddb0a956d03a5e395dd Mon Sep 17 00:00:00 2001 From: _blitzzz Date: Mon, 9 Feb 2026 23:14:12 +0700 Subject: [PATCH] feat: disable OpenCode compaction and add proactive token budget pruning Replace OpenCode's built-in compaction (which drops all history before a summary) with a plugin-managed proactive pruning strategy that preserves granular context. - Config hook disables OpenCode auto-compaction and tool output pruning - New proactivePrune strategy: at 70% context prunes old tool outputs, at 85% also prunes reasoning blocks - Compacting safety net hook preserves plugin state (todos, tracked files, stats) if compaction ever triggers - Configurable thresholds via TokenBudget schema (warningThreshold, criticalThreshold, targetPercent, protectedRecentTurns) - 13 E2E tests covering all threshold and config scenarios --- index.ts | 54 ++ lib/config/defaults.ts | 6 + lib/config/schema.ts | 43 ++ lib/hooks.ts | 3 +- lib/strategies/index.ts | 1 + lib/strategies/proactive-prune.ts | 234 +++++++++ tests/e2e/proactive-pruning.test.ts | 594 ++++++++++++++++++++++ tests/hooks.test.ts | 1 + tests/integration/opt-in-defaults.test.ts | 1 + 9 files changed, 936 insertions(+), 1 deletion(-) create mode 100644 lib/strategies/proactive-prune.ts create mode 100644 tests/e2e/proactive-pruning.test.ts diff --git a/index.ts b/index.ts index 23f61ec..3993584 100644 --- a/index.ts +++ b/index.ts @@ -84,6 +84,60 @@ const plugin: Plugin = (async (ctx) => { primary_tools: [...existingPrimaryTools, "context_prune"], } logger.info("Added 'context_prune' to experimental.primary_tools via config mutation") + + // Disable OpenCode's built-in compaction and tool output pruning. + // ACP plugin takes full ownership of context management via the + // experimental.chat.messages.transform hook. Without this, OpenCode's + // own compaction (summarize + drop history) and pruning (replace old + // tool outputs with "[Old tool result content cleared]") conflict with + // the plugin's more granular pruning strategies. + ;(opencodeConfig as Record).compaction = { auto: false, prune: false } + logger.info("Disabled OpenCode built-in compaction — ACP manages context") + }, + // Last-resort safety net: if compaction triggers despite being disabled + // (e.g., user re-enables it in their config), inject plugin state so the + // summary preserves critical context about what we've been tracking. + "experimental.session.compacting": async ( + _input: { sessionID: string }, + output: { context: string[]; prompt?: string }, + ) => { + const contextLines: string[] = [] + + // Inject active todo state + const activeTodos = state.todos.filter( + (t) => t.status === "in_progress" || t.status === "pending", + ) + if (activeTodos.length > 0) { + contextLines.push("## Active Tasks") + for (const todo of activeTodos) { + contextLines.push(`- [${todo.status}] ${todo.content} (${todo.priority})`) + } + } + + // Inject tracked file paths + const trackedFiles = Array.from(state.cursors.files.pathToCallIds.keys()) + if (trackedFiles.length > 0) { + contextLines.push("## Files Being Tracked") + contextLines.push(trackedFiles.slice(0, 20).join("\n")) + } + + // Inject pruning stats + contextLines.push("## Context Management Stats") + contextLines.push(`- Total tokens saved by ACP plugin: ${state.stats.totalPruneTokens}`) + contextLines.push(`- Tool outputs pruned: ${state.prune.toolIds.length}`) + contextLines.push(`- Current turn: ${state.currentTurn}`) + + if (state.contextPressure) { + contextLines.push( + `- Context pressure: ${state.contextPressure.contextPercent}% (${state.contextPressure.statusLabel})`, + ) + } + + output.context.push(contextLines.join("\n")) + logger.info("Injected ACP state into compaction context", { + activeTodos: activeTodos.length, + trackedFiles: trackedFiles.length, + }) }, } }) satisfies Plugin diff --git a/lib/config/defaults.ts b/lib/config/defaults.ts index 6ae1dbb..d25b18a 100644 --- a/lib/config/defaults.ts +++ b/lib/config/defaults.ts @@ -93,5 +93,11 @@ export const DEFAULT_CONFIG: PluginConfig = { stateQuerySupersede: true, truncateOldErrors: true, }, + tokenBudget: { + warningThreshold: 0.7, + criticalThreshold: 0.85, + targetPercent: 0.6, + protectedRecentTurns: 2, + }, }, } diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 35ca206..a3a71ce 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -258,9 +258,51 @@ export const AggressivePruningSchema = z.object({ .describe("Truncate old error outputs to first line only, removing stack traces"), }) +export const TokenBudgetSchema = z.object({ + /** Warning threshold — start proactive pruning of tool outputs (0.0-1.0) */ + warningThreshold: z + .number() + .min(0) + .max(1) + .default(0.7) + .describe( + "Context usage percentage that triggers proactive tool output pruning (0.7 = 70%)", + ), + /** Critical threshold — also prune reasoning blocks (0.0-1.0) */ + criticalThreshold: z + .number() + .min(0) + .max(1) + .default(0.85) + .describe("Context usage percentage that triggers reasoning block pruning (0.85 = 85%)"), + /** Target percentage to prune down to */ + targetPercent: z + .number() + .min(0) + .max(1) + .default(0.6) + .describe("Target context usage percentage after proactive pruning (0.6 = 60%)"), + /** Override model context window size (tokens). If set, ignores auto-detection. */ + modelContextOverride: z + .number() + .positive() + .optional() + .describe( + "Override model context window size in tokens. If set, ignores auto-detection from model ID", + ), + /** Number of recent turns protected from proactive pruning */ + protectedRecentTurns: z + .number() + .int() + .min(0) + .default(2) + .describe("Number of recent turns protected from proactive pruning"), +}) + export const StrategiesSchema = z.object({ purgeErrors: PurgeErrorsSchema, aggressivePruning: AggressivePruningSchema, + tokenBudget: TokenBudgetSchema, }) export const PluginConfigSchema = z.object({ @@ -305,5 +347,6 @@ export type Tools = z.infer export type Commands = z.infer export type PurgeErrors = z.infer export type AggressivePruning = z.infer +export type TokenBudget = z.infer export type Strategies = z.infer export type PluginConfig = z.infer diff --git a/lib/hooks.ts b/lib/hooks.ts index 09e2811..2f06f1e 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -3,7 +3,7 @@ import type { Logger } from "./logger" import type { PluginConfig } from "./config" import type { OpenCodeClient } from "./client" import { syncSessionState } from "./state/index" -import { purgeErrors } from "./strategies" +import { purgeErrors, proactivePrune } from "./strategies" import { prune, injectHashesIntoToolOutputs, @@ -39,6 +39,7 @@ type Strategy = ( const PRUNE_STRATEGIES: Record = { purgeErrors, + proactivePrune, prune, } diff --git a/lib/strategies/index.ts b/lib/strategies/index.ts index 49bff46..1aaf4e8 100644 --- a/lib/strategies/index.ts +++ b/lib/strategies/index.ts @@ -3,6 +3,7 @@ export { PruneToolContext } from "./_types" // Strategy implementations export { purgeErrors } from "./purge-errors" +export { proactivePrune } from "./proactive-prune" // Tool operations export { diff --git a/lib/strategies/proactive-prune.ts b/lib/strategies/proactive-prune.ts new file mode 100644 index 0000000..cb43f25 --- /dev/null +++ b/lib/strategies/proactive-prune.ts @@ -0,0 +1,234 @@ +import { PluginConfig } from "../config" +import { Logger } from "../logger" +import type { SessionState, WithParts } from "../state" +import { buildToolIdList } from "../messages/utils" +import { getFilePathFromParameters, isProtectedFilePath } from "../protected-file-patterns" +import { calculateTokensSaved, countTokens } from "./utils" +import { getPruneCache } from "../state/utils" +import { isMessageCompacted } from "../shared-utils" + +/** + * Proactive Prune strategy — replaces OpenCode's built-in PRUNE_PROTECT mechanism. + * + * When the plugin disables OpenCode's compaction (auto: false, prune: false), + * this strategy takes ownership of keeping context within budget. + * + * Thresholds (based on contextPressure.contextPercent): + * - 70-84%: Prune oldest tool outputs (largest first, skip recent 2 turns) + * - 85%+: Also prune reasoning blocks from older messages + * + * This runs as part of the PRUNE_STRATEGIES pipeline in hooks.ts, + * executing on every turn via experimental.chat.messages.transform. + */ + +/** Minimum tokens a tool output must have to be worth proactive pruning */ +const MIN_PRUNE_TOKENS = 200 + +export const proactivePrune = ( + state: SessionState, + logger: Logger, + config: PluginConfig, + messages: WithParts[], +): void => { + const pressure = state.contextPressure + const budget = config.strategies.tokenBudget + const warningPercent = Math.round(budget.warningThreshold * 100) + const criticalPercent = Math.round(budget.criticalThreshold * 100) + const targetPercent = Math.round(budget.targetPercent * 100) + const protectedRecentTurns = budget.protectedRecentTurns + + if (!pressure || pressure.contextPercent < warningPercent) { + return + } + + logger.info("Proactive prune triggered", { + percent: pressure.contextPercent, + tokens: pressure.contextTokens, + limit: pressure.effectiveLimit, + }) + + const protectedTools = config.tools.settings.protectedTools + const { prunedToolIds, prunedReasoningPartIds } = getPruneCache(state) + + // Calculate how many tokens we need to free + const targetTokens = Math.floor(pressure.effectiveLimit * (targetPercent / 100)) + let tokensToFree = pressure.contextTokens - targetTokens + if (tokensToFree <= 0) return + + let totalFreed = 0 + + // Phase 1: Prune oldest tool outputs (largest first, skip recent turns) + const toolCandidates = collectToolCandidates( + state, + messages, + protectedTools, + prunedToolIds, + config, + protectedRecentTurns, + ) + + for (const candidate of toolCandidates) { + if (totalFreed >= tokensToFree) break + + state.prune.toolIds.push(candidate.callId) + state.stats.totalPruneTokens += candidate.tokens + state.stats.totalPruneMessages += 1 + state.stats.strategyStats.autoSupersede.context.count += 1 + state.stats.strategyStats.autoSupersede.context.tokens += candidate.tokens + totalFreed += candidate.tokens + + logger.debug(`Proactive-pruned tool ${candidate.toolName} (${candidate.tokens} tokens)`, { + callId: candidate.callId, + }) + } + + // Phase 2: If still over critical threshold, prune reasoning blocks + if (pressure.contextPercent >= criticalPercent && totalFreed < tokensToFree) { + const reasoningCandidates = collectReasoningCandidates( + state, + messages, + prunedReasoningPartIds, + protectedRecentTurns, + ) + + for (const candidate of reasoningCandidates) { + if (totalFreed >= tokensToFree) break + + state.prune.reasoningPartIds.push(candidate.partId) + state.stats.totalPruneTokens += candidate.tokens + state.stats.strategyStats.manualDiscard.thinking.count += 1 + state.stats.strategyStats.manualDiscard.thinking.tokens += candidate.tokens + totalFreed += candidate.tokens + + logger.debug(`Proactive-pruned reasoning block (${candidate.tokens} tokens)`, { + partId: candidate.partId, + }) + } + } + + if (totalFreed > 0) { + // Invalidate cache since we modified prune arrays + state._cache = undefined + + logger.info("Proactive prune complete", { + tokensFreed: totalFreed, + targetFreed: tokensToFree, + newEstimatedTokens: pressure.contextTokens - totalFreed, + }) + } +} + +interface ToolCandidate { + callId: string + toolName: string + tokens: number + turn: number +} + +/** + * Collect tool output candidates for pruning, sorted by token count descending. + * Skips protected tools, recent turns, already-pruned, and small outputs. + */ +function collectToolCandidates( + state: SessionState, + messages: WithParts[], + protectedTools: string[], + prunedToolIds: Set, + config: PluginConfig, + protectedRecentTurns: number, +): ToolCandidate[] { + const candidates: ToolCandidate[] = [] + const recentTurnThreshold = state.currentTurn - protectedRecentTurns + + for (const msg of messages) { + if (isMessageCompacted(state, msg)) continue + + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part.type !== "tool" || !part.callID) continue + if (prunedToolIds.has(part.callID)) continue + if (protectedTools.includes(part.tool)) continue + if (part.state?.status !== "completed") continue + + // Check turn age + const metadata = state.toolParameters.get(part.callID) + if (metadata && metadata.turn > recentTurnThreshold) continue + + // Check protected file paths + if (metadata) { + const filePath = getFilePathFromParameters(metadata.parameters) + if (isProtectedFilePath(filePath, config.protectedFilePatterns)) continue + } + + // Estimate token count + const output = part.state.output + if (!output) continue + const content = typeof output === "string" ? output : JSON.stringify(output) + const tokens = countTokens(content) + if (tokens < MIN_PRUNE_TOKENS) continue + + candidates.push({ + callId: part.callID, + toolName: part.tool, + tokens, + turn: metadata?.turn ?? 0, + }) + } + } + + // Sort: oldest first, then largest first within same turn + return candidates.sort((a, b) => { + if (a.turn !== b.turn) return a.turn - b.turn + return b.tokens - a.tokens + }) +} + +interface ReasoningCandidate { + partId: string + tokens: number + turn: number +} + +/** + * Collect reasoning block candidates for pruning, sorted oldest-first then largest-first. + * Skips recent turns and already-pruned blocks. + */ +function collectReasoningCandidates( + state: SessionState, + messages: WithParts[], + prunedReasoningPartIds: Set, + protectedRecentTurns: number, +): ReasoningCandidate[] { + const candidates: ReasoningCandidate[] = [] + const recentTurnThreshold = state.currentTurn - protectedRecentTurns + + // Estimate turn from message position (messages are chronological) + let estimatedTurn = 0 + for (const msg of messages) { + if (msg.info.role === "user") estimatedTurn++ + if (isMessageCompacted(state, msg)) continue + if (msg.info.role !== "assistant") continue + + // Skip recent turns + if (estimatedTurn > recentTurnThreshold) continue + + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part = parts[partIndex] + if (!part || part.type !== "reasoning" || !part.text) continue + + const partId = `${msg.info.id}:${partIndex}` + if (prunedReasoningPartIds.has(partId)) continue + + const tokens = countTokens(part.text) + if (tokens < MIN_PRUNE_TOKENS) continue + + candidates.push({ partId, tokens, turn: estimatedTurn }) + } + } + + return candidates.sort((a, b) => { + if (a.turn !== b.turn) return a.turn - b.turn + return b.tokens - a.tokens + }) +} diff --git a/tests/e2e/proactive-pruning.test.ts b/tests/e2e/proactive-pruning.test.ts new file mode 100644 index 0000000..92c773a --- /dev/null +++ b/tests/e2e/proactive-pruning.test.ts @@ -0,0 +1,594 @@ +/** + * Proactive Pruning & Compaction Hook E2E Tests + * + * Tests the token budget management system that replaces OpenCode's + * built-in compaction: + * - Proactive pruning triggers at warning threshold (70%) + * - Critical threshold (85%) also prunes reasoning blocks + * - Protected recent turns are skipped + * - Compaction hook injects plugin state + * - Config hook disables OpenCode compaction + */ + +import { describe, it, expect, beforeEach, vi } from "vitest" +import { + createMockClient, + createMockLogger, + createMockState, + registerToolCall, +} from "../fixtures/mock-client" +import type { SessionState, WithParts } from "../../lib/state/types" +import type { PluginConfig } from "../../lib/config" + +// Mock the plugin module +vi.mock("@opencode-ai/plugin", () => { + const schema: any = { + string: vi.fn(() => schema), + array: vi.fn(() => schema), + union: vi.fn(() => schema), + tuple: vi.fn(() => schema), + enum: vi.fn(() => schema), + object: vi.fn(() => schema), + describe: vi.fn(() => schema), + } + const toolMock: any = vi.fn((spec) => ({ + ...spec, + execute: spec.execute, + })) + toolMock.schema = schema + return { tool: toolMock } +}) + +vi.mock("../../lib/prompts", () => ({ + loadPrompt: vi.fn((name: string) => `Mocked prompt: ${name}`), +})) + +import { proactivePrune } from "../../lib/strategies/proactive-prune" + +function createTestConfig(overrides: Record = {}): PluginConfig { + return { + enabled: true, + debug: false, + pruneNotification: "minimal", + commands: { + enabled: true, + protectedTools: ["task", "context_prune"], + }, + protectedFilePatterns: [], + tools: { + settings: { + protectedTools: ["task", "todowrite", "todoread", "context_prune", "write", "edit"], + enableAssistantMessagePruning: true, + enableReasoningPruning: true, + enableVisibleAssistantHashes: true, + }, + discard: { enabled: true }, + distill: { enabled: true, showDistillation: false }, + todoReminder: { + enabled: true, + initialTurns: 5, + repeatTurns: 4, + stuckTaskTurns: 12, + fallbackContextWindow: 200000, + warningThresholdPercent: 0.7, + }, + automataMode: { + enabled: true, + initialTurns: 8, + }, + }, + strategies: { + purgeErrors: { + enabled: false, + turns: 4, + protectedTools: [], + }, + aggressivePruning: { + pruneSourceUrls: true, + pruneFiles: true, + pruneSnapshots: true, + pruneStepMarkers: true, + pruneToolInputs: true, + pruneRetryParts: true, + pruneUserCodeBlocks: true, + aggressiveFilePrune: true, + stateQuerySupersede: true, + truncateOldErrors: true, + }, + tokenBudget: { + warningThreshold: 0.7, + criticalThreshold: 0.85, + targetPercent: 0.6, + protectedRecentTurns: 2, + }, + }, + ...overrides, + } as PluginConfig +} + +/** Generate text that produces ~N tokens (words produce ~1.3 tokens each) */ +function generateTokenText(targetTokens: number): string { + const words: string[] = [] + for (let i = 0; i < targetTokens; i++) { + words.push(`word${i}`) + } + return words.join(" ") +} + +/** Create messages with tool outputs of known sizes */ +function createMessagesWithTools( + tools: Array<{ callId: string; tool: string; output: string; turn: number }>, +): WithParts[] { + const messages: WithParts[] = [] + let turnCount = 0 + + for (const t of tools) { + while (turnCount < t.turn) { + messages.push({ + info: { + id: `user_${turnCount}`, + role: "user", + time: { created: Date.now() - (tools.length - turnCount) * 1000 }, + } as any, + parts: [{ type: "text", text: `User message ${turnCount}` } as any], + }) + turnCount++ + } + messages.push({ + info: { + id: `assistant_${t.callId}`, + role: "assistant", + time: { created: Date.now() - (tools.length - turnCount) * 1000 }, + finish: "stop", + } as any, + parts: [ + { + type: "tool", + callID: t.callId, + tool: t.tool, + state: { + status: "completed", + output: t.output, + input: {}, + time: { start: Date.now(), end: Date.now() }, + }, + } as any, + ], + }) + } + + return messages +} + +/** Create messages with reasoning blocks */ +function createMessagesWithReasoning( + blocks: Array<{ messageId: string; text: string; turn: number }>, +): WithParts[] { + const messages: WithParts[] = [] + let turnCount = 0 + + for (const b of blocks) { + while (turnCount < b.turn) { + messages.push({ + info: { + id: `user_${turnCount}`, + role: "user", + time: { created: Date.now() - (blocks.length - turnCount) * 1000 }, + } as any, + parts: [{ type: "text", text: `User message ${turnCount}` } as any], + }) + turnCount++ + } + messages.push({ + info: { + id: b.messageId, + role: "assistant", + time: { created: Date.now() - (blocks.length - turnCount) * 1000 }, + finish: "stop", + } as any, + parts: [ + { + type: "reasoning", + text: b.text, + } as any, + { + type: "text", + text: "Response text", + } as any, + ], + }) + } + + return messages +} + +describe("Proactive Pruning", () => { + let mockState: SessionState + let mockLogger: ReturnType + let config: PluginConfig + + beforeEach(() => { + mockState = createMockState({ currentTurn: 10 }) + mockLogger = createMockLogger() + config = createTestConfig() + }) + + describe("Warning threshold (70%)", () => { + it("does NOT prune when context is below 70%", () => { + mockState.contextPressure = { + contextTokens: 100000, + effectiveLimit: 200000, + contextPercent: 50, + statusLabel: "Nominal", + statusEmoji: "🟢", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 100000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_1", tool: "read", output: largeOutput, turn: 1 }, + ]) + registerToolCall(mockState, "call_1", "aaa111", "read", 1) + + proactivePrune(mockState, mockLogger as any, config, messages) + + expect(mockState.prune.toolIds).toHaveLength(0) + }) + + it("prunes oldest tool outputs when context exceeds 70%", () => { + mockState.contextPressure = { + contextTokens: 150000, + effectiveLimit: 200000, + contextPercent: 75, + statusLabel: "High", + statusEmoji: "🟠", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 50000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_1", tool: "read", output: largeOutput, turn: 1 }, + { callId: "call_2", tool: "grep", output: largeOutput, turn: 3 }, + { callId: "call_3", tool: "read", output: largeOutput, turn: 5 }, + ]) + registerToolCall(mockState, "call_1", "aaa111", "read", 1) + registerToolCall(mockState, "call_2", "bbb222", "grep", 3) + registerToolCall(mockState, "call_3", "ccc333", "read", 5) + + proactivePrune(mockState, mockLogger as any, config, messages) + + // Should prune oldest tools first + expect(mockState.prune.toolIds).toContain("call_1") + expect(mockState.stats.totalPruneTokens).toBeGreaterThan(0) + }) + + it("skips protected tools", () => { + mockState.contextPressure = { + contextTokens: 150000, + effectiveLimit: 200000, + contextPercent: 75, + statusLabel: "High", + statusEmoji: "🟠", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 50000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_task", tool: "task", output: largeOutput, turn: 1 }, + { callId: "call_read", tool: "read", output: largeOutput, turn: 2 }, + ]) + registerToolCall(mockState, "call_task", "aaa111", "task", 1) + registerToolCall(mockState, "call_read", "bbb222", "read", 2) + + proactivePrune(mockState, mockLogger as any, config, messages) + + // task is protected, should not be pruned + expect(mockState.prune.toolIds).not.toContain("call_task") + }) + + it("skips recent turns (within protectedRecentTurns)", () => { + mockState.currentTurn = 5 + mockState.contextPressure = { + contextTokens: 150000, + effectiveLimit: 200000, + contextPercent: 75, + statusLabel: "High", + statusEmoji: "🟠", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 50000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_old", tool: "read", output: largeOutput, turn: 1 }, + { callId: "call_recent", tool: "read", output: largeOutput, turn: 4 }, + ]) + registerToolCall(mockState, "call_old", "aaa111", "read", 1) + registerToolCall(mockState, "call_recent", "bbb222", "read", 4) // turn 4, current=5, protected=2 → threshold=3 + + proactivePrune(mockState, mockLogger as any, config, messages) + + // Old tool should be pruned, recent should be protected + expect(mockState.prune.toolIds).toContain("call_old") + expect(mockState.prune.toolIds).not.toContain("call_recent") + }) + + it("skips already-pruned tools", () => { + mockState.prune.toolIds = ["call_1"] + mockState.contextPressure = { + contextTokens: 150000, + effectiveLimit: 200000, + contextPercent: 75, + statusLabel: "High", + statusEmoji: "🟠", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 50000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_1", tool: "read", output: largeOutput, turn: 1 }, + { callId: "call_2", tool: "read", output: largeOutput, turn: 2 }, + ]) + registerToolCall(mockState, "call_1", "aaa111", "read", 1) + registerToolCall(mockState, "call_2", "bbb222", "read", 2) + + proactivePrune(mockState, mockLogger as any, config, messages) + + // call_1 was already pruned, should not be duplicated + expect(mockState.prune.toolIds.filter((id) => id === "call_1")).toHaveLength(1) + }) + }) + + describe("Critical threshold (85%)", () => { + it("also prunes reasoning blocks when context exceeds 85%", () => { + mockState.currentTurn = 10 + mockState.contextPressure = { + contextTokens: 175000, + effectiveLimit: 200000, + contextPercent: 88, + statusLabel: "Critical", + statusEmoji: "🔴", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 25000, + } + + // Create messages with reasoning blocks in old turns + const messages = createMessagesWithReasoning([ + { messageId: "msg_1", text: generateTokenText(500), turn: 1 }, + { messageId: "msg_2", text: generateTokenText(500), turn: 3 }, + ]) + + proactivePrune(mockState, mockLogger as any, config, messages) + + // Should prune reasoning from old turns + expect(mockState.prune.reasoningPartIds.length).toBeGreaterThan(0) + expect(mockState.stats.strategyStats.manualDiscard.thinking.count).toBeGreaterThan(0) + }) + + it("does NOT prune reasoning when below critical threshold", () => { + mockState.contextPressure = { + contextTokens: 150000, + effectiveLimit: 200000, + contextPercent: 75, + statusLabel: "High", + statusEmoji: "🟠", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 50000, + } + + const messages = createMessagesWithReasoning([ + { messageId: "msg_1", text: generateTokenText(500), turn: 1 }, + ]) + + proactivePrune(mockState, mockLogger as any, config, messages) + + expect(mockState.prune.reasoningPartIds).toHaveLength(0) + }) + }) + + describe("Configurable thresholds", () => { + it("respects custom warningThreshold from config", () => { + const customConfig = createTestConfig({ + strategies: { + ...config.strategies, + tokenBudget: { + warningThreshold: 0.5, + criticalThreshold: 0.85, + targetPercent: 0.4, + protectedRecentTurns: 2, + }, + }, + }) + + // 55% — above custom 50% threshold + mockState.contextPressure = { + contextTokens: 110000, + effectiveLimit: 200000, + contextPercent: 55, + statusLabel: "Elevated", + statusEmoji: "🟡", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 90000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_1", tool: "read", output: largeOutput, turn: 1 }, + ]) + registerToolCall(mockState, "call_1", "aaa111", "read", 1) + + proactivePrune(mockState, mockLogger as any, customConfig, messages) + + // Should trigger at 55% with custom 50% threshold + expect(mockState.prune.toolIds).toContain("call_1") + }) + + it("respects custom protectedRecentTurns", () => { + const customConfig = createTestConfig({ + strategies: { + ...config.strategies, + tokenBudget: { + warningThreshold: 0.7, + criticalThreshold: 0.85, + targetPercent: 0.6, + protectedRecentTurns: 5, // protect last 5 turns + }, + }, + }) + + mockState.currentTurn = 6 + mockState.contextPressure = { + contextTokens: 150000, + effectiveLimit: 200000, + contextPercent: 75, + statusLabel: "High", + statusEmoji: "🟠", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 50000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_1", tool: "read", output: largeOutput, turn: 2 }, + { callId: "call_2", tool: "read", output: largeOutput, turn: 3 }, + ]) + registerToolCall(mockState, "call_1", "aaa111", "read", 2) // turn 2, threshold = 6-5 = 1 + registerToolCall(mockState, "call_2", "bbb222", "read", 3) + + proactivePrune(mockState, mockLogger as any, customConfig, messages) + + // Both turns 2 and 3 are within protected range (threshold=1), should not be pruned + expect(mockState.prune.toolIds).not.toContain("call_2") + }) + }) + + describe("Cache invalidation", () => { + it("invalidates runtime cache after pruning", () => { + mockState._cache = { + prunedToolIds: new Set(), + prunedMessagePartIds: new Set(), + prunedReasoningPartIds: new Set(), + prunedSegmentIds: new Set(), + replacements: new Map(), + } + mockState.contextPressure = { + contextTokens: 150000, + effectiveLimit: 200000, + contextPercent: 75, + statusLabel: "High", + statusEmoji: "🟠", + modelMatch: "Claude Opus", + totalSaved: 0, + remaining: 50000, + } + + const largeOutput = generateTokenText(500) + const messages = createMessagesWithTools([ + { callId: "call_1", tool: "read", output: largeOutput, turn: 1 }, + ]) + registerToolCall(mockState, "call_1", "aaa111", "read", 1) + + proactivePrune(mockState, mockLogger as any, config, messages) + + // Cache should be invalidated + expect(mockState._cache).toBeUndefined() + }) + }) +}) + +describe("Compaction Hook", () => { + it("injects plugin state into compaction context", async () => { + const state = createMockState({ + currentTurn: 15, + todos: [ + { id: "1", content: "Fix bug", status: "in_progress", priority: "high" }, + { id: "2", content: "Write tests", status: "pending", priority: "medium" }, + { id: "3", content: "Done task", status: "completed", priority: "low" }, + ], + }) + state.stats.totalPruneTokens = 50000 + state.prune.toolIds = ["call_1", "call_2", "call_3"] + state.cursors.files.pathToCallIds.set("src/index.ts", new Set(["call_1"])) + state.cursors.files.pathToCallIds.set("lib/hooks.ts", new Set(["call_2"])) + + const output = { context: [] as string[], prompt: undefined } + + // Simulate the compacting hook logic from index.ts + const contextLines: string[] = [] + const activeTodos = state.todos.filter( + (t) => t.status === "in_progress" || t.status === "pending", + ) + if (activeTodos.length > 0) { + contextLines.push("## Active Tasks") + for (const todo of activeTodos) { + contextLines.push(`- [${todo.status}] ${todo.content} (${todo.priority})`) + } + } + const trackedFiles = Array.from(state.cursors.files.pathToCallIds.keys()) + if (trackedFiles.length > 0) { + contextLines.push("## Files Being Tracked") + contextLines.push(trackedFiles.slice(0, 20).join("\n")) + } + contextLines.push("## Context Management Stats") + contextLines.push(`- Total tokens saved by ACP plugin: ${state.stats.totalPruneTokens}`) + contextLines.push(`- Tool outputs pruned: ${state.prune.toolIds.length}`) + contextLines.push(`- Current turn: ${state.currentTurn}`) + output.context.push(contextLines.join("\n")) + + expect(output.context).toHaveLength(1) + const ctx = output.context[0] + expect(ctx).toContain("## Active Tasks") + expect(ctx).toContain("Fix bug") + expect(ctx).toContain("Write tests") + expect(ctx).not.toContain("Done task") // completed tasks excluded + expect(ctx).toContain("src/index.ts") + expect(ctx).toContain("lib/hooks.ts") + expect(ctx).toContain("50000") + expect(ctx).toContain("3") // 3 pruned tools + expect(ctx).toContain("15") // current turn + }) +}) + +describe("Config Hook — Compaction Disable", () => { + it("sets compaction auto and prune to false", () => { + const opencodeConfig: Record = { + experimental: { primary_tools: [] }, + } + + // Simulate the config hook logic from index.ts + ;(opencodeConfig as Record).compaction = { auto: false, prune: false } + + expect(opencodeConfig.compaction).toEqual({ auto: false, prune: false }) + }) + + it("preserves existing experimental config", () => { + const opencodeConfig: Record = { + experimental: { primary_tools: ["bash"], batch_tool: true }, + } + + const existingPrimaryTools = (opencodeConfig.experimental as any)?.primary_tools ?? [] + ;(opencodeConfig as any).experimental = { + ...(opencodeConfig.experimental as any), + primary_tools: [...existingPrimaryTools, "context_prune"], + } + ;(opencodeConfig as Record).compaction = { auto: false, prune: false } + + expect((opencodeConfig.experimental as any).primary_tools).toContain("bash") + expect((opencodeConfig.experimental as any).primary_tools).toContain("context_prune") + expect((opencodeConfig.experimental as any).batch_tool).toBe(true) + expect(opencodeConfig.compaction).toEqual({ auto: false, prune: false }) + }) +}) diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts index 9d66189..a6cc96b 100644 --- a/tests/hooks.test.ts +++ b/tests/hooks.test.ts @@ -17,6 +17,7 @@ vi.mock("../lib/strategies", () => ({ deduplicate: vi.fn(), supersedeWrites: vi.fn(), purgeErrors: vi.fn(), + proactivePrune: vi.fn(), })) vi.mock("../lib/messages", () => ({ diff --git a/tests/integration/opt-in-defaults.test.ts b/tests/integration/opt-in-defaults.test.ts index 6fba67f..0d2eb6d 100644 --- a/tests/integration/opt-in-defaults.test.ts +++ b/tests/integration/opt-in-defaults.test.ts @@ -13,6 +13,7 @@ vi.mock("../../lib/strategies", () => ({ deduplicate: vi.fn(), supersedeWrites: vi.fn(), purgeErrors: vi.fn(), + proactivePrune: vi.fn(), })) vi.mock("../../lib/messages", () => ({