feat(web): add a web render-intent card for web_search and web_fetch results
web_search and web_fetch returned only model-facing text, whose markdown source
list is lossy (title-or-hostname label, snippet and date concatenated), so a
client could not recover the structured sources. Add a card:'web' result view
with a kind discriminant ('search' carrying structured sources + answer +
truncated, 'fetch' carrying url + statusCode + truncated), projected through
each tool's output.presentationMeta and read back in presentResult. A UI
without the web card falls back to content; the TUI is unchanged. The web
consumer is a follow-up.
This commit is contained in:
@@ -14,8 +14,16 @@ import {
|
||||
parseFetchArgs,
|
||||
presentSearchCall,
|
||||
presentFetchCall,
|
||||
presentSearchResult,
|
||||
presentFetchResult,
|
||||
searchMetaFromValue,
|
||||
searchMetaFromResult,
|
||||
fetchMetaFromValue,
|
||||
fetchMetaFromResult,
|
||||
WEB_SEARCH_MAX_RESULTS,
|
||||
} from '@deepseek-ai/dsh-tool-web'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -91,6 +99,96 @@ describe('search formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** Build a completed non-error tool result with the given meta and text content. */
|
||||
function toolResult(meta: unknown, text = 'body', isError = false): ToolResult {
|
||||
const content: ContentBlock[] = [{ type: 'text', text }]
|
||||
return { content, isError, ...meta !== undefined ? { meta: meta as never } : {} }
|
||||
}
|
||||
|
||||
describe('web_search presentation meta and result view', () => {
|
||||
it('projects sources, answer, and truncation into meta, omitting absent optional fields', () => {
|
||||
const meta = searchMetaFromValue({
|
||||
content: 'an answer', truncated: true,
|
||||
sources: [
|
||||
{ url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
|
||||
{ url: 'https://b.test/y' },
|
||||
],
|
||||
})
|
||||
expect(meta).toEqual({
|
||||
answer: 'an answer',
|
||||
truncated: true,
|
||||
sources: [
|
||||
{ url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
|
||||
{ url: 'https://b.test/y' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits answer from meta when the provider returned none', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
expect(meta).toEqual({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
})
|
||||
|
||||
it('round-trips projected meta back to a typed search meta', () => {
|
||||
const value = {
|
||||
content: 'ans', truncated: false,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
|
||||
}
|
||||
expect(searchMetaFromResult(searchMetaFromValue(value))).toEqual({
|
||||
answer: 'ans', truncated: false,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('presents a completed search as a web/search card carrying the structured sources and fallback content', () => {
|
||||
const meta = searchMetaFromValue({
|
||||
content: 'an answer', truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
})
|
||||
expect(presentSearchResult(toolResult(meta, 'rendered'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'search',
|
||||
answer: 'an answer',
|
||||
truncated: true,
|
||||
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
|
||||
content: [{ type: 'text', text: 'rendered' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the answer from the view when meta carries none', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
const view = presentSearchResult(toolResult(meta))
|
||||
expect(view).toBeDefined()
|
||||
expect(view && 'answer' in view).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
|
||||
expect(presentSearchResult(toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentSearchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(searchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(searchMetaFromResult(null)).toBeUndefined()
|
||||
expect(searchMetaFromResult('nope')).toBeUndefined()
|
||||
expect(searchMetaFromResult([])).toBeUndefined()
|
||||
expect(searchMetaFromResult({})).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: 'x', truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [], truncated: 'no' })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [], truncated: false, answer: 1 })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [null], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 1 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', title: 2 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', snippet: 2 }], truncated: false })).toBeUndefined()
|
||||
expect(searchMetaFromResult({ sources: [{ url: 'u', publishedAt: 2 }], truncated: false })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts an empty source list as valid meta', () => {
|
||||
expect(searchMetaFromResult({ sources: [], truncated: false })).toEqual({ sources: [], truncated: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetch formatting', () => {
|
||||
const NO_CAP = 1_000_000
|
||||
const HEADER = 'Fetched https://a.test (HTTP 200)\n\n'
|
||||
@@ -259,6 +357,42 @@ describe('fetch formatting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('web_fetch presentation meta and result view', () => {
|
||||
it('projects url, status, and truncation into meta', () => {
|
||||
expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true }))
|
||||
.toEqual({ url: 'https://a.test', statusCode: 404, truncated: true })
|
||||
})
|
||||
|
||||
it('presents a completed fetch as a web/fetch card carrying the summary and the markdown body as fallback content', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false })
|
||||
expect(presentFetchResult(toolResult(meta, '# Title'))).toEqual({
|
||||
card: 'web',
|
||||
kind: 'fetch',
|
||||
url: 'https://a.test',
|
||||
statusCode: 200,
|
||||
truncated: false,
|
||||
content: [{ type: 'text', text: '# Title' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the generic card on an error result', () => {
|
||||
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false })
|
||||
expect(presentFetchResult(toolResult(meta, 'body', true))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the generic card on absent or malformed meta', () => {
|
||||
expect(presentFetchResult(toolResult(undefined))).toBeUndefined()
|
||||
expect(fetchMetaFromResult(undefined)).toBeUndefined()
|
||||
expect(fetchMetaFromResult(null)).toBeUndefined()
|
||||
expect(fetchMetaFromResult('nope')).toBeUndefined()
|
||||
expect(fetchMetaFromResult([])).toBeUndefined()
|
||||
expect(fetchMetaFromResult({})).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 1, statusCode: 200, truncated: false })).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 'u', statusCode: 'x', truncated: false })).toBeUndefined()
|
||||
expect(fetchMetaFromResult({ url: 'u', statusCode: 200, truncated: 'no' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-web registration', () => {
|
||||
it('registers both tools by default', async () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
@@ -323,6 +457,38 @@ describe('tool-web execution through the real registry', () => {
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('projects the search sources into the tool result meta and derives its web/search view', async () => {
|
||||
const result: WebSearchResult = {
|
||||
content: 'answer', truncated: true,
|
||||
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' })
|
||||
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 } : {} })
|
||||
expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('projects the fetch summary into the tool result meta and derives its web/fetch view', async () => {
|
||||
const fetchProvider = {
|
||||
id: 'stub-fetch',
|
||||
available: () => available,
|
||||
fetch: (request: { url: string }) => Promise.resolve({
|
||||
url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: true,
|
||||
}),
|
||||
}
|
||||
const { ctx, fiber, call } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
|
||||
const out = await call('web_fetch', { url: 'https://a.test' })
|
||||
expect(out.meta).toEqual({ url: 'https://a.test', statusCode: 200, truncated: true })
|
||||
const view = ctx.tools.get('web_fetch')?.presentResult?.({ url: 'https://a.test' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
|
||||
expect(view).toMatchObject({ card: 'web', kind: 'fetch', url: 'https://a.test', statusCode: 200, truncated: true })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('surfaces a structured WebError when no provider is available', async () => {
|
||||
const { fiber, call } = await mountTools()
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
|
||||
Reference in New Issue
Block a user