integration.spec.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. /**
  2. * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the
  3. * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell
  4. * process. These verify the world — actual commands run, stdout/stderr come
  5. * back, exit codes render, timeouts abort, background jobs settle through the
  6. * generic job runtime, and per-session cwd resolution works. The suite
  7. * self-skips when no usable `pwsh` resolves (a CI accommodation for hosts without
  8. * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage
  9. * gate.
  10. */
  11. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  12. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  13. import { tmpdir } from 'node:os'
  14. import { join } from 'node:path'
  15. import { spawnSync } from 'node:child_process'
  16. import { Context } from '@deepseek-ai/cordis'
  17. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  18. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  19. import ToolRuntime, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools'
  20. import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
  21. import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
  22. import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
  23. import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
  24. import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
  25. import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
  26. const testToolSignal = new AbortController().signal
  27. // The probe follows the executor's own resolution (Program Files installs on
  28. // Windows are found even when bare `pwsh` is not on PATH).
  29. const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
  30. /** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */
  31. const lf = (text: string): string => text.replace(/\r\n/g, '\n')
  32. let dir: string
  33. let ctx: Context
  34. let callCounter = 0
  35. function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) {
  36. return ctx.tools.execute({
  37. signal: signal ?? testToolSignal,
  38. callId: ToolCallId(`it-${++callCounter}`),
  39. name,
  40. arguments: args,
  41. ...agentObj ? { agent: agentObj as never } : {},
  42. })
  43. }
  44. function text(result: { content: { type: string; text?: string }[] }): string {
  45. return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
  46. }
  47. describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
  48. beforeEach(async () => {
  49. dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-'))
  50. await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n')
  51. ctx = new Context()
  52. await ctx.plugin(SystemPrompt)
  53. await ctx.plugin(ToolRuntime)
  54. await ctx.plugin(LocalJobRegistry)
  55. await ctx.plugin(ToolTasks)
  56. await ctx.plugin(LocalSubprocessRuntime)
  57. await ctx.plugin(BashEnvPlugin)
  58. await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 })
  59. await ctx.plugin(ToolPwsh)
  60. })
  61. afterEach(async () => {
  62. await rm(dir, { recursive: true, force: true })
  63. })
  64. const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
  65. it('runs a command and returns stdout with no marker on a clean exit', async () => {
  66. const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent())
  67. expect(result.isError).toBe(false)
  68. if (result.isError) throw new Error('expected pwsh success')
  69. expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 })
  70. expect(lf(text(result))).toBe('hi\n')
  71. })
  72. it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => {
  73. const result = await call('pwsh', {
  74. command: '[Console]::Error.WriteLine("boom"); exit 3',
  75. description: 'fail loudly',
  76. }, agent())
  77. expect(result.isError).toBe(false)
  78. expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]')
  79. })
  80. it('resolves relative paths in the session workspace', async () => {
  81. const result = await call('pwsh', {
  82. command: 'Get-Content greeting.txt',
  83. description: 'read greeting',
  84. }, agent())
  85. expect(result.isError).toBe(false)
  86. expect(lf(text(result))).toBe('hello pwsh\n')
  87. })
  88. it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => {
  89. const result = await call('pwsh', {
  90. command: 'Start-Sleep -Seconds 60',
  91. description: 'sleep forever',
  92. timeoutMs: 100,
  93. }, agent())
  94. expect(result.isError).toBe(false)
  95. if (result.isError) throw new Error('expected a timed-out foreground result')
  96. expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false })
  97. // Windows reports the forced termination as exit 1 without a signal;
  98. // POSIX reports SIGTERM — the timeout marker is the stable fact.
  99. expect(lf(text(result))).toContain('[timed out after 100ms]')
  100. })
  101. it('an upstream cancellation aborts the run', async () => {
  102. const controller = new AbortController()
  103. const pending = call('pwsh', {
  104. command: 'Start-Sleep -Seconds 60',
  105. description: 'sleep forever',
  106. }, agent(), controller.signal)
  107. setTimeout(() => { controller.abort() }, 50)
  108. const result = await pending
  109. expect(result.isError).toBe(true)
  110. expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
  111. })
  112. it('a background run settles through the REAL job_output tool', async () => {
  113. const started = await call('pwsh', {
  114. command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done',
  115. description: 'background greeting',
  116. run_in_background: true,
  117. })
  118. expect(started.isError).toBe(false)
  119. if (started.isError) throw new Error('expected background pwsh success')
  120. expect(started.value).toMatchObject({ kind: 'background' })
  121. const jobId = (started.value as { jobId: string }).jobId
  122. // The output delta and the terminal status can land in separate reads
  123. // (Windows flushes the child pipe at exit), so collect incrementally —
  124. // the same two-step shape as the bash background suite.
  125. const deadline = Date.now() + 10_000
  126. let output = ''
  127. while (Date.now() < deadline) {
  128. const read = await call('job_output', { job_id: jobId })
  129. output += text(read)
  130. if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break
  131. await new Promise(resolve => setTimeout(resolve, 50))
  132. }
  133. expect(output).toContain('bg-done')
  134. expect(output).toContain('[status: completed, exit code: 0]')
  135. })
  136. })