mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +00:00
fix: normalize websearch hook output for local providers
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user