From 50973752bac0eabea57e76e5d0c64b460fd525a7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 14:50:39 +0700 Subject: [PATCH] test(hooks): add image analyzer regression coverage --- lib/hooks/image-analyzer-transformer.cjs | 66 ++++-- tests/integration/image-analyzer-hook.test.ts | 203 ++++++++++++++++++ 2 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 tests/integration/image-analyzer-hook.test.ts diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 8fa3686c..ba86f4d3 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -12,7 +12,7 @@ * CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex) * CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60) * CCS_PROFILE_TYPE - Profile type (account/default skip) - * ANTHROPIC_MODEL - Fallback model if provider not in mapping + * ANTHROPIC_MODEL - Chat model env (not used for image analysis fallback) * CCS_DEBUG=1 - Enable debug output * * Exit codes: @@ -147,6 +147,46 @@ function parseProviderModels(envValue) { return result; } +/** + * Extract concatenated text content from CLIProxy response blocks. + * Skips thinking and other non-text blocks. + */ +function extractTextContent(response) { + if (!response || !Array.isArray(response.content)) { + return null; + } + + const textBlocks = response.content + .filter((block) => block && block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .filter((text) => text.trim()); + + if (textBlocks.length === 0) { + return null; + } + + return textBlocks.join('\n\n'); +} + +/** + * Parse raw CLIProxy response body and extract text content. + */ +function parseCliProxyResponse(data) { + let response; + try { + response = JSON.parse(data); + } catch (err) { + throw new Error(`Failed to parse response: ${err.message}`); + } + + const text = extractTextContent(response); + if (!text) { + throw new Error('No text content in response'); + } + + return text; +} + /** * Get model for current provider from provider_models mapping * Returns primary model only (for display/logging) @@ -160,13 +200,14 @@ function getModelForProvider() { /** * Get list of models to try in order: * 1. provider_models[current_provider] (if exists) - * 2. DEFAULT_MODEL - * 3. ANTHROPIC_MODEL from profile (if different and exists) + * 2. DEFAULT_MODEL when no provider-specific vision model is configured + * + * ANTHROPIC_MODEL is intentionally ignored here because the chat model may + * not be vision-capable on the current provider route. */ function getModelsToTry() { const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); - const anthropicModel = process.env.ANTHROPIC_MODEL; const models = []; const seen = new Set(); @@ -185,9 +226,6 @@ function getModelsToTry() { seen.add(DEFAULT_MODEL); } - // 3. ANTHROPIC_MODEL fallback — skip, the main chat model is not - // necessarily vision-capable and adds unnecessary retry latency - return models; } @@ -381,20 +419,10 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { } try { - const response = JSON.parse(data); - // Find the first text block — skip thinking blocks which use - // .thinking instead of .text (e.g. codex models with thinking enabled) - const textBlock = response.content?.find(b => b.type === 'text' && b.text); - const text = textBlock?.text; - - if (!text) { - reject(new Error('No text content in response')); - return; - } - + const text = parseCliProxyResponse(data); resolve(text); } catch (err) { - reject(new Error(`Failed to parse response: ${err.message}`)); + reject(err); } }); } diff --git a/tests/integration/image-analyzer-hook.test.ts b/tests/integration/image-analyzer-hook.test.ts new file mode 100644 index 00000000..593a7cae --- /dev/null +++ b/tests/integration/image-analyzer-hook.test.ts @@ -0,0 +1,203 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; + +const HOOK_PATH = path.join(__dirname, '../../lib/hooks/image-analyzer-transformer.cjs'); +const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-hook-it-')); +const TEST_PNG_PATH = path.join(TEST_DIR, 'thinking-block-test.png'); +const CLIPROXY_API_KEY = 'test-api-key-12345'; + +interface HookResult { + code: number; + stdout: string; + stderr: string; +} + +interface MockRequest { + method: string; + path: string; + body: unknown; +} + +interface MockResponse { + statusCode: number; + body: unknown; +} + +let mockServer: http.Server | null = null; +let mockPort = 0; +let requests: MockRequest[] = []; +let queuedResponses: MockResponse[] = []; + +function createTestPng(filepath: string): void { + const png = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, + 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, + 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + fs.writeFileSync(filepath, png); +} + +function enqueueResponses(...responses: MockResponse[]): void { + queuedResponses = responses; +} + +function invokeHook(env: Record = {}): Promise { + return new Promise((resolve, reject) => { + const child = spawn('node', [HOOK_PATH], { + env: { + ...process.env, + CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, + CCS_CLIPROXY_PORT: String(mockPort), + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'codex', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash', + ...env, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('Hook timed out')); + }, 10000); + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? -1, stdout, stderr }); + }); + + child.stdin.end( + JSON.stringify({ + tool_name: 'Read', + tool_input: { file_path: TEST_PNG_PATH }, + }) + ); + }); +} + +beforeAll(async () => { + createTestPng(TEST_PNG_PATH); + + mockServer = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + + req.on('end', () => { + requests.push({ + method: req.method || 'GET', + path: req.url || '/', + body: body ? JSON.parse(body) : null, + }); + + const nextResponse = queuedResponses.shift() || { + statusCode: 200, + body: { content: [{ type: 'text', text: 'default description' }] }, + }; + + res.writeHead(nextResponse.statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(nextResponse.body)); + }); + }); + + await new Promise((resolve, reject) => { + mockServer?.once('error', reject); + mockServer?.listen(0, '127.0.0.1', () => { + const address = mockServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock server port')); + return; + } + mockPort = address.port; + resolve(); + }); + }); +}); + +beforeEach(() => { + requests = []; + enqueueResponses({ + statusCode: 200, + body: { content: [{ type: 'text', text: 'default description' }] }, + }); +}); + +afterAll(async () => { + await new Promise((resolve) => mockServer?.close(() => resolve())); + fs.rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +describe('image analyzer hook regression coverage', () => { + it('uses the first text block after thinking blocks', async () => { + enqueueResponses({ + statusCode: 200, + body: { + content: [ + { type: 'thinking', thinking: 'internal reasoning' }, + { type: 'text', text: 'blue square with white border' }, + ], + }, + }); + + const result = await invokeHook(); + + expect(result.code).toBe(2); + expect(requests).toHaveLength(1); + const output = JSON.parse(result.stdout); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain( + 'blue square with white border' + ); + }); + + it('does not retry onto a cross-provider default model when a provider-specific model is set', async () => { + enqueueResponses({ + statusCode: 500, + body: { error: { message: 'mock failure' } }, + }); + + const result = await invokeHook(); + + expect(result.code).toBe(2); + expect(requests).toHaveLength(1); + expect((requests[0].body as { model: string }).model).toBe('gpt-5.1-codex-mini'); + }); + + it('skips analysis before contacting CLIProxy when the current provider has no mapped vision model', async () => { + const result = await invokeHook({ + CCS_CURRENT_PROVIDER: 'unknown-provider', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + }); + + expect(result.code).toBe(0); + expect(requests).toHaveLength(0); + }); +});