Merge branch 'master' into worktree/command-row-copy
This commit is contained in:
@@ -2754,7 +2754,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'ToolResultView',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;',
|
||||
declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;',
|
||||
},
|
||||
{
|
||||
name: 'ToolRunContext',
|
||||
@@ -2860,6 +2860,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WebFetchResult',
|
||||
declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebFetchResultView',
|
||||
declaration: 'export interface WebFetchResultView {\n card: \'web\';\n kind: \'fetch\';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebResultView',
|
||||
declaration: 'export type WebResultView = WebSearchResultView | WebFetchResultView;',
|
||||
},
|
||||
{
|
||||
name: 'WebRoute',
|
||||
declaration: 'export interface WebRoute {\n kind: WebRouteKind;\n path: string;\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;\n}',
|
||||
@@ -2880,10 +2888,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WebSearchResult',
|
||||
declaration: 'export interface WebSearchResult {\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchResultView',
|
||||
declaration: 'export interface WebSearchResultView {\n card: \'web\';\n kind: \'search\';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSearchSource',
|
||||
declaration: 'export interface WebSearchSource {\n readonly url: string;\n readonly title?: string;\n readonly snippet?: string;\n readonly publishedAt?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WebSource',
|
||||
declaration: 'export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'WorkflowMeta',
|
||||
declaration: 'export interface WorkflowMeta {\n name: string;\n description: string;\n whenToUse?: string;\n phases?: WorkflowPhase[];\n}',
|
||||
|
||||
@@ -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/core/tools/README.md
|
||||
README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e
|
||||
README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2
|
||||
README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12
|
||||
README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e
|
||||
|
||||
@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
|
||||
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
|
||||
|
||||
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
|
||||
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
|
||||
|
||||
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
|
||||
工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
|
||||
|
||||
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。
|
||||
- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。
|
||||
|
||||
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ export type {
|
||||
GenericResultView,
|
||||
TerminalResultView,
|
||||
DiffResultView,
|
||||
WebResultView,
|
||||
WebSearchResultView,
|
||||
WebFetchResultView,
|
||||
WebSource,
|
||||
} from './presentation.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
|
||||
@@ -125,7 +125,7 @@ export interface DiffCallView {
|
||||
* `ToolDefinition.presentResult`; omitting the method keeps the pending
|
||||
* title and renders the raw result content.
|
||||
*/
|
||||
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
|
||||
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView
|
||||
|
||||
/**
|
||||
* The default completed card: an optional replacement title and reformatted
|
||||
@@ -176,3 +176,84 @@ export interface DiffResultView {
|
||||
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
|
||||
diffs: FileDiff[]
|
||||
}
|
||||
|
||||
/**
|
||||
* One citeable source in a completed {@link WebSearchResultView}, the faithful
|
||||
* projection of one web-search source. The presentation projection of `dsh-web`'s
|
||||
* `WebSearchSource`: that seam type is the authoritative shape (core cannot depend
|
||||
* on the web seam, so the two are declared separately and MUST evolve together).
|
||||
* A web tool projects this shape through `output.presentationMeta` because the
|
||||
* render text cannot losslessly carry it (see the web-result-card Agent Note); its
|
||||
* `presentResult` reads it back.
|
||||
*/
|
||||
export interface WebSource {
|
||||
/** The source URL. */
|
||||
url: string
|
||||
/** The source title, when the provider returned one. */
|
||||
title?: string
|
||||
/** A short excerpt or summary, when the provider returned one. */
|
||||
snippet?: string
|
||||
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string, when present. */
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed web retrieval rendered as a structured card by a capable UI. Set
|
||||
* by a web tool whose call retrieves from the web (`web_search`, `web_fetch`).
|
||||
* One `kind`-tagged union carries both shapes because both are web retrieval and
|
||||
* a UI renders them with one component family; a UI switches on `kind`. An
|
||||
* incapable UI falls back to the raw `tool/result` content (this view carries no
|
||||
* `content` copy — see the web-result-card Agent Note). This is the result-time
|
||||
* analogue of the `web_search`/`web_fetch` calls' generic call views
|
||||
* (`kind: 'search'`/`'fetch'`); those tools keep their generic pending card and
|
||||
* add only this completed card.
|
||||
*
|
||||
* The `kind` field here is this union's own discriminant, NOT a
|
||||
* {@link ToolCallKind}: the two values deliberately match the tools' pending
|
||||
* `ToolCallKind` (`'search'`/`'fetch'`) so a call and its result read as one
|
||||
* category, but a new arm is a union edit plus a consumer branch, not any
|
||||
* arbitrary `ToolCallKind` value.
|
||||
*/
|
||||
export type WebResultView = WebSearchResultView | WebFetchResultView
|
||||
|
||||
/**
|
||||
* The completed state of a `web_search` call: the structured sources the model
|
||||
* cited, an optional provider answer, and whether the source list was cut to the
|
||||
* result cap. A capable UI renders the sources as a citation list; a UI without
|
||||
* the `web` capability falls back to the raw `tool/result` content.
|
||||
*/
|
||||
export interface WebSearchResultView {
|
||||
card: 'web'
|
||||
kind: 'search'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** The faithful, structured sources — the field render text cannot losslessly carry. */
|
||||
sources: WebSource[]
|
||||
/** The provider-generated answer text, when any. */
|
||||
answer?: string
|
||||
/** True when the seam cut the source list to honor the result cap. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The completed state of a `web_fetch` call: the fetched URL, its HTTP status,
|
||||
* and whether the content was cut. The body itself is already markdown in the
|
||||
* raw `tool/result` content, so this card carries only the retrieval summary and
|
||||
* a UI without the `web` capability falls back to that content.
|
||||
*/
|
||||
export interface WebFetchResultView {
|
||||
card: 'web'
|
||||
kind: 'fetch'
|
||||
/** Replacement title for the completed call. Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/** The final URL after allowed redirects. */
|
||||
url: string
|
||||
/** HTTP status code of the fetched response. */
|
||||
statusCode: number
|
||||
/**
|
||||
* True when the provider capped the decoded body, or the output cap or a
|
||||
* pre-conversion source cut trimmed the rendered text (the effective
|
||||
* truncation the model-facing text also reflects).
|
||||
*/
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
@@ -389,10 +389,23 @@ export class ToolCardComponent implements Component {
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined
|
||||
const unknownXml = this.definition === undefined && genericContent !== undefined
|
||||
// A generic card's own content, or a web card's fallback to the raw result
|
||||
// content (the `web` view carries no `content` copy), both render as one dim
|
||||
// Markdown block below, so links/lists/headings keep the unified dim styling
|
||||
// rather than reading as bare text. Terminal and diff cards own their body
|
||||
// styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
|
||||
const markdownContent = view.card === 'generic'
|
||||
? view.content ?? this.result?.content
|
||||
: view.card === 'web'
|
||||
// A web resultView is only assigned alongside this.result (the result
|
||||
// handler sets both) and the pending callView is never a web card, so
|
||||
// the optional-chain undefined side is unreachable here.
|
||||
/* v8 ignore next */
|
||||
? this.result?.content
|
||||
: undefined
|
||||
const unknownXml = this.definition === undefined && markdownContent !== undefined
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(genericContent)),
|
||||
displayText(contentText(markdownContent)),
|
||||
this.maxOutputLines,
|
||||
this.visibility === 'expanded',
|
||||
displayText,
|
||||
@@ -405,7 +418,7 @@ export class ToolCardComponent implements Component {
|
||||
// A generic card renders title and result as one Markdown document, so the
|
||||
// document's own block spacing is preserved, then dims every row — the whole
|
||||
// card body reads as one dim block under the status-colored header.
|
||||
const body = unknownXml ?? (genericContent !== undefined && rawBody.lines.length > 0
|
||||
const body = unknownXml ?? (markdownContent !== undefined && rawBody.lines.length > 0
|
||||
? this.dimBody(rawBody, width)
|
||||
: [...rawBody.prelude, ...rawBody.lines])
|
||||
const visibleBody = unknownXml !== undefined || this.visibility === 'expanded'
|
||||
@@ -502,7 +515,11 @@ export class ToolCardComponent implements Component {
|
||||
// rather than under the dim result-output color.
|
||||
return { prelude: [...hunks, footer], lines: [] }
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
// The web card carries no `content` copy, so a `web` result view falls back
|
||||
// to the raw result content here (`view.card === 'generic'` narrows the
|
||||
// generic union arm; a `web` card takes the same fallback, mirroring the
|
||||
// `markdownContent` selection in render()).
|
||||
const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
|
||||
const prelude: string[] = []
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
|
||||
@@ -4376,6 +4376,14 @@ describe('tool cards and surface replay', () => {
|
||||
name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Known XML' }),
|
||||
},
|
||||
// A web card carries no `content` copy, so it falls back to the raw result
|
||||
// content, which must still render through the dim Markdown path (bold
|
||||
// markers stripped) rather than as bare text.
|
||||
webCard: {
|
||||
name: 'webCard', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Fetch page', kind: 'fetch' }),
|
||||
presentResult: () => ({ card: 'web', kind: 'fetch', title: 'https://a.test', url: 'https://a.test', statusCode: 200, truncated: false }),
|
||||
},
|
||||
}
|
||||
|
||||
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
|
||||
@@ -4396,6 +4404,7 @@ describe('tool cards and surface replay', () => {
|
||||
['c11', 'terminalResult', '{}'],
|
||||
['c12', 'symbolic', '{}'],
|
||||
['c13', 'knownXml', '{}'],
|
||||
['c16', 'webCard', '{}'],
|
||||
] as const
|
||||
appendAssistant(result.session, [
|
||||
{ type: 'text', text: 'Calling tools' },
|
||||
@@ -4489,6 +4498,14 @@ describe('tool cards and surface replay', () => {
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: 'c16' as never,
|
||||
content: [{ type: 'text', text: 'Fetched **body** text' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
result.session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
@@ -4538,6 +4555,11 @@ describe('tool cards and surface replay', () => {
|
||||
expect(output).toContain('Empty card')
|
||||
expect(output).toContain('converted terminal')
|
||||
expect(output).toContain('<known><value>literal</value></known>')
|
||||
// A web card carries no `content` copy, so it falls back to the raw result
|
||||
// content, which still renders through the dim Markdown path: the bold
|
||||
// markers are stripped rather than shown literally.
|
||||
expect(output).toContain('Fetched body text')
|
||||
expect(output).not.toContain('Fetched **body** text')
|
||||
expect(output).toContain('path: /tmp/a.txt')
|
||||
expect(output).toContain('line (number="1"): hello')
|
||||
expect(output).not.toContain('<result>')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }`).
|
||||
|
||||
|
||||
@@ -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 }`)。
|
||||
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user