keyless-smoke.e2e.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import { createServer } from 'node:http'
  2. import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } 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 { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
  9. import { execa } from 'execa'
  10. import { describe, expect, it } from 'vitest'
  11. const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url))
  12. const launch = resolveExampleLaunch({
  13. srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)),
  14. mode: 'lib',
  15. })
  16. const decompress = promisify(zstdDecompress)
  17. /** Frame one text or tool response from the local Messages endpoint. */
  18. function messagesResponse(content: Record<string, unknown>, stopReason: 'end_turn' | 'max_tokens' | 'tool_use'): string {
  19. return [
  20. { type: 'message_start', message: { id: 'sdk-smoke-response', model: 'deepseek-v4-pro', usage: { input_tokens: 3, output_tokens: 0 } } },
  21. { type: 'content_block_start', index: 0, content_block: content },
  22. { type: 'content_block_stop', index: 0 },
  23. { type: 'message_delta', delta: { stop_reason: stopReason }, usage: { output_tokens: 1 } },
  24. { type: 'message_stop' },
  25. ].map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
  26. }
  27. function waitForLine(
  28. lines: string[],
  29. predicate: (value: Record<string, unknown>) => boolean,
  30. stderr: () => string,
  31. ): Promise<Record<string, unknown>> {
  32. return new Promise((resolve, reject) => {
  33. const deadline = Date.now() + 30_000
  34. const poll = (): void => {
  35. while (lines.length > 0) {
  36. const line = lines.shift()!
  37. if (!line.trim()) continue
  38. try {
  39. const value = JSON.parse(line) as Record<string, unknown>
  40. if (predicate(value)) {
  41. resolve(value)
  42. return
  43. }
  44. } catch {
  45. reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`))
  46. return
  47. }
  48. }
  49. if (Date.now() >= deadline) {
  50. reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`))
  51. return
  52. }
  53. setTimeout(poll, 10)
  54. }
  55. poll()
  56. })
  57. }
  58. describe('Python SDK dsh profile keyless smoke', () => {
  59. it.each([
  60. { label: 'reports max-token turns with the default mapping config', envValue: undefined, editorEnabled: false },
  61. { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false },
  62. { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false },
  63. { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true },
  64. ])('$label', async ({ envValue, editorEnabled }) => {
  65. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-smoke-'))
  66. const editorPatch = join(root, 'editor.patch.yml')
  67. if (editorEnabled) await writeFile(editorPatch, [
  68. '- insert:',
  69. ' - id: tool-str-replace-editor',
  70. " name: '@deepseek-ai/dsh-tool-str-replace-editor'",
  71. '',
  72. ].join('\n'))
  73. const modelRequests: Record<string, unknown>[] = []
  74. const modelServer = createServer((request, response) => {
  75. let body = ''
  76. request.setEncoding('utf8')
  77. request.on('data', (chunk: string) => { body += chunk })
  78. request.on('end', () => {
  79. modelRequests.push(JSON.parse(body) as Record<string, unknown>)
  80. response.writeHead(200, { 'content-type': 'text/event-stream' })
  81. response.end(messagesResponse({ type: 'text', text: 'done' }, 'max_tokens'))
  82. })
  83. })
  84. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  85. const address = modelServer.address()
  86. if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
  87. // The line-predicate protocol driving below is the genuinely custom part;
  88. // execa owns spawn, the deadline, and exit settlement around it.
  89. const child = execa(launch.command, [
  90. ...launch.args,
  91. '--profile',
  92. 'sdk',
  93. ...(editorEnabled ? ['--patch', editorPatch] : []),
  94. ], {
  95. cwd: repoRoot,
  96. env: {
  97. ...launch.env,
  98. DSH_HOME: join(root, '.dsh'),
  99. DSH_PERMISSION_MODE: 'danger-full-access',
  100. DSH_TELEMETRY_DISABLED: '1',
  101. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  102. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  103. ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
  104. },
  105. timeout: 35_000,
  106. killSignal: 'SIGKILL',
  107. reject: false,
  108. })
  109. const lines: string[] = []
  110. let stdoutBuffer = ''
  111. let stderr = ''
  112. child.stdout.on('data', (chunk: Buffer) => {
  113. stdoutBuffer += chunk.toString('utf8')
  114. const parts = stdoutBuffer.split('\n')
  115. stdoutBuffer = parts.pop() ?? ''
  116. lines.push(...parts)
  117. })
  118. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  119. try {
  120. child.stdin.write(`${JSON.stringify({
  121. jsonrpc: '2.0',
  122. id: 1,
  123. method: 'initialize',
  124. params: {
  125. cwd: root,
  126. provider: 'deepseek-official',
  127. model: 'deepseek-v4-pro',
  128. reasoningEffort: 'max',
  129. maxTokens: 1234,
  130. },
  131. })}\n`)
  132. const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
  133. expect(initialized).toMatchObject({
  134. jsonrpc: '2.0',
  135. id: 1,
  136. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  137. })
  138. child.stdin.write(`${JSON.stringify({
  139. jsonrpc: '2.0',
  140. id: 2,
  141. method: 'session/prompt',
  142. params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
  143. })}\n`)
  144. const prompt = await waitForLine(lines, value => value.id === 2, () => stderr)
  145. expect(prompt).toMatchObject({
  146. jsonrpc: '2.0',
  147. id: 2,
  148. result: { messageId: expect.any(String) as unknown },
  149. })
  150. const turnEnd = await waitForLine(lines, (value) => {
  151. if (value.method !== 'session.event') return false
  152. const params = value.params as Record<string, unknown> | undefined
  153. const event = params?.event as Record<string, unknown> | undefined
  154. return params?.sessionId === 'main' && event?.type === 'turn/end'
  155. }, () => stderr)
  156. expect(turnEnd).toMatchObject({
  157. jsonrpc: '2.0',
  158. method: 'session.event',
  159. params: {
  160. sessionId: 'main',
  161. event: {
  162. type: 'turn/end',
  163. data: { reason: { kind: 'max-tokens' } },
  164. },
  165. },
  166. })
  167. expect(modelRequests[0]?.tools).toEqual(expect.any(Array))
  168. const tools = modelRequests[0]?.tools as { name?: string }[]
  169. const toolNames = tools.map(tool => tool.name)
  170. expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' })
  171. expect(modelRequests[0]?.max_tokens).toBe(1234)
  172. expect(toolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch', 'web_search']))
  173. expect(toolNames.includes('str_replace_editor')).toBe(editorEnabled)
  174. expect(toolNames).not.toContain('list_subagent_models')
  175. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
  176. const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
  177. expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
  178. const exit = await child
  179. expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
  180. const sessionsRoot = join(root, '.dsh', 'sessions')
  181. const files = await readdir(sessionsRoot, { recursive: true })
  182. const log = files.find(file => file.endsWith('.jsonl.zstd'))
  183. expect(log).toBeDefined()
  184. const compressed = await readFile(join(sessionsRoot, log!))
  185. expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
  186. expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
  187. } finally {
  188. // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
  189. child.kill('SIGKILL')
  190. await child
  191. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  192. await rm(root, { recursive: true, force: true })
  193. }
  194. }, 40_000)
  195. it.each([
  196. { label: 'boots the standalone minimal profile through its generated manifest', editorEnabled: false },
  197. { label: 'executes the documented editor opt-in patch with sdk-minimal', editorEnabled: true },
  198. ])('$label', async ({ editorEnabled }) => {
  199. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-'))
  200. const editorPatch = join(root, 'editor.patch.yml')
  201. if (editorEnabled) {
  202. const guide = await readFile(join(repoRoot, 'docs/user/guide/python-sdk.md'), 'utf8')
  203. const yaml = guide.split('<a id="opt-in-to-str_replace_editor"></a>')[1]
  204. ?.match(/```yaml\n([\s\S]*?)```/)?.[1]
  205. expect(yaml).toBeDefined()
  206. await writeFile(editorPatch, yaml!)
  207. }
  208. const editorFile = join(root, 'editor.txt')
  209. const editorContent = 'sdk-minimal editor opt-in\n'
  210. const editorCalls = editorEnabled ? [
  211. { command: 'create', path: editorFile, file_text: editorContent },
  212. { command: 'view', path: editorFile },
  213. ] : []
  214. const modelRequests: Record<string, unknown>[] = []
  215. const modelServer = createServer((request, response) => {
  216. let body = ''
  217. request.setEncoding('utf8')
  218. request.on('data', (chunk: string) => { body += chunk })
  219. request.on('end', () => {
  220. modelRequests.push(JSON.parse(body) as Record<string, unknown>)
  221. response.writeHead(200, { 'content-type': 'text/event-stream' })
  222. const toolCall = editorCalls[modelRequests.length - 1]
  223. response.end(messagesResponse(toolCall ? {
  224. type: 'tool_use',
  225. id: `editor-${toolCall.command}`,
  226. name: 'str_replace_editor',
  227. input: toolCall,
  228. } : { type: 'text', text: 'done' }, toolCall ? 'tool_use' : 'end_turn'))
  229. })
  230. })
  231. await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
  232. const address = modelServer.address()
  233. if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
  234. const child = execa(launch.command, [
  235. ...launch.args,
  236. '--profile',
  237. 'sdk-minimal',
  238. ...(editorEnabled ? ['--patch', editorPatch] : []),
  239. ], {
  240. cwd: repoRoot,
  241. env: {
  242. ...launch.env,
  243. DSH_HOME: join(root, '.dsh'),
  244. DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.',
  245. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  246. DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
  247. },
  248. timeout: 35_000,
  249. killSignal: 'SIGKILL',
  250. reject: false,
  251. })
  252. const lines: string[] = []
  253. let stdoutBuffer = ''
  254. let stderr = ''
  255. child.stdout.on('data', (chunk: Buffer) => {
  256. stdoutBuffer += chunk.toString('utf8')
  257. const parts = stdoutBuffer.split('\n')
  258. stdoutBuffer = parts.pop() ?? ''
  259. lines.push(...parts)
  260. })
  261. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  262. try {
  263. child.stdin.write(`${JSON.stringify({
  264. jsonrpc: '2.0',
  265. id: 1,
  266. method: 'initialize',
  267. params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro' },
  268. })}\n`)
  269. await waitForLine(lines, value => value.id === 1, () => stderr)
  270. child.stdin.write(`${JSON.stringify({
  271. jsonrpc: '2.0',
  272. id: 2,
  273. method: 'session/prompt',
  274. params: { sessionId: 'minimal', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
  275. })}\n`)
  276. const turnEnd = await waitForLine(lines, (value) => {
  277. const params = value.params as Record<string, unknown> | undefined
  278. const event = params?.event as Record<string, unknown> | undefined
  279. return params?.sessionId === 'minimal' && event?.type === 'turn/end'
  280. }, () => stderr)
  281. expect(turnEnd, `${JSON.stringify(turnEnd)}\n${stderr}`).toMatchObject({
  282. params: { event: { data: { reason: { kind: 'completed' } } } },
  283. })
  284. const profile = JSON.parse(
  285. await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'),
  286. ) as { dsh?: { profile?: { bundles?: string[] } } }
  287. expect(profile.dsh?.profile).toEqual({
  288. bundles: ['@deepseek-ai/dsh-sdk-minimal'],
  289. })
  290. expect(modelRequests[0]?.tools).toEqual(expect.any(Array))
  291. const tools = modelRequests[0]?.tools as { name?: string }[]
  292. expect(tools.map(tool => tool.name)).toEqual([
  293. process.platform === 'win32' ? 'pwsh' : 'bash',
  294. ...(editorEnabled ? ['str_replace_editor'] : []),
  295. ])
  296. expect(modelRequests).toHaveLength(editorEnabled ? 3 : 1)
  297. if (editorEnabled) {
  298. expect(await readFile(editorFile, 'utf8')).toBe(editorContent)
  299. expect(modelRequests[2]?.messages).toEqual(expect.arrayContaining([
  300. expect.objectContaining({
  301. role: 'user',
  302. content: expect.arrayContaining([
  303. expect.objectContaining({
  304. type: 'tool_result',
  305. tool_use_id: 'editor-view',
  306. content: expect.arrayContaining([
  307. { type: 'text', text: expect.stringContaining(editorContent.trim()) as unknown },
  308. ]) as unknown,
  309. }),
  310. ]) as unknown,
  311. }),
  312. ]))
  313. }
  314. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
  315. await waitForLine(lines, value => value.id === 3, () => stderr)
  316. const exit = await child
  317. expect(exit.timedOut, stderr).toBe(false)
  318. expect(exit.signal, stderr).toBeUndefined()
  319. expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
  320. } finally {
  321. child.kill('SIGKILL')
  322. await child
  323. await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
  324. await rm(root, { recursive: true, force: true })
  325. }
  326. }, 40_000)
  327. it.each([false, true])('exits after startup failure with stdin open (logs blocked: %s)', async (blocked) => {
  328. const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-startup-exit-'))
  329. const home = join(root, '.dsh')
  330. const patch = join(root, 'failure.yml')
  331. await mkdir(home)
  332. if (blocked) await writeFile(join(home, 'logs'), 'blocked')
  333. await writeFile(patch, '- id: agent-loop\n config:\n maxParallelToolCalls: 0\n')
  334. const child = execa(launch.command, [
  335. ...launch.args, '--profile', 'sdk', '--patch', patch,
  336. ], {
  337. cwd: repoRoot,
  338. env: { ...launch.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' },
  339. stdin: 'pipe',
  340. stripFinalNewline: false,
  341. timeout: 25_000,
  342. killSignal: 'SIGKILL',
  343. reject: false,
  344. })
  345. try {
  346. const result = await child
  347. expect(result.timedOut, result.stderr).toBe(false)
  348. expect(result.signal, result.stderr).toBeUndefined()
  349. expect(result.exitCode, result.stderr).toBe(1)
  350. expect(result.stderr).toContain('startup failed:')
  351. expect(result.stderr).toContain('maxParallelToolCalls')
  352. if (blocked) {
  353. expect(result.stderr).toContain('Full diagnostics:\nWARNING: Raw diagnostics')
  354. expect(result.stderr.trimEnd()).toMatch(/\}$/u)
  355. } else {
  356. const files = await readdir(join(home, 'logs'))
  357. expect(files).toHaveLength(1)
  358. expect(result.stderr).toContain(`Full diagnostics: ${join(home, 'logs', files[0]!)}\n`)
  359. }
  360. } finally {
  361. child.stdin.end()
  362. child.kill('SIGKILL')
  363. await child
  364. await rm(root, { recursive: true, force: true })
  365. }
  366. }, 30_000)
  367. it('rejects an invalid max-token success env value', async () => {
  368. const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-'))
  369. try {
  370. const { exitCode, stdout, stderr } = await execa(launch.command, [
  371. ...launch.args,
  372. '--profile',
  373. 'sdk',
  374. ], {
  375. cwd: repoRoot,
  376. env: {
  377. ...launch.env,
  378. DSH_HOME: join(root, '.dsh'),
  379. DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
  380. DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
  381. },
  382. stdin: 'ignore',
  383. timeout: 25_000,
  384. killSignal: 'SIGKILL',
  385. reject: false,
  386. })
  387. expect(exitCode, stderr).toBe(1)
  388. expect(stdout).toBe('')
  389. expect(stderr).toContain('startup failed:')
  390. expect(stderr).toContain('sdk-jsonrpc-server (required)\n Package: @deepseek-ai/dsh-sdk-jsonrpc-server\n SyntaxError')
  391. expect(stderr).toContain('sometimes')
  392. } finally {
  393. await rm(root, { recursive: true, force: true })
  394. }
  395. }, 30_000)
  396. })