fix: normalize websearch hook output for local providers

This commit is contained in:
Tam Nhu Tran
2026-03-30 15:10:30 -04:00
parent 39ba295d5f
commit a5d71e3560
2 changed files with 189 additions and 33 deletions
+44 -30
View File
@@ -183,23 +183,58 @@ function extractDuckDuckGoResults(html, count) {
}
function formatStructuredSearchResults(query, providerName, results) {
const lines = [
'CCS local WebSearch evidence',
`Provider: ${providerName}`,
`Query: "${query}"`,
`Result count: ${results.length}`,
'',
];
if (!results.length) {
return `No search results found for "${query}" via ${providerName}.`;
lines.push('No results found.');
return lines.join('\n');
}
const lines = [`Search results for "${query}" via ${providerName}:`, ''];
for (const [index, result] of results.entries()) {
lines.push(`${index + 1}. ${result.title}`);
lines.push(` ${result.url}`);
lines.push(` URL: ${result.url}`);
if (result.description) {
lines.push(` ${result.description}`);
lines.push(` Snippet: ${result.description}`);
}
lines.push('');
}
lines.push('Use these results to answer the user directly.');
return lines.join('\n');
}
function buildSuccessHookOutput(query, providerName, content) {
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `CCS already retrieved WebSearch results locally via ${providerName}. Use the provided context instead of calling native WebSearch for "${query}".`,
additionalContext: content,
},
};
}
function buildFailureHookOutput(query, errors) {
const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `CCS could not complete local WebSearch for "${query}". Native WebSearch is unavailable for this profile.`,
additionalContext: `CCS local WebSearch failed for "${query}". Attempted providers: ${detail}`,
},
};
}
function emitHookOutput(output) {
console.log(JSON.stringify(output));
process.exit(0);
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -534,34 +569,11 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
}
function outputSuccess(query, content, providerName) {
const output = {
decision: 'block',
reason: `WebSearch handled via ${providerName}`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `[WebSearch Result via ${providerName}]\n\nQuery: "${query}"\n\n${content}`,
},
};
console.log(JSON.stringify(output));
process.exit(2);
emitHookOutput(buildSuccessHookOutput(query, providerName, content));
}
function outputAllFailedMessage(query, errors) {
const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
const output = {
decision: 'block',
reason: 'WebSearch fallback failed',
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: `WebSearch could not be completed for "${query}". ${detail}`,
},
};
console.log(JSON.stringify(output));
process.exit(2);
emitHookOutput(buildFailureHookOutput(query, errors));
}
async function processHook(input) {
@@ -675,6 +687,8 @@ if (require.main === module) {
}
module.exports = {
buildFailureHookOutput,
buildSuccessHookOutput,
extractDuckDuckGoResults,
formatStructuredSearchResults,
tryExaSearch,
+145 -3
View File
@@ -1,6 +1,29 @@
import { describe, expect, it } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
const hookPath = join(process.cwd(), 'lib', 'hooks', 'websearch-transformer.cjs');
type HookOutput = {
hookSpecificOutput: {
additionalContext: string;
hookEventName: string;
permissionDecision: string;
permissionDecisionReason: string;
};
};
const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
buildFailureHookOutput: (
query: string,
errors: Array<{ provider: string; error: string }>
) => HookOutput;
buildSuccessHookOutput: (
query: string,
providerName: string,
content: string
) => HookOutput;
extractDuckDuckGoResults: (html: string, count: number) => Array<{
title: string;
url: string;
@@ -13,6 +36,45 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
) => string;
};
function runHookWithMockedFetch(mode: 'success' | 'failure') {
const tempDir = mkdtempSync(join(tmpdir(), 'websearch-hook-'));
const preloadPath = join(tempDir, 'mock-fetch.cjs');
const html = `
<a class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Farticle">Example title</a>
<a class="result__snippet">Example snippet</a>
`.trim();
const preloadScript =
mode === 'success'
? `global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`
: `global.fetch = async () => ({ ok: false, status: 503, text: async () => 'Service unavailable' });\n`;
writeFileSync(preloadPath, preloadScript, 'utf8');
try {
return spawnSync('node', ['-r', preloadPath, hookPath], {
encoding: 'utf8',
input: JSON.stringify({
tool_name: 'WebSearch',
tool_input: { query: 'btc price' },
}),
env: {
...process.env,
CCS_WEBSEARCH_ENABLED: '1',
CCS_WEBSEARCH_SKIP: '0',
CCS_WEBSEARCH_BRAVE: '0',
CCS_WEBSEARCH_DUCKDUCKGO: '1',
CCS_WEBSEARCH_EXA: '0',
CCS_WEBSEARCH_GEMINI: '0',
CCS_WEBSEARCH_GROK: '0',
CCS_WEBSEARCH_OPENCODE: '0',
CCS_WEBSEARCH_TAVILY: '0',
},
});
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
}
describe('websearch-transformer hook helpers', () => {
it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => {
const html = `
@@ -46,9 +108,89 @@ describe('websearch-transformer hook helpers', () => {
},
]);
expect(formatted).toContain('Search results for "ccs websearch" via DuckDuckGo');
expect(formatted).toContain('CCS local WebSearch evidence');
expect(formatted).toContain('Provider: DuckDuckGo');
expect(formatted).toContain('Query: "ccs websearch"');
expect(formatted).toContain('Result count: 1');
expect(formatted).toContain('1. Result title');
expect(formatted).toContain('https://example.com');
expect(formatted).toContain('Result snippet');
expect(formatted).toContain('URL: https://example.com');
expect(formatted).toContain('Snippet: Result snippet');
expect(formatted).not.toContain('Use these results to answer the user directly.');
});
it('builds a structured success hook output with short deny reason and additional context', () => {
const output = hook.buildSuccessHookOutput(
'btc price',
'Exa',
'CCS local WebSearch evidence\nProvider: Exa'
);
expect(output.hookSpecificOutput).toEqual({
additionalContext: 'CCS local WebSearch evidence\nProvider: Exa',
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason:
'CCS already retrieved WebSearch results locally via Exa. Use the provided context instead of calling native WebSearch for "btc price".',
});
expect(output).not.toHaveProperty('decision');
expect(output).not.toHaveProperty('reason');
expect(output).not.toHaveProperty('additionalContext');
});
it('builds a concise failure hook output with provider failure details in additional context', () => {
const output = hook.buildFailureHookOutput('btc price', [
{ provider: 'Exa', error: 'Exa timed out' },
{ provider: 'DuckDuckGo', error: 'DuckDuckGo returned 503' },
]);
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toBe(
'CCS could not complete local WebSearch for "btc price". Native WebSearch is unavailable for this profile.'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'Attempted providers: Exa: Exa timed out'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'DuckDuckGo: DuckDuckGo returned 503'
);
});
it('emits runtime success output with additionalContext nested under hookSpecificOutput', () => {
const result = runHookWithMockedFetch('success');
expect(result.status).toBe(0);
expect(result.stderr.trim()).toBe('');
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse');
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.additionalContext).toContain(
'CCS local WebSearch evidence'
);
expect(output.hookSpecificOutput.additionalContext).toContain('Provider: DuckDuckGo');
expect(output.hookSpecificOutput.additionalContext).toContain(
'URL: https://example.com/article'
);
expect(output).not.toHaveProperty('additionalContext');
});
it('emits runtime failure output with attempted provider details nested under hookSpecificOutput', () => {
const result = runHookWithMockedFetch('failure');
expect(result.status).toBe(0);
expect(result.stderr.trim()).toBe('');
const output = JSON.parse(result.stdout.trim()) as HookOutput;
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain(
'Native WebSearch is unavailable for this profile.'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'CCS local WebSearch failed for "btc price".'
);
expect(output.hookSpecificOutput.additionalContext).toContain(
'Attempted providers: DuckDuckGo: DuckDuckGo returned 503'
);
expect(output).not.toHaveProperty('additionalContext');
});
});