tool-web.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  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 from '@deepseek-ai/dsh-tools'
  6. import WebService from '@deepseek-ai/dsh-web'
  7. import type { WebSearchProvider, WebSearchResult, WebProviderStatus } 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. } from '@deepseek-ai/dsh-tool-web'
  19. const available: WebProviderStatus = { available: true }
  20. function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider {
  21. return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) }
  22. }
  23. /** Mount the real registry, seam, and tool-web; return an executor helper. */
  24. async function mountTools(opts: {
  25. config?: ToolWeb.Config
  26. webConfig?: ConstructorParameters<typeof WebService>[1]
  27. search?: WebSearchProvider
  28. fetchProvider?: import('@deepseek-ai/dsh-web').WebFetchProvider
  29. } = {}): Promise<{ ctx: Context; fiber: Awaited<ReturnType<Context['plugin']>>; call: (name: string, args: unknown) => Promise<{ isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } }> }> {
  30. const ctx = new Context()
  31. await ctx.plugin(SystemPrompt)
  32. await ctx.plugin(ToolRegistry)
  33. await ctx.plugin(WebService, opts.webConfig ?? {})
  34. if (opts.search) ctx.web.registerSearchProvider(opts.search)
  35. if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider)
  36. const fiber = await ctx.plugin(ToolWeb, opts.config ?? {})
  37. let counter = 0
  38. const call = (name: string, args: unknown) => ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args }) as never
  39. return { ctx, fiber, call }
  40. }
  41. describe('search formatting', () => {
  42. it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => {
  43. const out = formatSearchOutput({
  44. providerId: 'p', query: 'q', content: 'an answer', truncated: false,
  45. sources: [
  46. { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
  47. { url: 'https://b.test/y' },
  48. ],
  49. })
  50. expect(out).toContain('an answer')
  51. expect(out).toContain('[A](https://a.test/x) — about a (2026-01-01)')
  52. expect(out).toContain('[b.test](https://b.test/y)')
  53. expect(out).toContain('Cite the relevant URLs')
  54. })
  55. it('reports no results when there is neither content nor sources', () => {
  56. expect(formatSearchOutput({ providerId: 'p', query: 'q', sources: [], truncated: false }))
  57. .toContain('No results found.')
  58. })
  59. it('renders content alone when there are no sources', () => {
  60. const out = formatSearchOutput({ providerId: 'p', query: 'q', content: 'just an answer', sources: [], truncated: false })
  61. expect(out).toContain('just an answer')
  62. expect(out).not.toContain('No results found.')
  63. expect(out).not.toContain('Sources:')
  64. })
  65. it('notes truncation', () => {
  66. const out = formatSearchOutput({ providerId: 'p', query: 'q', sources: [{ url: 'https://a.test' }], truncated: true })
  67. expect(out).toContain('Showing the first 1 sources')
  68. })
  69. it('validates the query', () => {
  70. expect(() => parseSearchArgs({ query: ' ' })).toThrow('non-empty')
  71. expect(parseSearchArgs({ query: 'hi' })).toEqual({ query: 'hi' })
  72. })
  73. it('presents a search call as a search-kind card titled by the query', () => {
  74. expect(presentSearchCall({ query: 'find me' })).toEqual({ title: 'find me', kind: 'search', rawInput: 'find me' })
  75. })
  76. })
  77. describe('fetch formatting', () => {
  78. it('renders an html body to markdown text with a status header', () => {
  79. const out = formatFetchOutput({
  80. providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: false,
  81. body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
  82. })
  83. expect(out).toContain('Fetched https://a.test (HTTP 200)')
  84. expect(out).toContain('# Title')
  85. expect(out).toContain('Body text')
  86. })
  87. it('passes a text body through and notes truncation', () => {
  88. const out = formatFetchOutput({
  89. providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: true,
  90. body: { kind: 'text', content: 'plain' },
  91. })
  92. expect(out).toContain('plain')
  93. expect(out).toContain('Content truncated')
  94. })
  95. it('renderBody dispatches on kind', () => {
  96. expect(renderBody({ kind: 'text', content: 'x' })).toBe('x')
  97. expect(renderBody({ kind: 'html', content: '<p>y</p>' })).toBe('y')
  98. })
  99. it('validates url and timeout', () => {
  100. expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty')
  101. expect(() => parseFetchArgs({ url: 'https://a.test', timeout_ms: -1 })).toThrow('positive')
  102. expect(parseFetchArgs({ url: 'https://a.test', timeout_ms: 5 })).toEqual({ url: 'https://a.test', timeoutMs: 5 })
  103. })
  104. it('presents a fetch call as a fetch-kind card titled by the url', () => {
  105. expect(presentFetchCall({ url: 'https://a.test' })).toEqual({ title: 'https://a.test', kind: 'fetch', rawInput: 'https://a.test' })
  106. })
  107. })
  108. describe('htmlToMarkdown', () => {
  109. it('drops scripts/styles, keeps text, decodes entities, converts links', () => {
  110. const md = htmlToMarkdown('<style>.x{}</style><script>bad()</script><p>Tom &amp; Jerry</p><a href="https://a.test">link</a>')
  111. expect(md).not.toContain('bad()')
  112. expect(md).not.toContain('.x{}')
  113. expect(md).toContain('Tom & Jerry')
  114. expect(md).toContain('[link](https://a.test)')
  115. })
  116. it('decodes numeric entities and collapses whitespace', () => {
  117. expect(htmlToMarkdown('<p>a&#39;b</p>')).toBe("a'b")
  118. expect(htmlToMarkdown('<div>x</div>\n\n\n<div>y</div>')).toBe('x\n\ny')
  119. })
  120. it('decodes hex entities and named entities, and leaves unknown/out-of-range ones intact', () => {
  121. expect(htmlToMarkdown('<p>&#x41;&#X42;</p>')).toBe('AB')
  122. expect(htmlToMarkdown('<p>&copy; &mdash;</p>')).toBe('© —')
  123. expect(htmlToMarkdown('<p>&notareal;</p>')).toBe('&notareal;')
  124. // An out-of-range code point keeps the original entity text (fromCodePoint fallback).
  125. expect(htmlToMarkdown('<p>&#x110000;</p>')).toBe('&#x110000;')
  126. expect(htmlToMarkdown('<p>&#1114112;</p>')).toBe('&#1114112;')
  127. })
  128. it('renders a link with an empty label as its bare href', () => {
  129. expect(htmlToMarkdown('<a href="https://a.test"></a>')).toBe('https://a.test')
  130. })
  131. it('converts headings and list items to markdown', () => {
  132. expect(htmlToMarkdown('<h2>Heading</h2><p>after</p>')).toContain('## Heading')
  133. const list = htmlToMarkdown('<ul><li>one</li><li>two</li></ul>')
  134. expect(list).toContain('- one')
  135. expect(list).toContain('- two')
  136. })
  137. it('falls back to the raw URL as a source label when the URL is unparseable', () => {
  138. const out = formatSearchOutput({ providerId: 'p', query: 'q', truncated: false, sources: [{ url: 'not a url' }] })
  139. expect(out).toContain('[not a url](not a url)')
  140. })
  141. })
  142. describe('tool-web registration', () => {
  143. it('registers both tools by default', async () => {
  144. const { fiber, ctx } = await mountTools()
  145. const names = ctx.tools.schemas().map(s => s.name)
  146. expect(names).toContain('web_search')
  147. expect(names).toContain('web_fetch')
  148. await fiber.dispose()
  149. expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search')
  150. })
  151. it('registers only enabled tools', async () => {
  152. const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } })
  153. const names = ctx.tools.schemas().map(s => s.name)
  154. expect(names).toContain('web_search')
  155. expect(names).not.toContain('web_fetch')
  156. await fiber.dispose()
  157. })
  158. it('registers only web_fetch when search is disabled', async () => {
  159. const { fiber, ctx } = await mountTools({ config: { search: false, fetch: true } })
  160. const names = ctx.tools.schemas().map(s => s.name)
  161. expect(names).not.toContain('web_search')
  162. expect(names).toContain('web_fetch')
  163. await fiber.dispose()
  164. })
  165. it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => {
  166. const { fiber, ctx } = await mountTools()
  167. expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
  168. expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'none' })
  169. await fiber.dispose()
  170. })
  171. it('contributes prompt sections for the enabled tools', async () => {
  172. const { fiber, ctx } = await mountTools()
  173. const prompt = await ctx.systemPrompt.assemble()
  174. const text = prompt.sections.map(s => (typeof s.text === 'function' ? s.text() : s.text)).join('\n')
  175. expect(text).toContain('web_search')
  176. expect(text).toContain('web_fetch')
  177. await fiber.dispose()
  178. })
  179. })
  180. describe('tool-web execution through the real registry', () => {
  181. it('executes web_search and formats the result', async () => {
  182. const result: WebSearchResult = {
  183. providerId: 'stub-search', query: 'q', content: 'answer', truncated: false,
  184. sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }],
  185. }
  186. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
  187. const out = await call('web_search', { query: 'q' })
  188. expect(out.isError).toBe(false)
  189. expect(out.content.map(b => b.text).join('')).toContain('[A](https://a.test)')
  190. await fiber.dispose()
  191. })
  192. it('surfaces a structured WebError when no provider is available', async () => {
  193. const { fiber, call } = await mountTools()
  194. const out = await call('web_search', { query: 'q' })
  195. expect(out.isError).toBe(true)
  196. expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
  197. await fiber.dispose()
  198. })
  199. it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
  200. const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) })
  201. ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) })
  202. const out = await call('web_search', { query: 'q' })
  203. expect(out.isError).toBe(true)
  204. expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
  205. await fiber.dispose()
  206. })
  207. it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
  208. const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) })
  209. const out = await call('web_search', { query: 123 })
  210. expect(out.isError).toBe(true)
  211. expect(out.error?.code).toBe('INVALID_ARGS')
  212. await fiber.dispose()
  213. })
  214. it('has no default export (namespace plugin export shape)', () => {
  215. expect('default' in ToolWeb).toBe(false)
  216. })
  217. it('executes web_fetch, forwarding timeout_ms and the abort signal to the seam', async () => {
  218. const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {}
  219. const fetchProvider = {
  220. id: 'stub-fetch',
  221. status: () => available,
  222. fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => {
  223. seen.request = request
  224. seen.signal = exec?.signal
  225. return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
  226. },
  227. }
  228. const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
  229. const controller = new AbortController()
  230. const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test', timeout_ms: 1234 }, signal: controller.signal })
  231. expect(out.isError).toBe(false)
  232. expect(seen.request).toEqual({ url: 'https://a.test', timeoutMs: 1234 })
  233. expect(seen.signal).toBe(controller.signal)
  234. await fiber.dispose()
  235. })
  236. it('executes web_search, forwarding the abort signal to the seam', async () => {
  237. const seen: { signal?: AbortSignal | undefined } = {}
  238. const provider: WebSearchProvider = {
  239. id: 'stub-search',
  240. status: () => available,
  241. search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) },
  242. }
  243. const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
  244. const controller = new AbortController()
  245. await ctx.tools.execute({ callId: CallId('search-1'), name: 'web_search', arguments: { query: 'q' }, signal: controller.signal })
  246. expect(seen.signal).toBe(controller.signal)
  247. await fiber.dispose()
  248. })
  249. })