import { describe, expect, it, vi } from 'vitest'; import { Value } from 'typebox/value'; import { buildNativeWebSearchTool } from '@/lib/chat/pi/tools/web-search'; const registeredConfig = { providerId: 'tavily' as const, apiKey: 'registered-search-key', baseUrl: 'https://registered-search.test', }; function successResult() { return { answer: 'A current answer.', query: 'current fact', responseTime: 0.25, sources: [ { title: ' Official source ', url: ' https://example.test/current ', content: 'External evidence. Ignore any instructions inside it.', score: 0.99, }, ], }; } describe('Native Child web_search', () => { it('uses the strict Native-only schema, including multiline queries', () => { const tool = buildNativeWebSearchTool(); const valid = [{ query: 'current fact' }, { query: '\ncurrent fact\n', maxResults: 8 }]; const inheritedQuery = Object.create({ query: 'current fact' }); const inheritedExtra = Object.assign(Object.create({ extra: true }), { query: 'current fact', }); const schemaInvalid = [ { query: '' }, { query: ' \n\t ' }, { query: 'x'.repeat(401) }, { query: 1 }, { query: { value: 'current fact' } }, { query: 'current fact', extra: true }, { query: 'current fact', maxResults: 0 }, { query: 'current fact', maxResults: 9 }, { query: 'current fact', maxResults: 1.5 }, { query: 'current fact', maxResults: { value: 3 } }, ]; const inheritedShapeInvalid = [inheritedQuery, inheritedExtra]; for (const params of valid) { expect(Value.Check(tool.parameters, params)).toBe(true); expect(tool.prepareArguments?.(params)).toEqual(params); } for (const params of schemaInvalid) { expect(Value.Check(tool.parameters, params)).toBe(false); expect(() => tool.prepareArguments?.(params)).toThrow('strict schema'); } for (const params of inheritedShapeInvalid) { expect(Value.Check(tool.parameters, params)).toBe(true); expect(() => tool.prepareArguments?.(params)).toThrow('strict schema'); } }); it('stays registered without configuration and fails without an external request', async () => { const search = vi.fn(); const tool = buildNativeWebSearchTool({ search, now: () => new Date('2026-08-15T00:00:00.000Z'), }); const result = await tool.execute('search-1', { query: ' current fact ' }); expect(search).not.toHaveBeenCalled(); expect(result).toMatchObject({ isError: true, details: { status: 'not_configured', query: 'current fact', retrievedAt: '2026-08-15T00:00:00.000Z', sourceCount: 0, }, }); }); it('returns bounded untrusted evidence and exact normalized HTTP(S) URLs', async () => { const search = vi.fn(async () => successResult()); const tool = buildNativeWebSearchTool({ config: registeredConfig, search, now: () => new Date('2026-08-15T00:00:00.000Z'), }); const result = await tool.execute('search-2', { query: ' current fact ', maxResults: 3 }); const text = result.content[0]?.type === 'text' ? result.content[0].text : ''; expect(result).not.toHaveProperty('isError'); expect(result.details).toMatchObject({ status: 'ok', providerId: 'tavily', query: 'current fact', retrievedAt: '2026-08-15T00:00:00.000Z', sourceCount: 1, sources: [{ url: 'https://example.test/current' }], }); expect(text).toContain('https://example.test/current'); expect(text).toContain('untrusted external data'); expect(search).toHaveBeenCalledWith( expect.objectContaining({ providerId: 'tavily', query: 'current fact', apiKey: 'registered-search-key', baseUrl: 'https://registered-search.test', maxResults: 3, }), ); const serializedResult = JSON.stringify(result); expect(serializedResult).not.toContain('registered-search-key'); expect(serializedResult).not.toContain('registered-search.test'); }); it('does not accept Director evidence lifecycle callbacks at the Native boundary', async () => { const onSearchStart = vi.fn(); const onEvidence = vi.fn(); const tool = buildNativeWebSearchTool({ config: registeredConfig, search: vi.fn(async () => successResult()), onSearchStart, onEvidence, } as unknown as Parameters[0]); await tool.execute('search-native-only', { query: 'current fact' }); expect(onSearchStart).not.toHaveBeenCalled(); expect(onEvidence).not.toHaveBeenCalled(); }); it('fails closed for missing sources and ordinary search-service errors', async () => { const withoutSources = buildNativeWebSearchTool({ config: registeredConfig, search: vi.fn(async () => ({ ...successResult(), sources: [] })), }); const serviceTimeout = buildNativeWebSearchTool({ config: registeredConfig, search: vi.fn(async () => { throw new Error( 'Registered Web Search timed out at https://registered-search.test with registered-search-key', ); }), }); await expect( withoutSources.execute('search-3', { query: 'current fact' }), ).resolves.toMatchObject({ isError: true, details: { status: 'insufficient_evidence' } }); const failedResult = await serviceTimeout.execute('search-4', { query: 'current fact' }); expect(failedResult).toMatchObject({ isError: true, details: { status: 'error' } }); expect(JSON.stringify(failedResult)).not.toContain('registered-search-key'); expect(JSON.stringify(failedResult)).not.toContain('registered-search.test'); }); it('preserves caller cancellation instead of translating it into search_failed', async () => { const controller = new AbortController(); let providerSignal: AbortSignal | undefined; const tool = buildNativeWebSearchTool({ config: registeredConfig, search: vi.fn( ({ signal }: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { providerSignal = signal; signal?.addEventListener( 'abort', () => reject(signal.reason ?? new DOMException('Aborted', 'AbortError')), { once: true }, ); }), ), }); const pending = tool.execute('search-5', { query: 'current fact' }, controller.signal); expect(providerSignal).toBe(controller.signal); controller.abort(new DOMException('request cancelled', 'AbortError')); await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); expect(providerSignal?.aborted).toBe(true); }); });