Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ export function WebSearchToolRenderer({
</div>
)}

{results.length > 0 &&
results.slice(0, 4).map((result, index) => (
{results.length > 0 && (
<div className="max-h-80 space-y-3 overflow-y-auto pr-1">
{results.map((result, index) => (
<div
key={`${result.url ?? result.title}-${index}`}
className="rounded-lg border border-border/60 bg-background/30 p-3"
Expand All @@ -54,7 +55,9 @@ export function WebSearchToolRenderer({
</div>
</div>
</div>
))}
))}
</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect } from 'vitest'
import { presentWebSearchExa } from '../web-search-exa'

// Exa returns every result inside a single output text item, with individual
// results separated by a `---` horizontal rule.
const makeEntry = (n: number) =>
[
`Title: Result ${n}`,
`URL: https://example.com/${n}`,
`Published: 2024-01-0${n}`,
`Author: Author ${n}`,
'Highlights:',
`highlight ${n}a`,
`highlight ${n}b`,
].join('\n')

const combinedBlock = (count: number) =>
Array.from({ length: count }, (_, i) => makeEntry(i + 1)).join('\n\n---\n\n')

describe('presentWebSearchExa', () => {
it('renders every result when Exa packs them into one text block', () => {
const presentation = presentWebSearchExa({
input: { query: 'cafe photos' },
output: [{ text: combinedBlock(5) }],
})

expect(presentation.kind).toBe('web_search_exa')
expect(presentation.results).toHaveLength(5)
expect(presentation.subtitle).toBe('5 results')
expect(presentation.results.map((r) => r.url)).toEqual([
'https://example.com/1',
'https://example.com/2',
'https://example.com/3',
'https://example.com/4',
'https://example.com/5',
])
expect(presentation.results[0].title).toBe('Result 1')
expect(presentation.results[0].domain).toBe('example.com')
expect(presentation.results[0].highlights).toEqual([
'highlight 1a',
'highlight 1b',
])
})

it('handles a single result with no separator', () => {
const presentation = presentWebSearchExa({
input: { query: 'q' },
output: [{ text: makeEntry(1) }],
})

expect(presentation.results).toHaveLength(1)
expect(presentation.subtitle).toBe('1 result')
expect(presentation.results[0].title).toBe('Result 1')
})

it('still handles one result per output item (array shape)', () => {
const presentation = presentWebSearchExa({
input: { query: 'q' },
output: [{ text: makeEntry(1) }, { text: makeEntry(2) }],
})

expect(presentation.results).toHaveLength(2)
expect(presentation.subtitle).toBe('2 results')
})

it('returns no results for empty output', () => {
const presentation = presentWebSearchExa({ input: { query: 'q' }, output: [] })
expect(presentation.results).toHaveLength(0)
expect(presentation.subtitle).toBeUndefined()
})
})
14 changes: 13 additions & 1 deletion web-app/src/lib/tools/presenters/web-search-exa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ type SearchResultCard = {
highlights: string[]
}

// Exa concatenates multiple results into a single text block, separated by a
// horizontal rule (`---`). Split them so each result becomes its own card.
// Falls back to the whole string when there is no separator (a single result,
// or one result per output item), so both output shapes are handled.
function splitExaResults(text: string): string[] {
return text
.split(/\n\s*-{3,}\s*\n/)
.map((chunk) => chunk.trim())
.filter((chunk) => chunk.includes('Title:'))
}

function parseExaTextEntry(text: string): SearchResultCard | undefined {
const lines = text.split('\n').map((l) => l.trim())

Expand Down Expand Up @@ -70,7 +81,8 @@ export function presentWebSearchExa(args: {
(item): item is { text?: string } =>
!!item && typeof item === 'object' && 'text' in item
)
.map((item) => parseExaTextEntry(item.text ?? ''))
.flatMap((item) => splitExaResults(item.text ?? ''))
.map((entry) => parseExaTextEntry(entry))
.filter(isSearchResultCard)

return {
Expand Down