diff --git a/web-app/src/components/ai-elements/tools/renderers/web-search-tool-renderer.tsx b/web-app/src/components/ai-elements/tools/renderers/web-search-tool-renderer.tsx index 57f1d6f4b..e14448118 100644 --- a/web-app/src/components/ai-elements/tools/renderers/web-search-tool-renderer.tsx +++ b/web-app/src/components/ai-elements/tools/renderers/web-search-tool-renderer.tsx @@ -28,8 +28,9 @@ export function WebSearchToolRenderer({ )} - {results.length > 0 && - results.slice(0, 4).map((result, index) => ( + {results.length > 0 && ( +
+ {results.map((result, index) => (
- ))} + ))} + + )} ) } diff --git a/web-app/src/lib/tools/presenters/__tests__/web-search-exa.test.ts b/web-app/src/lib/tools/presenters/__tests__/web-search-exa.test.ts new file mode 100644 index 000000000..655a115fd --- /dev/null +++ b/web-app/src/lib/tools/presenters/__tests__/web-search-exa.test.ts @@ -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() + }) +}) diff --git a/web-app/src/lib/tools/presenters/web-search-exa.ts b/web-app/src/lib/tools/presenters/web-search-exa.ts index b4074f9e3..7e3b94ecd 100644 --- a/web-app/src/lib/tools/presenters/web-search-exa.ts +++ b/web-app/src/lib/tools/presenters/web-search-exa.ts @@ -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()) @@ -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 {