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) { function formatStructuredSearchResults(query, providerName, results) {
const lines = [
'CCS local WebSearch evidence',
`Provider: ${providerName}`,
`Query: "${query}"`,
`Result count: ${results.length}`,
'',
];
if (!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()) { for (const [index, result] of results.entries()) {
lines.push(`${index + 1}. ${result.title}`); lines.push(`${index + 1}. ${result.title}`);
lines.push(` ${result.url}`); lines.push(` URL: ${result.url}`);
if (result.description) { if (result.description) {
lines.push(` ${result.description}`); lines.push(` Snippet: ${result.description}`);
} }
lines.push(''); lines.push('');
} }
lines.push('Use these results to answer the user directly.');
return lines.join('\n'); 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) { async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -534,34 +569,11 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
} }
function outputSuccess(query, content, providerName) { function outputSuccess(query, content, providerName) {
const output = { emitHookOutput(buildSuccessHookOutput(query, providerName, content));
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);
} }
function outputAllFailedMessage(query, errors) { function outputAllFailedMessage(query, errors) {
const detail = errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | '); emitHookOutput(buildFailureHookOutput(query, errors));
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);
} }
async function processHook(input) { async function processHook(input) {
@@ -675,6 +687,8 @@ if (require.main === module) {
} }
module.exports = { module.exports = {
buildFailureHookOutput,
buildSuccessHookOutput,
extractDuckDuckGoResults, extractDuckDuckGoResults,
formatStructuredSearchResults, formatStructuredSearchResults,
tryExaSearch, tryExaSearch,
+145 -3
View File
@@ -1,6 +1,29 @@
import { describe, expect, it } from 'bun:test'; 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 { 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<{ extractDuckDuckGoResults: (html: string, count: number) => Array<{
title: string; title: string;
url: string; url: string;
@@ -13,6 +36,45 @@ const hook = require('../../../lib/hooks/websearch-transformer.cjs') as {
) => string; ) => 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', () => { describe('websearch-transformer hook helpers', () => {
it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => { it('extracts DuckDuckGo results and unwraps uddg redirect URLs', () => {
const html = ` 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('1. Result title');
expect(formatted).toContain('https://example.com'); expect(formatted).toContain('URL: https://example.com');
expect(formatted).toContain('Result snippet'); 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');
}); });
}); });