keyless-smoke.e2e.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import { spawn } from 'node:child_process'
  2. import { createServer } from 'node:http'
  3. import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import { promisify } from 'node:util'
  8. import { zstdDecompress } from 'node:zlib'
  9. import { describe, expect, it } from 'vitest'
  10. const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
  11. const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
  12. const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
  13. const decompress = promisify(zstdDecompress)
  14. function waitForLine(
  15. lines: string[],
  16. predicate: (value: Record<string, unknown>) => boolean,
  17. stderr: () => string,
  18. ): Promise<Record<string, unknown>> {
  19. return new Promise((resolve, reject) => {
  20. const deadline = Date.now() + 30_000
  21. const poll = (): void => {
  22. while (lines.length > 0) {
  23. const line = lines.shift()!
  24. if (!line.trim()) continue
  25. try {
  26. const value = JSON.parse(line) as Record<string, unknown>
  27. if (predicate(value)) {
  28. resolve(value)
  29. return
  30. }
  31. } catch {
  32. reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`))
  33. return
  34. }
  35. }
  36. if (Date.now() >= deadline) {
  37. reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`))
  38. return
  39. }
  40. setTimeout(poll, 10)
  41. }
  42. poll()
  43. })
  44. }
  45. describe('jsonrpc-agent keyless smoke', () => {
  46. it.each([
  47. { label: 'accepts max-token results by default', envValue: undefined, expectedStatus: 'ok' },
  48. { label: 'accepts max-token results when enabled through env', envValue: 'true', expectedStatus: 'ok' },
  49. { label: 'reports max-token results as errors when disabled through env', envValue: 'false', expectedStatus: 'error' },
  50. ])('$label', async ({ envValue, expectedStatus }) => {
  51. const root = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-agent-smoke-'))
  52. const modelRequests: Record<string, unknown>[] = []
  53. const modelServer = createServer((request, response) => {
  54. let body = ''
  55. request.setEncoding('utf8')
  56. request.on('data', (chunk: string) => { body += chunk })
  57. request.on('end', () => {
  58. modelRequests.push(JSON.parse(body) as Record<string, unknown>)
  59. response.writeHead(200, { 'content-type': 'text/event-stream' })
  60. response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
  61. response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
  62. response.write('data: {"choices":[{"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
  63. response.end('data: [DONE]\n\n')
  64. })
  65. })
  66. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  67. const address = modelServer.address()
  68. if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
  69. const child = spawn(process.execPath, [
  70. '--expose-internals',
  71. '--import',
  72. 'tsx',
  73. binScript,
  74. configPath,
  75. ], {
  76. cwd: repoRoot,
  77. env: {
  78. ...process.env,
  79. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  80. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  81. DSH_CWD: root,
  82. DSH_SESSION_ROOT: join(root, '.sessions'),
  83. ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
  84. },
  85. stdio: ['pipe', 'pipe', 'pipe'],
  86. })
  87. const lines: string[] = []
  88. let stdoutBuffer = ''
  89. let stderr = ''
  90. child.stdout.setEncoding('utf8')
  91. child.stdout.on('data', (chunk: string) => {
  92. stdoutBuffer += chunk
  93. const parts = stdoutBuffer.split('\n')
  94. stdoutBuffer = parts.pop() ?? ''
  95. lines.push(...parts)
  96. })
  97. child.stderr.setEncoding('utf8')
  98. child.stderr.on('data', (chunk: string) => { stderr += chunk })
  99. try {
  100. child.stdin.write(`${JSON.stringify({
  101. jsonrpc: '2.0',
  102. id: 1,
  103. method: 'initialize',
  104. params: { cwd: root, provider: 'deepseek', model: 'deepseek-v4-pro' },
  105. })}\n`)
  106. const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
  107. expect(initialized).toMatchObject({
  108. jsonrpc: '2.0',
  109. id: 1,
  110. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  111. })
  112. child.stdin.write(`${JSON.stringify({
  113. jsonrpc: '2.0',
  114. id: 2,
  115. method: 'session/prompt',
  116. params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
  117. })}\n`)
  118. const finished = await waitForLine(lines, value => value.method === 'session.finished', () => stderr)
  119. expect(finished).toMatchObject({
  120. jsonrpc: '2.0',
  121. method: 'session.finished',
  122. params: {
  123. sessionId: 'main',
  124. status: expectedStatus,
  125. reason: { kind: 'max-tokens' },
  126. },
  127. })
  128. const prompt = await waitForLine(lines, value => value.id === 2, () => stderr)
  129. expect(prompt).toMatchObject({ jsonrpc: '2.0', id: 2, result: { accepted: true } })
  130. const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
  131. expect(tools.map(tool => tool.function?.name).sort()).toEqual([
  132. 'bash',
  133. 'edit',
  134. 'read',
  135. 'subagent',
  136. 'todo_write',
  137. 'write',
  138. ])
  139. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
  140. const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
  141. expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
  142. if (child.exitCode === null) {
  143. await new Promise<void>((resolve, reject) => {
  144. child.once('exit', (code) => {
  145. if (code === 0) resolve()
  146. else reject(new Error(`runtime exited ${code}; stderr=${stderr}`))
  147. })
  148. })
  149. } else {
  150. expect(child.exitCode, stderr).toBe(0)
  151. }
  152. const sessionsRoot = join(root, '.sessions')
  153. const files = await readdir(sessionsRoot, { recursive: true })
  154. const log = files.find(file => file.endsWith('.jsonl.zstd'))
  155. expect(log).toBeDefined()
  156. const compressed = await readFile(join(sessionsRoot, log!))
  157. expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
  158. expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
  159. } finally {
  160. if (child.exitCode === null) child.kill('SIGKILL')
  161. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  162. await rm(root, { recursive: true, force: true })
  163. }
  164. }, 40_000)
  165. it('rejects an invalid max-token success env value', async () => {
  166. const child = spawn(process.execPath, [
  167. '--expose-internals',
  168. '--import',
  169. 'tsx',
  170. binScript,
  171. configPath,
  172. ], {
  173. cwd: repoRoot,
  174. env: {
  175. ...process.env,
  176. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  177. DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
  178. },
  179. stdio: ['ignore', 'pipe', 'pipe'],
  180. })
  181. let stdout = ''
  182. let stderr = ''
  183. child.stdout.setEncoding('utf8')
  184. child.stdout.on('data', (chunk: string) => { stdout += chunk })
  185. child.stderr.setEncoding('utf8')
  186. child.stderr.on('data', (chunk: string) => { stderr += chunk })
  187. const exitCode = await new Promise<number | null>((resolve, reject) => {
  188. child.once('error', reject)
  189. child.once('exit', resolve)
  190. })
  191. expect(exitCode, stderr).toBe(1)
  192. expect(stdout).toBe('')
  193. expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc')
  194. }, 10_000)
  195. })