feat: 工具层及 mcp 大重构 (#252)

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-13 09:52:05 +08:00
committed by GitHub
parent bbb8b613a9
commit 2fb1c9dcd8
559 changed files with 9346 additions and 1837 deletions

View File

@@ -0,0 +1,59 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
let isFirstPartyBaseUrl = true
// Only mock the external dependency that controls adapter selection
mock.module('src/utils/model/providers.js', () => ({
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl,
}))
const { createAdapter } = await import('../adapters/index')
const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER
afterEach(() => {
isFirstPartyBaseUrl = true
if (originalWebSearchAdapter === undefined) {
delete process.env.WEB_SEARCH_ADAPTER
} else {
process.env.WEB_SEARCH_ADAPTER = originalWebSearchAdapter
}
})
describe('createAdapter', () => {
test('reuses the same instance when the selected backend does not change', () => {
process.env.WEB_SEARCH_ADAPTER = 'brave'
const firstAdapter = createAdapter()
const secondAdapter = createAdapter()
expect(firstAdapter).toBe(secondAdapter)
expect(firstAdapter.constructor.name).toBe('BraveSearchAdapter')
})
test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => {
process.env.WEB_SEARCH_ADAPTER = 'brave'
const braveAdapter = createAdapter()
process.env.WEB_SEARCH_ADAPTER = 'bing'
const bingAdapter = createAdapter()
expect(bingAdapter).not.toBe(braveAdapter)
expect(bingAdapter.constructor.name).toBe('BingSearchAdapter')
})
test('selects the API adapter for first-party Anthropic URLs', () => {
delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = true
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
})
test('selects the Bing adapter for third-party Anthropic base URLs', () => {
delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = false
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
})
})

View File

@@ -0,0 +1,82 @@
/**
* Integration test for BingSearchAdapter — hits the real Bing search.
*
* Usage:
* bun run src/tools/WebSearchTool/__tests__/bingAdapter.integration.ts
*
* Optional env vars:
* BING_QUERY — search query (default: "Claude AI Anthropic")
*/
// Provide MACRO globals needed by the codebase when running outside dev mode
if (!globalThis.MACRO) {
globalThis.MACRO = { VERSION: '0.0.0-test', BUILD_TIME: '0' } as any
}
import { BingSearchAdapter, extractBingResults } from '../adapters/bingAdapter'
const query = process.env.BING_QUERY || 'Claude AI Anthropic'
async function main() {
console.log(`\n🔍 Searching Bing for: "${query}"\n`)
const adapter = new BingSearchAdapter()
const startTime = Date.now()
const results = await adapter.search(query, {
onProgress: (p) => {
if (p.type === 'query_update') {
console.log(` → Query sent: ${p.query}`)
}
if (p.type === 'search_results_received') {
console.log(` → Received ${p.resultCount} results`)
}
},
})
const elapsed = Date.now() - startTime
console.log(`\n✅ Done in ${elapsed}ms — ${results.length} result(s)\n`)
if (results.length === 0) {
console.log('⚠️ No results returned. Possible causes:')
console.log(' - Bing returned a CAPTCHA or rate-limited the request')
console.log(' - Network/firewall issue')
console.log(' - Bing HTML structure changed')
console.log(' - Anti-bot detection triggered\n')
process.exit(1)
}
for (const [i, r] of results.entries()) {
console.log(` ${i + 1}. ${r.title}`)
console.log(` ${r.url}`)
if (r.snippet) {
const snippet = r.snippet.replace(/\n/g, ' ')
console.log(` ${snippet.slice(0, 150)}${snippet.length > 150 ? '…' : ''}`)
}
console.log()
}
// Validate result structure
let passed = true
for (const [i, r] of results.entries()) {
if (!r.title || typeof r.title !== 'string') {
console.error(`❌ Result ${i + 1}: missing or non-string title`, r)
passed = false
}
if (!r.url || !r.url.startsWith('http')) {
console.error(`❌ Result ${i + 1}: missing or non-http url`, r)
passed = false
}
}
if (passed) {
console.log('✅ All results have valid structure.\n')
} else {
process.exit(1)
}
}
main().catch((e) => {
console.error('❌ Fatal error:', e)
process.exit(1)
})

View File

@@ -0,0 +1,499 @@
import { describe, expect, mock, test } from 'bun:test'
import { extractBingResults, decodeHtmlEntities } from '../adapters/bingAdapter'
// ---------------------------------------------------------------------------
// decodeHtmlEntities
// ---------------------------------------------------------------------------
describe('decodeHtmlEntities', () => {
test('decodes common named entities', () => {
expect(decodeHtmlEntities('&amp; &lt; &gt;')).toBe('& < >')
})
test('decodes quote entities', () => {
expect(decodeHtmlEntities('&quot;hello&quot;')).toBe('"hello"')
})
test('decodes numeric and hex apostrophe entities', () => {
expect(decodeHtmlEntities('&#39;it&#x27;s')).toBe("'it's")
})
test('decodes &nbsp; to non-breaking space (\\u00A0)', () => {
expect(decodeHtmlEntities('a&nbsp;b')).toBe('a\u00A0b')
})
test('returns plain text unchanged', () => {
expect(decodeHtmlEntities('hello world')).toBe('hello world')
})
test('handles empty string', () => {
expect(decodeHtmlEntities('')).toBe('')
})
test('decodes multiple occurrences of the same entity', () => {
expect(decodeHtmlEntities('a&amp;b&amp;c')).toBe('a&b&c')
})
test('handles mixed entities in one string', () => {
expect(decodeHtmlEntities('&lt;a&nbsp;href=&quot;x&quot;&gt;')).toBe('<a\u00A0href="x">')
})
})
// ---------------------------------------------------------------------------
// extractBingResults
// ---------------------------------------------------------------------------
describe('extractBingResults', () => {
test('extracts results from standard Bing HTML', () => {
const html = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://example.com/page1" h="ID=SERP,1">Example Title 1</a></h2>
<div class="b_caption">
<p class="b_lineclamp">First result description</p>
</div>
</li>
<li class="b_algo">
<h2><a href="https://example.com/page2" h="ID=SERP,2">Example Title 2</a></h2>
<div class="b_caption">
<p class="b_lineclamp">Second result description</p>
</div>
</li>
</ol>
`
const results = extractBingResults(html)
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
title: 'Example Title 1',
url: 'https://example.com/page1',
snippet: 'First result description',
})
expect(results[1]).toEqual({
title: 'Example Title 2',
url: 'https://example.com/page2',
snippet: 'Second result description',
})
})
test('returns empty array when no b_algo blocks exist', () => {
const html = `
<ol id="b_results">
<li class="b_ad">Ad result</li>
<li class="b_ans">Answer card</li>
</ol>
`
expect(extractBingResults(html)).toEqual([])
})
test('returns empty array for empty HTML', () => {
expect(extractBingResults('')).toEqual([])
})
test('returns empty array for unrelated HTML', () => {
expect(extractBingResults('<html><body>Hello</body></html>')).toEqual([])
})
test('skips Bing-internal links', () => {
const html = `
<li class="b_algo">
<h2><a href="/search?q=more">More results</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://www.bing.com/videos">Bing Videos</a></h2>
</li>
<li class="b_algo">
<h2><a href="#anchor">Jump link</a></h2>
</li>
`
expect(extractBingResults(html)).toEqual([])
})
test('strips HTML tags from titles', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Result with <strong>bold</strong> and <em>italic</em></a></h2>
</li>
`
const results = extractBingResults(html)
expect(results).toHaveLength(1)
expect(results[0].title).toBe('Result with bold and italic')
})
test('decodes HTML entities in titles', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Tom &amp; Jerry &lt;cartoon&gt;</a></h2>
</li>
`
const results = extractBingResults(html)
expect(results[0].title).toBe('Tom & Jerry <cartoon>')
})
test('extracts snippet from b_lineclamp class', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<p class="b_lineclamp3 b_algo_slug">Lineclamp snippet text here</p>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('Lineclamp snippet text here')
})
test('extracts snippet from b_caption paragraph fallback', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<div class="b_caption">
<p>Caption paragraph text</p>
</div>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('Caption paragraph text')
})
test('extracts snippet from b_caption div fallback', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<div class="b_caption">Direct caption text without p tag</div>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('Direct caption text without p tag')
})
test('returns undefined snippet when no caption exists', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title Only</a></h2>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBeUndefined()
})
test('handles mixed result types and only extracts b_algo', () => {
const html = `
<ol id="b_results">
<li class="b_ad"><h2><a href="https://ad.com">Ad Title</a></h2></li>
<li class="b_algo">
<h2><a href="https://real-result.com">Real Result</a></h2>
<p class="b_lineclamp">A real snippet</p>
</li>
<li class="b_ans"><div>People also ask</div></li>
<li class="b_algo">
<h2><a href="https://another.com">Another Result</a></h2>
</li>
</ol>
`
const results = extractBingResults(html)
expect(results).toHaveLength(2)
expect(results[0].title).toBe('Real Result')
expect(results[1].title).toBe('Another Result')
})
test('skips b_algo blocks without h2 > a structure', () => {
const html = `
<li class="b_algo">
<div>No link here</div>
</li>
<li class="b_algo">
<h2><a href="https://example.com">Valid Result</a></h2>
</li>
`
const results = extractBingResults(html)
expect(results).toHaveLength(1)
expect(results[0].title).toBe('Valid Result')
})
test('handles extra whitespace in h2 > a structure', () => {
const html = `
<li class="b_algo">
<h2>
<a href="https://example.com" h="ID=SERP,1" >
Whitespace Title
</a>
</h2>
</li>
`
const results = extractBingResults(html)
expect(results).toHaveLength(1)
expect(results[0].title).toBe('Whitespace Title')
})
test('handles snippet with HTML entities', () => {
const html = `
<li class="b_algo">
<h2><a href="https://example.com">Title</a></h2>
<p class="b_lineclamp">5 &lt; 10 &amp; 10 &gt; 5</p>
</li>
`
const results = extractBingResults(html)
expect(results[0].snippet).toBe('5 < 10 & 10 > 5')
})
test('handles real-world Bing HTML structure', () => {
const html = `
<ol id="b_results" role="main">
<li class="b_algo" data-id="">
<div class="b_title">
<h2>
<a href="https://docs.python.org/3/tutorial/index.html" target="_blank" h="ID=SERP,5125.1">
Python Tutorial
</a>
</h2>
</div>
<div class="b_caption">
<div class="b_attribution" u="0|5125|4976674477245">
<cite>https://docs.python.org</cite>
</div>
<p class="b_lineclamp3">
Welcome to the Python Tutorial. This tutorial introduces you to the basic concepts and features...
</p>
</div>
</li>
<li class="b_algo">
<h2>
<a href="https://realpython.com/python-guide/" h="ID=SERP,5125.2">
Real Python Guide
</a>
</h2>
<div class="b_caption">
<div class="b_attribution">
<cite>https://realpython.com</cite>
</div>
<p>
The ultimate Python guide for beginners and experts alike.
</p>
</div>
</li>
</ol>
`
const results = extractBingResults(html)
expect(results).toHaveLength(2)
expect(results[0].title).toBe('Python Tutorial')
expect(results[0].url).toBe('https://docs.python.org/3/tutorial/index.html')
expect(results[0].snippet).toContain('Welcome to the Python Tutorial')
expect(results[1].title).toBe('Real Python Guide')
expect(results[1].snippet).toContain('ultimate Python guide')
})
})
// ---------------------------------------------------------------------------
// BingSearchAdapter.search (integration with mocked axios)
// ---------------------------------------------------------------------------
describe('BingSearchAdapter.search', () => {
// Dynamic import so mock.module() takes effect
const createAdapter = async () => {
const { BingSearchAdapter } = await import('../adapters/bingAdapter')
return new BingSearchAdapter()
}
const SAMPLE_HTML = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://example.com/result1">Result One</a></h2>
<p class="b_lineclamp">Snippet one</p>
</li>
<li class="b_algo">
<h2><a href="https://example.com/result2">Result Two</a></h2>
<p class="b_lineclamp">Snippet two</p>
</li>
</ol>
`
test('returns parsed results from fetched HTML', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_HTML })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test query', {})
expect(results).toHaveLength(2)
expect(results[0].title).toBe('Result One')
expect(results[1].title).toBe('Result Two')
})
test('calls onProgress with query_update and search_results_received', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_HTML })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const progressCalls: any[] = []
const onProgress = (p: any) => progressCalls.push(p)
const adapter = await createAdapter()
await adapter.search('test', { onProgress })
expect(progressCalls).toHaveLength(2)
expect(progressCalls[0].type).toBe('query_update')
expect(progressCalls[0].query).toBe('test')
expect(progressCalls[1].type).toBe('search_results_received')
expect(progressCalls[1].resultCount).toBe(2)
})
test('filters results by allowedDomains', async () => {
const mixedHtml = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://allowed.com/a">Allowed Result</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://blocked.com/b">Blocked Result</a></h2>
</li>
</ol>
`
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedHtml })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['allowed.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://allowed.com/a')
})
test('filters results by blockedDomains', async () => {
const mixedHtml = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://good.com/a">Good Result</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://spam.com/b">Spam Result</a></h2>
</li>
</ol>
`
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedHtml })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
blockedDomains: ['spam.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://good.com/a')
})
test('filters subdomains with allowedDomains', async () => {
const html = `
<ol id="b_results">
<li class="b_algo">
<h2><a href="https://docs.example.com/page">Subdomain Result</a></h2>
</li>
<li class="b_algo">
<h2><a href="https://other.com/page">Other Result</a></h2>
</li>
</ol>
`
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: html })),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['example.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://docs.example.com/page')
})
test('throws AbortError when signal is already aborted', async () => {
mock.module('axios', () => ({
default: {
get: mock((_url: string, config: any) => {
if (config?.signal?.aborted) {
const err = new Error('canceled')
;(err as any).__CANCEL__ = true
return Promise.reject(err)
}
return Promise.resolve({ data: SAMPLE_HTML })
}),
isCancel: (e: any) => e?.__CANCEL__ === true,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
const controller = new AbortController()
controller.abort()
const { AbortError } = await import('src/utils/errors')
await expect(
adapter.search('test', { signal: controller.signal }),
).rejects.toThrow(AbortError)
})
test('re-throws non-abort axios errors', async () => {
const networkError = new Error('Network error')
mock.module('axios', () => ({
default: {
get: mock(() => Promise.reject(networkError)),
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
})
test('encodes query parameter in URL', async () => {
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_HTML }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
mock.module('src/utils/http', () => ({
getWebFetchUserAgent: () => 'TestAgent/1.0',
}))
const adapter = await createAdapter()
await adapter.search('hello world & special=chars', {})
const calledUrl = (axiosGet.mock.calls as string[][])[0][0]
expect(calledUrl).toContain('q=hello%20world%20%26%20special%3Dchars')
})
})

View File

@@ -0,0 +1,106 @@
import { describe, expect, test } from 'bun:test'
import { extractBraveResults } from '../adapters/braveAdapter'
describe('extractBraveResults', () => {
test('extracts generic grounding results', () => {
const results = extractBraveResults({
grounding: {
generic: [
{
title: 'Example Title 1',
url: 'https://example.com/page1',
snippets: ['First result description'],
},
{
title: 'Example Title 2',
url: 'https://example.com/page2',
snippets: ['Second result description'],
},
],
},
})
expect(results).toEqual([
{
title: 'Example Title 1',
url: 'https://example.com/page1',
snippet: 'First result description',
},
{
title: 'Example Title 2',
url: 'https://example.com/page2',
snippet: 'Second result description',
},
])
})
test('combines generic, poi, and map grounding results', () => {
const results = extractBraveResults({
grounding: {
generic: [{ title: 'Generic', url: 'https://example.com/generic' }],
poi: { title: 'POI', url: 'https://maps.example.com/poi' },
map: [{ title: 'Map', url: 'https://maps.example.com/map' }],
},
})
expect(results).toEqual([
{ title: 'Generic', url: 'https://example.com/generic', snippet: undefined },
{ title: 'POI', url: 'https://maps.example.com/poi', snippet: undefined },
{ title: 'Map', url: 'https://maps.example.com/map', snippet: undefined },
])
})
test('joins multiple snippets into one summary string', () => {
const results = extractBraveResults({
grounding: {
generic: [
{
title: 'Joined Snippets',
url: 'https://example.com/joined',
snippets: ['First snippet.', 'Second snippet.'],
},
],
},
})
expect(results[0].snippet).toBe('First snippet. Second snippet.')
})
test('skips entries without a title or URL', () => {
const results = extractBraveResults({
grounding: {
generic: [
{ title: 'Missing URL' },
{ url: 'https://example.com/missing-title' },
{ title: 'Valid', url: 'https://example.com/valid' },
],
},
})
expect(results).toEqual([
{ title: 'Valid', url: 'https://example.com/valid', snippet: undefined },
])
})
test('deduplicates repeated URLs across grounding buckets', () => {
const results = extractBraveResults({
grounding: {
generic: [{ title: 'First', url: 'https://example.com/dup' }],
poi: { title: 'Second', url: 'https://example.com/dup' },
map: [{ title: 'Third', url: 'https://example.com/dup' }],
},
})
expect(results).toEqual([
{ title: 'First', url: 'https://example.com/dup', snippet: undefined },
])
})
test('returns empty array when grounding is missing', () => {
expect(extractBraveResults({})).toEqual([])
})
test('returns empty array when grounding arrays are absent', () => {
expect(extractBraveResults({ grounding: {} })).toEqual([])
})
})

View File

@@ -0,0 +1,91 @@
/**
* Integration test for BraveSearchAdapter — hits Brave's LLM context API.
*
* Usage:
* BRAVE_SEARCH_API_KEY=... bun run src/tools/WebSearchTool/__tests__/braveAdapter.integration.ts
*
* Optional env vars:
* BRAVE_QUERY — search query (default: "Claude AI Anthropic")
* BRAVE_API_KEY — fallback key env var
*/
if (!globalThis.MACRO) {
globalThis.MACRO = { VERSION: '0.0.0-test', BUILD_TIME: '0' } as any
}
import { BraveSearchAdapter } from '../adapters/braveAdapter'
const query = process.env.BRAVE_QUERY || 'Claude AI Anthropic'
async function main() {
if (!process.env.BRAVE_SEARCH_API_KEY && !process.env.BRAVE_API_KEY) {
console.error(
'❌ Missing Brave API key. Set BRAVE_SEARCH_API_KEY or BRAVE_API_KEY.',
)
process.exit(1)
}
console.log(`\n🔍 Searching Brave for: "${query}"\n`)
const adapter = new BraveSearchAdapter()
const startTime = Date.now()
const results = await adapter.search(query, {
onProgress: p => {
if (p.type === 'query_update') {
console.log(` → Query sent: ${p.query}`)
}
if (p.type === 'search_results_received') {
console.log(` → Received ${p.resultCount} results`)
}
},
})
const elapsed = Date.now() - startTime
console.log(`\n✅ Done in ${elapsed}ms — ${results.length} result(s)\n`)
if (results.length === 0) {
console.log('⚠️ No results returned. Possible causes:')
console.log(' - Brave returned no grounding data for the query')
console.log(' - Network/firewall issue')
console.log(' - Invalid or rate-limited Brave API key\n')
process.exit(1)
}
for (const [i, r] of results.entries()) {
console.log(` ${i + 1}. ${r.title}`)
console.log(` ${r.url}`)
if (r.snippet) {
const snippet = r.snippet.replace(/\n/g, ' ')
console.log(
` ${snippet.slice(0, 150)}${snippet.length > 150 ? '…' : ''}`,
)
}
console.log()
}
let passed = true
for (const [i, r] of results.entries()) {
if (!r.title || typeof r.title !== 'string') {
console.error(`❌ Result ${i + 1}: missing or non-string title`, r)
passed = false
}
if (!r.url || !r.url.startsWith('http')) {
console.error(`❌ Result ${i + 1}: missing or non-http url`, r)
passed = false
}
}
if (passed) {
console.log('✅ All results have valid structure.\n')
} else {
process.exit(1)
}
}
if (import.meta.main) {
main().catch(e => {
console.error('❌ Fatal error:', e)
process.exit(1)
})
}

View File

@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
const originalBraveSearchApiKey = process.env.BRAVE_SEARCH_API_KEY
const originalBraveApiKey = process.env.BRAVE_API_KEY
describe('BraveSearchAdapter.search', () => {
const createAdapter = async () => {
const { BraveSearchAdapter } = await import('../adapters/braveAdapter')
return new BraveSearchAdapter()
}
const SAMPLE_RESPONSE = {
grounding: {
generic: [
{
title: 'Result One',
url: 'https://example.com/result1',
snippets: ['Snippet one'],
},
{
title: 'Result Two',
url: 'https://example.com/result2',
snippets: ['Snippet two'],
},
],
},
}
beforeEach(() => {
process.env.BRAVE_SEARCH_API_KEY = 'test-brave-key'
delete process.env.BRAVE_API_KEY
})
afterEach(() => {
mock.restore()
if (originalBraveSearchApiKey === undefined) {
delete process.env.BRAVE_SEARCH_API_KEY
} else {
process.env.BRAVE_SEARCH_API_KEY = originalBraveSearchApiKey
}
if (originalBraveApiKey === undefined) {
delete process.env.BRAVE_API_KEY
} else {
process.env.BRAVE_API_KEY = originalBraveApiKey
}
})
test('returns parsed results from Brave LLM context payload', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test query', {})
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
title: 'Result One',
url: 'https://example.com/result1',
snippet: 'Snippet one',
})
expect(results[1].title).toBe('Result Two')
})
test('calls onProgress with query_update and search_results_received', async () => {
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const progressCalls: any[] = []
const onProgress = (p: any) => progressCalls.push(p)
const adapter = await createAdapter()
await adapter.search('test', { onProgress })
expect(progressCalls).toHaveLength(2)
expect(progressCalls[0]).toEqual({
type: 'query_update',
query: 'test',
})
expect(progressCalls[1]).toEqual({
type: 'search_results_received',
resultCount: 2,
query: 'test',
})
})
test('filters results by allowedDomains', async () => {
const mixedResponse = {
grounding: {
generic: [
{ title: 'Allowed', url: 'https://allowed.com/a' },
{ title: 'Blocked', url: 'https://blocked.com/b' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedResponse })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['allowed.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://allowed.com/a')
})
test('filters results by blockedDomains', async () => {
const mixedResponse = {
grounding: {
generic: [
{ title: 'Good', url: 'https://good.com/a' },
{ title: 'Spam', url: 'https://spam.com/b' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: mixedResponse })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
blockedDomains: ['spam.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://good.com/a')
})
test('filters subdomains with allowedDomains', async () => {
const response = {
grounding: {
generic: [
{ title: 'Subdomain', url: 'https://docs.example.com/page' },
{ title: 'Other', url: 'https://other.com/page' },
],
},
}
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: response })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
const results = await adapter.search('test', {
allowedDomains: ['example.com'],
})
expect(results).toHaveLength(1)
expect(results[0].url).toBe('https://docs.example.com/page')
})
test('throws AbortError when signal is already aborted', async () => {
mock.module('axios', () => ({
default: {
get: mock((_url: string, config: any) => {
if (config?.signal?.aborted) {
const err = new Error('canceled')
;(err as any).__CANCEL__ = true
return Promise.reject(err)
}
return Promise.resolve({ data: SAMPLE_RESPONSE })
}),
isCancel: (e: any) => e?.__CANCEL__ === true,
},
}))
const adapter = await createAdapter()
const controller = new AbortController()
controller.abort()
const { AbortError } = await import('src/utils/errors')
await expect(
adapter.search('test', { signal: controller.signal }),
).rejects.toThrow(AbortError)
})
test('re-throws non-abort axios errors', async () => {
const networkError = new Error('Network error')
mock.module('axios', () => ({
default: {
get: mock(() => Promise.reject(networkError)),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow('Network error')
})
test('sends the documented HTTPS endpoint with query params and auth header', async () => {
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('hello world & special=chars', {})
expect(axiosGet.mock.calls).toHaveLength(1)
expect((axiosGet.mock.calls as any[][])[0][0]).toBe(
'https://api.search.brave.com/res/v1/llm/context',
)
expect((axiosGet.mock.calls as any[][])[0][1]).toMatchObject({
params: { q: 'hello world & special=chars' },
headers: {
Accept: 'application/json',
'X-Subscription-Token': 'test-brave-key',
},
})
})
test('accepts BRAVE_API_KEY as a fallback env var', async () => {
delete process.env.BRAVE_SEARCH_API_KEY
process.env.BRAVE_API_KEY = 'fallback-key'
const axiosGet = mock(() => Promise.resolve({ data: SAMPLE_RESPONSE }))
mock.module('axios', () => ({
default: {
get: axiosGet,
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await adapter.search('test', {})
expect((axiosGet.mock.calls as any[][])[0][1].headers).toMatchObject({
'X-Subscription-Token': 'fallback-key',
})
})
test('throws when no Brave API key is configured', async () => {
delete process.env.BRAVE_SEARCH_API_KEY
delete process.env.BRAVE_API_KEY
mock.module('axios', () => ({
default: {
get: mock(() => Promise.resolve({ data: SAMPLE_RESPONSE })),
isCancel: () => false,
},
}))
const adapter = await createAdapter()
await expect(adapter.search('test', {})).rejects.toThrow(
'BraveSearchAdapter requires BRAVE_SEARCH_API_KEY or BRAVE_API_KEY',
)
})
})