tool-web.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  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. WEB_SEARCH_MAX_RESULTS,
  18. } from '@deepseek-ai/dsh-tool-web'
  19. const testToolSignal = new AbortController().signal
  20. const available = true
  21. function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider {
  22. return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) }
  23. }
  24. /** Mount the real registry, seam, and tool-web; return an executor helper. */
  25. async function mountTools(opts: {
  26. config?: ToolWeb.Config
  27. webConfig?: ConstructorParameters<typeof WebService>[1]
  28. search?: WebSearchProvider
  29. fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider
  30. } = {}): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>>; call: (name: string, args: unknown) => Promise<ToolExecutionResult> }> {
  31. const ctx = new Context()
  32. await ctx.plugin(SystemPrompt)
  33. await ctx.plugin(ToolRegistry)
  34. await ctx.plugin(WebService, opts.webConfig ?? {})
  35. if (opts.search) ctx.web.registerSearchProvider(opts.search)
  36. if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider)
  37. const fiber = await ctx.plugin(ToolWeb, opts.config ?? {})
  38. let counter = 0
  39. const call = (name: string, args: unknown) => ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args })
  40. return { ctx, fiber, call }
  41. }
  42. describe('search formatting', () => {
  43. it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => {
  44. const out = formatSearchOutput({
  45. content: 'an answer', truncated: false,
  46. sources: [
  47. { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
  48. { url: 'https://b.test/y' },
  49. ],
  50. })
  51. expect(out).toContain('an answer')
  52. expect(out).toContain('[A](https://a.test/x) — about a (2026-01-01)')
  53. expect(out).toContain('[b.test](https://b.test/y)')
  54. expect(out).toContain('Cite the relevant URLs')
  55. })
  56. it('reports no results when there is neither content nor sources', () => {
  57. expect(formatSearchOutput({ sources: [], truncated: false }))
  58. .toContain('No results found.')
  59. })
  60. it('renders content alone when there are no sources', () => {
  61. const out = formatSearchOutput({ content: 'just an answer', sources: [], truncated: false })
  62. expect(out).toContain('just an answer')
  63. expect(out).not.toContain('No results found.')
  64. expect(out).not.toContain('Sources:')
  65. })
  66. it('notes truncation', () => {
  67. const out = formatSearchOutput({ sources: [{ url: 'https://a.test' }], truncated: true })
  68. expect(out).toContain('Showing the first 1 sources')
  69. })
  70. it('validates the query', () => {
  71. expect(() => parseSearchArgs({ query: ' ' })).toThrow('non-empty')
  72. expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' })
  73. })
  74. it('falls back to the raw URL as a source label when the URL is unparseable', () => {
  75. const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
  76. expect(out).toContain('[not a url](not a url)')
  77. })
  78. it('presents a search call as a search-kind card titled by the query', () => {
  79. expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' })
  80. })
  81. })
  82. describe('fetch formatting', () => {
  83. const NO_CAP = 1_000_000
  84. const HEADER = 'Fetched https://a.test (HTTP 200)\n\n'
  85. const renderHtml = (content: string) => formatFetchOutput({
  86. url: 'https://a.test', statusCode: 200, truncated: false,
  87. body: { kind: 'html', content },
  88. }, NO_CAP).slice(HEADER.length)
  89. it('renders an html body to markdown text with a status header', () => {
  90. const out = formatFetchOutput({
  91. url: 'https://a.test', statusCode: 200, truncated: false,
  92. body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
  93. }, NO_CAP)
  94. expect(out).toContain('Fetched https://a.test (HTTP 200)')
  95. expect(out).toContain('# Title')
  96. expect(out).toContain('Body text')
  97. })
  98. it('passes a text body through and notes truncation', () => {
  99. const out = formatFetchOutput({
  100. url: 'https://a.test', statusCode: 200, truncated: true,
  101. body: { kind: 'text', content: 'plain' },
  102. }, NO_CAP)
  103. expect(out).toContain('plain')
  104. expect(out).toContain('Content truncated')
  105. })
  106. it('caps the complete output and notes truncation, even when markdown escaping expands the body', () => {
  107. // 1,000 underscores render as 2,000 escaped characters — conversion can
  108. // outgrow a provider-side body cap, so the bound applies to the output.
  109. const out = formatFetchOutput({
  110. url: 'https://a.test', statusCode: 200, truncated: false,
  111. body: { kind: 'html', content: `<p>${'_'.repeat(1000)}</p>` },
  112. }, 500)
  113. expect(out.length).toBeLessThanOrEqual(500)
  114. expect(out).toContain('Fetched https://a.test (HTTP 200)')
  115. expect(out).toContain('\\_\\_')
  116. expect(out).toContain('Content truncated')
  117. // Exact and tiny caps: the complete result is bounded, header and footer included.
  118. const exact = formatFetchOutput({
  119. url: 'https://a.test', statusCode: 200, truncated: false,
  120. body: { kind: 'text', content: 'abc' },
  121. }, 'Fetched https://a.test (HTTP 200)\n\nabc'.length)
  122. expect(exact).toBe('Fetched https://a.test (HTTP 200)\n\nabc')
  123. const tiny = formatFetchOutput({
  124. url: 'https://a.test', statusCode: 200, truncated: true,
  125. body: { kind: 'text', content: 'abcdef' },
  126. }, 10)
  127. expect(tiny.length).toBeLessThanOrEqual(10)
  128. expect(tiny).toBe('Fetched ht')
  129. })
  130. it('dispatches text and html bodies', () => {
  131. expect(formatFetchOutput({
  132. url: 'https://a.test', statusCode: 200, truncated: false,
  133. body: { kind: 'text', content: 'x' },
  134. }, NO_CAP)).toBe(`${HEADER}x`)
  135. expect(renderHtml('<p>y</p>')).toBe('y')
  136. })
  137. it('converts html via turndown: entities, links, tables, nesting; drops script/style/noscript', () => {
  138. 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>'))
  139. .toBe('Tom & Jerry © Résumé\n\n[link](https://a.test)')
  140. expect(renderHtml('<h2>Heading</h2><ul><li>one</li><li>two</li></ul>'))
  141. .toBe('## Heading\n\n- one\n- two')
  142. expect(renderHtml('<table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>'))
  143. .toBe('| A | B |\n| --- | --- |\n| 1 | 2 |')
  144. 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>'))
  145. .toBe('| L | R | C |\n| :--- | ---: | :---: |\n| 1 | 2 | 3 |')
  146. expect(renderHtml('<p><strong>bold <em>italic</em></strong></p><blockquote><p>quoted</p></blockquote>'))
  147. .toBe('**bold _italic_**\n\n> quoted')
  148. })
  149. it('does not expand numeric colspan attributes into unbounded output', () => {
  150. const table = '<table><thead><tr><th colspan="1000000">A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>'
  151. expect(renderHtml(table)).toBe('| A |\n| --- |\n| B |')
  152. })
  153. it('passes deeply nested html through raw without attempting conversion', () => {
  154. // Unclosed-tag nesting makes the synchronous conversion superlinear
  155. // (seconds at 20k levels, during which the cooperative timeout cannot
  156. // fire), so the depth preflight skips conversion entirely; this must
  157. // return fast, not merely not-throw.
  158. const depth = 20_000
  159. const pathological = '<div>'.repeat(depth) + 'x' + '</div>'.repeat(depth)
  160. const started = Date.now()
  161. expect(formatFetchOutput({
  162. url: 'https://a.test', statusCode: 200, truncated: false,
  163. body: { kind: 'html', content: pathological },
  164. }, NO_CAP)).toBe(`${HEADER}${pathological}`)
  165. expect(Date.now() - started).toBeLessThan(2_000)
  166. })
  167. it('comments and mismatched closing tags cannot hide deep nesting from the preflight', () => {
  168. const pathological = '<div><!-- </div> --></span>'.repeat(600) + 'x'
  169. expect(formatFetchOutput({
  170. url: 'https://a.test', statusCode: 200, truncated: false,
  171. body: { kind: 'html', content: pathological },
  172. }, NO_CAP)).toBe(`${HEADER}${pathological}`)
  173. const abruptlyClosedComments = '<div><!-->'.repeat(600) + 'x'
  174. expect(formatFetchOutput({
  175. url: 'https://a.test', statusCode: 200, truncated: false,
  176. body: { kind: 'html', content: abruptlyClosedComments },
  177. }, NO_CAP)).toBe(`${HEADER}${abruptlyClosedComments}`)
  178. })
  179. it('the preflight accepts ordinary closed, void, self-closing, quoted, and raw-text markup', () => {
  180. const paragraphs = '<p title=\'>\'>x<br ><img src="x"><input/></p>'.repeat(600)
  181. const script = `<script>const invalid = '</scriptx>'; const template = '${'<div>'.repeat(600)}'</script >`
  182. expect(renderHtml(`<!doctype html><?pi><1bad>${paragraphs}${script}`))
  183. .not.toContain('<p')
  184. expect(renderHtml('plain text')).toBe('plain text')
  185. expect(renderHtml('<p>x</p><!-- unfinished')).toBe('x')
  186. expect(renderHtml('<script>unclosed')).toBe('')
  187. expect(renderHtml('<script>closed by slash</script/>')).toBe('')
  188. expect(renderHtml('<script>closed at end</script')).toBe('')
  189. })
  190. it('scans malformed unterminated tags in bounded time', () => {
  191. const malformed = '<a'.repeat(100_000)
  192. const started = Date.now()
  193. const out = formatFetchOutput({
  194. url: 'https://a.test', statusCode: 200, truncated: false,
  195. body: { kind: 'html', content: malformed },
  196. }, 200_000)
  197. expect(out.length).toBeLessThanOrEqual(200_000)
  198. expect(Date.now() - started).toBeLessThan(2_000)
  199. })
  200. it('falls back to the raw html when turndown throws despite a shallow depth scan', () => {
  201. const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockImplementation(() => {
  202. throw new RangeError('Maximum call stack size exceeded')
  203. })
  204. try {
  205. expect(formatFetchOutput({
  206. url: 'https://a.test', statusCode: 200, truncated: false,
  207. body: { kind: 'html', content: '<p>x</p>' },
  208. }, NO_CAP)).toBe(`${HEADER}<p>x</p>`)
  209. } finally {
  210. spy.mockRestore()
  211. }
  212. })
  213. it('bounds source conversion work before rendering a custom provider body', () => {
  214. const spy = vi.spyOn(TurndownService.prototype, 'turndown').mockReturnValue('converted')
  215. try {
  216. const out = formatFetchOutput({
  217. url: 'https://a.test', statusCode: 200, truncated: false,
  218. body: { kind: 'html', content: `<p>${'x'.repeat(10_000)}</p>` },
  219. }, 500)
  220. expect(spy).toHaveBeenCalledWith(`<p>${'x'.repeat(497)}`)
  221. expect(out.length).toBeLessThanOrEqual(500)
  222. expect(out).toContain('Content truncated')
  223. } finally {
  224. spy.mockRestore()
  225. }
  226. })
  227. it('validates url (non-empty), no timeout parameter', () => {
  228. expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty')
  229. expect(parseFetchArgs({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' })
  230. })
  231. it('presents a fetch call as a fetch-kind card titled by the url', () => {
  232. expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ card: 'generic', title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' })
  233. })
  234. })
  235. describe('tool-web registration', () => {
  236. it('registers both tools by default', async () => {
  237. const { fiber, ctx } = await mountTools()
  238. const names = ctx.tools.schemas().map(s => s.name)
  239. expect(names).toContain('web_search')
  240. expect(names).toContain('web_fetch')
  241. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
  242. .toEqual({ kind: 'parallel' })
  243. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
  244. .toEqual({ kind: 'parallel' })
  245. await fiber.dispose()
  246. expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search')
  247. })
  248. it('registers only enabled tools', async () => {
  249. const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
  250. const names = ctx.tools.schemas().map(s => s.name)
  251. expect(names).toContain('web_search')
  252. expect(names).not.toContain('web_fetch')
  253. await fiber.dispose()
  254. })
  255. it('registers only web_fetch when search is disabled', async () => {
  256. const { fiber, ctx } = await mountTools({ config: { search: false, fetch: true } })
  257. const names = ctx.tools.schemas().map(s => s.name)
  258. expect(names).not.toContain('web_search')
  259. expect(names).toContain('web_fetch')
  260. await fiber.dispose()
  261. })
  262. it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => {
  263. const { fiber, ctx, call } = await mountTools()
  264. expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
  265. // No provider is registered: the schema stays visible and execution reports
  266. // the structured unavailability instead.
  267. const out = await call('web_search', { query: 'q' })
  268. expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
  269. await fiber.dispose()
  270. })
  271. it('contributes prompt sections for the enabled tools', async () => {
  272. const { fiber, ctx } = await mountTools()
  273. const prompt = await ctx.systemPrompt.assemble()
  274. const text = prompt.sections.map(s => s.text).join('\n')
  275. expect(text).toContain('web_search')
  276. expect(text).toContain('web_fetch')
  277. await fiber.dispose()
  278. })
  279. })
  280. describe('tool-web execution through the real registry', () => {
  281. it('executes web_search and formats the result', async () => {
  282. const result: WebSearchResult = {
  283. content: 'answer', truncated: false,
  284. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
  285. }
  286. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
  287. const out = await call('web_search', { query: 'q' })
  288. expect(out.isError).toBe(false)
  289. expect(out.value).toEqual(result)
  290. expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[A](https://a.test)')
  291. await fiber.dispose()
  292. })
  293. it('surfaces a structured WebError when no provider is available', async () => {
  294. const { fiber, call } = await mountTools()
  295. const out = await call('web_search', { query: 'q' })
  296. expect(out.isError).toBe(true)
  297. expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
  298. await fiber.dispose()
  299. })
  300. it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
  301. const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
  302. ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
  303. const out = await call('web_search', { query: 'q' })
  304. expect(out.isError).toBe(true)
  305. expect(out.error?.info?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
  306. await fiber.dispose()
  307. })
  308. it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
  309. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
  310. const out = await call('web_search', { query: 123 })
  311. expect(out.isError).toBe(true)
  312. expect(out.error?.info?.code).toBe('INVALID_ARGS')
  313. await fiber.dispose()
  314. })
  315. it('has no default export (namespace plugin export shape)', () => {
  316. expect('default' in ToolWeb).toBe(false)
  317. })
  318. it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => {
  319. const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {}
  320. const fetchProvider = {
  321. id: 'stub-fetch',
  322. available: () => available,
  323. fetch: (request: { url: string }, signal?: AbortSignal) => {
  324. seen.request = request
  325. seen.signal = signal
  326. return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
  327. },
  328. }
  329. const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  330. const controller = new AbortController()
  331. const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test' }, signal: controller.signal })
  332. expect(out.isError).toBe(false)
  333. expect(out.value).toEqual({
  334. url: 'https://a.test',
  335. statusCode: 200,
  336. body: { kind: 'text', content: 'ok' },
  337. truncated: false,
  338. })
  339. // The model schema exposes no timeout: the tool forwards only the url; the
  340. // tool-call budget is owned by dsh-timeout-policy over exec.signal.
  341. expect(seen.request).toEqual({ url: 'https://a.test' })
  342. expect(seen.signal).toBe(controller.signal)
  343. await fiber.dispose()
  344. })
  345. it('forwards the required caller signal to web_fetch', async () => {
  346. const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {}
  347. const fetchProvider = {
  348. id: 'stub-fetch',
  349. available: () => available,
  350. fetch: (request: { url: string }, signal?: AbortSignal) => {
  351. seen.passedSignal = signal !== undefined
  352. seen.signal = signal
  353. return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
  354. },
  355. }
  356. const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  357. const out = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
  358. expect(out.isError).toBe(false)
  359. expect(out.value).toEqual({
  360. url: 'https://a.test',
  361. statusCode: 200,
  362. body: { kind: 'text', content: 'ok' },
  363. truncated: false,
  364. })
  365. expect(seen.passedSignal).toBe(true)
  366. expect(seen.signal).toBe(testToolSignal)
  367. await fiber.dispose()
  368. })
  369. it('executes web_search, forwarding the abort signal to the seam', async () => {
  370. const seen: { signal?: AbortSignal | undefined } = {}
  371. const provider: WebSearchProvider = {
  372. id: 'stub-search',
  373. available: () => available,
  374. search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) },
  375. }
  376. const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
  377. const controller = new AbortController()
  378. await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal })
  379. expect(seen.signal).toBe(controller.signal)
  380. await fiber.dispose()
  381. })
  382. })
  383. describe('searchMaxResults is plugin config', () => {
  384. it('forwards the default cap to the seam when unconfigured', async () => {
  385. const seen: { maxResults?: number | undefined } = {}
  386. const provider: WebSearchProvider = {
  387. id: 'stub-search',
  388. available: () => available,
  389. search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
  390. }
  391. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
  392. await call('web_search', { query: 'q' })
  393. expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS)
  394. await fiber.dispose()
  395. })
  396. it('forwards a configured cap to the seam, which enforces it', async () => {
  397. const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
  398. const provider: WebSearchProvider = {
  399. id: 'stub-search',
  400. available: () => available,
  401. search: () => Promise.resolve({ sources, truncated: false }),
  402. }
  403. const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
  404. const out = await call('web_search', { query: 'q' })
  405. expect(out.isError).toBe(false)
  406. const body = out.content.map(b => b.type === 'text' ? b.text : '').join('')
  407. expect(body).toContain('https://s1.test')
  408. expect(body).not.toContain('https://s2.test')
  409. expect(body).toContain('Showing the first 2 sources.')
  410. await fiber.dispose()
  411. })
  412. it.each([
  413. ['zero', 0],
  414. ['negative', -3],
  415. ['fractional', 1.5],
  416. ])('rejects a %s searchMaxResults at load', async (_label, value) => {
  417. const ctx = new Context()
  418. await ctx.plugin(SystemPrompt)
  419. await ctx.plugin(ToolRegistry)
  420. await ctx.plugin(WebService, {})
  421. await expect(ctx.plugin(ToolWeb, { searchMaxResults: value }))
  422. .rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
  423. })
  424. })
  425. describe('tool-call timeout budget is plugin config', () => {
  426. it('attaches the default 30s budget to web_fetch and web_search', async () => {
  427. const { fiber, ctx } = await mountTools()
  428. expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(30_000)
  429. expect(ctx.tools.get('web_search')?.timeoutMs).toBe(30_000)
  430. await fiber.dispose()
  431. })
  432. it('honors per-tool timeout overrides from config', async () => {
  433. const { fiber, ctx } = await mountTools({ config: { fetchTimeoutMs: 60_000, searchTimeoutMs: 10_000 } })
  434. expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(60_000)
  435. expect(ctx.tools.get('web_search')?.timeoutMs).toBe(10_000)
  436. await fiber.dispose()
  437. })
  438. it.each([
  439. ['fetchTimeoutMs', { fetchTimeoutMs: 0 }],
  440. ['searchTimeoutMs', { searchTimeoutMs: -5 }],
  441. ])('rejects a non-positive-integer %s at load', async (key, config) => {
  442. const ctx = new Context()
  443. await ctx.plugin(SystemPrompt)
  444. await ctx.plugin(ToolRegistry)
  445. await ctx.plugin(WebService, {})
  446. await expect(ctx.plugin(ToolWeb, config))
  447. .rejects.toThrow(new RegExp(`tool-web: ${key} must be a positive integer`))
  448. })
  449. })
  450. describe('fetchMaxOutputChars is plugin config', () => {
  451. it('bounds the rendered output of the registered web_fetch tool', async () => {
  452. const fetchProvider = {
  453. id: 'stub-fetch',
  454. available: () => available,
  455. fetch: (request: { url: string }) => Promise.resolve({
  456. url: request.url,
  457. statusCode: 200,
  458. body: { kind: 'html' as const, content: `<p>${'_'.repeat(1_000)}</p>` },
  459. truncated: false,
  460. }),
  461. }
  462. const { fiber, call } = await mountTools({
  463. config: { fetchMaxOutputChars: 100 },
  464. webConfig: { fetchProvider: 'stub-fetch' },
  465. fetchProvider,
  466. })
  467. const out = await call('web_fetch', { url: 'https://a.test' })
  468. expect(out.content.map(block => block.type === 'text' ? block.text : '').join('')).toHaveLength(100)
  469. await fiber.dispose()
  470. })
  471. it.each([0, -1, 1.5])('rejects an invalid fetchMaxOutputChars value %s at load', async (value) => {
  472. const ctx = new Context()
  473. await ctx.plugin(SystemPrompt)
  474. await ctx.plugin(ToolRegistry)
  475. await ctx.plugin(WebService, {})
  476. await expect(ctx.plugin(ToolWeb, { fetchMaxOutputChars: value }))
  477. .rejects.toThrow(/tool-web: fetchMaxOutputChars must be a positive integer/)
  478. })
  479. })