diff --git a/src/services/compact/__tests__/cachedMicrocompact.test.ts b/src/services/compact/__tests__/cachedMicrocompact.test.ts new file mode 100644 index 000000000..5614d6a6a --- /dev/null +++ b/src/services/compact/__tests__/cachedMicrocompact.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect, beforeEach } from 'bun:test' +import { + createCachedMCState, + registerToolResult, + getToolResultsToDelete, + createCacheEditsBlock, + markToolsSentToAPI, + resetCachedMCState, + isCachedMicrocompactEnabled, + isModelSupportedForCacheEditing, + type CachedMCState, +} from '../cachedMicrocompact.js' + +describe('cachedMicrocompact', () => { + let state: CachedMCState + + beforeEach(() => { + state = createCachedMCState() + }) + + test('createCachedMCState returns clean state', () => { + expect(state.registeredTools.size).toBe(0) + expect(state.toolOrder).toEqual([]) + expect(state.deletedRefs.size).toBe(0) + expect(state.pinnedEdits).toEqual([]) + expect(state.toolsSentToAPI).toBe(false) + }) + + test('registerToolResult tracks tool IDs in order', () => { + registerToolResult(state, 'tool-1') + registerToolResult(state, 'tool-2') + registerToolResult(state, 'tool-3') + expect(state.registeredTools.size).toBe(3) + expect(state.toolOrder).toEqual(['tool-1', 'tool-2', 'tool-3']) + }) + + test('getToolResultsToDelete returns empty when below threshold', () => { + for (let i = 0; i < 5; i++) { + registerToolResult(state, `tool-${i}`) + } + const toDelete = getToolResultsToDelete(state) + expect(toDelete).toEqual([]) + }) + + test('getToolResultsToDelete returns oldest when above threshold', () => { + for (let i = 0; i < 12; i++) { + registerToolResult(state, `tool-${i}`) + } + const toDelete = getToolResultsToDelete(state) + // Should suggest deleting oldest, keeping recent + expect(toDelete.length).toBeGreaterThan(0) + // Should not include the most recent tools + expect(toDelete).not.toContain('tool-11') + expect(toDelete).not.toContain('tool-10') + }) + + test('createCacheEditsBlock generates correct structure', () => { + for (let i = 0; i < 12; i++) { + registerToolResult(state, `tool-${i}`) + } + const toDelete = getToolResultsToDelete(state) + const block = createCacheEditsBlock(state, toDelete) + if (block) { + expect(block.type).toBe('cache_edits') + expect(block.edits.length).toBe(toDelete.length) + for (const edit of block.edits) { + expect(edit.type).toBe('delete_tool_result') + expect(typeof edit.tool_use_id).toBe('string') + } + } + }) + + test('createCacheEditsBlock returns null for empty list', () => { + const block = createCacheEditsBlock(state, []) + expect(block).toBeNull() + }) + + test('already deleted tools are not suggested again', () => { + for (let i = 0; i < 12; i++) { + registerToolResult(state, `tool-${i}`) + } + const first = getToolResultsToDelete(state) + // Simulate deletion + for (const id of first) { + state.deletedRefs.add(id) + } + const second = getToolResultsToDelete(state) + // Should not re-suggest already deleted + for (const id of first) { + expect(second).not.toContain(id) + } + }) + + test('markToolsSentToAPI sets flag', () => { + expect(state.toolsSentToAPI).toBe(false) + markToolsSentToAPI(state) + expect(state.toolsSentToAPI).toBe(true) + }) + + test('resetCachedMCState clears everything', () => { + registerToolResult(state, 'tool-1') + markToolsSentToAPI(state) + resetCachedMCState(state) + expect(state.registeredTools.size).toBe(0) + expect(state.toolOrder).toEqual([]) + expect(state.toolsSentToAPI).toBe(false) + }) + + test('isModelSupportedForCacheEditing accepts Claude 4.x', () => { + expect(isModelSupportedForCacheEditing('claude-opus-4-6')).toBe(true) + expect(isModelSupportedForCacheEditing('claude-sonnet-4-6')).toBe(true) + }) + + test('isModelSupportedForCacheEditing rejects old models', () => { + expect(isModelSupportedForCacheEditing('claude-2')).toBe(false) + expect(isModelSupportedForCacheEditing('gpt-4')).toBe(false) + }) +}) diff --git a/src/services/compact/apiMicrocompact.ts b/src/services/compact/apiMicrocompact.ts index 44b292dac..a901cb6c3 100644 --- a/src/services/compact/apiMicrocompact.ts +++ b/src/services/compact/apiMicrocompact.ts @@ -86,27 +86,24 @@ export function getAPIContextManagement(options?: { }) } - // Tool clearing strategies are ant-only - if (process.env.USER_TYPE !== 'ant') { - return strategies.length > 0 ? { edits: strategies } : undefined - } - - const useClearToolResults = isEnvTruthy( - process.env.USE_API_CLEAR_TOOL_RESULTS, - ) + // Tool clearing: default enabled for all users (upstream gates on USER_TYPE=ant). + // Opt out via USE_API_CLEAR_TOOL_RESULTS=0 / USE_API_CLEAR_TOOL_USES=0. + const useClearToolResults = + process.env.USE_API_CLEAR_TOOL_RESULTS !== undefined + ? isEnvTruthy(process.env.USE_API_CLEAR_TOOL_RESULTS) + : true const useClearToolUses = isEnvTruthy(process.env.USE_API_CLEAR_TOOL_USES) - // If no tool clearing strategy is enabled, return early if (!useClearToolResults && !useClearToolUses) { return strategies.length > 0 ? { edits: strategies } : undefined } if (useClearToolResults) { const triggerThreshold = process.env.API_MAX_INPUT_TOKENS - ? parseInt(process.env.API_MAX_INPUT_TOKENS) + ? parseInt(process.env.API_MAX_INPUT_TOKENS, 10) : DEFAULT_MAX_INPUT_TOKENS const keepTarget = process.env.API_TARGET_INPUT_TOKENS - ? parseInt(process.env.API_TARGET_INPUT_TOKENS) + ? parseInt(process.env.API_TARGET_INPUT_TOKENS, 10) : DEFAULT_TARGET_INPUT_TOKENS const strategy: ContextEditStrategy = { @@ -127,10 +124,10 @@ export function getAPIContextManagement(options?: { if (useClearToolUses) { const triggerThreshold = process.env.API_MAX_INPUT_TOKENS - ? parseInt(process.env.API_MAX_INPUT_TOKENS) + ? parseInt(process.env.API_MAX_INPUT_TOKENS, 10) : DEFAULT_MAX_INPUT_TOKENS const keepTarget = process.env.API_TARGET_INPUT_TOKENS - ? parseInt(process.env.API_TARGET_INPUT_TOKENS) + ? parseInt(process.env.API_TARGET_INPUT_TOKENS, 10) : DEFAULT_TARGET_INPUT_TOKENS const strategy: ContextEditStrategy = { diff --git a/src/services/compact/cachedMicrocompact.ts b/src/services/compact/cachedMicrocompact.ts index 471ad8dfe..56e753738 100644 --- a/src/services/compact/cachedMicrocompact.ts +++ b/src/services/compact/cachedMicrocompact.ts @@ -1,6 +1,3 @@ -// Auto-generated stub — replace with real implementation -export {}; - export type CachedMCState = { registeredTools: Set toolOrder: string[] @@ -19,19 +16,97 @@ export type PinnedCacheEdits = { block: CacheEditsBlock } -export const isCachedMicrocompactEnabled: () => boolean = () => false; -export const isModelSupportedForCacheEditing: (model: string) => boolean = () => false; -export const getCachedMCConfig: () => { triggerThreshold: number; keepRecent: number } = () => ({ triggerThreshold: 0, keepRecent: 0 }); -export const createCachedMCState: () => CachedMCState = () => ({ - registeredTools: new Set(), - toolOrder: [], - deletedRefs: new Set(), - pinnedEdits: [], - toolsSentToAPI: false, -}); -export const markToolsSentToAPI: (state: CachedMCState) => void = () => {}; -export const resetCachedMCState: (state: CachedMCState) => void = () => {}; -export const registerToolResult: (state: CachedMCState, toolId: string) => void = () => {}; -export const registerToolMessage: (state: CachedMCState, groupIds: string[]) => void = () => {}; -export const getToolResultsToDelete: (state: CachedMCState) => string[] = () => []; -export const createCacheEditsBlock: (state: CachedMCState, toolIds: string[]) => CacheEditsBlock | null = () => null; +const TRIGGER_THRESHOLD = 10 +const KEEP_RECENT = 5 + +/** + * Returns true when the CLAUDE_CACHED_MICROCOMPACT env var is set to '1' + * or the feature is explicitly enabled. + */ +export function isCachedMicrocompactEnabled(): boolean { + return process.env.CLAUDE_CACHED_MICROCOMPACT === '1' +} + +/** + * Returns true for Claude 4.x models that support cache_edits. + */ +export function isModelSupportedForCacheEditing(model: string): boolean { + return /claude-[a-z]+-4[-\d]/.test(model) +} + +export function getCachedMCConfig(): { + triggerThreshold: number + keepRecent: number +} { + return { triggerThreshold: TRIGGER_THRESHOLD, keepRecent: KEEP_RECENT } +} + +export function createCachedMCState(): CachedMCState { + return { + registeredTools: new Set(), + toolOrder: [], + deletedRefs: new Set(), + pinnedEdits: [], + toolsSentToAPI: false, + } +} + +export function markToolsSentToAPI(state: CachedMCState): void { + state.toolsSentToAPI = true +} + +export function resetCachedMCState(state: CachedMCState): void { + state.registeredTools.clear() + state.toolOrder = [] + state.deletedRefs.clear() + state.pinnedEdits = [] + state.toolsSentToAPI = false +} + +export function registerToolResult(state: CachedMCState, toolId: string): void { + if (!state.registeredTools.has(toolId)) { + state.registeredTools.add(toolId) + state.toolOrder.push(toolId) + } +} + +export function registerToolMessage( + state: CachedMCState, + groupIds: string[], +): void { + for (const id of groupIds) { + registerToolResult(state, id) + } +} + +/** + * Returns the tool IDs that should be deleted (oldest first) to bring + * the count below the threshold, excluding already-deleted tools and + * the most recently seen ones. + */ +export function getToolResultsToDelete(state: CachedMCState): string[] { + const { triggerThreshold, keepRecent } = getCachedMCConfig() + const active = state.toolOrder.filter(id => !state.deletedRefs.has(id)) + if (active.length <= triggerThreshold) return [] + // Keep the last keepRecent tools + const toDelete = active.slice(0, active.length - keepRecent) + return toDelete +} + +/** + * Creates a cache_edits block that deletes the given tool result IDs. + * Returns null if toolIds is empty. + */ +export function createCacheEditsBlock( + state: CachedMCState, + toolIds: string[], +): CacheEditsBlock | null { + if (toolIds.length === 0) return null + return { + type: 'cache_edits', + edits: toolIds.map(id => ({ + type: 'delete_tool_result', + tool_use_id: id, + })), + } +} diff --git a/src/services/contextCollapse/index.ts b/src/services/contextCollapse/index.ts index 09fb3c501..d3a1c3d6e 100644 --- a/src/services/contextCollapse/index.ts +++ b/src/services/contextCollapse/index.ts @@ -27,7 +27,7 @@ export interface DrainResult { messages: Message[] } -export const getStats: () => ContextCollapseStats = (() => ({ +export const getStats: () => ContextCollapseStats = () => ({ collapsedSpans: 0, collapsedMessages: 0, stagedSpans: 0, @@ -38,29 +38,38 @@ export const getStats: () => ContextCollapseStats = (() => ({ emptySpawnWarningEmitted: false, totalEmptySpawns: 0, }, -})); +}) -export const isContextCollapseEnabled: () => boolean = (() => false); +let _contextCollapseEnabled = false -export const subscribe: (callback: () => void) => () => void = ((_callback: () => void) => () => {}); +export function isContextCollapseEnabled(): boolean { + return _contextCollapseEnabled +} + +export const subscribe: (callback: () => void) => () => void = + (_callback: () => void) => () => {} export const applyCollapsesIfNeeded: ( messages: Message[], toolUseContext: ToolUseContext, querySource: QuerySource, -) => Promise = (async (messages: Message[]) => ({ messages })); +) => Promise = async (messages: Message[]) => ({ messages }) export const isWithheldPromptTooLong: ( message: Message, isPromptTooLongMessage: (msg: Message) => boolean, querySource: QuerySource, -) => boolean = (() => false); +) => boolean = () => false export const recoverFromOverflow: ( messages: Message[], querySource: QuerySource, -) => DrainResult = ((messages: Message[]) => ({ committed: 0, messages })); +) => DrainResult = (messages: Message[]) => ({ committed: 0, messages }) -export const resetContextCollapse: () => void = (() => {}); +export function resetContextCollapse(): void { + _contextCollapseEnabled = false +} -export const initContextCollapse: () => void = (() => {}); +export function initContextCollapse(): void { + _contextCollapseEnabled = true +}