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'); }); });