tool-web.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import TurndownService from 'turndown'
  4. import { CallId } from '@deepseek-ai/dsh-llm'
  5. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  6. import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  7. import WebService from '@deepseek-ai/dsh-web'
  8. import type { WebSearchProvider, WebSearchResult } from '@deepseek-ai/dsh-web'
  9. import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
  10. import {
  11. formatSearchOutput,
  12. formatFetchOutput,
  13. parseSearchArgs,
  14. parseFetchArgs,
  15. presentSearchCall,
  16. presentFetchCall,
  17. presentSearchResult,
  18. presentFetchResult,
  19. searchMetaFromValue,
  20. searchMetaFromResult,
  21. fetchMetaFromValue,
  22. fetchMetaFromResult,
  23. WEB_SEARCH_MAX_RESULTS,
  24. } from '@deepseek-ai/dsh-tool-web'
  25. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  26. import type { ToolResult } from '@deepseek-ai/dsh-tools'
  27. const testToolSignal = new AbortController().signal
  28. const available = true
  29. function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider {
  30. return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) }
  31. }
  32. /** Mount the real registry, seam, and tool-web; return an executor helper. */
  33. async function mountTools(opts: {
  34. config?: ToolWeb.Config
  35. webConfig?: ConstructorParameters<typeof WebService>[1]
  36. search?: WebSearchProvider
  37. fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider
  38. } = {}): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>>; call: (name: string, args: unknown) => Promise<ToolExecutionResult> }> {
  39. const ctx = new Context()
  40. await ctx.plugin(SystemPrompt)
  41. await ctx.plugin(ToolRegistry)
  42. await ctx.plugin(WebService, opts.webConfig ?? {})
  43. if (opts.search) ctx.web.registerSearchProvider(opts.search)
  44. if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider)
  45. const fiber = await ctx.plugin(ToolWeb, opts.config ?? {})
  46. let counter = 0
  47. const call = (name: string, args: unknown) => ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args })
  48. return { ctx, fiber, call }
  49. }
  50. describe('search formatting', () => {
  51. it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => {
  52. const out = formatSearchOutput({
  53. content: 'an answer', truncated: false,
  54. sources: [
  55. { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
  56. { url: 'https://b.test/y' },
  57. ],
  58. })
  59. expect(out).toContain('an answer')
  60. expect(out).toContain('[A](https://a.test/x) — about a (2026-01-01)')
  61. expect(out).toContain('[b.test](https://b.test/y)')
  62. expect(out).toContain('Cite the relevant URLs')
  63. })
  64. it('reports no results when there is neither content nor sources', () => {
  65. expect(formatSearchOutput({ sources: [], truncated: false }))
  66. .toContain('No results found.')
  67. })
  68. it('renders content alone when there are no sources', () => {
  69. const out = formatSearchOutput({ content: 'just an answer', sources: [], truncated: false })
  70. expect(out).toContain('just an answer')
  71. expect(out).not.toContain('No results found.')
  72. expect(out).not.toContain('Sources:')
  73. })
  74. it('notes truncation', () => {
  75. const out = formatSearchOutput({ sources: [{ url: 'https://a.test' }], truncated: true })
  76. expect(out).toContain('Showing the first 1 sources')
  77. })
  78. it('validates the query', () => {
  79. expect(() => parseSearchArgs({ query: ' ' })).toThrow('non-empty')
  80. expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' })
  81. })
  82. it('falls back to the raw URL as a source label when the URL is unparseable', () => {
  83. const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
  84. expect(out).toContain('[not a url](not a url)')
  85. })
  86. it('presents a search call as a search-kind card titled by the query', () => {
  87. expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' })
  88. })
  89. })
  90. /** Build a completed non-error tool result with the given meta and text content. */
  91. function toolResult(meta: unknown, text = 'body', isError = false): ToolResult {
  92. const content: ContentBlock[] = [{ type: 'text', text }]
  93. return { content, isError, ...meta !== undefined ? { meta: meta as never } : {} }
  94. }
  95. describe('web_search presentation meta and result view', () => {
  96. it('projects sources, answer, and truncation into meta, omitting absent optional fields', () => {
  97. const meta = searchMetaFromValue({
  98. content: 'an answer', truncated: true,
  99. sources: [
  100. { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
  101. { url: 'https://b.test/y' },
  102. ],
  103. })
  104. expect(meta).toEqual({
  105. answer: 'an answer',
  106. truncated: true,
  107. sources: [
  108. { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
  109. { url: 'https://b.test/y' },
  110. ],
  111. })
  112. })
  113. it('omits answer from meta when the provider returned none', () => {
  114. const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
  115. expect(meta).toEqual({ truncated: false, sources: [{ url: 'https://a.test' }] })
  116. })
  117. it('round-trips projected meta back to a typed search meta', () => {
  118. const value = {
  119. content: 'ans', truncated: false,
  120. sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
  121. }
  122. expect(searchMetaFromResult(searchMetaFromValue(value))).toEqual({
  123. answer: 'ans', truncated: false,
  124. sources: [{ url: 'https://a.test', title: 'A', snippet: 's', publishedAt: '2026-01-01' }],
  125. })
  126. })
  127. it('presents a completed search as a web/search card carrying the structured sources, titled by the query', () => {
  128. const meta = searchMetaFromValue({
  129. content: 'an answer', truncated: true,
  130. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
  131. })
  132. expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'rendered'))).toEqual({
  133. card: 'web',
  134. kind: 'search',
  135. title: 'q',
  136. answer: 'an answer',
  137. truncated: true,
  138. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
  139. })
  140. })
  141. it('omits the answer from the view when meta carries none', () => {
  142. const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
  143. const view = presentSearchResult({ query: 'q' }, toolResult(meta))
  144. expect(view).toBeDefined()
  145. expect(view && 'answer' in view).toBe(false)
  146. expect(view && 'content' in view).toBe(false)
  147. })
  148. it('falls back to the generic card on an error result', () => {
  149. const meta = searchMetaFromValue({ truncated: false, sources: [{ url: 'https://a.test' }] })
  150. expect(presentSearchResult({ query: 'q' }, toolResult(meta, 'body', true))).toBeUndefined()
  151. })
  152. it('falls back to the generic card on absent or malformed meta', () => {
  153. expect(presentSearchResult({ query: 'q' }, toolResult(undefined))).toBeUndefined()
  154. expect(searchMetaFromResult(undefined)).toBeUndefined()
  155. expect(searchMetaFromResult(null)).toBeUndefined()
  156. expect(searchMetaFromResult('nope')).toBeUndefined()
  157. expect(searchMetaFromResult([])).toBeUndefined()
  158. expect(searchMetaFromResult({})).toBeUndefined()
  159. expect(searchMetaFromResult({ sources: 'x', truncated: false })).toBeUndefined()
  160. expect(searchMetaFromResult({ sources: [], truncated: 'no' })).toBeUndefined()
  161. expect(searchMetaFromResult({ sources: [], truncated: false, answer: 1 })).toBeUndefined()
  162. expect(searchMetaFromResult({ sources: [null], truncated: false })).toBeUndefined()
  163. expect(searchMetaFromResult({ sources: [{ url: 1 }], truncated: false })).toBeUndefined()
  164. expect(searchMetaFromResult({ sources: [{ url: 'u', title: 2 }], truncated: false })).toBeUndefined()
  165. expect(searchMetaFromResult({ sources: [{ url: 'u', snippet: 2 }], truncated: false })).toBeUndefined()
  166. expect(searchMetaFromResult({ sources: [{ url: 'u', publishedAt: 2 }], truncated: false })).toBeUndefined()
  167. })
  168. it('accepts an empty source list as valid meta', () => {
  169. expect(searchMetaFromResult({ sources: [], truncated: false })).toEqual({ sources: [], truncated: false })
  170. })
  171. })
  172. describe('fetch formatting', () => {
  173. const NO_CAP = 1_000_000
  174. const HEADER = 'Fetched https://a.test (HTTP 200)\n\n'
  175. const renderHtml = (content: string) => formatFetchOutput({
  176. url: 'https://a.test', statusCode: 200, truncated: false,
  177. body: { kind: 'html', content },
  178. }, NO_CAP).slice(HEADER.length)
  179. it('renders an html body to markdown text with a status header', () => {
  180. const out = formatFetchOutput({
  181. url: 'https://a.test', statusCode: 200, truncated: false,
  182. body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
  183. }, NO_CAP)
  184. expect(out).toContain('Fetched https://a.test (HTTP 200)')
  185. expect(out).toContain('# Title')
  186. expect(out).toContain('Body text')
  187. })
  188. it('passes a text body through and notes truncation', () => {
  189. const out = formatFetchOutput({
  190. url: 'https://a.test', statusCode: 200, truncated: true,
  191. body: { kind: 'text', content: 'plain' },
  192. }, NO_CAP)
  193. expect(out).toContain('plain')
  194. expect(out).toContain('Content truncated')
  195. })
  196. it('caps the complete output and notes truncation, even when markdown escaping expands the body', () => {
  197. // 1,000 underscores render as 2,000 escaped characters — conversion can
  198. // outgrow a provider-side body cap, so the bound applies to the output.
  199. const out = formatFetchOutput({
  200. url: 'https://a.test', statusCode: 200, truncated: false,
  201. body: { kind: 'html', content: `<p>${'_'.repeat(1000)}</p>` },
  202. }, 500)
  203. expect(out.length).toBeLessThanOrEqual(500)
  204. expect(out).toContain('Fetched https://a.test (HTTP 200)')
  205. expect(out).toContain('\\_\\_')
  206. expect(out).toContain('Content truncated')
  207. // Exact and tiny caps: the complete result is bounded, header and footer included.
  208. const exact = formatFetchOutput({
  209. url: 'https://a.test', statusCode: 200, truncated: false,
  210. body: { kind: 'text', content: 'abc' },
  211. }, 'Fetched https://a.test (HTTP 200)\n\nabc'.length)
  212. expect(exact).toBe('Fetched https://a.test (HTTP 200)\n\nabc')
  213. const tiny = formatFetchOutput({
  214. url: 'https://a.test', statusCode: 200, truncated: true,
  215. body: { kind: 'text', content: 'abcdef' },
  216. }, 10)
  217. expect(tiny.length).toBeLessThanOrEqual(10)
  218. expect(tiny).toBe('Fetched ht')
  219. })
  220. it('dispatches text and html bodies', () => {
  221. expect(formatFetchOutput({
  222. url: 'https://a.test', statusCode: 200, truncated: false,
  223. body: { kind: 'text', content: 'x' },
  224. }, NO_CAP)).toBe(`${HEADER}x`)
  225. expect(renderHtml('<p>y</p>')).toBe('y')
  226. })
  227. it('converts html via turndown: entities, links, tables, nesting; drops script/style/noscript', () => {
  228. expect(renderHtml('<style>.x{}</style><script>bad()</script><noscript>ns</noscript><p>Tom &amp; Jerry &copy; R&eacute;sum&eacute;</p><a href="https://a.test">link</a>'))
  229. .toBe('Tom & Jerry © Résumé\n\n[link](https://a.test)')
  230. expect(renderHtml('<h2>Heading</h2><ul><li>one</li><li>two</li></ul>'))
  231. .toBe('## Heading\n\n- one\n- two')
  232. expect(renderHtml('<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>'))
  233. .toBe('| A | B |\n| --- | --- |\n| 1 | 2 |')
  234. expect(renderHtml('<table><thead><tr><th align="left">L</th><th align="right">R</th><th style="text-align:center">C</th></tr></thead><tbody><tr><td>1</td><td>2</td><td>3</td></tr></tbody></table>'))
  235. .toBe('| L | R | C |\n| :--- | ---: | :---: |\n| 1 | 2 | 3 |')
  236. expect(renderHtml('<p><strong>bold <em>italic</em></strong></p><blockquote><p>quoted</p></blockquote>'))
  237. .toBe('**bold _italic_**\n\n> quoted')
  238. })
  239. it('does not expand numeric colspan attributes into unbounded output', () => {
  240. const table = '<table><thead><tr><th colspan="1000000">A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'
  241. expect(renderHtml(table)).toBe('| A |\n| --- |\n| B |')
  242. })
  243. it('passes deeply nested html through raw without attempting conversion', () => {
  244. // Unclosed-tag nesting makes the synchronous conversion superlinear
  245. // (seconds at 20k levels, during which the cooperative timeout cannot
  246. // fire), so the depth preflight skips conversion entirely; this must
  247. // return fast, not merely not-throw.
  248. const depth = 20_000
  249. const pathological = '<div>'.repeat(depth) + 'x' + '</div>'.repeat(depth)
  250. const started = Date.now()
  251. expect(formatFetchOutput({
  252. url: 'https://a.test', statusCode: 200, truncated: false,
  253. body: { kind: 'html', content: pathological },
  254. }, NO_CAP)).toBe(`${HEADER}${pathological}`)
  255. expect(Date.now() - started).toBeLessThan(2_000)
  256. })
  257. it('comments and mismatched closing tags cannot hide deep nesting from the preflight', () => {
  258. const pathological = '<div><!-- </div> --></span>'.repeat(600) + 'x'
  259. expect(formatFetchOutput({
  260. url: 'https://a.test', statusCode: 200, truncated: false,
  261. body: { kind: 'html', content: pathological },
  262. }, NO_CAP)).toBe(`${HEADER}${pathological}`)
  263. const abruptlyClosedComments = '<div><!-->'.repeat(600) + 'x'
  264. expect(formatFetchOutput({
  265. url: 'https://a.test', statusCode: 200, truncated: false,
  266. body: { kind: 'html', content: abruptlyClosedComments },
  267. }, NO_CAP)).toBe(`${HEADER}${abruptlyClosedComments}`)
  268. })
  269. it('the preflight accepts ordinary closed, void, self-closing, quoted, and raw-text markup', () => {
  270. const paragraphs = '<p title=\'>\'>x<br ><img src="x"><input/></p>'.repeat(600)
  271. const script = `<script>const invalid = '</scriptx>'; const template = '${'<div>'.repeat(600)}'</script >`
  272. expect(renderHtml(`<!doctype html><?pi><1bad>${paragraphs}${script}`))
  273. .not.toContain('<p')
  274. expect(renderHtml('plain text')).toBe('plain text')
  275. expect(renderHtml('<p>x</p><!-- unfinished')).toBe('x')
  276. expect(renderHtml('<script>unclosed')).toBe('')
  277. expect(renderHtml('<script>closed by slash</script/>')).toBe('')
  278. expect(renderHtml('<script>closed at end</script')).toBe('')
  279. })
  280. it('scans malformed unterminated tags in bounded time', () => {
  281. const malformed = '<a'.repeat(100_000)
  282. const started = Date.now()
  283. const out = formatFetchOutput({
  284. url: 'https://a.test', statusCode: 200, truncated: false,
  285. body: { kind: 'html', content: malformed },
  286. }, 200_000)
  287. expect(out.length).toBeLessThanOrEqual(200_000)
  288. expect(Date.now() - started).toBeLessThan(2_000)
  289. })
  290. it('falls back to the raw html when turndown throws despite a shallow depth scan', () => {
  291. const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockImplementation(() => {
  292. throw new RangeError('Maximum call stack size exceeded')
  293. })
  294. try {
  295. expect(formatFetchOutput({
  296. url: 'https://a.test', statusCode: 200, truncated: false,
  297. body: { kind: 'html', content: '<p>x</p>' },
  298. }, NO_CAP)).toBe(`${HEADER}<p>x</p>`)
  299. } finally {
  300. spy.mockRestore()
  301. }
  302. })
  303. it('bounds source conversion work before rendering a custom provider body', () => {
  304. const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockReturnValue('converted')
  305. try {
  306. const out = formatFetchOutput({
  307. url: 'https://a.test', statusCode: 200, truncated: false,
  308. body: { kind: 'html', content: `<p>${'x'.repeat(10_000)}</p>` },
  309. }, 500)
  310. expect(spy).toHaveBeenCalledWith(`<p>${'x'.repeat(497)}`)
  311. expect(out.length).toBeLessThanOrEqual(500)
  312. expect(out).toContain('Content truncated')
  313. } finally {
  314. spy.mockRestore()
  315. }
  316. })
  317. it('validates url (non-empty), no timeout parameter', () => {
  318. expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty')
  319. expect(parseFetchArgs({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' })
  320. })
  321. it('presents a fetch call as a fetch-kind card titled by the url', () => {
  322. expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ card: 'generic', title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' })
  323. })
  324. })
  325. describe('web_fetch presentation meta and result view', () => {
  326. const NO_CAP = 1_000_000
  327. it('projects url, status, and the provider truncation into meta', () => {
  328. expect(fetchMetaFromValue({ url: 'https://a.test', statusCode: 404, truncated: true, body: { kind: 'text', content: 'x' } }, NO_CAP))
  329. .toEqual({ url: 'https://a.test', statusCode: 404, truncated: true })
  330. })
  331. it('projects truncated: true when the output cap cut a body the provider did not, matching the render footer', () => {
  332. // The provider reports truncated: false, but conversion outgrows the cap, so
  333. // the render text carries the truncation footer. The meta must agree.
  334. const value = {
  335. url: 'https://a.test', statusCode: 200, truncated: false,
  336. body: { kind: 'html' as const, content: `<p>${'_'.repeat(1000)}</p>` },
  337. }
  338. const meta = fetchMetaFromValue(value, 500) as { truncated: boolean }
  339. expect(meta.truncated).toBe(true)
  340. expect(formatFetchOutput(value, 500)).toContain('Content truncated')
  341. })
  342. it('projects truncated: false when neither the provider nor the cap cut the body', () => {
  343. const value = {
  344. url: 'https://a.test', statusCode: 200, truncated: false,
  345. body: { kind: 'text' as const, content: 'short' },
  346. }
  347. const meta = fetchMetaFromValue(value, NO_CAP) as { truncated: boolean }
  348. expect(meta.truncated).toBe(false)
  349. expect(formatFetchOutput(value, NO_CAP)).not.toContain('Content truncated')
  350. })
  351. it('converts one HTML body once across the render and meta projections of the same result', () => {
  352. // The registry calls output.render and output.presentationMeta with the same
  353. // frozen result value; the memo must collapse them into one turndown walk so
  354. // a large or deeply nested page is not parsed and converted twice. A second
  355. // cap on the same result is a distinct entry, so it converts again.
  356. const spy = vi.spyOn(TurndownService.prototype, 'turndown')
  357. const value = {
  358. url: 'https://a.test', statusCode: 200, truncated: false,
  359. body: { kind: 'html' as const, content: '<p>hello</p>' },
  360. }
  361. try {
  362. formatFetchOutput(value, NO_CAP)
  363. fetchMetaFromValue(value, NO_CAP)
  364. expect(spy).toHaveBeenCalledTimes(1)
  365. formatFetchOutput(value, NO_CAP - 1)
  366. expect(spy).toHaveBeenCalledTimes(2)
  367. } finally {
  368. spy.mockRestore()
  369. }
  370. })
  371. it('presents a completed fetch as a web/fetch card carrying the summary, titled by the url, without content', () => {
  372. const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: '# Title' } }, NO_CAP)
  373. expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, '# Title'))).toEqual({
  374. card: 'web',
  375. kind: 'fetch',
  376. title: 'https://a.test',
  377. url: 'https://a.test',
  378. statusCode: 200,
  379. truncated: false,
  380. })
  381. })
  382. it('falls back to the generic card on an error result', () => {
  383. const meta = fetchMetaFromValue({ url: 'https://a.test', statusCode: 200, truncated: false, body: { kind: 'text', content: 'ok' } }, NO_CAP)
  384. expect(presentFetchResult({ url: 'https://a.test' }, toolResult(meta, 'body', true))).toBeUndefined()
  385. })
  386. it('falls back to the generic card on absent or malformed meta', () => {
  387. expect(presentFetchResult({ url: 'https://a.test' }, toolResult(undefined))).toBeUndefined()
  388. expect(fetchMetaFromResult(undefined)).toBeUndefined()
  389. expect(fetchMetaFromResult(null)).toBeUndefined()
  390. expect(fetchMetaFromResult('nope')).toBeUndefined()
  391. expect(fetchMetaFromResult([])).toBeUndefined()
  392. expect(fetchMetaFromResult({})).toBeUndefined()
  393. expect(fetchMetaFromResult({ url: 1, statusCode: 200, truncated: false })).toBeUndefined()
  394. expect(fetchMetaFromResult({ url: 'u', statusCode: 'x', truncated: false })).toBeUndefined()
  395. expect(fetchMetaFromResult({ url: 'u', statusCode: 200, truncated: 'no' })).toBeUndefined()
  396. })
  397. })
  398. describe('tool-web registration', () => {
  399. it('registers both tools by default', async () => {
  400. const { fiber, ctx } = await mountTools()
  401. const names = ctx.tools.schemas().map(s => s.name)
  402. expect(names).toContain('web_search')
  403. expect(names).toContain('web_fetch')
  404. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
  405. .toEqual({ kind: 'parallel' })
  406. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
  407. .toEqual({ kind: 'parallel' })
  408. await fiber.dispose()
  409. expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search')
  410. })
  411. it('registers only enabled tools', async () => {
  412. const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
  413. const names = ctx.tools.schemas().map(s => s.name)
  414. expect(names).toContain('web_search')
  415. expect(names).not.toContain('web_fetch')
  416. await fiber.dispose()
  417. })
  418. it('registers only web_fetch when search is disabled', async () => {
  419. const { fiber, ctx } = await mountTools({ config: { search: false, fetch: true } })
  420. const names = ctx.tools.schemas().map(s => s.name)
  421. expect(names).not.toContain('web_search')
  422. expect(names).toContain('web_fetch')
  423. await fiber.dispose()
  424. })
  425. it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => {
  426. const { fiber, ctx, call } = await mountTools()
  427. expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
  428. // No provider is registered: the schema stays visible and execution reports
  429. // the structured unavailability instead.
  430. const out = await call('web_search', { query: 'q' })
  431. expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
  432. await fiber.dispose()
  433. })
  434. it('contributes prompt sections for the enabled tools', async () => {
  435. const { fiber, ctx } = await mountTools()
  436. const prompt = await ctx.systemPrompt.assemble()
  437. const text = prompt.sections.map(s => s.text).join('\n')
  438. expect(text).toContain('Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.')
  439. expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL')
  440. await fiber.dispose()
  441. })
  442. it('does not advertise web_fetch in search-only prompt guidance', async () => {
  443. const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
  444. const prompt = await ctx.systemPrompt.assemble()
  445. const text = prompt.sections.map(s => s.text).join('\n')
  446. expect(text).toContain('Use the returned source snippets when available')
  447. expect(text).not.toContain('web_fetch')
  448. await fiber.dispose()
  449. })
  450. })
  451. describe('tool-web execution through the real registry', () => {
  452. it('executes web_search and formats the result', async () => {
  453. const result: WebSearchResult = {
  454. content: 'answer', truncated: false,
  455. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
  456. }
  457. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
  458. const out = await call('web_search', { query: 'q' })
  459. expect(out.isError).toBe(false)
  460. expect(out.value).toEqual(result)
  461. expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[A](https://a.test)')
  462. await fiber.dispose()
  463. })
  464. it('projects the search sources into the tool result meta and derives its web/search view', async () => {
  465. const result: WebSearchResult = {
  466. content: 'answer', truncated: true,
  467. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
  468. }
  469. const { ctx, fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
  470. const out = await call('web_search', { query: 'q' })
  471. expect(out.meta).toEqual({
  472. answer: 'answer', truncated: true,
  473. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
  474. })
  475. const view = ctx.tools.get('web_search')?.presentResult?.({ query: 'q' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
  476. expect(view).toMatchObject({ card: 'web', kind: 'search', truncated: true, answer: 'answer' })
  477. await fiber.dispose()
  478. })
  479. it('projects the fetch summary into the tool result meta and derives its web/fetch view', async () => {
  480. const fetchProvider = {
  481. id: 'stub-fetch',
  482. available: () => available,
  483. fetch: (request: { url: string }) => Promise.resolve({
  484. url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: true,
  485. }),
  486. }
  487. const { ctx, fiber, call } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  488. const out = await call('web_fetch', { url: 'https://a.test' })
  489. expect(out.meta).toEqual({ url: 'https://a.test', statusCode: 200, truncated: true })
  490. const view = ctx.tools.get('web_fetch')?.presentResult?.({ url: 'https://a.test' }, { content: out.content, isError: out.isError, ...out.meta !== undefined ? { meta: out.meta } : {} })
  491. expect(view).toMatchObject({ card: 'web', kind: 'fetch', url: 'https://a.test', statusCode: 200, truncated: true })
  492. await fiber.dispose()
  493. })
  494. it('surfaces a structured WebError when no provider is available', async () => {
  495. const { fiber, call } = await mountTools()
  496. const out = await call('web_search', { query: 'q' })
  497. expect(out.isError).toBe(true)
  498. expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
  499. await fiber.dispose()
  500. })
  501. it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
  502. const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
  503. ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
  504. const out = await call('web_search', { query: 'q' })
  505. expect(out.isError).toBe(true)
  506. expect(out.error?.info?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
  507. await fiber.dispose()
  508. })
  509. it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
  510. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
  511. const out = await call('web_search', { query: 123 })
  512. expect(out.isError).toBe(true)
  513. expect(out.error?.info?.code).toBe('INVALID_ARGS')
  514. await fiber.dispose()
  515. })
  516. it('has no default export (namespace plugin export shape)', () => {
  517. expect('default' in ToolWeb).toBe(false)
  518. })
  519. it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => {
  520. const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {}
  521. const fetchProvider = {
  522. id: 'stub-fetch',
  523. available: () => available,
  524. fetch: (request: { url: string }, signal?: AbortSignal) => {
  525. seen.request = request
  526. seen.signal = signal
  527. return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
  528. },
  529. }
  530. const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  531. const controller = new AbortController()
  532. const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test' }, signal: controller.signal })
  533. expect(out.isError).toBe(false)
  534. expect(out.value).toEqual({
  535. url: 'https://a.test',
  536. statusCode: 200,
  537. body: { kind: 'text', content: 'ok' },
  538. truncated: false,
  539. })
  540. // The model schema exposes no timeout: the tool forwards only the url; the
  541. // tool-call budget is owned by dsh-timeout-policy over exec.signal.
  542. expect(seen.request).toEqual({ url: 'https://a.test' })
  543. expect(seen.signal).toBe(controller.signal)
  544. await fiber.dispose()
  545. })
  546. it('forwards the required caller signal to web_fetch', async () => {
  547. const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {}
  548. const fetchProvider = {
  549. id: 'stub-fetch',
  550. available: () => available,
  551. fetch: (request: { url: string }, signal?: AbortSignal) => {
  552. seen.passedSignal = signal !== undefined
  553. seen.signal = signal
  554. return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
  555. },
  556. }
  557. const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  558. const out = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
  559. expect(out.isError).toBe(false)
  560. expect(out.value).toEqual({
  561. url: 'https://a.test',
  562. statusCode: 200,
  563. body: { kind: 'text', content: 'ok' },
  564. truncated: false,
  565. })
  566. expect(seen.passedSignal).toBe(true)
  567. expect(seen.signal).toBe(testToolSignal)
  568. await fiber.dispose()
  569. })
  570. it('executes web_search, forwarding the abort signal to the seam', async () => {
  571. const seen: { signal?: AbortSignal | undefined } = {}
  572. const provider: WebSearchProvider = {
  573. id: 'stub-search',
  574. available: () => available,
  575. search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) },
  576. }
  577. const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
  578. const controller = new AbortController()
  579. await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal })
  580. expect(seen.signal).toBe(controller.signal)
  581. await fiber.dispose()
  582. })
  583. })
  584. describe('searchMaxResults is plugin config', () => {
  585. it('forwards the default cap to the seam when unconfigured', async () => {
  586. const seen: { maxResults?: number | undefined } = {}
  587. const provider: WebSearchProvider = {
  588. id: 'stub-search',
  589. available: () => available,
  590. search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
  591. }
  592. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
  593. await call('web_search', { query: 'q' })
  594. expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS)
  595. await fiber.dispose()
  596. })
  597. it('forwards a configured cap to the seam, which enforces it', async () => {
  598. const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
  599. const provider: WebSearchProvider = {
  600. id: 'stub-search',
  601. available: () => available,
  602. search: () => Promise.resolve({ sources, truncated: false }),
  603. }
  604. const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
  605. const out = await call('web_search', { query: 'q' })
  606. expect(out.isError).toBe(false)
  607. const body = out.content.map(b => b.type === 'text' ? b.text : '').join('')
  608. expect(body).toContain('https://s1.test')
  609. expect(body).not.toContain('https://s2.test')
  610. expect(body).toContain('Showing the first 2 sources.')
  611. await fiber.dispose()
  612. })
  613. it.each([
  614. ['zero', 0],
  615. ['negative', -3],
  616. ['fractional', 1.5],
  617. ])('rejects a %s searchMaxResults at load', async (_label, value) => {
  618. const ctx = new Context()
  619. await ctx.plugin(SystemPrompt)
  620. await ctx.plugin(ToolRegistry)
  621. await ctx.plugin(WebService, {})
  622. await expect(ctx.plugin(ToolWeb, { searchMaxResults: value }))
  623. .rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
  624. })
  625. })
  626. describe('tool-call timeout budget is plugin config', () => {
  627. it('attaches the default 30s budget to web_fetch and web_search', async () => {
  628. const { fiber, ctx } = await mountTools()
  629. expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(30_000)
  630. expect(ctx.tools.get('web_search')?.timeoutMs).toBe(30_000)
  631. await fiber.dispose()
  632. })
  633. it('honors per-tool timeout overrides from config', async () => {
  634. const { fiber, ctx } = await mountTools({ config: { fetchTimeoutMs: 60_000, searchTimeoutMs: 10_000 } })
  635. expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(60_000)
  636. expect(ctx.tools.get('web_search')?.timeoutMs).toBe(10_000)
  637. await fiber.dispose()
  638. })
  639. it.each([
  640. ['fetchTimeoutMs', { fetchTimeoutMs: 0 }],
  641. ['searchTimeoutMs', { searchTimeoutMs: -5 }],
  642. ])('rejects a non-positive-integer %s at load', async (key, config) => {
  643. const ctx = new Context()
  644. await ctx.plugin(SystemPrompt)
  645. await ctx.plugin(ToolRegistry)
  646. await ctx.plugin(WebService, {})
  647. await expect(ctx.plugin(ToolWeb, config))
  648. .rejects.toThrow(new RegExp(`tool-web: ${key} must be a positive integer`))
  649. })
  650. })
  651. describe('fetchMaxOutputChars is plugin config', () => {
  652. it('bounds the rendered output of the registered web_fetch tool', async () => {
  653. const fetchProvider = {
  654. id: 'stub-fetch',
  655. available: () => available,
  656. fetch: (request: { url: string }) => Promise.resolve({
  657. url: request.url,
  658. statusCode: 200,
  659. body: { kind: 'html' as const, content: `<p>${'_'.repeat(1_000)}</p>` },
  660. truncated: false,
  661. }),
  662. }
  663. const { fiber, call } = await mountTools({
  664. config: { fetchMaxOutputChars: 100 },
  665. webConfig: { fetchProvider: 'stub-fetch' },
  666. fetchProvider,
  667. })
  668. const out = await call('web_fetch', { url: 'https://a.test' })
  669. expect(out.content.map(block => block.type === 'text' ? block.text : '').join('')).toHaveLength(100)
  670. await fiber.dispose()
  671. })
  672. it.each([0, -1, 1.5])('rejects an invalid fetchMaxOutputChars value %s at load', async (value) => {
  673. const ctx = new Context()
  674. await ctx.plugin(SystemPrompt)
  675. await ctx.plugin(ToolRegistry)
  676. await ctx.plugin(WebService, {})
  677. await expect(ctx.plugin(ToolWeb, { fetchMaxOutputChars: value }))
  678. .rejects.toThrow(/tool-web: fetchMaxOutputChars must be a positive integer/)
  679. })
  680. })