sdk-client.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733
  1. /**
  2. * SDK client against a real scripted runtime subprocess
  3. * (`tests/fake-runtime.ts`, protocol-only — the only faked boundary is the
  4. * model-owning runtime itself). Covers the turn loop, notification routing
  5. * and session-tree scoping, error surfaces, timeouts, and the dispose ladder.
  6. */
  7. import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'
  8. import { tmpdir } from 'node:os'
  9. import { isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
  10. import { fileURLToPath } from 'node:url'
  11. import { afterEach, describe, expect, it, vi } from 'vitest'
  12. import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  13. import {
  14. DeepSeekHarness,
  15. HarnessClient,
  16. HarnessSession,
  17. JsonRpcResponseError,
  18. RequestTimeoutError,
  19. SdkProtocolError,
  20. TransportClosedError,
  21. type HarnessNotification,
  22. } from '../src/index.ts'
  23. import { createProcessDeepSeekHarness, finalResponse, normalizeInput } from '../src/api.ts'
  24. import { createProcessHarnessClient } from '../src/client.ts'
  25. import type { RuntimeProcessOptions } from '../src/launch.ts'
  26. const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url))
  27. const cleanups: (() => Promise<void>)[] = []
  28. afterEach(async () => {
  29. for (const cleanup of cleanups.splice(0)) await cleanup()
  30. })
  31. type LaunchOverrides = Partial<RuntimeProcessOptions>
  32. /** Launch options running the fake runtime on the current node (type stripping). */
  33. function fakeLaunch(env: Record<string, string> = {}, extra: LaunchOverrides = {}): RuntimeProcessOptions {
  34. return {
  35. command: process.execPath,
  36. args: [fakeRuntime],
  37. environment: () => ({ ...process.env as Record<string, string>, ...env }),
  38. description: 'scripted fake runtime',
  39. initializeTimeoutMs: 5_000,
  40. ...extra,
  41. }
  42. }
  43. function processClient(options: RuntimeProcessOptions): HarnessClient {
  44. return createProcessHarnessClient(options)
  45. }
  46. function harnessWith(env: Record<string, string> = {}, extra: LaunchOverrides = {}): DeepSeekHarness {
  47. const harness = createProcessDeepSeekHarness(fakeLaunch(env, extra))
  48. cleanups.push(() => harness.close())
  49. return harness
  50. }
  51. async function tempDir(prefix: string): Promise<string> {
  52. const dir = await mkdtemp(join(tmpdir(), prefix))
  53. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  54. return dir
  55. }
  56. describe('DeepSeekHarness', () => {
  57. it('ignores notifications that precede the submitted message receipt', async () => {
  58. const notifications = [
  59. { method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
  60. {
  61. method: 'session.event',
  62. params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } },
  63. },
  64. {
  65. method: 'session.event',
  66. params: {
  67. sessionId: 'owned',
  68. event: { type: 'agent/inbox/spliced', data: { inserted: null } },
  69. },
  70. },
  71. {
  72. method: 'session.event',
  73. params: {
  74. sessionId: 'owned',
  75. event: {
  76. type: 'agent/inbox/spliced',
  77. seq: 0,
  78. time: 0,
  79. data: {
  80. target: 'next-turn',
  81. start: 0,
  82. inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
  83. },
  84. },
  85. },
  86. },
  87. { method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
  88. ] as HarnessNotification[]
  89. let closed = false
  90. const harness = {
  91. start: () => Promise.resolve(),
  92. client: {
  93. prompt: () => Promise.resolve('accepted-message'),
  94. subscribeSessionTree: () => ({
  95. next: async () => {
  96. const notification = notifications.shift()
  97. if (notification === undefined) throw new Error('scripted notification queue exhausted')
  98. return notification
  99. },
  100. tryNext: () => notifications.shift(),
  101. close: () => { closed = true },
  102. async * [Symbol.asyncIterator]() {},
  103. }),
  104. },
  105. } as unknown as DeepSeekHarness
  106. const result = await new HarnessSession(harness, 'owned').run('go')
  107. expect(result.notifications.map(notification => notification.method))
  108. .toEqual(['session.event', 'session.status'])
  109. expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
  110. expect(closed).toBe(true)
  111. })
  112. it('preserves Auto review denial details in native and PTC session events', async () => {
  113. const receipt = {
  114. type: 'agent/inbox/spliced',
  115. data: {
  116. target: 'next-turn',
  117. start: 0,
  118. inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
  119. },
  120. }
  121. const nativeResult = {
  122. type: 'tool/result',
  123. data: {
  124. turn: 1,
  125. step: 1,
  126. message: {
  127. source: { kind: 'tool', callId: 'native-call' },
  128. content: [{
  129. type: 'tool-result',
  130. toolCallId: 'native-call',
  131. content: [{ type: 'text', text: 'Error: blocked by policy' }],
  132. isError: true,
  133. }],
  134. role: 'user',
  135. id: 'native-result',
  136. },
  137. error: {
  138. name: 'AutoReviewDeniedError',
  139. code: 'AUTO_REVIEW_DENIED',
  140. reason: ' native raw\nreason ',
  141. },
  142. },
  143. }
  144. const ptcStart = {
  145. type: 'tool/ptc-dispatch-start',
  146. data: {
  147. rootCallId: 'run-code-call',
  148. parentCallId: 'run-code-call',
  149. subCallId: 'run-code-call:ptc:1',
  150. name: 'bash',
  151. arguments: { command: 'git push --force' },
  152. },
  153. }
  154. const ptcResult = {
  155. type: 'tool/ptc-dispatch',
  156. data: {
  157. rootCallId: 'run-code-call',
  158. parentCallId: 'run-code-call',
  159. subCallId: 'run-code-call:ptc:1',
  160. name: 'bash',
  161. arguments: { command: 'git push --force' },
  162. isError: true,
  163. content: [{ type: 'text', text: 'Error: blocked by policy' }],
  164. error: {
  165. name: 'AutoReviewDeniedError',
  166. code: 'AUTO_REVIEW_DENIED',
  167. reason: ' ptc raw\nreason ',
  168. },
  169. },
  170. }
  171. const notifications = [
  172. { method: 'session.event', params: { sessionId: 'owned', event: receipt } },
  173. { method: 'session.event', params: { sessionId: 'owned', event: nativeResult } },
  174. { method: 'session.event', params: { sessionId: 'owned', event: ptcStart } },
  175. { method: 'session.event', params: { sessionId: 'owned', event: ptcResult } },
  176. { method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
  177. ] as HarnessNotification[]
  178. const harness = {
  179. start: () => Promise.resolve(),
  180. client: {
  181. prompt: () => Promise.resolve('accepted-message'),
  182. subscribeSessionTree: () => ({
  183. next: async () => {
  184. const notification = notifications.shift()
  185. if (notification === undefined) throw new Error('scripted notification queue exhausted')
  186. return notification
  187. },
  188. tryNext: () => notifications.shift(),
  189. close: () => {},
  190. async * [Symbol.asyncIterator]() {},
  191. }),
  192. },
  193. } as unknown as DeepSeekHarness
  194. const result = await new HarnessSession(harness, 'owned').run('go')
  195. expect(result.events).toEqual([receipt, nativeResult, ptcStart, ptcResult])
  196. for (const event of result.events.filter(event => event.type.startsWith('tool/ptc-dispatch'))) {
  197. expect(event.data).not.toHaveProperty('description')
  198. expect(event.data).not.toHaveProperty('parameters')
  199. expect(event.data).not.toHaveProperty('schema')
  200. }
  201. })
  202. it('runs a turn end to end and reuses the runtime across sessions', async () => {
  203. const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
  204. const first = await harness.run('say hi')
  205. expect(first.finalResponse).toBe('turn answer')
  206. expect(first.events.map(event => event.type)).toEqual([
  207. 'agent/inbox/spliced', 'turn/start', 'assistant/message', 'turn/end',
  208. ])
  209. const message = first.events.find(event => event.type === 'assistant/message')
  210. expect(message?.data.stream).toEqual([
  211. { type: 'chunk', time: 0, chunk: { type: 'block-start', index: 0, blockType: 'text' } },
  212. { type: 'text-chunks', time0: 0, index: 0, dt: [], texts: ['turn answer'] },
  213. {
  214. type: 'chunk',
  215. time: 0,
  216. chunk: { type: 'block-end', index: 0, block: { type: 'text', text: 'turn answer' } },
  217. },
  218. { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
  219. ])
  220. // Same subprocess, second session: ids differ, protocol state is reusable.
  221. const second = await harness.run([{ type: 'text', text: 'again' }])
  222. expect(second.sessionId).not.toBe(first.sessionId)
  223. await harness.close()
  224. })
  225. it('keeps events root-scoped while streaming notifications for the session tree', async () => {
  226. const harness = harnessWith({ FAKE_SUBAGENT: '1' })
  227. const seen: HarnessNotification[] = []
  228. const result = await harness.run('delegate', {
  229. sessionId: 'parent-1',
  230. onNotification: (n) => { seen.push(n) },
  231. })
  232. // The child session's events arrive through subagent.started lineage.
  233. expect(seen.map(n => n.method)).toContain('subagent.started')
  234. expect(seen.map(n => n.method)).toContain('subagent.finished')
  235. const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
  236. expect(childEvents.length).toBeGreaterThan(0)
  237. // RunResult.events is the root session's typed stream; descendants retain
  238. // their session ids in the raw notification stream above.
  239. expect(result.events.every(event => event.type !== 'assistant/message'
  240. || event.data.message.content[0]?.type !== 'text'
  241. || event.data.message.content[0].text !== 'child says hi')).toBe(true)
  242. await harness.close()
  243. })
  244. it('sends the configured cwd/provider/model/reasoningEffort/maxTokens in the handshake exactly once', async () => {
  245. const dir = await tempDir('sdk-client-init-')
  246. const recordFile = join(dir, 'init.jsonl')
  247. const harness = createProcessDeepSeekHarness(fakeLaunch({ FAKE_RECORD_INIT: recordFile }), {
  248. cwd: dir,
  249. provider: 'custom-provider',
  250. model: 'custom-model',
  251. reasoningEffort: ReasoningEffortId('max'),
  252. maxTokens: 4096,
  253. })
  254. cleanups.push(() => harness.close())
  255. await harness.run('one')
  256. await harness.run('two')
  257. await harness.close()
  258. const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object)
  259. expect(records).toEqual([{
  260. cwd: dir,
  261. provider: 'custom-provider',
  262. model: 'custom-model',
  263. reasoningEffort: 'max',
  264. maxTokens: 4096,
  265. }])
  266. })
  267. it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => {
  268. // vitest workers forbid chdir, so derive a RELATIVE path from the real
  269. // process cwd to a temp worker dir; resolution is lexical either way.
  270. const dir = await mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-'))
  271. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  272. const recordFile = join(dir, 'init.jsonl')
  273. const inner = join(dir, 'worker')
  274. await mkdir(inner)
  275. const relativeCwd = relative(process.cwd(), inner)
  276. expect(isAbsolute(relativeCwd)).toBe(false)
  277. const harness = createProcessDeepSeekHarness(
  278. fakeLaunch({ FAKE_RECORD_INIT: recordFile, FAKE_ECHO_CWD_IN_INIT: '1' }, { cwd: relativeCwd }),
  279. )
  280. cleanups.push(() => harness.close())
  281. await harness.start()
  282. const identity = await harness.client.initialize({ cwd: inner, provider: 'p', model: 'm' })
  283. await harness.close()
  284. // The child spawned under the temp worker dir (its physical cwd)...
  285. expect(identity.serverInfo.version).toBe(await realpath(inner))
  286. // ...and the handshake wire cwd went out ABSOLUTE, so the child cannot
  287. // re-resolve a relative string into dir/worker/worker.
  288. const records = (await readFile(recordFile, 'utf8')).trim().split('\n')
  289. .map(line => (JSON.parse(line) as { cwd: string }).cwd)
  290. expect(records).toEqual([resolvePath(relativeCwd), inner])
  291. })
  292. it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => {
  293. const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
  294. const failure = await harness.run('boom').then(
  295. () => { throw new Error('run unexpectedly succeeded') },
  296. (error: unknown) => error,
  297. )
  298. expect(failure).toBeInstanceOf(JsonRpcResponseError)
  299. expect(failure).toMatchObject({ code: 7, message: 'scripted init failure', data: { hint: 'fake' } })
  300. // The failed handshake reset lets a later start retry instead of wedging.
  301. await expect(harness.run('later')).rejects.toThrow()
  302. })
  303. it('preserves both initialize and SDK-owned cleanup failures', async () => {
  304. const initializeError = new SdkProtocolError('malformed initialize')
  305. const cleanupError = new Error('cleanup failed')
  306. const start = vi.spyOn(HarnessClient.prototype, 'start').mockImplementation(() => {})
  307. const initialize = vi.spyOn(HarnessClient.prototype, 'initialize').mockRejectedValue(initializeError)
  308. const close = vi.spyOn(HarnessClient.prototype, 'close').mockRejectedValue(cleanupError)
  309. try {
  310. const harness = createProcessDeepSeekHarness(fakeLaunch())
  311. const failedClient = harness.client
  312. const failure = await harness.start().catch((error: unknown) => error)
  313. expect(failure).toBeInstanceOf(AggregateError)
  314. expect((failure as AggregateError).errors).toEqual([initializeError, cleanupError])
  315. expect((failure as Error).message).toBe('DeepSeek Harness initialization and cleanup failed')
  316. expect(harness.client).toBe(failedClient)
  317. } finally {
  318. start.mockRestore()
  319. initialize.mockRestore()
  320. close.mockRestore()
  321. }
  322. })
  323. it('does not replace the client after terminal close wins a failed handshake', async () => {
  324. let rejectInitialize!: (error: Error) => void
  325. const initializeResult = new Promise<never>((_resolve, reject) => { rejectInitialize = reject })
  326. const start = vi.spyOn(HarnessClient.prototype, 'start').mockImplementation(() => {})
  327. const initialize = vi.spyOn(HarnessClient.prototype, 'initialize').mockReturnValue(initializeResult)
  328. const close = vi.spyOn(HarnessClient.prototype, 'close').mockResolvedValue()
  329. try {
  330. const harness = createProcessDeepSeekHarness(fakeLaunch())
  331. const original = harness.client
  332. const pending = harness.start()
  333. await harness.close()
  334. rejectInitialize(new SdkProtocolError('late initialize failure'))
  335. await expect(pending).rejects.toThrow('late initialize failure')
  336. expect(harness.client).toBe(original)
  337. } finally {
  338. start.mockRestore()
  339. initialize.mockRestore()
  340. close.mockRestore()
  341. }
  342. })
  343. it('retries a failed handshake with a fresh runtime process', async () => {
  344. const dir = await tempDir('sdk-client-retry-')
  345. const marker = join(dir, 'first-boot-failed')
  346. const harness = harnessWith({ FAKE_INIT_ERROR_ONCE_FILE: marker, FAKE_TEXT: 'second boot answer' })
  347. const firstClient = harness.client
  348. // First start: the scripted runtime fails the handshake and is reaped.
  349. await expect(harness.start()).rejects.toThrow('scripted first-boot failure')
  350. // Retry spawns a NEW subprocess through a fresh client (close is permanent).
  351. const result = await harness.run('again')
  352. expect(harness.client).not.toBe(firstClient)
  353. expect(result.finalResponse).toBe('second boot answer')
  354. await harness.close()
  355. // close() is terminal: a handshake failure after it must not respawn.
  356. await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError)
  357. })
  358. it('rejects a malformed initialize result as a protocol error', async () => {
  359. const harness = harnessWith({ FAKE_MALFORMED: '1' })
  360. await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
  361. })
  362. it('supports await using disposal', async () => {
  363. let captured: DeepSeekHarness
  364. {
  365. await using harness = createProcessDeepSeekHarness(fakeLaunch())
  366. captured = harness
  367. const result = await harness.run('scoped')
  368. expect(result.finalResponse).toBe('hello from fake runtime')
  369. }
  370. // After scope exit the runtime is closed: reuse fails loudly.
  371. await expect(captured.run('after')).rejects.toThrow(TransportClosedError)
  372. })
  373. it('constructs the public dsh-backed client lazily', async () => {
  374. const harness = new DeepSeekHarness()
  375. expect(harness.client).toBeInstanceOf(HarnessClient)
  376. await harness.close()
  377. })
  378. })
  379. describe('HarnessClient', () => {
  380. it('bounds profile initialization and names the selected profile in its diagnostic', async () => {
  381. const client = processClient(fakeLaunch(
  382. { FAKE_HANG_INIT: '1' },
  383. {
  384. description: 'dsh profile "profile-without-sdk-server"',
  385. initializeTimeoutMs: 50,
  386. disposeEofGraceMs: 100,
  387. // Wide SIGKILL confirmation: the hang-init child may still be
  388. // starting up on a contended runner when close() escalates, so a
  389. // tight window misreports a slow reap as a dispose failure.
  390. disposeGraceMs: 3_000,
  391. },
  392. ))
  393. cleanups.push(() => client.close())
  394. await expect(client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }))
  395. .rejects.toThrow(/initialize timed out after 50ms waiting for dsh profile "profile-without-sdk-server"/)
  396. await client.close()
  397. })
  398. it('times out a hung request at the per-call bound', async () => {
  399. const client = processClient(fakeLaunch({
  400. FAKE_HANG_PROMPT: '1',
  401. FAKE_STDERR: 'runtime accepted initialize but hung the prompt',
  402. }))
  403. cleanups.push(() => client.close())
  404. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  405. await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('hi') }, 200))
  406. .rejects.toThrow(/session\/prompt timed out.*stderr tail:\nruntime accepted initialize but hung the prompt/s)
  407. await client.close()
  408. })
  409. it('a timed-out request leaves no pending transport state', async () => {
  410. const client = processClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
  411. cleanups.push(() => client.close())
  412. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  413. for (let round = 0; round < 3; round++) {
  414. await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('x') }, 50))
  415. .rejects.toThrow(RequestTimeoutError)
  416. }
  417. // Abandonment removed each pending entry at its timeout; a hung method
  418. // retains nothing per call. (Private map read is the observable here —
  419. // no wire surface reports transport bookkeeping.)
  420. const transport = (client as unknown as { transport: { pending: Map<string, unknown> } }).transport
  421. expect(transport.pending.size).toBe(0)
  422. await client.close()
  423. })
  424. it('applies the client-wide request timeout when no per-call bound is given', async () => {
  425. const client = processClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
  426. cleanups.push(() => client.close())
  427. // The bound applies from send, so it holds regardless of runtime boot time.
  428. await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(RequestTimeoutError)
  429. await client.close()
  430. })
  431. it('rejects a malformed prompt acceptance as a protocol error', async () => {
  432. const client = processClient(fakeLaunch({ FAKE_MALFORMED: '1' }))
  433. cleanups.push(() => client.close())
  434. await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(SdkProtocolError)
  435. await client.close()
  436. })
  437. it('fails pending requests with exit code and stderr tail when the runtime dies', async () => {
  438. const client = processClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'fatal: scripted death' }))
  439. cleanups.push(() => client.close())
  440. const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
  441. () => { throw new Error('initialize unexpectedly succeeded') },
  442. (error: unknown) => error,
  443. )
  444. expect(failure).toBeInstanceOf(TransportClosedError)
  445. expect(String(failure)).toContain('exit code: 3')
  446. expect(String(failure)).toContain('fatal: scripted death')
  447. // Requests after death fail immediately with the same context.
  448. await expect(client.request('initialize', {})).rejects.toThrow('exit code: 3')
  449. })
  450. it('flushes an unterminated stderr line into the tail at close', async () => {
  451. const client = processClient(fakeLaunch({ FAKE_STDERR_NO_NEWLINE: 'no trailing newline', FAKE_EXIT_BEFORE_INIT: '1' }))
  452. cleanups.push(() => client.close())
  453. const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
  454. () => { throw new Error('initialize unexpectedly succeeded') },
  455. (error: unknown) => error,
  456. )
  457. expect(String(failure)).toContain('no trailing newline')
  458. })
  459. it('fails when the configured dsh CLI module does not exist', async () => {
  460. const client = new HarnessClient({ dshBin: join(tmpdir(), 'dsh-no-such-runtime-bin') })
  461. cleanups.push(() => client.close())
  462. await expect(client.request('initialize', {}, 1_000)).rejects.toThrow(TransportClosedError)
  463. })
  464. it('reports a generic process spawn failure to internal transports', async () => {
  465. const client = processClient(fakeLaunch({}, {
  466. command: join(tmpdir(), 'dsh-no-such-process-command'),
  467. args: [],
  468. }))
  469. cleanups.push(() => client.close())
  470. await expect(client.request('initialize', {}, 1_000))
  471. .rejects.toThrow(/spawn error:.*ENOENT/s)
  472. })
  473. it('close() is idempotent, reaps the child, and fails later use', async () => {
  474. const client = processClient(fakeLaunch())
  475. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  476. await Promise.all([client.close(), client.close()])
  477. expect(() => { client.start() }).toThrow(TransportClosedError)
  478. await expect(client.request('anything')).rejects.toThrow(TransportClosedError)
  479. // Close with no child ever spawned is a no-op.
  480. const untouched = processClient(fakeLaunch())
  481. await untouched.close()
  482. })
  483. it('escalates through SIGTERM when the runtime ignores EOF', async () => {
  484. const dir = await tempDir('sdk-client-ladder-')
  485. const sigtermFile = join(dir, 'sigterm.txt')
  486. const client = processClient(fakeLaunch(
  487. { FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile },
  488. { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 3_000 },
  489. ))
  490. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  491. await client.close()
  492. if (process.platform === 'win32') {
  493. await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' })
  494. } else {
  495. expect((await stat(sigtermFile)).isFile()).toBe(true)
  496. }
  497. })
  498. it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => {
  499. const client = processClient(fakeLaunch(
  500. { FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' },
  501. { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 3_000 },
  502. ))
  503. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  504. // Resolves (does not hang or reject): the SIGKILL rung reaped the child.
  505. await client.close()
  506. })
  507. it('delivers notifications to unfiltered and filtered subscriptions in wire order', async () => {
  508. const client = processClient(fakeLaunch())
  509. cleanups.push(() => client.close())
  510. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  511. const all = client.subscribe()
  512. const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
  513. const firstPending = all.next()
  514. await client.prompt('sub-test', normalizeInput('go'))
  515. const first = await firstPending
  516. expect(first.method).toBe('session.event')
  517. const idle = await idleOnly.next()
  518. expect(idle.method).toBe('session.status')
  519. expect(idleOnly.tryNext()).toBeUndefined()
  520. // A bare unbounded request with omitted params sends `{}` on the wire.
  521. const identity = await client.request('initialize') as { serverInfo: { name: string } }
  522. expect(identity.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
  523. // Async iteration consumes queued items and then parks.
  524. const collected: string[] = []
  525. for await (const notification of all) {
  526. collected.push(notification.method)
  527. if (notification.method === 'session.status' && notification.params.status === 'idle') break
  528. }
  529. expect(collected.at(-1)).toBe('session.status')
  530. all.close()
  531. idleOnly.close()
  532. await expect(all.next()).rejects.toThrow('notification subscription closed')
  533. await client.close()
  534. })
  535. it('contains a throwing filter to its own subscription', async () => {
  536. const client = processClient(fakeLaunch())
  537. cleanups.push(() => client.close())
  538. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  539. const broken = client.subscribe(() => { throw new Error('filter exploded') })
  540. // A non-Error throw is normalized rather than crashing dispatch.
  541. const brokenNonError = client.subscribe(() => { throw 'string boom' })
  542. const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
  543. await client.prompt('filter-contain', normalizeInput('go'))
  544. // The sibling subscription and the read loop are undisturbed.
  545. expect((await healthy.next()).method).toBe('session.status')
  546. // Each broken subscription failed with ITS OWN error and detached.
  547. await expect(broken.next()).rejects.toThrow('filter exploded')
  548. await expect(brokenNonError.next()).rejects.toThrow('string boom')
  549. healthy.close()
  550. await client.close()
  551. })
  552. it('close() drops queued notifications; runtime death keeps them drainable', async () => {
  553. const client = processClient(fakeLaunch())
  554. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  555. const closed = client.subscribe()
  556. const drainable = client.subscribe()
  557. await client.prompt('queue-drop', normalizeInput('go'))
  558. expect(closed.tryNext()).toBeDefined()
  559. closed.close()
  560. // Manual close drops the rest of the queue outright.
  561. expect(closed.tryNext()).toBeUndefined()
  562. await expect(closed.next()).rejects.toThrow('notification subscription closed')
  563. // Runtime teardown, by contrast, only stops FUTURE delivery: what was
  564. // already delivered before close() stays drainable.
  565. await client.close()
  566. expect(drainable.tryNext()).toBeDefined()
  567. })
  568. it('subscriptions created after termination are born failed', async () => {
  569. const client = processClient(fakeLaunch())
  570. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  571. await client.close()
  572. // No producer can ever feed this subscription; next() must not park forever.
  573. await expect(client.subscribe().next()).rejects.toThrow(TransportClosedError)
  574. const dead = processClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1' }))
  575. cleanups.push(() => dead.close())
  576. await dead.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).catch(() => {})
  577. await expect(dead.subscribe().next()).rejects.toThrow(TransportClosedError)
  578. })
  579. it('closes subscriptions with the runtime and rejects parked waiters', async () => {
  580. const client = processClient(fakeLaunch())
  581. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  582. const subscription = client.subscribe()
  583. const parked = subscription.next()
  584. await client.close()
  585. await expect(parked).rejects.toThrow(TransportClosedError)
  586. })
  587. it('scopes the session tree across multi-hop lineage and ignores foreign sessions', async () => {
  588. const client = processClient(fakeLaunch())
  589. cleanups.push(() => client.close())
  590. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  591. const tree = client.subscribeSessionTree('root')
  592. // Lineage edges arrive as subagent.started notifications.
  593. const inject = (method: string, params: Record<string, unknown>): void => {
  594. (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params })
  595. }
  596. inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' })
  597. inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' })
  598. inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } })
  599. inject('session.event', { sessionId: 'stranger', event: { type: 'noop' } })
  600. inject('subagent.started', { parentSessionId: 'other-root', childSessionId: 'other-child' })
  601. inject('subagent.finished', { parentSessionId: 'child', childSessionId: 'grandchild' })
  602. // Self-loop and empty edges must not corrupt the lineage map.
  603. inject('subagent.started', { parentSessionId: 'loop', childSessionId: 'loop' })
  604. inject('subagent.started', { parentSessionId: '', childSessionId: 'x' })
  605. inject('subagent.finished', { childSessionId: 'root' })
  606. expect((await tree.next()).method).toBe('subagent.started')
  607. expect((await tree.next()).method).toBe('subagent.started')
  608. expect((await tree.next()).params.sessionId).toBe('grandchild')
  609. expect((await tree.next()).method).toBe('subagent.finished')
  610. // The foreign-root edge and stranger event were filtered; next is the root-child edge.
  611. expect((await tree.next()).params.childSessionId).toBe('root')
  612. tree.close()
  613. await client.close()
  614. })
  615. })
  616. describe('wire payload validation', () => {
  617. it('rejects a non-object session.event envelope as a protocol error', async () => {
  618. const harness = harnessWith({ FAKE_MALFORMED_EVENT: '1' })
  619. await expect(harness.run('bad-event')).rejects.toThrow(SdkProtocolError)
  620. })
  621. it('rejects an assistant/message without a content array as a protocol error', async () => {
  622. const harness = harnessWith({ FAKE_MALFORMED_MESSAGE: '1' })
  623. await expect(harness.run('bad-message')).rejects.toThrow(SdkProtocolError)
  624. })
  625. it('rejects an assistant/message without a data member as a protocol error', async () => {
  626. const harness = harnessWith({ FAKE_MESSAGE_WITHOUT_DATA: '1' })
  627. await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
  628. })
  629. it.each(['1', 'aborted', 'abort-unknown', 'hook', 'no-data'])('rejects malformed turn/end input %s as a protocol error', async (mode) => {
  630. const harness = harnessWith({ FAKE_MALFORMED_REASON: mode })
  631. await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError)
  632. })
  633. it('accepts the complete hook cancellation cause', async () => {
  634. const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: 'hook' })
  635. const result = await harness.run('hook-abort')
  636. const end = result.events.findLast(event => event.type === 'turn/end')
  637. expect(end?.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'hook', reason: 'scripted hook abort' } })
  638. })
  639. })
  640. describe('stderr tail bound', () => {
  641. it('keeps only the newest lines up to the limit', async () => {
  642. const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')
  643. const client = processClient(fakeLaunch({ FAKE_STDERR: manyLines, FAKE_EXIT_BEFORE_INIT: '1' }))
  644. cleanups.push(() => client.close())
  645. const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
  646. () => { throw new Error('initialize unexpectedly succeeded') },
  647. (error: unknown) => error,
  648. )
  649. const text = String(failure)
  650. // The tail is bounded to the newest 400 lines: the oldest are dropped.
  651. expect(text).toContain('line-449')
  652. expect(text).not.toContain('line-0\n')
  653. })
  654. })
  655. describe('pure helpers', () => {
  656. it('normalizeInput wraps strings and passes blocks through', () => {
  657. expect(normalizeInput('x')).toEqual([{ type: 'text', text: 'x' }])
  658. const blocks = [{ type: 'text' as const, text: 'y' }]
  659. expect(normalizeInput(blocks)).toBe(blocks)
  660. })
  661. it('finalResponse reads the last assistant message and tolerates absence', () => {
  662. expect(finalResponse([])).toBe('')
  663. expect(finalResponse([{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } } as never])).toBe('')
  664. expect(finalResponse([
  665. { type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'first' }] } } } as never,
  666. { type: 'assistant/message', seq: 1, time: 0, data: { message: { content: [{ type: 'text', text: 'a' }, { type: 'tool-call' }, { type: 'text', text: 'b' }] } } } as never,
  667. ])).toBe('ab')
  668. })
  669. })