diff --git a/src/services/compact/compact.test.ts b/src/services/compact/compact.test.ts index 5b391c821..668be0125 100644 --- a/src/services/compact/compact.test.ts +++ b/src/services/compact/compact.test.ts @@ -6,6 +6,7 @@ import { describe, expect, mock, + spyOn, test, vi, } from 'bun:test' @@ -363,9 +364,23 @@ export type CompactMockOptions = { * mocks that the provider gate test specifically depends on; all others are * DEFENSIVE (prevent transitive import / side-effect / I/O failures). */ -function registerCommonCompactStubs(options: CompactMockOptions = {}) { +async function registerCommonCompactStubs(options: CompactMockOptions = {}) { mock.restore() + const [config, path, settings, model] = await Promise.all([ + import('../../utils/config.js'), + import('../../utils/path.js'), + import('../../utils/settings/settings.js'), + import('../../utils/model/model.js'), + ]) + spyOn(config, 'getMemoryPath').mockReturnValue('/tmp/memory') + if (options.getGlobalConfig) { + spyOn(config, 'getGlobalConfig').mockImplementation(options.getGlobalConfig) + } + spyOn(path, 'expandPath').mockImplementation((p: string) => p) + spyOn(settings, 'getInitialSettings').mockReturnValue({}) + spyOn(model, 'getCanonicalName').mockImplementation((m: string) => m) + // --- Provider gate control --- // The isAnthropicProvider() gate is exercised via environment variables // (e.g. CLAUDE_CODE_USE_OPENAI=1) instead of mock.module() on betas.ts. @@ -510,15 +525,6 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { groupMessagesByApiRound: mock((msgs: Message[]) => [msgs]), })) - // --- Config (DEFENSIVE) --- - mock.module('../../utils/config.js', () => ({ - ..._realConfigModule, - getMemoryPath: mock(() => '/tmp/memory'), - ...(options.getGlobalConfig - ? { getGlobalConfig: options.getGlobalConfig } - : {}), - })) - // --- File state cache (DEFENSIVE) --- mock.module('../../utils/fileStateCache.js', () => ({ cacheToObject: mock(() => ({})), @@ -555,12 +561,6 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { getPlanFilePath: mock(() => '/tmp/plan'), })) - // --- Path (DEFENSIVE) --- - mock.module('../../utils/path.js', () => ({ - ..._realPathModule, - expandPath: mock((p: string) => p), - })) - // --- Sleep (DEFENSIVE) --- mock.module('../../utils/sleep.js', () => ({ sleep: mock(async () => {}), @@ -646,18 +646,6 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { get3PModelCapabilityOverride: mock(() => undefined), })) - // --- Settings (DEFENSIVE) --- - mock.module('../../utils/settings/settings.js', () => ({ - ..._realSettingsModule, - getInitialSettings: mock(() => ({})), - })) - - // --- Model (DEFENSIVE) --- - mock.module('../../utils/model/model.js', () => ({ - ..._realModelModule, - getCanonicalName: mock((m: string) => m), - })) - return { runForkedAgent } } @@ -682,7 +670,7 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { * trigger hundreds of failed transitive resolution steps. */ async function importCompact(options: CompactMockOptions = {}) { - const { runForkedAgent } = registerCommonCompactStubs(options) + const { runForkedAgent } = await registerCommonCompactStubs(options) // Dynamic import with cache-busting so each test gets fresh module state const nonce = `${Date.now()}-${Math.random()}` @@ -820,6 +808,143 @@ afterAll(async () => { } }) +describe('compact media stripping', () => { + test('strips top-level images and documents without mutating user messages', async () => { + const { stripImagesFromMessages } = await import( + `./compact.ts?test=${Date.now()}-${Math.random()}` + ) + const message = { + type: 'user', + uuid: randomUUID(), + timestamp: '2026-10-21T00:00:00.000Z', + origin: { kind: 'human' }, + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'text', text: 'Summarize these attachments.' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'aW1hZ2U=' }, + }, + { + type: 'document', + source: { type: 'text', media_type: 'text/plain', data: 'document' }, + }, + ], + }, + } satisfies Message + const original = structuredClone(message) + + expect(stripImagesFromMessages([message])).toEqual([ + { + ...original, + message: { + ...original.message, + content: [ + { type: 'text', text: 'Summarize these attachments.' }, + { type: 'text', text: '[image]' }, + { type: 'text', text: '[document]' }, + ], + }, + }, + ]) + expect(message).toEqual(original) + expect(stripImagesFromMessages([])).toEqual([]) + + const stringMessage = userMessage('plain text') + const textOnlyMessage = { + ...message, + message: { role: 'user', content: [{ type: 'text', text: 'only text' }] }, + } satisfies Message + const assistant = assistantMessage('assistant text') + const unaffected = [stringMessage, textOnlyMessage, assistant] + const stripped = stripImagesFromMessages(unaffected) + expect(stripped).toEqual(unaffected) + for (const [index, unchanged] of unaffected.entries()) { + expect(stripped[index]).toBe(unchanged) + } + }) + + test('strips media inside tool results while preserving tool metadata and text', async () => { + const { stripImagesFromMessages } = await import( + `./compact.ts?test=${Date.now()}-${Math.random()}` + ) + const message = { + type: 'user', + uuid: randomUUID(), + timestamp: '2026-10-21T00:00:00.000Z', + sourceToolUseID: 'toolu_media', + toolUseResult: { status: 'failed' }, + message: { + role: 'user', + content: [ + { type: 'text', text: 'before the tool result' }, + { + type: 'tool_result', + tool_use_id: 'toolu_media', + is_error: true, + content: [ + { type: 'text', text: 'tool text' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'aW1hZ2U=' }, + }, + { + type: 'document', + source: { type: 'text', media_type: 'text/plain', data: 'document' }, + }, + { type: 'text', text: 'trailing tool text' }, + ], + }, + { type: 'tool_result', tool_use_id: 'toolu_string', content: 'string result' }, + { type: 'tool_result', tool_use_id: 'toolu_empty', content: [] }, + { + type: 'tool_result', + tool_use_id: 'toolu_text', + content: [{ type: 'text', text: 'text-only result' }], + }, + { type: 'text', text: 'after the tool result' }, + ], + }, + } satisfies Message + const original = structuredClone(message) + const stripped = stripImagesFromMessages([message])[0] as typeof message + + expect(stripped).toEqual({ + ...original, + message: { + ...original.message, + content: [ + original.message.content[0], + { + type: 'tool_result', + tool_use_id: 'toolu_media', + is_error: true, + content: [ + { type: 'text', text: 'tool text' }, + { type: 'text', text: '[image]' }, + { type: 'text', text: '[document]' }, + { type: 'text', text: 'trailing tool text' }, + ], + }, + ...original.message.content.slice(2), + ], + }, + }) + expect(message).toEqual(original) + for (const index of [0, 2, 3, 4, 5]) { + expect(stripped.message.content[index]).toBe(message.message.content[index]) + } + + const unchanged = { + ...message, + message: { ...message.message, content: message.message.content.slice(2) }, + } + expect(stripImagesFromMessages([unchanged])[0]).toBe(unchanged) + }) +}) + describe('compactConversation provider gate', () => { test('attributes cache-sharing timeout aborts to the compact fork', async () => { const originalTrace = process.env.OPENCLAUDE_INTERRUPT_TRACE diff --git a/src/services/extractMemories/extractMemories.abort.test.ts b/src/services/extractMemories/extractMemories.abort.test.ts index 86b38c435..2b8c00343 100644 --- a/src/services/extractMemories/extractMemories.abort.test.ts +++ b/src/services/extractMemories/extractMemories.abort.test.ts @@ -42,6 +42,64 @@ function createContext(messageUuids: string | string[]): any { } } +function registerExtractMemoriesStubs({ + runForkedAgent, + debugLog, + events, +}: { + runForkedAgent: ReturnType + debugLog: ReturnType void>> + events: string[] +}) { + mock.module('../../bootstrap/state.js', () => ({ + ...realBootstrapState, + getIsRemoteMode: () => false, + })) + mock.module('../../memdir/memoryScan.js', () => ({ + ...realMemoryScan, + formatMemoryManifest: () => 'No memories yet.', + scanMemoryFiles: async () => [], + })) + mock.module('../../memdir/paths.js', () => ({ + ...realPaths, + getAutoMemPath: () => '/tmp/memory-extraction-test', + isAutoMemoryEnabled: () => true, + isAutoMemPath: () => false, + })) + mock.module('../../utils/governancePolicy.js', () => ({ + ...realGovernance, + isMemoryWriteApprovalRequired: () => false, + })) + mock.module('../../utils/debug.js', () => ({ + ...realDebug, + logForDebugging: debugLog, + })) + mock.module('../../utils/forkedAgent.js', () => ({ + ...realForkedAgent, + createCacheSafeParams: (context: any) => ({ + systemPrompt: context.systemPrompt, + userContext: context.userContext, + systemContext: context.systemContext, + toolUseContext: context.toolUseContext, + forkContextMessages: context.messages, + }), + runForkedAgent, + })) + mock.module('../analytics/growthbook.js', () => ({ + ...realGrowthbook, + getFeatureValue_CACHED_MAY_BE_STALE: ( + key: string, + defaultValue: unknown, + ) => (key === 'tengu_passport_quail' ? true : defaultValue), + })) + mock.module('../analytics/index.js', () => ({ + ...realAnalytics, + logEvent: (name: string) => { + events.push(name) + }, + })) +} + describe('extractMemories cancellation', () => { beforeEach(async () => { await acquireSharedMutationLock('extractMemories.abort.test.ts') @@ -94,53 +152,7 @@ describe('extractMemories cancellation', () => { }) }) - mock.module('../../bootstrap/state.js', () => ({ - ...realBootstrapState, - getIsRemoteMode: () => false, - })) - mock.module('../../memdir/memoryScan.js', () => ({ - ...realMemoryScan, - formatMemoryManifest: () => 'No memories yet.', - scanMemoryFiles: async () => [], - })) - mock.module('../../memdir/paths.js', () => ({ - ...realPaths, - getAutoMemPath: () => '/tmp/memory-extraction-test', - isAutoMemoryEnabled: () => true, - isAutoMemPath: () => false, - })) - mock.module('../../utils/governancePolicy.js', () => ({ - ...realGovernance, - isMemoryWriteApprovalRequired: () => false, - })) - mock.module('../../utils/debug.js', () => ({ - ...realDebug, - logForDebugging: debugLog, - })) - mock.module('../../utils/forkedAgent.js', () => ({ - ...realForkedAgent, - createCacheSafeParams: (context: any) => ({ - systemPrompt: context.systemPrompt, - userContext: context.userContext, - systemContext: context.systemContext, - toolUseContext: context.toolUseContext, - forkContextMessages: context.messages, - }), - runForkedAgent, - })) - mock.module('../analytics/growthbook.js', () => ({ - ...realGrowthbook, - getFeatureValue_CACHED_MAY_BE_STALE: ( - key: string, - defaultValue: unknown, - ) => (key === 'tengu_passport_quail' ? true : defaultValue), - })) - mock.module('../analytics/index.js', () => ({ - ...realAnalytics, - logEvent: (name: string) => { - events.push(name) - }, - })) + registerExtractMemoriesStubs({ runForkedAgent, debugLog, events }) const { executeExtractMemories, initExtractMemories } = await import( `./extractMemories.js?ts=${Date.now()}-${Math.random()}` @@ -202,53 +214,7 @@ describe('extractMemories cancellation', () => { }) }) - mock.module('../../bootstrap/state.js', () => ({ - ...realBootstrapState, - getIsRemoteMode: () => false, - })) - mock.module('../../memdir/memoryScan.js', () => ({ - ...realMemoryScan, - formatMemoryManifest: () => 'No memories yet.', - scanMemoryFiles: async () => [], - })) - mock.module('../../memdir/paths.js', () => ({ - ...realPaths, - getAutoMemPath: () => '/tmp/memory-extraction-test', - isAutoMemoryEnabled: () => true, - isAutoMemPath: () => false, - })) - mock.module('../../utils/governancePolicy.js', () => ({ - ...realGovernance, - isMemoryWriteApprovalRequired: () => false, - })) - mock.module('../../utils/debug.js', () => ({ - ...realDebug, - logForDebugging: debugLog, - })) - mock.module('../../utils/forkedAgent.js', () => ({ - ...realForkedAgent, - createCacheSafeParams: (context: any) => ({ - systemPrompt: context.systemPrompt, - userContext: context.userContext, - systemContext: context.systemContext, - toolUseContext: context.toolUseContext, - forkContextMessages: context.messages, - }), - runForkedAgent, - })) - mock.module('../analytics/growthbook.js', () => ({ - ...realGrowthbook, - getFeatureValue_CACHED_MAY_BE_STALE: ( - key: string, - defaultValue: unknown, - ) => (key === 'tengu_passport_quail' ? true : defaultValue), - })) - mock.module('../analytics/index.js', () => ({ - ...realAnalytics, - logEvent: (name: string) => { - events.push(name) - }, - })) + registerExtractMemoriesStubs({ runForkedAgent, debugLog, events }) const { executeExtractMemories, initExtractMemories } = await import( `./extractMemories.js?ts=${Date.now()}-${Math.random()}` @@ -307,53 +273,7 @@ describe('extractMemories cancellation', () => { }) }) - mock.module('../../bootstrap/state.js', () => ({ - ...realBootstrapState, - getIsRemoteMode: () => false, - })) - mock.module('../../memdir/memoryScan.js', () => ({ - ...realMemoryScan, - formatMemoryManifest: () => 'No memories yet.', - scanMemoryFiles: async () => [], - })) - mock.module('../../memdir/paths.js', () => ({ - ...realPaths, - getAutoMemPath: () => '/tmp/memory-extraction-test', - isAutoMemoryEnabled: () => true, - isAutoMemPath: () => false, - })) - mock.module('../../utils/governancePolicy.js', () => ({ - ...realGovernance, - isMemoryWriteApprovalRequired: () => false, - })) - mock.module('../../utils/debug.js', () => ({ - ...realDebug, - logForDebugging: debugLog, - })) - mock.module('../../utils/forkedAgent.js', () => ({ - ...realForkedAgent, - createCacheSafeParams: (context: any) => ({ - systemPrompt: context.systemPrompt, - userContext: context.userContext, - systemContext: context.systemContext, - toolUseContext: context.toolUseContext, - forkContextMessages: context.messages, - }), - runForkedAgent, - })) - mock.module('../analytics/growthbook.js', () => ({ - ...realGrowthbook, - getFeatureValue_CACHED_MAY_BE_STALE: ( - key: string, - defaultValue: unknown, - ) => (key === 'tengu_passport_quail' ? true : defaultValue), - })) - mock.module('../analytics/index.js', () => ({ - ...realAnalytics, - logEvent: (name: string) => { - events.push(name) - }, - })) + registerExtractMemoriesStubs({ runForkedAgent, debugLog, events }) const { executeExtractMemories, initExtractMemories } = await import( `./extractMemories.js?ts=${Date.now()}-${Math.random()}`