Skip to content

Commit 474f764

Browse files
committed
use advanced exa search, highlights and parallel queries
1 parent d98661a commit 474f764

4 files changed

Lines changed: 269 additions & 99 deletions

File tree

packages/opencode/src/cli/cmd/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ function codesearch(info: ToolProps<typeof CodeSearchTool>) {
162162
function websearch(info: ToolProps<typeof WebSearchTool>) {
163163
inline({
164164
icon: "◈",
165-
title: `Exa Web Search "${info.input.query}"`,
165+
title: `Exa Web Search (${info.input.queries.length} ${info.input.queries.length === 1 ? "query" : "queries"})`,
166166
})
167167
}
168168

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, test, expect, beforeAll } from "bun:test"
2+
import z from "zod"
3+
4+
const API_CONFIG = {
5+
BASE_URL: "https://mcp.exa.ai",
6+
ENDPOINTS: { SEARCH: "/mcp" },
7+
NUM_RESULTS_PER_QUERY: 5,
8+
} as const
9+
10+
interface McpSearchRequest {
11+
jsonrpc: string
12+
id: number
13+
method: string
14+
params: {
15+
name: string
16+
arguments: {
17+
query: string
18+
numResults: number
19+
livecrawl: "fallback"
20+
type: "auto"
21+
enableHighlights: boolean
22+
highlightsPerUrl: number
23+
}
24+
}
25+
}
26+
27+
function buildSearchRequest(query: string, id: number): McpSearchRequest {
28+
return {
29+
jsonrpc: "2.0",
30+
id,
31+
method: "tools/call",
32+
params: {
33+
name: "web_search_advanced_exa",
34+
arguments: {
35+
query,
36+
type: "auto",
37+
numResults: API_CONFIG.NUM_RESULTS_PER_QUERY,
38+
livecrawl: "fallback",
39+
enableHighlights: true,
40+
highlightsPerUrl: 2,
41+
},
42+
},
43+
}
44+
}
45+
46+
interface McpSearchResponse {
47+
jsonrpc: string
48+
result: {
49+
content: Array<{ type: string; text: string }>
50+
}
51+
}
52+
53+
function parseSearchResponse(responseText: string): string | undefined {
54+
const lines = responseText.split("\n")
55+
for (const line of lines) {
56+
if (line.startsWith("data: ")) {
57+
const data: McpSearchResponse = JSON.parse(line.substring(6))
58+
if (data.result?.content?.length > 0) {
59+
return data.result.content[0].text
60+
}
61+
}
62+
}
63+
return undefined
64+
}
65+
66+
async function fetchSearch(request: McpSearchRequest, searchUrl: string, signal?: AbortSignal): Promise<string> {
67+
const response = await fetch(searchUrl, {
68+
method: "POST",
69+
headers: {
70+
accept: "application/json, text/event-stream",
71+
"content-type": "application/json",
72+
},
73+
body: JSON.stringify(request),
74+
signal,
75+
})
76+
77+
if (!response.ok) {
78+
const errorText = await response.text()
79+
throw new Error(`Search error (${response.status}): ${errorText}`)
80+
}
81+
82+
const responseText = await response.text()
83+
return parseSearchResponse(responseText) ?? "No results found."
84+
}
85+
86+
const queriesSchema = z.array(z.string()).min(1).max(5)
87+
88+
describe("websearch", () => {
89+
let searchUrl: string
90+
91+
beforeAll(() => {
92+
const exaKey = process.env.EXA_API_KEY
93+
if (!exaKey) throw new Error("EXA_API_KEY must be set")
94+
searchUrl = `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}?exaApiKey=${exaKey}&tools=web_search_advanced_exa`
95+
})
96+
97+
test(
98+
"single query returns non-empty results",
99+
async () => {
100+
const request = buildSearchRequest("capital of France", 0)
101+
const result = await fetchSearch(request, searchUrl)
102+
expect(result).toBeTruthy()
103+
expect(result).not.toBe("No results found.")
104+
expect(result.length).toBeGreaterThan(50)
105+
},
106+
{ timeout: 30000 },
107+
)
108+
109+
test(
110+
"multiple queries return results for each",
111+
async () => {
112+
const queries = ["capital of France", "population of Japan"]
113+
const results = await Promise.all(
114+
queries.map((query, i) => {
115+
const request = buildSearchRequest(query, i)
116+
return fetchSearch(request, searchUrl)
117+
}),
118+
)
119+
120+
expect(results).toHaveLength(2)
121+
for (const result of results) {
122+
expect(result).toBeTruthy()
123+
expect(result).not.toBe("No results found.")
124+
}
125+
},
126+
{ timeout: 30000 },
127+
)
128+
129+
test(
130+
"5 queries works",
131+
async () => {
132+
const queries = ["TypeScript generics", "Rust ownership", "Python asyncio", "Go goroutines", "Java streams"]
133+
const results = await Promise.all(
134+
queries.map((query, i) => {
135+
const request = buildSearchRequest(query, i)
136+
return fetchSearch(request, searchUrl)
137+
}),
138+
)
139+
140+
expect(results).toHaveLength(5)
141+
for (const result of results) {
142+
expect(result).toBeTruthy()
143+
}
144+
},
145+
{ timeout: 45000 },
146+
)
147+
148+
test("schema rejects >5 queries", () => {
149+
const sixQueries = ["a", "b", "c", "d", "e", "f"]
150+
const parsed = queriesSchema.safeParse(sixQueries)
151+
expect(parsed.success).toBe(false)
152+
})
153+
154+
test("schema rejects empty array", () => {
155+
const parsed = queriesSchema.safeParse([])
156+
expect(parsed.success).toBe(false)
157+
})
158+
159+
test(
160+
"highlights are present in results",
161+
async () => {
162+
const request = buildSearchRequest("climate change effects 2025", 0)
163+
const result = await fetchSearch(request, searchUrl)
164+
// Advanced search with enableHighlights should return content with highlights
165+
// Highlights are typically marked with <highlight> tags or contain substantial text excerpts
166+
expect(result.length).toBeGreaterThan(200)
167+
// The result should contain URLs (indicating structured search results)
168+
expect(result).toMatch(/https?:\/\//)
169+
},
170+
{ timeout: 30000 },
171+
)
172+
})

0 commit comments

Comments
 (0)