integration.spec.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  2. import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  8. import ToolRuntime from '@deepseek-ai/dsh-tools'
  9. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  10. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  11. import Lsp from '@deepseek-ai/dsh-lsp'
  12. import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
  13. import * as TimeoutPolicy from '@deepseek-ai/dsh-tool-call-timeout-policy'
  14. import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
  15. /**
  16. * Focused in-process integration of the model-facing tool, seam, local provider, and timeout policy.
  17. * The `lsp-definition` ACP snapshot owns the shipped Loader/app entry path.
  18. */
  19. let root: string
  20. let ws: string
  21. beforeEach(async () => {
  22. root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-')))
  23. ws = join(root, 'ws')
  24. await mkdir(ws)
  25. await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
  26. })
  27. afterEach(async () => {
  28. await rm(root, { recursive: true, force: true })
  29. })
  30. /** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */
  31. function serverScript(hang: boolean): string {
  32. const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
  33. return 'let b=Buffer.alloc(0);'
  34. + `const DEF=${definition};`
  35. + 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
  36. + 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
  37. + 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
  38. + `else if(m.method==="textDocument/definition"){${hang ? '' : 'process.stdout.write(fr({id:m.id,result:DEF}));'}}`
  39. + 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
  40. + 'else if(m.method==="exit")process.exit(0);'
  41. + '}});'
  42. }
  43. async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
  44. const ctx = new Context()
  45. await ctx.plugin(SystemPrompt)
  46. await ctx.plugin(ToolRuntime)
  47. await ctx.plugin(Lsp)
  48. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
  49. await ctx.plugin(LocalSubprocessRuntime)
  50. await ctx.plugin(LspLocal, {
  51. servers: {
  52. inline: {
  53. command: process.execPath,
  54. args: ['-e', serverScript(hang)],
  55. extensionToLanguage: { '.ts': 'typescript' },
  56. shutdownTimeoutMs: 200,
  57. killGraceMs: 200,
  58. },
  59. },
  60. })
  61. await ctx.plugin(TimeoutPolicy)
  62. await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {})
  63. return ctx
  64. }
  65. let seq = 0
  66. const testToolSignal = new AbortController().signal
  67. function call(ctx: Context, args: unknown) {
  68. return ctx.tools.execute({
  69. signal: testToolSignal,
  70. callId: `int-${++seq}` as never,
  71. name: 'lsp',
  72. arguments: args,
  73. agent: { session: { header: { cwd: ws } } } as never,
  74. })
  75. }
  76. describe('tool-lsp integration', () => {
  77. it('round-trips a definition query through the real provider and renders a location', async () => {
  78. const ctx = await mount(false)
  79. const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
  80. expect(result.isError).toBe(false)
  81. expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
  82. await ctx.fiber.dispose()
  83. }, 30_000)
  84. it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => {
  85. const ctx = await mount(true, 300)
  86. const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
  87. expect(result.isError).toBe(true)
  88. expect(result.error?.info?.code).toBe('TOOL_TIMEOUT')
  89. await ctx.fiber.dispose()
  90. }, 30_000)
  91. })