spill.spec.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. /**
  2. * Showcase integration: the real `web_fetch` tool + the real spill stack
  3. * (`dsh-spill-local` backend + `dsh-spill-policy`), exercised through
  4. * `ctx.tools.execute()`. Proves the Agent Note's default local-backend path — a large
  5. * formatted fetch result is automatically retained and spilled with NO
  6. * tool-specific spill code, and the model-facing text changes ONLY by the
  7. * deliberate spill notice (the full formatted result lands in the spill file).
  8. */
  9. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  10. import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
  11. import { AddressInfo } from 'node:net'
  12. import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
  13. import { tmpdir } from 'node:os'
  14. import { join } from 'node:path'
  15. import { Context } from '@deepseek-ai/cordis'
  16. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  17. import { SessionId } from '@deepseek-ai/dsh-session'
  18. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  19. import ToolRuntime from '@deepseek-ai/dsh-tools'
  20. import type { ToolExecution } from '@deepseek-ai/dsh-tools'
  21. const testToolSignal = new AbortController().signal
  22. import WebRuntime from '@deepseek-ai/dsh-web'
  23. import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-http'
  24. import LocalSpillStore from '@deepseek-ai/dsh-spill-local'
  25. import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
  26. import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
  27. import { publicHttpNetwork } from '../../web-fetch-http/src/network.ts'
  28. type Handler = (req: IncomingMessage, res: ServerResponse) => void
  29. let server: Server
  30. let base: string
  31. let handler: Handler
  32. let spillRoot: string
  33. let ctx: Context
  34. const BODY = 'X'.repeat(4000) // formatted result is well over the policy cap
  35. const MAX_INLINE_BYTES = 1000 // leaves room for a head/tail preview beside the notice
  36. beforeEach(async () => {
  37. vi.spyOn(publicHttpNetwork, 'resolve').mockResolvedValue([{ address: '127.0.0.1', family: 4 }])
  38. handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end(BODY) }
  39. server = createServer((req, res) => { handler(req, res) })
  40. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  41. base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
  42. spillRoot = mkdtempSync(join(tmpdir(), 'dsh-spill-web-'))
  43. ctx = new Context()
  44. await ctx.plugin(SystemPrompt)
  45. await ctx.plugin(ToolRuntime)
  46. await ctx.plugin(WebRuntime, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
  47. // Provider cap generous so the tool returns a large formatted result; the
  48. // policy cap is what triggers the spill (the Agent Note's separation of concerns).
  49. await ctx.plugin(WebFetchLocal, { maxBodyChars: 500_000 })
  50. await ctx.plugin(LocalSpillStore, { root: spillRoot })
  51. await ctx.plugin(SpillPolicy, { maxInlineBytes: MAX_INLINE_BYTES })
  52. await ctx.plugin(ToolWeb)
  53. })
  54. afterEach(async () => {
  55. vi.restoreAllMocks()
  56. await new Promise<void>(resolve => server.close(() => { resolve() }))
  57. rmSync(spillRoot, { recursive: true, force: true })
  58. })
  59. /** A web_fetch call carrying a session owner (so the policy can scope the spill). */
  60. function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> {
  61. const agent = { session: { header: { id: SessionId('web-sess') } } }
  62. const exec = { callId: ToolCallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent, signal: testToolSignal } as unknown as ToolExecution
  63. return ctx.tools.execute(exec)
  64. }
  65. describe('web_fetch spill showcase', () => {
  66. it('spills a large formatted result and returns a preview + spill locator', async () => {
  67. const out = await fetchCall()
  68. expect(out.isError).toBe(false)
  69. const text = out.content.map(b => b.text).join('')
  70. // Model-facing text is a preview + notice within the cap, NOT the full body.
  71. expect(text.length).toBeLessThan(BODY.length)
  72. expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(MAX_INLINE_BYTES)
  73. expect(text).toContain(`Fetched ${base}`) // the head of the formatted result survives
  74. expect(text).toContain('Full formatted result stored at:')
  75. expect(text).toContain('Use read with offset/limit, or grep this path')
  76. // The spill file holds the FULL formatted result the tool returned.
  77. const match = /stored at: (\S+?)\. Use read/.exec(text)
  78. expect(match).not.toBeNull()
  79. const spillPath = match![1]!
  80. const saved = readFileSync(spillPath, 'utf8')
  81. // The provider cap was generous, so the tool did not truncate: the spill file
  82. // holds the full formatted result (header + the complete body), far larger
  83. // than the model-facing preview.
  84. expect(saved).toContain('(HTTP 200)')
  85. expect(saved).toContain(BODY)
  86. expect(saved.length).toBeGreaterThan(text.length)
  87. })
  88. })