integration.spec.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. /**
  2. * Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search
  3. * provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool
  4. * (`dsh-tool-web`) + the tool-call timeout policy (`dsh-timeout-policy`),
  5. * exercised through `ctx.tools.execute()` — nothing bypasses the tool registry.
  6. * Fetch hits a real loopback HTTP server (verifying the WORLD); search runs the
  7. * real Exa provider over a stubbed global `fetch` (the network is the one
  8. * boundary we mock).
  9. */
  10. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  11. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  12. import { AddressInfo } from 'node:net'
  13. import { Context } from 'cordis'
  14. import { CallId } from '@deepseek-ai/dsh-llm'
  15. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  16. import ToolRegistry from '@deepseek-ai/dsh-tools'
  17. import WebService from '@deepseek-ai/dsh-web'
  18. import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
  19. import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
  20. import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
  21. import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
  22. type Handler = (req: IncomingMessage, res: ServerResponse) => void
  23. let server: Server
  24. let base: string
  25. let handler: Handler
  26. let ctx: Context
  27. let fiber: Awaited<ReturnType<Context['plugin']>>
  28. beforeEach(async () => {
  29. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>Hello</h1><p>World</p>') }
  30. server = createServer((req, res) => { handler(req, res) })
  31. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  32. base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
  33. ctx = new Context()
  34. await ctx.plugin(SystemPrompt)
  35. await ctx.plugin(ToolRegistry)
  36. await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
  37. await ctx.plugin(WebFetchLocal, {})
  38. await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' })
  39. // The shipped deployment shape: the tool-call budget is declared by tool-web
  40. // config (default 30s, attached as ToolDefinition.timeoutMs) and enforced by
  41. // the zero-config timeout-policy plugin, set above the provider backstop so the
  42. // policy normally wins.
  43. await ctx.plugin(TimeoutPolicy)
  44. fiber = await ctx.plugin(ToolWeb)
  45. })
  46. afterEach(async () => {
  47. await fiber.dispose()
  48. vi.unstubAllGlobals()
  49. await new Promise<void>(resolve => server.close(() => { resolve() }))
  50. })
  51. let counter = 0
  52. type ToolResult = { isError: boolean; content: { type: string; text?: string }[]; error?: { code: string } }
  53. function call(name: string, args: unknown): Promise<ToolResult> {
  54. return ctx.tools.execute({ callId: CallId(`call-${++counter}`), name, arguments: args })
  55. }
  56. describe('web_fetch integration over the real backend', () => {
  57. it('fetches an html page and renders it to markdown', async () => {
  58. const out = await call('web_fetch', { url: base })
  59. expect(out.isError).toBe(false)
  60. const text = out.content.map(b => b.text).join('')
  61. expect(text).toContain(`Fetched ${base}`)
  62. expect(text).toContain('# Hello')
  63. expect(text).toContain('World')
  64. })
  65. it('reports a 404 as a result, not an error', async () => {
  66. handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('missing') }
  67. const out = await call('web_fetch', { url: base })
  68. expect(out.isError).toBe(false)
  69. expect(out.content.map(b => b.text).join('')).toContain('HTTP 404')
  70. })
  71. it('surfaces WEB_INVALID_URL as a structured tool error', async () => {
  72. const out = await call('web_fetch', { url: 'ftp://example.com' })
  73. expect(out.isError).toBe(true)
  74. expect(out.error?.code).toBe('WEB_INVALID_URL')
  75. })
  76. it('surfaces a blocked cross-origin redirect as WEB_REDIRECT_BLOCKED', async () => {
  77. handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() }
  78. const out = await call('web_fetch', { url: base })
  79. expect(out.isError).toBe(true)
  80. expect(out.error?.code).toBe('WEB_REDIRECT_BLOCKED')
  81. })
  82. })
  83. describe('web_search integration over the real Exa provider', () => {
  84. it('runs web_search end-to-end and formats the provider result', async () => {
  85. vi.stubGlobal('fetch', vi.fn(async () => new Response(
  86. JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', highlights: ['a highlight'] }] }),
  87. { status: 200, headers: { 'content-type': 'application/json' } },
  88. )))
  89. const out = await call('web_search', { query: 'deepseek' })
  90. expect(out.isError).toBe(false)
  91. expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)')
  92. })
  93. })
  94. describe('tool-call timeout policy over the migrated web tools', () => {
  95. it('neither model schema exposes a timeout parameter after the migration', () => {
  96. const byName = new Map(ctx.tools.schemas().map(s => [s.name, s]))
  97. const fetchParams = byName.get('web_fetch')!.parameters as { properties: Record<string, unknown> }
  98. const searchParams = byName.get('web_search')!.parameters as { properties: Record<string, unknown> }
  99. expect(Object.keys(fetchParams.properties)).toEqual(['url'])
  100. expect('timeout_ms' in fetchParams.properties).toBe(false)
  101. expect(Object.keys(searchParams.properties)).toEqual(['query'])
  102. })
  103. })
  104. describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetch)', () => {
  105. let slowServer: Server
  106. let slowBase: string
  107. let openSockets: ServerResponse[]
  108. let tctx: Context
  109. let tfiber: Awaited<ReturnType<Context['plugin']>>
  110. beforeEach(async () => {
  111. // A server that never responds: it holds the connection open until the
  112. // client aborts. The cooperative deadline (via exec.signal → the fetch
  113. // provider → undici) is what ends the call.
  114. openSockets = []
  115. slowServer = createServer((_req, res) => { openSockets.push(res) })
  116. await new Promise<void>(resolve => slowServer.listen(0, '127.0.0.1', resolve))
  117. slowBase = `http://127.0.0.1:${(slowServer.address() as AddressInfo).port}`
  118. tctx = new Context()
  119. await tctx.plugin(SystemPrompt)
  120. await tctx.plugin(ToolRegistry)
  121. await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
  122. // Provider backstop well ABOVE the tool-call budget, so the policy wins.
  123. await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 })
  124. await tctx.plugin(TimeoutPolicy)
  125. // The tool-call budget is declared by tool-web config, enforced by the policy.
  126. tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 })
  127. })
  128. afterEach(async () => {
  129. for (const res of openSockets) res.destroy()
  130. await tfiber.dispose()
  131. await new Promise<void>(resolve => slowServer.close(() => { resolve() }))
  132. })
  133. it('returns a structured TOOL_TIMEOUT (not the provider WEB_FETCH_TIMEOUT) when the tool-call budget wins', async () => {
  134. const out = await tctx.tools.execute({ callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } })
  135. expect(out.isError).toBe(true)
  136. // The outer tool-call deadline won: TOOL_TIMEOUT, owned by dsh-timeout-policy,
  137. // NOT the provider's own WEB_FETCH_TIMEOUT (its 30s backstop never fired).
  138. expect(out.error?.code).toBe('TOOL_TIMEOUT')
  139. const text = out.content.map(b => (b.type === 'text' ? b.text : '')).join('')
  140. expect(text).toContain('timed out after 50ms')
  141. })
  142. it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => {
  143. // A direct seam caller does not go through tools/execute, so the tool-call
  144. // policy never applies; the provider's OWN timeout is the only budget. A
  145. // short per-request hint proves the provider backstop is intact and classifies
  146. // as WEB_FETCH_TIMEOUT (the provider-owned code), never TOOL_TIMEOUT.
  147. const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then(
  148. () => undefined,
  149. (e: unknown) => e as { code?: string },
  150. )
  151. expect(err?.code).toBe('WEB_FETCH_TIMEOUT')
  152. })
  153. })