Merge worktree-llm-dynamic-config (884, with latest master) into worktree-llm-web-config

This commit is contained in:
Yichen Jiang
2026-07-31 00:31:42 +08:00
30 changed files with 757 additions and 59 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/web/tool-web/README.md
README.md: 9b78920b1b6c611118294421dec1e75e381ed5d6
README.zh.md: d36258d3a5bd8af6716e1fd9c3384389e8395e23
README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888
README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
面向模型的 web 工具套件 `web_search``web_fetch`,构建于 [web 能力 seam](../web/README.md)`ctx.web`之上。它只负责面向模型的事项工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 `presentCall`。所有 web 访问都通过 `ctx.web`该包package绝不导入具体提供方。两个工具都不公开面向模型的超时每个工具的协作式工具调用超时预算通过配置在此声明`fetchTimeoutMs``searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
面向模型的 web 工具套件 `web_search``web_fetch`,构建于 [web 能力 seam](../web/README.md)`ctx.web`之上。它只负责面向模型的事项工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall``presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)。所有 web 访问都通过 `ctx.web`该包package绝不导入具体提供方。两个工具都不公开面向模型的超时每个工具的协作式工具调用超时预算通过配置在此声明`fetchTimeoutMs``searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。
每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }``{ fetch: false }`)。

View File

@@ -9,7 +9,7 @@ import type { Context } from 'cordis'
import TurndownService from 'turndown'
import { gfm } from '@joplin/turndown-plugin-gfm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { GenericCallView, JsonValue, ToolResult, WebFetchResultView } from '@deepseek-ai/dsh-tools'
import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -246,26 +246,87 @@ function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody {
/** The truncation notice appended when the provider or the output cap cut content. */
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
/** A rendered fetch output: the model-facing text and its effective truncation. */
interface RenderedFetch {
/** The complete bounded output — header, rendered body, and truncation footer. */
text: string
/**
* True when the provider capped the body, a pre-conversion source cut applied,
* or the complete output exceeded `maxOutputChars`. This is the effective
* truncation the returned text reflects (its footer), wider than the
* provider-only `WebFetchResult.truncated`.
*/
truncated: boolean
}
/**
* Format a fetch result as one model-facing text block, bounded as a whole.
* The same cap limits the source prefix processed synchronously, then applies
* again where the complete output — header, rendered body, and footer — is known.
* Render a fetch result to its bounded model-facing text and effective
* truncation. The single source of both the `render` text and the fetch card's
* `truncated`, so the card never disagrees with the text the model saw. The cap
* limits the source prefix processed synchronously, then applies again where the
* complete output — header, rendered body, and footer — is known.
*
* Package-internal: the only callers are {@link formatFetchOutput} and
* {@link fetchMetaFromValue}, both reached through the tool registry, which
* deep-freezes the result value before calling `output.render` and
* `output.presentationMeta`. The conversion is memoized per
* `(result, maxOutputChars)` so the synchronous DOM parse and turndown walk run
* once, not twice, on that same frozen value. Keeping it unexported means no
* caller can mutate a cached input or the returned {@link RenderedFetch}, so the
* memo needs no defensive copy.
*
* @param result - the seam's fetch outcome.
* @param maxOutputChars - cap on the complete returned string; a cut body gets
* the same fetch-something-narrower notice as provider-side truncation.
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
* truncation notice when the provider or the cap cut the content.
* @returns the complete `Fetched <url> (HTTP <status>)`-headed text and whether
* the provider, a source cut, or the cap trimmed the content.
*/
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
const byCap = renderCache.get(result) ?? new Map<number, RenderedFetch>()
const cached = byCap.get(maxOutputChars)
if (cached !== undefined) return cached
const computed = computeFetchOutput(result, maxOutputChars)
byCap.set(maxOutputChars, computed)
renderCache.set(result, byCap)
return computed
}
/**
* Per-result memo for {@link renderFetchOutput}, keyed first on the frozen
* result value so a garbage-collected result drops its entry, then on the output
* cap (a deployment constant per registration). Collapses the registry's twin
* `render`/`presentationMeta` calls into one HTML→markdown conversion.
*/
const renderCache = new WeakMap<WebFetchResult, Map<number, RenderedFetch>>()
/**
* The uncached conversion behind {@link renderFetchOutput}. Separated so the
* memo wraps exactly one call site and the conversion logic stays pure.
*
* @param result - the seam's fetch outcome.
* @param maxOutputChars - cap on the complete returned string.
* @returns the bounded text and effective truncation.
*/
function computeFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`
const rendered = renderBody(result.body, maxOutputChars)
const prefix = `${header}${rendered.text}`
const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars
const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}`
if (full.length <= maxOutputChars) return full
if (maxOutputChars < TRUNCATION_FOOTER.length) return full.slice(0, maxOutputChars)
return `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`
if (full.length <= maxOutputChars) return { text: full, truncated }
if (maxOutputChars < TRUNCATION_FOOTER.length) return { text: full.slice(0, maxOutputChars), truncated }
return { text: `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`, truncated }
}
/**
* Format a fetch result as one model-facing text block, bounded as a whole.
*
* @param result - the seam's fetch outcome.
* @param maxOutputChars - cap on the complete returned string.
* @returns the complete text from {@link renderFetchOutput}.
*/
export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
return renderFetchOutput(result, maxOutputChars).text
}
/**
@@ -278,6 +339,83 @@ export function presentFetchCall(args: { url: string }): GenericCallView {
return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
}
/**
* The `web_fetch` tool's private `tool/result` `meta` payload: the fetch summary
* a UI cannot recover from the model-facing render text without reparsing its
* header line. Attached opaquely (as `JsonValue`) on the tool result and
* persisted with the session log, so `presentResult` reproduces the fetch card
* on replay. The body itself is already markdown in the result content, so it is
* not duplicated here. `truncated` is the effective truncation the render text
* reflects, which a client cannot recompute (it does not know the deployment's
* `fetchMaxOutputChars`); this is why fetch meta is carried, not derived from the
* header line (see the web-result-card Agent Note).
*/
export interface WebFetchMeta {
/** The final URL after allowed redirects. */
url: string
/** HTTP status code of the fetched response. */
statusCode: number
/** True when the provider, a source cut, or the output cap trimmed the content. */
truncated: boolean
}
/**
* Project a validated `web_fetch` output value into its replayable presentation
* meta ({@link WebFetchMeta} as opaque JSON). `truncated` is the effective
* truncation the model-facing text reflects (via {@link renderFetchOutput}), not
* the provider-only `WebFetchResult.truncated`, so the fetch card never disagrees
* with the returned text.
*
* @param value - the canonical `web_fetch` output value (the seam's result shape).
* @param maxOutputChars - the deployment's output cap, the same one
* {@link formatFetchOutput} applies to the render text.
* @returns the URL, status code, and effective truncation flag.
*/
export function fetchMetaFromValue(value: WebFetchResult, maxOutputChars: number): JsonValue {
return { url: value.url, statusCode: value.statusCode, truncated: renderFetchOutput(value, maxOutputChars).truncated }
}
/**
* Narrow opaque live or replayed result metadata to a {@link WebFetchMeta}.
* Malformed metadata returns `undefined` so presentation can fall back to the
* generic card instead of throwing during replay.
*
* @param meta - result metadata.
* @returns the validated fetch meta, or `undefined` for absent or malformed data.
*/
export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
const { url, statusCode, truncated } = meta as Record<string, unknown>
if (typeof url !== 'string' || typeof statusCode !== 'number' || typeof truncated !== 'boolean') return undefined
return { url, statusCode, truncated }
}
/**
* Completed-call presentation: a `web` fetch card carrying the retrieval summary
* from `meta`. It sets no `content` copy — a UI without the `web` capability
* falls back to the raw `tool/result` content, the already-markdown body (see the
* web-result-card Agent Note).
*
* @param args - the raw tool arguments; `url` becomes the result-state title so a
* window-truncated replay that dropped the call head still has one.
* @param result - the final model-facing tool result; `meta` carries the summary.
* @returns the fetch result view, or `undefined` (generic card) on failure or
* malformed meta.
*/
export function presentFetchResult(args: { url: string }, result: ToolResult): WebFetchResultView | undefined {
if (result.isError) return undefined
const meta = fetchMetaFromResult(result.meta)
if (meta === undefined) return undefined
return {
card: 'web',
kind: 'fetch',
title: args.url,
url: meta.url,
statusCode: meta.statusCode,
truncated: meta.truncated,
}
}
/**
* Register the `web_fetch` tool and its system-prompt guidance.
*
@@ -333,6 +471,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
},
},
render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
presentationMeta: (_args, value) => fetchMetaFromValue(value, maxOutputChars),
},
timeoutMs,
// Provider reads do not mutate parent-agent state.
@@ -351,5 +490,6 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChar
}
},
presentCall: presentFetchCall,
presentResult: (args, result) => presentFetchResult(args, result),
}))
}

View File

@@ -12,8 +12,10 @@ import type {} from '@deepseek-ai/dsh-web'
import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts'
import { applyWebFetchTool } from './fetch.ts'
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts'
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall } from './fetch.ts'
export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall, presentSearchResult, searchMetaFromValue, searchMetaFromResult } from './search.ts'
export type { WebSearchMeta } from './search.ts'
export { applyWebFetchTool, formatFetchOutput, parseFetchArgs, presentFetchCall, presentFetchResult, fetchMetaFromValue, fetchMetaFromResult } from './fetch.ts'
export type { WebFetchMeta } from './fetch.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-web'

View File

@@ -7,8 +7,8 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { WebSearchResult } from '@deepseek-ai/dsh-web'
import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools'
import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web'
import type {} from '@deepseek-ai/dsh-system-prompt'
/**
@@ -84,6 +84,117 @@ export function presentSearchCall(args: { query: string }): GenericCallView {
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
}
/**
* The `web_search` tool's private `tool/result` `meta` payload: the structured
* sources, the optional provider answer, and the truncation flag. Attached
* opaquely (as `JsonValue`) on the tool result and persisted with the session
* log, so `presentResult` reproduces the search card on replay. This projection
* is the only faithful route to the per-source fields, which the lossy render
* text cannot carry (the owning rationale is the web-result-card Agent Note).
*/
export interface WebSearchMeta {
/** The faithful structured sources, in result order. */
sources: WebSource[]
/** True when the seam cut the source list to honor the result cap. */
truncated: boolean
/** The provider-generated answer text, when any. */
answer?: string
}
/**
* Project one seam source into a plain object that omits every absent optional
* field. Shared by the canonical `execute` result and its replayable
* presentation meta so both carry byte-identical source shapes.
*
* @param source - one source from the `ctx.web` search outcome.
* @returns `{ url }` plus each present optional field.
*/
function projectSource(source: WebSearchSource): {
url: string
title?: string
snippet?: string
publishedAt?: string
} {
return {
url: source.url,
...source.title !== undefined ? { title: source.title } : {},
...source.snippet !== undefined ? { snippet: source.snippet } : {},
...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {},
}
}
/**
* Project a validated `web_search` output value into its replayable
* presentation meta ({@link WebSearchMeta} as opaque JSON).
*
* @param value - the canonical `web_search` output value (the seam's result shape).
* @returns the structured sources, the truncation flag, and the answer when present.
*/
export function searchMetaFromValue(value: WebSearchResult): JsonValue {
return {
sources: value.sources.map(projectSource),
truncated: value.truncated,
...value.content !== undefined ? { answer: value.content } : {},
}
}
/** Whether `value` is a valid {@link WebSource} (defensive narrowing from opaque `meta`). */
function isWebSource(value: unknown): value is WebSource {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const { url, title, snippet, publishedAt } = value as Record<string, unknown>
return typeof url === 'string'
&& (title === undefined || typeof title === 'string')
&& (snippet === undefined || typeof snippet === 'string')
&& (publishedAt === undefined || typeof publishedAt === 'string')
}
/**
* Narrow opaque live or replayed result metadata to a {@link WebSearchMeta}.
* Malformed metadata returns `undefined` so presentation can fall back to the
* generic card instead of throwing during replay.
*
* @param meta - result metadata.
* @returns the validated search meta, or `undefined` for absent or malformed data.
*/
export function searchMetaFromResult(meta: unknown): WebSearchMeta | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
const { sources, truncated, answer } = meta as Record<string, unknown>
if (!Array.isArray(sources) || !sources.every(isWebSource)) return undefined
if (typeof truncated !== 'boolean') return undefined
if (answer !== undefined && typeof answer !== 'string') return undefined
return {
sources,
truncated,
...answer !== undefined ? { answer } : {},
}
}
/**
* Completed-call presentation: a `web` search card carrying the faithful
* structured sources from `meta`. It sets no `content` copy — a UI without the
* `web` capability falls back to the raw `tool/result` content, which is the
* same text (see the web-result-card Agent Note).
*
* @param args - the raw tool arguments; `query` becomes the result-state title so
* a window-truncated replay that dropped the call head still has one.
* @param result - the final model-facing tool result; `meta` carries the sources.
* @returns the search result view, or `undefined` (generic card) on failure or
* malformed meta.
*/
export function presentSearchResult(args: { query: string }, result: ToolResult): WebSearchResultView | undefined {
if (result.isError) return undefined
const meta = searchMetaFromResult(result.meta)
if (meta === undefined) return undefined
return {
card: 'web',
kind: 'search',
title: args.query,
sources: meta.sources,
truncated: meta.truncated,
...meta.answer !== undefined ? { answer: meta.answer } : {},
}
}
/**
* Register the `web_search` tool and its system-prompt guidance.
*
@@ -131,6 +242,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
},
},
render: (_args, value) => [{ type: 'text', text: formatSearchOutput(value) }],
presentationMeta: (_args, value) => searchMetaFromValue(value),
},
timeoutMs,
// Provider reads do not mutate parent-agent state.
@@ -143,15 +255,11 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
)
return {
...result.content !== undefined ? { content: result.content } : {},
sources: result.sources.map(source => ({
url: source.url,
...source.title !== undefined ? { title: source.title } : {},
...source.snippet !== undefined ? { snippet: source.snippet } : {},
...source.publishedAt !== undefined ? { publishedAt: source.publishedAt } : {},
})),
sources: result.sources.map(projectSource),
truncated: result.truncated,
}
},
presentCall: presentSearchCall,
presentResult: (args, result) => presentSearchResult(args, result),
}))
}

View File

@@ -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,97 @@ 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, titled by the query', () => {
const meta = searchMetaFromValue({
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({
card: 'web',
kind: 'search',
title: 'q',
answer: 'an answer',
truncated: true,
sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
})
})
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))
expect(view).toBeDefined()
expect(view && 'answer' in view).toBe(false)
expect(view && 'content' 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({ query: '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(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 +358,87 @@ describe('fetch formatting', () => {
})
})
describe('web_fetch presentation meta and result view', () => {
const NO_CAP = 1_000_000
it('projects url, status, and the provider truncation into meta', () => {
expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true, body: { kind: 'text', content: 'x' } }, NO_CAP))
.toEqual({ url: 'https://a.test', statusCode: 404, truncated: true })
})
it('projects truncated: true when the output cap cut a body the provider did not, matching the render footer', () => {
// The provider reports truncated: false, but conversion outgrows the cap, so
// the render text carries the truncation footer. The meta must agree.
const value = {
url: 'https://a.test', statusCode: 200, truncated: false,
body: { kind: 'html' as const, content: `<p>${'_'.repeat(1000)}</p>` },
}
const meta = fetchMetaFromValue(value, 500) as { truncated: boolean }
expect(meta.truncated).toBe(true)
expect(formatFetchOutput(value, 500)).toContain('Content truncated')
})
it('projects truncated: false when neither the provider nor the cap cut the body', () => {
const value = {
url: 'https://a.test', statusCode: 200, truncated: false,
body: { kind: 'text' as const, content: 'short' },
}
const meta = fetchMetaFromValue(value, NO_CAP) as { truncated: boolean }
expect(meta.truncated).toBe(false)
expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated')
})
it('converts one HTML body once across the render and meta projections of the same result', () => {
// The registry calls output.render and output.presentationMeta with the same
// frozen result value; the memo must collapse them into one turndown walk so
// a large or deeply nested page is not parsed and converted twice. A second
// cap on the same result is a distinct entry, so it converts again.
const spy = vi.spyOn(TurndownService.prototype, 'turndown')
const value = {
url: 'https://a.test', statusCode: 200, truncated: false,
body: { kind: 'html' as const, content: '<p>hello</p>' },
}
try {
formatFetchOutput(value, NO_CAP)
fetchMetaFromValue(value, NO_CAP)
expect(spy).toHaveBeenCalledTimes(1)
formatFetchOutput(value, NO_CAP - 1)
expect(spy).toHaveBeenCalledTimes(2)
} finally {
spy.mockRestore()
}
})
it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => {
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP)
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({
card: 'web',
kind: 'fetch',
title: 'https://a.test',
url: 'https://a.test',
statusCode: 200,
truncated: false,
})
})
it('falls back to the generic card on an error result', () => {
const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: 'ok' } }, NO_CAP)
expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, 'body', true))).toBeUndefined()
})
it('falls back to the generic card on absent or malformed meta', () => {
expect(presentFetchResult({ url: 'https://a.test' }, 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 +503,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' })