keyless-smoke.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { createServer } from 'node:http'
  2. import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { promisify } from 'node:util'
  7. import { zstdDecompress } from 'node:zlib'
  8. import { execa } from 'execa'
  9. import { describe, expect, it } from 'vitest'
  10. const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url))
  11. const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
  12. const decompress = promisify(zstdDecompress)
  13. function waitForLine(
  14. lines: string[],
  15. predicate: (value: Record<string, unknown>) => boolean,
  16. stderr: () => string,
  17. ): Promise<Record<string, unknown>> {
  18. return new Promise((resolve, reject) => {
  19. const deadline = Date.now() + 30_000
  20. const poll = (): void => {
  21. while (lines.length > 0) {
  22. const line = lines.shift()!
  23. if (!line.trim()) continue
  24. try {
  25. const value = JSON.parse(line) as Record<string, unknown>
  26. if (predicate(value)) {
  27. resolve(value)
  28. return
  29. }
  30. } catch {
  31. reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`))
  32. return
  33. }
  34. }
  35. if (Date.now() >= deadline) {
  36. reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`))
  37. return
  38. }
  39. setTimeout(poll, 10)
  40. }
  41. poll()
  42. })
  43. }
  44. describe('Python SDK dsh profile keyless smoke', () => {
  45. it.each([
  46. { label: 'reports max-token turns with the default mapping config', envValue: undefined },
  47. { label: 'reports max-token turns with mapping enabled through env', envValue: 'true' },
  48. { label: 'reports max-token turns with mapping disabled through env', envValue: 'false' },
  49. ])('$label', async ({ envValue }) => {
  50. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-'))
  51. const modelRequests: Record<string, unknown>[] = []
  52. const modelServer = createServer((request, response) => {
  53. let body = ''
  54. request.setEncoding('utf8')
  55. request.on('data', (chunk: string) => { body += chunk })
  56. request.on('end', () => {
  57. modelRequests.push(JSON.parse(body) as Record<string, unknown>)
  58. response.writeHead(200, { 'content-type': 'text/event-stream' })
  59. response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
  60. response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
  61. response.write('data: {"choices":[{"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
  62. response.end('data: [DONE]\n\n')
  63. })
  64. })
  65. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  66. const address = modelServer.address()
  67. if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
  68. // The line-predicate protocol driving below is the genuinely custom part;
  69. // execa owns spawn, the deadline, and exit settlement around it.
  70. const child = execa(process.execPath, [
  71. '--import',
  72. 'tsx/esm',
  73. binScript,
  74. '--profile',
  75. 'sdk',
  76. ], {
  77. cwd: repoRoot,
  78. env: {
  79. DSH_HOME: join(root, '.dsh'),
  80. DSH_PERMISSION_MODE: 'danger-full-access',
  81. DSH_TELEMETRY_DISABLED: '1',
  82. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  83. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  84. ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
  85. },
  86. timeout: 35_000,
  87. killSignal: 'SIGKILL',
  88. reject: false,
  89. })
  90. const lines: string[] = []
  91. let stdoutBuffer = ''
  92. let stderr = ''
  93. child.stdout.on('data', (chunk: Buffer) => {
  94. stdoutBuffer += chunk.toString('utf8')
  95. const parts = stdoutBuffer.split('\n')
  96. stdoutBuffer = parts.pop() ?? ''
  97. lines.push(...parts)
  98. })
  99. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  100. try {
  101. child.stdin.write(`${JSON.stringify({
  102. jsonrpc: '2.0',
  103. id: 1,
  104. method: 'initialize',
  105. params: {
  106. cwd: root,
  107. provider: 'deepseek-official',
  108. model: 'deepseek-v4-pro',
  109. reasoningEffort: 'max',
  110. maxTokens: 1234,
  111. },
  112. })}\n`)
  113. const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
  114. expect(initialized).toMatchObject({
  115. jsonrpc: '2.0',
  116. id: 1,
  117. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  118. })
  119. child.stdin.write(`${JSON.stringify({
  120. jsonrpc: '2.0',
  121. id: 2,
  122. method: 'session/prompt',
  123. params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
  124. })}\n`)
  125. const prompt = await waitForLine(lines, value => value.id === 2, () => stderr)
  126. expect(prompt).toMatchObject({
  127. jsonrpc: '2.0',
  128. id: 2,
  129. result: { messageId: expect.any(String) as unknown },
  130. })
  131. const turnEnd = await waitForLine(lines, (value) => {
  132. if (value.method !== 'session.event') return false
  133. const params = value.params as Record<string, unknown> | undefined
  134. const event = params?.event as Record<string, unknown> | undefined
  135. return params?.sessionId === 'main' && event?.type === 'turn/end'
  136. }, () => stderr)
  137. expect(turnEnd).toMatchObject({
  138. jsonrpc: '2.0',
  139. method: 'session.event',
  140. params: {
  141. sessionId: 'main',
  142. event: {
  143. type: 'turn/end',
  144. data: { reason: { kind: 'max-tokens' } },
  145. },
  146. },
  147. })
  148. const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
  149. expect(modelRequests[0]?.reasoning_effort).toBe('max')
  150. expect(modelRequests[0]?.max_tokens).toBe(1234)
  151. expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models')
  152. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
  153. const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
  154. expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
  155. const exit = await child
  156. expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
  157. const sessionsRoot = join(root, '.dsh', 'sessions')
  158. const files = await readdir(sessionsRoot, { recursive: true })
  159. const log = files.find(file => file.endsWith('.jsonl.zstd'))
  160. expect(log).toBeDefined()
  161. const compressed = await readFile(join(sessionsRoot, log!))
  162. expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
  163. expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
  164. } finally {
  165. // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
  166. child.kill('SIGKILL')
  167. await child
  168. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  169. await rm(root, { recursive: true, force: true })
  170. }
  171. }, 40_000)
  172. it('boots the standalone minimal profile with its exact model-facing roster', async () => {
  173. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-'))
  174. const modelRequests: Record<string, unknown>[] = []
  175. const modelServer = createServer((request, response) => {
  176. let body = ''
  177. request.setEncoding('utf8')
  178. request.on('data', (chunk: string) => { body += chunk })
  179. request.on('end', () => {
  180. modelRequests.push(JSON.parse(body) as Record<string, unknown>)
  181. response.writeHead(200, { 'content-type': 'text/event-stream' })
  182. response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
  183. response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
  184. response.write('data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
  185. response.end('data: [DONE]\n\n')
  186. })
  187. })
  188. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  189. const address = modelServer.address()
  190. if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
  191. const child = execa(process.execPath, [
  192. '--import',
  193. 'tsx/esm',
  194. binScript,
  195. '--profile',
  196. 'sdk-minimal',
  197. ], {
  198. cwd: repoRoot,
  199. env: {
  200. DSH_HOME: join(root, '.dsh'),
  201. DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.',
  202. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  203. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  204. },
  205. timeout: 35_000,
  206. killSignal: 'SIGKILL',
  207. reject: false,
  208. })
  209. const lines: string[] = []
  210. let stdoutBuffer = ''
  211. let stderr = ''
  212. child.stdout.on('data', (chunk: Buffer) => {
  213. stdoutBuffer += chunk.toString('utf8')
  214. const parts = stdoutBuffer.split('\n')
  215. stdoutBuffer = parts.pop() ?? ''
  216. lines.push(...parts)
  217. })
  218. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  219. try {
  220. child.stdin.write(`${JSON.stringify({
  221. jsonrpc: '2.0',
  222. id: 1,
  223. method: 'initialize',
  224. params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' },
  225. })}\n`)
  226. await waitForLine(lines, value => value.id === 1, () => stderr)
  227. child.stdin.write(`${JSON.stringify({
  228. jsonrpc: '2.0',
  229. id: 2,
  230. method: 'session/prompt',
  231. params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
  232. })}\n`)
  233. await waitForLine(lines, (value) => {
  234. const params = value.params as Record<string, unknown> | undefined
  235. const event = params?.event as Record<string, unknown> | undefined
  236. return params?.sessionId === 'minimal' && event?.type === 'turn/end'
  237. }, () => stderr)
  238. const request = modelRequests[0] as {
  239. messages?: Array<{ role?: string; content?: unknown }>
  240. tools?: Array<{ function?: { name?: string } }>
  241. }
  242. expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' })
  243. const shellTool = process.platform === 'win32' ? 'pwsh' : 'bash'
  244. expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual([shellTool, 'str_replace_editor'].sort())
  245. const profile = JSON.parse(
  246. await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'),
  247. ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } }
  248. expect(profile.dsh?.profile).toEqual({
  249. bundles: ['@deepseek-ai/dsh-sdk-minimal'],
  250. patchReload: 'startup',
  251. })
  252. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
  253. await waitForLine(lines, value => value.id === 3, () => stderr)
  254. const exit = await child
  255. expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
  256. } finally {
  257. child.kill('SIGKILL')
  258. await child
  259. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  260. await rm(root, { recursive: true, force: true })
  261. }
  262. }, 40_000)
  263. it('rejects an invalid max-token success env value', async () => {
  264. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-'))
  265. try {
  266. const { exitCode, stdout, stderr } = await execa(process.execPath, [
  267. '--import',
  268. 'tsx/esm',
  269. binScript,
  270. '--profile',
  271. 'sdk',
  272. ], {
  273. cwd: repoRoot,
  274. env: {
  275. DSH_HOME: join(root, '.dsh'),
  276. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  277. DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
  278. },
  279. stdin: 'ignore',
  280. timeout: 25_000,
  281. killSignal: 'SIGKILL',
  282. reject: false,
  283. })
  284. expect(exitCode, stderr).toBe(1)
  285. expect(stdout).toBe('')
  286. expect(stderr).toContain('plugin tree failed to load')
  287. expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)')
  288. expect(stderr).toContain('sometimes')
  289. } finally {
  290. await rm(root, { recursive: true, force: true })
  291. }
  292. }, 30_000)
  293. })