tool-web.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
  6. import WebService from '@deepseek-ai/dsh-web'
  7. import type { WebSearchProvider, WebSearchResult } from '@deepseek-ai/dsh-web'
  8. import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
  9. import {
  10. formatSearchOutput,
  11. formatFetchOutput,
  12. parseSearchArgs,
  13. parseFetchArgs,
  14. presentSearchCall,
  15. presentFetchCall,
  16. renderBody,
  17. htmlToMarkdown,
  18. WEB_SEARCH_MAX_RESULTS,
  19. } from '@deepseek-ai/dsh-tool-web'
  20. const testToolSignal = new AbortController().signal
  21. const available = true
  22. function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider {
  23. return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) }
  24. }
  25. /** Mount the real registry, seam, and tool-web; return an executor helper. */
  26. async function mountTools(opts: {
  27. config?: ToolWeb.Config
  28. webConfig?: ConstructorParameters<typeof WebService>[1]
  29. search?: WebSearchProvider
  30. fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider
  31. } = {}): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>>; call: (name: string, args: unknown) => Promise<ToolExecutionResult> }> {
  32. const ctx = new Context()
  33. await ctx.plugin(SystemPrompt)
  34. await ctx.plugin(ToolRegistry)
  35. await ctx.plugin(WebService, opts.webConfig ?? {})
  36. if (opts.search) ctx.web.registerSearchProvider(opts.search)
  37. if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider)
  38. const fiber = await ctx.plugin(ToolWeb, opts.config ?? {})
  39. let counter = 0
  40. const call = (name: string, args: unknown) => ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++counter}`), name, arguments: args })
  41. return { ctx, fiber, call }
  42. }
  43. describe('search formatting', () => {
  44. it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => {
  45. const out = formatSearchOutput({
  46. content: 'an answer', truncated: false,
  47. sources: [
  48. { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
  49. { url: 'https://b.test/y' },
  50. ],
  51. })
  52. expect(out).toContain('an answer')
  53. expect(out).toContain('[A](https://a.test/x) — about a (2026-01-01)')
  54. expect(out).toContain('[b.test](https://b.test/y)')
  55. expect(out).toContain('Cite the relevant URLs')
  56. })
  57. it('reports no results when there is neither content nor sources', () => {
  58. expect(formatSearchOutput({ sources: [], truncated: false }))
  59. .toContain('No results found.')
  60. })
  61. it('renders content alone when there are no sources', () => {
  62. const out = formatSearchOutput({ content: 'just an answer', sources: [], truncated: false })
  63. expect(out).toContain('just an answer')
  64. expect(out).not.toContain('No results found.')
  65. expect(out).not.toContain('Sources:')
  66. })
  67. it('notes truncation', () => {
  68. const out = formatSearchOutput({ sources: [{ url: 'https://a.test' }], truncated: true })
  69. expect(out).toContain('Showing the first 1 sources')
  70. })
  71. it('validates the query', () => {
  72. expect(() => parseSearchArgs({ query: ' ' })).toThrow('non-empty')
  73. expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' })
  74. })
  75. it('presents a search call as a search-kind card titled by the query', () => {
  76. expect(presentSearchCall({ query: 'find me' })).toEqual({ card: 'generic', title: 'find me', kind: 'search', rawInput: 'find me' })
  77. })
  78. })
  79. describe('fetch formatting', () => {
  80. it('renders an html body to markdown text with a status header', () => {
  81. const out = formatFetchOutput({
  82. url: 'https://a.test', statusCode: 200, truncated: false,
  83. body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
  84. })
  85. expect(out).toContain('Fetched https://a.test (HTTP 200)')
  86. expect(out).toContain('# Title')
  87. expect(out).toContain('Body text')
  88. })
  89. it('passes a text body through and notes truncation', () => {
  90. const out = formatFetchOutput({
  91. url: 'https://a.test', statusCode: 200, truncated: true,
  92. body: { kind: 'text', content: 'plain' },
  93. })
  94. expect(out).toContain('plain')
  95. expect(out).toContain('Content truncated')
  96. })
  97. it('renderBody dispatches on kind', () => {
  98. expect(renderBody({ kind: 'text', content: 'x' })).toBe('x')
  99. expect(renderBody({ kind: 'html', content: '<p>y</p>' })).toBe('y')
  100. })
  101. it('validates url (non-empty), no timeout parameter', () => {
  102. expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty')
  103. expect(parseFetchArgs({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' })
  104. })
  105. it('presents a fetch call as a fetch-kind card titled by the url', () => {
  106. expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ card: 'generic', title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' })
  107. })
  108. })
  109. describe('htmlToMarkdown', () => {
  110. it('drops scripts/styles, keeps text, decodes entities, converts links', () => {
  111. const md = htmlToMarkdown('<style>.x{}</style><script>bad()</script><p>Tom &amp; Jerry</p><a href="https://a.test">link</a>')
  112. expect(md).not.toContain('bad()')
  113. expect(md).not.toContain('.x{}')
  114. expect(md).toContain('Tom & Jerry')
  115. expect(md).toContain('[link](https://a.test)')
  116. })
  117. it('decodes numeric entities and collapses whitespace', () => {
  118. expect(htmlToMarkdown('<p>a&#39;b</p>')).toBe("a'b")
  119. expect(htmlToMarkdown('<div>x</div>\n\n\n<div>y</div>')).toBe('x\n\ny')
  120. })
  121. it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => {
  122. expect(htmlToMarkdown('<p>&#x41;&#X42;</p>')).toBe('AB')
  123. expect(htmlToMarkdown('<p>&copy; &mdash;</p>')).toBe('© —')
  124. expect(htmlToMarkdown('<p>&notareal;</p>')).toBe('&notareal;')
  125. // An out-of-range code point keeps the original entity text (fromCodePoint fallback).
  126. expect(htmlToMarkdown('<p>&#x110000;</p>')).toBe('&#x110000;')
  127. expect(htmlToMarkdown('<p>&#1114112;</p>')).toBe('&#1114112;')
  128. })
  129. it('renders a link with an empty label as its bare href', () => {
  130. expect(htmlToMarkdown('<a href="https://a.test"></a>')).toBe('https://a.test')
  131. })
  132. it('converts headings and list items to markdown', () => {
  133. expect(htmlToMarkdown('<h2>Heading</h2><p>after</p>')).toContain('## Heading')
  134. const list = htmlToMarkdown('<ul><li>one</li><li>two</li></ul>')
  135. expect(list).toContain('- one')
  136. expect(list).toContain('- two')
  137. })
  138. it('falls back to the raw URL as a source label when the URL is unparseable', () => {
  139. const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
  140. expect(out).toContain('[not a url](not a url)')
  141. })
  142. })
  143. describe('tool-web registration', () => {
  144. it('registers both tools by default', async () => {
  145. const { fiber, ctx } = await mountTools()
  146. const names = ctx.tools.schemas().map(s => s.name)
  147. expect(names).toContain('web_search')
  148. expect(names).toContain('web_fetch')
  149. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('search-safe'), name: 'web_search', arguments: { query: 'q' } }))
  150. .toEqual({ kind: 'parallel' })
  151. expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } }))
  152. .toEqual({ kind: 'parallel' })
  153. await fiber.dispose()
  154. expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search')
  155. })
  156. it('registers only enabled tools', async () => {
  157. const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
  158. const names = ctx.tools.schemas().map(s => s.name)
  159. expect(names).toContain('web_search')
  160. expect(names).not.toContain('web_fetch')
  161. await fiber.dispose()
  162. })
  163. it('registers only web_fetch when search is disabled', async () => {
  164. const { fiber, ctx } = await mountTools({ config: { search: false, fetch: true } })
  165. const names = ctx.tools.schemas().map(s => s.name)
  166. expect(names).not.toContain('web_search')
  167. expect(names).toContain('web_fetch')
  168. await fiber.dispose()
  169. })
  170. it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => {
  171. const { fiber, ctx, call } = await mountTools()
  172. expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
  173. // No provider is registered: the schema stays visible and execution reports
  174. // the structured unavailability instead.
  175. const out = await call('web_search', { query: 'q' })
  176. expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
  177. await fiber.dispose()
  178. })
  179. it('contributes prompt sections for the enabled tools', async () => {
  180. const { fiber, ctx } = await mountTools()
  181. const prompt = await ctx.systemPrompt.assemble()
  182. const text = prompt.sections.map(s => s.text).join('\n')
  183. expect(text).toContain('web_search')
  184. expect(text).toContain('web_fetch')
  185. await fiber.dispose()
  186. })
  187. })
  188. describe('tool-web execution through the real registry', () => {
  189. it('executes web_search and formats the result', async () => {
  190. const result: WebSearchResult = {
  191. content: 'answer', truncated: false,
  192. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-07-20' }],
  193. }
  194. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
  195. const out = await call('web_search', { query: 'q' })
  196. expect(out.isError).toBe(false)
  197. expect(out.value).toEqual(result)
  198. expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[A](https://a.test)')
  199. await fiber.dispose()
  200. })
  201. it('surfaces a structured WebError when no provider is available', async () => {
  202. const { fiber, call } = await mountTools()
  203. const out = await call('web_search', { query: 'q' })
  204. expect(out.isError).toBe(true)
  205. expect(out.error?.info?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
  206. await fiber.dispose()
  207. })
  208. it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
  209. const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
  210. ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
  211. const out = await call('web_search', { query: 'q' })
  212. expect(out.isError).toBe(true)
  213. expect(out.error?.info?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
  214. await fiber.dispose()
  215. })
  216. it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
  217. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
  218. const out = await call('web_search', { query: 123 })
  219. expect(out.isError).toBe(true)
  220. expect(out.error?.info?.code).toBe('INVALID_ARGS')
  221. await fiber.dispose()
  222. })
  223. it('has no default export (namespace plugin export shape)', () => {
  224. expect('default' in ToolWeb).toBe(false)
  225. })
  226. it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => {
  227. const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {}
  228. const fetchProvider = {
  229. id: 'stub-fetch',
  230. available: () => available,
  231. fetch: (request: { url: string }, signal?: AbortSignal) => {
  232. seen.request = request
  233. seen.signal = signal
  234. return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
  235. },
  236. }
  237. const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  238. const controller = new AbortController()
  239. const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test' }, signal: controller.signal })
  240. expect(out.isError).toBe(false)
  241. expect(out.value).toEqual({
  242. url: 'https://a.test',
  243. statusCode: 200,
  244. body: { kind: 'text', content: 'ok' },
  245. truncated: false,
  246. })
  247. // The model schema exposes no timeout: the tool forwards only the url; the
  248. // tool-call budget is owned by dsh-timeout-policy over exec.signal.
  249. expect(seen.request).toEqual({ url: 'https://a.test' })
  250. expect(seen.signal).toBe(controller.signal)
  251. await fiber.dispose()
  252. })
  253. it('forwards the required caller signal to web_fetch', async () => {
  254. const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {}
  255. const fetchProvider = {
  256. id: 'stub-fetch',
  257. available: () => available,
  258. fetch: (request: { url: string }, signal?: AbortSignal) => {
  259. seen.passedSignal = signal !== undefined
  260. seen.signal = signal
  261. return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
  262. },
  263. }
  264. const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  265. const out = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
  266. expect(out.isError).toBe(false)
  267. expect(out.value).toEqual({
  268. url: 'https://a.test',
  269. statusCode: 200,
  270. body: { kind: 'text', content: 'ok' },
  271. truncated: false,
  272. })
  273. expect(seen.passedSignal).toBe(true)
  274. expect(seen.signal).toBe(testToolSignal)
  275. await fiber.dispose()
  276. })
  277. it('executes web_search, forwarding the abort signal to the seam', async () => {
  278. const seen: { signal?: AbortSignal | undefined } = {}
  279. const provider: WebSearchProvider = {
  280. id: 'stub-search',
  281. available: () => available,
  282. search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) },
  283. }
  284. const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
  285. const controller = new AbortController()
  286. await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal })
  287. expect(seen.signal).toBe(controller.signal)
  288. await fiber.dispose()
  289. })
  290. })
  291. describe('searchMaxResults is plugin config', () => {
  292. it('forwards the default cap to the seam when unconfigured', async () => {
  293. const seen: { maxResults?: number | undefined } = {}
  294. const provider: WebSearchProvider = {
  295. id: 'stub-search',
  296. available: () => available,
  297. search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
  298. }
  299. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
  300. await call('web_search', { query: 'q' })
  301. expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS)
  302. await fiber.dispose()
  303. })
  304. it('forwards a configured cap to the seam, which enforces it', async () => {
  305. const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
  306. const provider: WebSearchProvider = {
  307. id: 'stub-search',
  308. available: () => available,
  309. search: () => Promise.resolve({ sources, truncated: false }),
  310. }
  311. const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
  312. const out = await call('web_search', { query: 'q' })
  313. expect(out.isError).toBe(false)
  314. const body = out.content.map(b => b.type === 'text' ? b.text : '').join('')
  315. expect(body).toContain('https://s1.test')
  316. expect(body).not.toContain('https://s2.test')
  317. expect(body).toContain('Showing the first 2 sources.')
  318. await fiber.dispose()
  319. })
  320. it.each([
  321. ['zero', 0],
  322. ['negative', -3],
  323. ['fractional', 1.5],
  324. ])('rejects a %s searchMaxResults at load', async (_label, value) => {
  325. const ctx = new Context()
  326. await ctx.plugin(SystemPrompt)
  327. await ctx.plugin(ToolRegistry)
  328. await ctx.plugin(WebService, {})
  329. await expect(ctx.plugin(ToolWeb, { searchMaxResults: value }))
  330. .rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
  331. })
  332. })
  333. describe('tool-call timeout budget is plugin config', () => {
  334. it('attaches the default 30s budget to web_fetch and web_search', async () => {
  335. const { fiber, ctx } = await mountTools()
  336. expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(30_000)
  337. expect(ctx.tools.get('web_search')?.timeoutMs).toBe(30_000)
  338. await fiber.dispose()
  339. })
  340. it('honors per-tool timeout overrides from config', async () => {
  341. const { fiber, ctx } = await mountTools({ config: { fetchTimeoutMs: 60_000, searchTimeoutMs: 10_000 } })
  342. expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(60_000)
  343. expect(ctx.tools.get('web_search')?.timeoutMs).toBe(10_000)
  344. await fiber.dispose()
  345. })
  346. it.each([
  347. ['fetchTimeoutMs', { fetchTimeoutMs: 0 }],
  348. ['searchTimeoutMs', { searchTimeoutMs: -5 }],
  349. ])('rejects a non-positive-integer %s at load', async (key, config) => {
  350. const ctx = new Context()
  351. await ctx.plugin(SystemPrompt)
  352. await ctx.plugin(ToolRegistry)
  353. await ctx.plugin(WebService, {})
  354. await expect(ctx.plugin(ToolWeb, config))
  355. .rejects.toThrow(new RegExp(`tool-web: ${key} must be a positive integer`))
  356. })
  357. })