|
|
@@ -85,20 +85,14 @@ describe('search formatting', () => {
|
|
|
expect(out).toContain('Showing the first 1 sources')
|
|
|
})
|
|
|
|
|
|
- it('validates the query', () => {
|
|
|
- expect(() => parseSearchArgs({ query: ' ' }, WEB_SEARCH_MAX_QUERIES)).toThrow('non-empty')
|
|
|
- expect(parseSearchArgs({ query: 'hi' }, WEB_SEARCH_MAX_QUERIES)).toEqual({ query: 'hi' })
|
|
|
- })
|
|
|
-
|
|
|
- it('validates multiple queries', () => {
|
|
|
+ it('validates queries', () => {
|
|
|
+ expect(parseSearchArgs({ queries: ['hi'] }, WEB_SEARCH_MAX_QUERIES)).toEqual(['hi'])
|
|
|
expect(parseSearchArgs({ queries: ['one', 'one', ' two '] }, WEB_SEARCH_MAX_QUERIES))
|
|
|
- .toEqual({ queries: ['one', ' two '] })
|
|
|
- expect(() => parseSearchArgs({}, WEB_SEARCH_MAX_QUERIES)).toThrow('provide either query or queries')
|
|
|
+ .toEqual(['one', ' two '])
|
|
|
expect(() => parseSearchArgs({ queries: [] }, WEB_SEARCH_MAX_QUERIES)).toThrow('at least one query')
|
|
|
expect(() => parseSearchArgs({ queries: ['one', 'two'] }, 1)).toThrow('at most 1 query')
|
|
|
expect(() => parseSearchArgs({ queries: ['one', 'two', 'three'] }, 2)).toThrow('at most 2 queries')
|
|
|
expect(() => parseSearchArgs({ queries: ['ok', ' '] }, WEB_SEARCH_MAX_QUERIES)).toThrow('each query must be a non-empty string')
|
|
|
- expect(() => parseSearchArgs({ query: 'one', queries: ['two'] }, WEB_SEARCH_MAX_QUERIES)).toThrow('not both')
|
|
|
})
|
|
|
|
|
|
it('falls back to the raw URL as a source label when the URL is unparseable', () => {
|
|
|
@@ -106,17 +100,9 @@ describe('search formatting', () => {
|
|
|
expect(out).toContain('[not a url](not a url)')
|
|
|
})
|
|
|
|
|
|
- it('presents a search call as a search-kind card titled by the query', () => {
|
|
|
- expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' })
|
|
|
- })
|
|
|
-
|
|
|
- it('presents a multi-query search call with a joined title', () => {
|
|
|
+ it('presents a search call with a joined query title', () => {
|
|
|
expect(presentSearchCall({ queries: ['one', 'two'] })).toEqual({ card: 'generic', title: 'one, two', kind: 'search', rawInput: 'one, two' })
|
|
|
})
|
|
|
-
|
|
|
- it('presents malformed search arguments with an empty title', () => {
|
|
|
- expect(presentSearchCall({})).toEqual({ card: 'generic', title: '', kind: 'search', rawInput: '' })
|
|
|
- })
|
|
|
})
|
|
|
|
|
|
/** Build a completed non-error tool result with the given meta and text content. */
|
|
|
@@ -165,7 +151,7 @@ describe('web_search presentation meta and result view', () => {
|
|
|
content: 'an answer', truncated: true,
|
|
|
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
|
|
})
|
|
|
- expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({
|
|
|
+ expect(presentSearchResult({ queries: ['q'] }, toolResult(meta, 'rendered'))).toEqual({
|
|
|
card: 'web',
|
|
|
kind: 'search',
|
|
|
title: 'q',
|
|
|
@@ -177,7 +163,7 @@ describe('web_search presentation meta and result view', () => {
|
|
|
|
|
|
it('omits the answer from the view when meta carries none', () => {
|
|
|
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
|
|
- const view = presentSearchResult({ query: 'q' }, toolResult(meta))
|
|
|
+ const view = presentSearchResult({ queries: ['q'] }, toolResult(meta))
|
|
|
expect(view).toBeDefined()
|
|
|
expect(view && 'answer' in view).toBe(false)
|
|
|
expect(view && 'content' in view).toBe(false)
|
|
|
@@ -185,11 +171,11 @@ describe('web_search presentation meta and result view', () => {
|
|
|
|
|
|
it('falls back to the generic card on an error result', () => {
|
|
|
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
|
|
- expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined()
|
|
|
+ expect(presentSearchResult({ queries: ['q'] }, toolResult(meta, 'body', true))).toBeUndefined()
|
|
|
})
|
|
|
|
|
|
it('falls back to the generic card on absent or malformed meta', () => {
|
|
|
- expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined()
|
|
|
+ expect(presentSearchResult({ queries: ['q'] }, toolResult(undefined))).toBeUndefined()
|
|
|
expect(searchMetaFromResult(undefined)).toBeUndefined()
|
|
|
expect(searchMetaFromResult(null)).toBeUndefined()
|
|
|
expect(searchMetaFromResult('nope')).toBeUndefined()
|
|
|
@@ -465,7 +451,7 @@ describe('tool-web registration', () => {
|
|
|
const names = ctx.tools.schemas().map(s => s.name)
|
|
|
expect(names).toContain('web_search')
|
|
|
expect(names).toContain('web_fetch')
|
|
|
- expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
|
|
|
+ expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { queries: ['q'] } }))
|
|
|
.toEqual({ kind: 'parallel' })
|
|
|
expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
|
|
|
.toEqual({ kind: 'parallel' })
|
|
|
@@ -494,7 +480,7 @@ describe('tool-web registration', () => {
|
|
|
expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
|
|
|
// No provider is registered: the schema stays visible and execution reports
|
|
|
// the structured unavailability instead.
|
|
|
- const out = await call('web_search', { query: 'q' })
|
|
|
+ const out = await call('web_search', { queries: ['q'] })
|
|
|
expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
|
|
|
await fiber.dispose()
|
|
|
})
|
|
|
@@ -503,7 +489,7 @@ describe('tool-web registration', () => {
|
|
|
const { fiber, ctx } = await mountTools()
|
|
|
const prompt = await ctx.systemPrompt.assemble()
|
|
|
const text = prompt.sections.map(s => s.text).join('\n')
|
|
|
- expect(text).toContain(`Use the web_search tool to discover current information on the web. You can pass up to ${WEB_SEARCH_MAX_QUERIES} queries in one call via the queries parameter when you need several distinct searches. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.`)
|
|
|
+ expect(text).toContain(`Use the web_search tool to discover current information on the web. The required queries array accepts 1–${WEB_SEARCH_MAX_QUERIES} non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.`)
|
|
|
expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL')
|
|
|
await fiber.dispose()
|
|
|
})
|
|
|
@@ -525,7 +511,7 @@ describe('tool-web execution through the real registry', () => {
|
|
|
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
|
|
}
|
|
|
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
|
|
|
- const out = await call('web_search', { query: 'q' })
|
|
|
+ const out = await call('web_search', { queries: ['q'] })
|
|
|
expect(out.isError).toBe(false)
|
|
|
expect(out.value).toEqual(result)
|
|
|
expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[A](https://a.test)')
|
|
|
@@ -669,12 +655,12 @@ describe('tool-web execution through the real registry', () => {
|
|
|
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
|
|
}
|
|
|
const { ctx, fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
|
|
|
- const out = await call('web_search', { query: 'q' })
|
|
|
+ const out = await call('web_search', { queries: ['q'] })
|
|
|
expect(out.meta).toEqual({
|
|
|
answer: 'answer', truncated: true,
|
|
|
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
|
|
})
|
|
|
- const view = ctx.tools.get('web_search')?.presentResult?.({ query: 'q' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
|
|
|
+ const view = ctx.tools.get('web_search')?.presentResult?.({ queries: ['q'] }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
|
|
|
expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' })
|
|
|
await fiber.dispose()
|
|
|
})
|
|
|
@@ -697,7 +683,7 @@ describe('tool-web execution through the real registry', () => {
|
|
|
|
|
|
it('surfaces a structured WebError when no provider is available', async () => {
|
|
|
const { fiber, call } = await mountTools()
|
|
|
- const out = await call('web_search', { query: 'q' })
|
|
|
+ const out = await call('web_search', { queries: ['q'] })
|
|
|
expect(out.isError).toBe(true)
|
|
|
expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
|
|
|
await fiber.dispose()
|
|
|
@@ -706,15 +692,15 @@ describe('tool-web execution through the real registry', () => {
|
|
|
it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
|
|
|
const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
|
|
|
ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
|
|
|
- const out = await call('web_search', { query: 'q' })
|
|
|
+ const out = await call('web_search', { queries: ['q'] })
|
|
|
expect(out.isError).toBe(true)
|
|
|
expect(out.error?.info?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
|
|
|
await fiber.dispose()
|
|
|
})
|
|
|
|
|
|
- it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
|
|
|
+ it.each([{}, { queries: [123] }])('rejects absent or wrongly typed queries with a structured INVALID_ARGS error', async (args) => {
|
|
|
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
|
|
|
- const out = await call('web_search', { query: 123 })
|
|
|
+ const out = await call('web_search', args)
|
|
|
expect(out.isError).toBe(true)
|
|
|
expect(out.error?.info?.code).toBe('INVALID_ARGS')
|
|
|
await fiber.dispose()
|
|
|
@@ -786,7 +772,7 @@ describe('tool-web execution through the real registry', () => {
|
|
|
}
|
|
|
const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
|
|
|
const controller = new AbortController()
|
|
|
- await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal })
|
|
|
+ await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { queries: ['q'] }, signal: controller.signal })
|
|
|
expect(seen.signal).toBe(controller.signal)
|
|
|
await fiber.dispose()
|
|
|
})
|
|
|
@@ -825,7 +811,7 @@ describe('searchMaxResults is plugin config', () => {
|
|
|
search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
|
|
|
}
|
|
|
const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
|
|
|
- await call('web_search', { query: 'q' })
|
|
|
+ await call('web_search', { queries: ['q'] })
|
|
|
expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS)
|
|
|
await fiber.dispose()
|
|
|
})
|
|
|
@@ -838,7 +824,7 @@ describe('searchMaxResults is plugin config', () => {
|
|
|
search: () => Promise.resolve({ sources, truncated: false }),
|
|
|
}
|
|
|
const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
|
|
|
- const out = await call('web_search', { query: 'q' })
|
|
|
+ const out = await call('web_search', { queries: ['q'] })
|
|
|
expect(out.isError).toBe(false)
|
|
|
const body = out.content.map(b => b.type === 'text' ? b.text : '').join('')
|
|
|
expect(body).toContain('https://s1.test')
|
|
|
@@ -878,9 +864,9 @@ describe('searchMaxQueries is plugin config', () => {
|
|
|
search: provider,
|
|
|
})
|
|
|
const schema = ctx.tools.schemas().find(item => item.name === 'web_search')
|
|
|
- expect(schema?.description).toContain('up to 2 queries')
|
|
|
+ expect(schema?.description).toContain('1–2 queries')
|
|
|
const prompt = await ctx.systemPrompt.assemble()
|
|
|
- expect(prompt.sections.map(section => section.text).join('\n')).toContain('pass up to 2 queries')
|
|
|
+ expect(prompt.sections.map(section => section.text).join('\n')).toContain('accepts 1–2 non-empty search queries')
|
|
|
const out = await call('web_search', { queries: ['one', 'two', 'three'] })
|
|
|
expect(out.isError).toBe(true)
|
|
|
expect(out.content).toEqual([{ type: 'text', text: 'Error: queries must contain at most 2 queries' }])
|