mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
test(hooks): add image analyzer regression coverage
This commit is contained in:
1 parent
0060c77c25
commit
50973752ba
2 files changed
+250
-19
No files matched your search
@@ -12,7 +12,7 @@
|
|||||||
* CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex)
|
* CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex)
|
||||||
* CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
|
* CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
|
||||||
* CCS_PROFILE_TYPE - Profile type (account/default skip)
|
* 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
|
* CCS_DEBUG=1 - Enable debug output
|
||||||
*
|
*
|
||||||
* Exit codes:
|
* Exit codes:
|
||||||
@@ -147,6 +147,46 @@ function parseProviderModels(envValue) {
|
|||||||
return result;
|
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
|
* Get model for current provider from provider_models mapping
|
||||||
* Returns primary model only (for display/logging)
|
* Returns primary model only (for display/logging)
|
||||||
@@ -160,13 +200,14 @@ function getModelForProvider() {
|
|||||||
/**
|
/**
|
||||||
* Get list of models to try in order:
|
* Get list of models to try in order:
|
||||||
* 1. provider_models[current_provider] (if exists)
|
* 1. provider_models[current_provider] (if exists)
|
||||||
* 2. DEFAULT_MODEL
|
* 2. DEFAULT_MODEL when no provider-specific vision model is configured
|
||||||
* 3. ANTHROPIC_MODEL from profile (if different and exists)
|
*
|
||||||
|
* ANTHROPIC_MODEL is intentionally ignored here because the chat model may
|
||||||
|
* not be vision-capable on the current provider route.
|
||||||
*/
|
*/
|
||||||
function getModelsToTry() {
|
function getModelsToTry() {
|
||||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
||||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||||
const anthropicModel = process.env.ANTHROPIC_MODEL;
|
|
||||||
|
|
||||||
const models = [];
|
const models = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -185,9 +226,6 @@ function getModelsToTry() {
|
|||||||
seen.add(DEFAULT_MODEL);
|
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;
|
return models;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,20 +419,10 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(data);
|
const text = parseCliProxyResponse(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(text);
|
resolve(text);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(new Error(`Failed to parse response: ${err.message}`));
|
reject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string> = {}): Promise<HookResult> {
|
||||||
|
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<void>((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<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user