diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs
index 0e09d48f..95311502 100644
--- a/lib/hooks/websearch-transformer.cjs
+++ b/lib/hooks/websearch-transformer.cjs
@@ -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,
diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts
index ee91863f..e731e197 100644
--- a/tests/unit/hooks/websearch-transformer.test.ts
+++ b/tests/unit/hooks/websearch-transformer.test.ts
@@ -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 = `
+ Example title
+ Example snippet
+ `.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');
});
});