sdk-client.spec.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  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 } from 'vitest'
  12. import {
  13. DeepSeekHarness,
  14. HarnessClient,
  15. HarnessSession,
  16. JsonRpcResponseError,
  17. RequestTimeoutError,
  18. SdkProtocolError,
  19. TransportClosedError,
  20. type HarnessNotification,
  21. } from '../src/index.ts'
  22. import { createProcessDeepSeekHarness, finalResponse, normalizeInput } from '../src/api.ts'
  23. import { createProcessHarnessClient } from '../src/client.ts'
  24. import type { RuntimeProcessOptions } from '../src/launch.ts'
  25. const fakeRuntime = fileURLToPath(new URL('./fake-runtime.ts', import.meta.url))
  26. const cleanups: (() => Promise<void>)[] = []
  27. afterEach(async () => {
  28. for (const cleanup of cleanups.splice(0)) await cleanup()
  29. })
  30. type LaunchOverrides = Partial<RuntimeProcessOptions>
  31. /** Launch options running the fake runtime on the current node (type stripping). */
  32. function fakeLaunch(env: Record<string, string> = {}, extra: LaunchOverrides = {}): RuntimeProcessOptions {
  33. return {
  34. command: process.execPath,
  35. args: [fakeRuntime],
  36. environment: () => ({ ...process.env as Record<string, string>, ...env }),
  37. description: 'scripted fake runtime',
  38. initializeTimeoutMs: 5_000,
  39. ...extra,
  40. }
  41. }
  42. function processClient(options: RuntimeProcessOptions): HarnessClient {
  43. return createProcessHarnessClient(options)
  44. }
  45. function harnessWith(env: Record<string, string> = {}, extra: LaunchOverrides = {}): DeepSeekHarness {
  46. const harness = createProcessDeepSeekHarness(fakeLaunch(env, extra))
  47. cleanups.push(() => harness.close())
  48. return harness
  49. }
  50. async function tempDir(prefix: string): Promise<string> {
  51. const dir = await mkdtemp(join(tmpdir(), prefix))
  52. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  53. return dir
  54. }
  55. describe('DeepSeekHarness', () => {
  56. it('ignores notifications that precede the submitted message receipt', async () => {
  57. const notifications = [
  58. { method: 'session.status', params: { sessionId: 'owned', status: 'running' } },
  59. {
  60. method: 'session.event',
  61. params: { sessionId: 'owned', event: { type: 'turn/start', data: { turn: 1 } } },
  62. },
  63. {
  64. method: 'session.event',
  65. params: {
  66. sessionId: 'owned',
  67. event: { type: 'agent/inbox/spliced', data: { inserted: null } },
  68. },
  69. },
  70. {
  71. method: 'session.event',
  72. params: {
  73. sessionId: 'owned',
  74. event: {
  75. type: 'agent/inbox/spliced',
  76. seq: 0,
  77. time: 0,
  78. data: {
  79. target: 'next-turn',
  80. start: 0,
  81. inserted: [{ id: 'accepted-message', role: 'user', content: [], source: { kind: 'user' } }],
  82. },
  83. },
  84. },
  85. },
  86. { method: 'session.status', params: { sessionId: 'owned', status: 'idle' } },
  87. ] as HarnessNotification[]
  88. let closed = false
  89. const harness = {
  90. start: () => Promise.resolve(),
  91. client: {
  92. prompt: () => Promise.resolve('accepted-message'),
  93. subscribeSessionTree: () => ({
  94. next: async () => {
  95. const notification = notifications.shift()
  96. if (notification === undefined) throw new Error('scripted notification queue exhausted')
  97. return notification
  98. },
  99. tryNext: () => notifications.shift(),
  100. close: () => { closed = true },
  101. async * [Symbol.asyncIterator]() {},
  102. }),
  103. },
  104. } as unknown as DeepSeekHarness
  105. const result = await new HarnessSession(harness, 'owned').run('go')
  106. expect(result.notifications.map(notification => notification.method))
  107. .toEqual(['session.event', 'session.status'])
  108. expect(result.events.map(event => event.type)).toEqual(['agent/inbox/spliced'])
  109. expect(closed).toBe(true)
  110. })
  111. it('runs a turn end to end and reuses the runtime across sessions', async () => {
  112. const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
  113. const first = await harness.run('say hi')
  114. expect(first.finalResponse).toBe('turn answer')
  115. expect(first.events.map(event => event.type)).toEqual([
  116. 'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end',
  117. ])
  118. // Same subprocess, second session: ids differ, protocol state is reusable.
  119. const second = await harness.run([{ type: 'text', text: 'again' }])
  120. expect(second.sessionId).not.toBe(first.sessionId)
  121. await harness.close()
  122. })
  123. it('keeps events root-scoped while streaming notifications for the session tree', async () => {
  124. const harness = harnessWith({ FAKE_SUBAGENT: '1' })
  125. const seen: HarnessNotification[] = []
  126. const result = await harness.run('delegate', {
  127. sessionId: 'parent-1',
  128. onNotification: (n) => { seen.push(n) },
  129. })
  130. // The child session's events arrive through subagent.started lineage.
  131. expect(seen.map(n => n.method)).toContain('subagent.started')
  132. expect(seen.map(n => n.method)).toContain('subagent.finished')
  133. const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
  134. expect(childEvents.length).toBeGreaterThan(0)
  135. // RunResult.events is the root session's typed stream; descendants retain
  136. // their session ids in the raw notification stream above.
  137. expect(result.events.every(event => event.type !== 'assistant/message'
  138. || event.data.message.content[0]?.type !== 'text'
  139. || event.data.message.content[0].text !== 'child says hi')).toBe(true)
  140. await harness.close()
  141. })
  142. it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
  143. const dir = await tempDir('sdk-client-init-')
  144. const recordFile = join(dir, 'init.jsonl')
  145. const harness = createProcessDeepSeekHarness(fakeLaunch({ FAKE_RECORD_INIT: recordFile }), {
  146. cwd: dir,
  147. provider: 'custom-provider',
  148. model: 'custom-model',
  149. maxTokens: 4096,
  150. })
  151. cleanups.push(() => harness.close())
  152. await harness.run('one')
  153. await harness.run('two')
  154. await harness.close()
  155. const records = (await readFile(recordFile, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as object)
  156. expect(records).toEqual([{
  157. cwd: dir,
  158. provider: 'custom-provider',
  159. model: 'custom-model',
  160. maxTokens: 4096,
  161. }])
  162. })
  163. it('resolves a relative launch cwd to an absolute workspace before the handshake', async () => {
  164. // vitest workers forbid chdir, so derive a RELATIVE path from the real
  165. // process cwd to a temp worker dir; resolution is lexical either way.
  166. const dir = await mkdtemp(join(process.cwd(), '.dsh-sdk-client-relcwd-'))
  167. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  168. const recordFile = join(dir, 'init.jsonl')
  169. const inner = join(dir, 'worker')
  170. await mkdir(inner)
  171. const relativeCwd = relative(process.cwd(), inner)
  172. expect(isAbsolute(relativeCwd)).toBe(false)
  173. const harness = createProcessDeepSeekHarness(
  174. fakeLaunch({ FAKE_RECORD_INIT: recordFile, FAKE_ECHO_CWD_IN_INIT: '1' }, { cwd: relativeCwd }),
  175. )
  176. cleanups.push(() => harness.close())
  177. await harness.start()
  178. const identity = await harness.client.initialize({ cwd: inner, provider: 'p', model: 'm' })
  179. await harness.close()
  180. // The child spawned under the temp worker dir (its physical cwd)...
  181. expect(identity.serverInfo.version).toBe(await realpath(inner))
  182. // ...and the handshake wire cwd went out ABSOLUTE, so the child cannot
  183. // re-resolve a relative string into dir/worker/worker.
  184. const records = (await readFile(recordFile, 'utf8')).trim().split('\n')
  185. .map(line => (JSON.parse(line) as { cwd: string }).cwd)
  186. expect(records).toEqual([resolvePath(relativeCwd), inner])
  187. })
  188. it('propagates a JSON-RPC error response from initialize and closes the runtime', async () => {
  189. const harness = harnessWith({ FAKE_INIT_ERROR: '1' })
  190. const failure = await harness.run('boom').then(
  191. () => { throw new Error('run unexpectedly succeeded') },
  192. (error: unknown) => error,
  193. )
  194. expect(failure).toBeInstanceOf(JsonRpcResponseError)
  195. expect(failure).toMatchObject({ code: 7, message: 'scripted init failure', data: { hint: 'fake' } })
  196. // The failed handshake reset lets a later start retry instead of wedging.
  197. await expect(harness.run('later')).rejects.toThrow()
  198. })
  199. it('retries a failed handshake with a fresh runtime process', async () => {
  200. const dir = await tempDir('sdk-client-retry-')
  201. const marker = join(dir, 'first-boot-failed')
  202. const harness = harnessWith({ FAKE_INIT_ERROR_ONCE_FILE: marker, FAKE_TEXT: 'second boot answer' })
  203. const firstClient = harness.client
  204. // First start: the scripted runtime fails the handshake and is reaped.
  205. await expect(harness.start()).rejects.toThrow('scripted first-boot failure')
  206. // Retry spawns a NEW subprocess through a fresh client (close is permanent).
  207. const result = await harness.run('again')
  208. expect(harness.client).not.toBe(firstClient)
  209. expect(result.finalResponse).toBe('second boot answer')
  210. await harness.close()
  211. // close() is terminal: a handshake failure after it must not respawn.
  212. await expect(harness.run('after-close')).rejects.toThrow(TransportClosedError)
  213. })
  214. it('rejects a malformed initialize result as a protocol error', async () => {
  215. const harness = harnessWith({ FAKE_MALFORMED: '1' })
  216. await expect(harness.run('bad')).rejects.toThrow(SdkProtocolError)
  217. })
  218. it('supports await using disposal', async () => {
  219. let captured: DeepSeekHarness
  220. {
  221. await using harness = createProcessDeepSeekHarness(fakeLaunch())
  222. captured = harness
  223. const result = await harness.run('scoped')
  224. expect(result.finalResponse).toBe('hello from fake runtime')
  225. }
  226. // After scope exit the runtime is closed: reuse fails loudly.
  227. await expect(captured.run('after')).rejects.toThrow(TransportClosedError)
  228. })
  229. it('constructs the public dsh-backed client lazily', async () => {
  230. const harness = new DeepSeekHarness()
  231. expect(harness.client).toBeInstanceOf(HarnessClient)
  232. await harness.close()
  233. })
  234. })
  235. describe('HarnessClient', () => {
  236. it('bounds profile initialization and names the selected profile in its diagnostic', async () => {
  237. const client = processClient(fakeLaunch(
  238. { FAKE_HANG_INIT: '1' },
  239. {
  240. description: 'dsh profile "profile-without-sdk-server"',
  241. initializeTimeoutMs: 50,
  242. disposeEofGraceMs: 100,
  243. disposeGraceMs: 100,
  244. },
  245. ))
  246. cleanups.push(() => client.close())
  247. await expect(client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }))
  248. .rejects.toThrow(/initialize timed out after 50ms waiting for dsh profile "profile-without-sdk-server"/)
  249. await client.close()
  250. })
  251. it('times out a hung request at the per-call bound', async () => {
  252. const client = processClient(fakeLaunch({
  253. FAKE_HANG_PROMPT: '1',
  254. FAKE_STDERR: 'runtime accepted initialize but hung the prompt',
  255. }))
  256. cleanups.push(() => client.close())
  257. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  258. await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('hi') }, 200))
  259. .rejects.toThrow(/session\/prompt timed out.*stderr tail:\nruntime accepted initialize but hung the prompt/s)
  260. await client.close()
  261. })
  262. it('a timed-out request leaves no pending transport state', async () => {
  263. const client = processClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }))
  264. cleanups.push(() => client.close())
  265. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  266. for (let round = 0; round < 3; round++) {
  267. await expect(client.request('session/prompt', { sessionId: 's', contentBlocks: normalizeInput('x') }, 50))
  268. .rejects.toThrow(RequestTimeoutError)
  269. }
  270. // Abandonment removed each pending entry at its timeout; a hung method
  271. // retains nothing per call. (Private map read is the observable here —
  272. // no wire surface reports transport bookkeeping.)
  273. const transport = (client as unknown as { transport: { pending: Map<string, unknown> } }).transport
  274. expect(transport.pending.size).toBe(0)
  275. await client.close()
  276. })
  277. it('applies the client-wide request timeout when no per-call bound is given', async () => {
  278. const client = processClient(fakeLaunch({ FAKE_HANG_PROMPT: '1' }, { requestTimeoutMs: 400 }))
  279. cleanups.push(() => client.close())
  280. // The bound applies from send, so it holds regardless of runtime boot time.
  281. await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(RequestTimeoutError)
  282. await client.close()
  283. })
  284. it('rejects a malformed prompt acceptance as a protocol error', async () => {
  285. const client = processClient(fakeLaunch({ FAKE_MALFORMED: '1' }))
  286. cleanups.push(() => client.close())
  287. await expect(client.prompt('s', normalizeInput('hi'))).rejects.toThrow(SdkProtocolError)
  288. await client.close()
  289. })
  290. it('fails pending requests with exit code and stderr tail when the runtime dies', async () => {
  291. const client = processClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1', FAKE_STDERR: 'fatal: scripted death' }))
  292. cleanups.push(() => client.close())
  293. const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
  294. () => { throw new Error('initialize unexpectedly succeeded') },
  295. (error: unknown) => error,
  296. )
  297. expect(failure).toBeInstanceOf(TransportClosedError)
  298. expect(String(failure)).toContain('exit code: 3')
  299. expect(String(failure)).toContain('fatal: scripted death')
  300. // Requests after death fail immediately with the same context.
  301. await expect(client.request('initialize', {})).rejects.toThrow('exit code: 3')
  302. })
  303. it('flushes an unterminated stderr line into the tail at close', async () => {
  304. const client = processClient(fakeLaunch({ FAKE_STDERR_NO_NEWLINE: 'no trailing newline', FAKE_EXIT_BEFORE_INIT: '1' }))
  305. cleanups.push(() => client.close())
  306. const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
  307. () => { throw new Error('initialize unexpectedly succeeded') },
  308. (error: unknown) => error,
  309. )
  310. expect(String(failure)).toContain('no trailing newline')
  311. })
  312. it('fails when the configured dsh CLI module does not exist', async () => {
  313. const client = new HarnessClient({ dshBin: join(tmpdir(), 'dsh-no-such-runtime-bin') })
  314. cleanups.push(() => client.close())
  315. await expect(client.request('initialize', {}, 1_000)).rejects.toThrow(TransportClosedError)
  316. })
  317. it('reports a generic process spawn failure to internal transports', async () => {
  318. const client = processClient(fakeLaunch({}, {
  319. command: join(tmpdir(), 'dsh-no-such-process-command'),
  320. args: [],
  321. }))
  322. cleanups.push(() => client.close())
  323. await expect(client.request('initialize', {}, 1_000))
  324. .rejects.toThrow(/spawn error:.*ENOENT/s)
  325. })
  326. it('close() is idempotent, reaps the child, and fails later use', async () => {
  327. const client = processClient(fakeLaunch())
  328. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  329. await Promise.all([client.close(), client.close()])
  330. expect(() => { client.start() }).toThrow(TransportClosedError)
  331. await expect(client.request('anything')).rejects.toThrow(TransportClosedError)
  332. // Close with no child ever spawned is a no-op.
  333. const untouched = processClient(fakeLaunch())
  334. await untouched.close()
  335. })
  336. it('escalates through SIGTERM when the runtime ignores EOF', async () => {
  337. const dir = await tempDir('sdk-client-ladder-')
  338. const sigtermFile = join(dir, 'sigterm.txt')
  339. const client = processClient(fakeLaunch(
  340. { FAKE_IGNORE_EOF: '1', FAKE_SIGTERM_FILE: sigtermFile },
  341. { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 1_000 },
  342. ))
  343. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  344. await client.close()
  345. if (process.platform === 'win32') {
  346. await expect(stat(sigtermFile)).rejects.toMatchObject({ code: 'ENOENT' })
  347. } else {
  348. expect((await stat(sigtermFile)).isFile()).toBe(true)
  349. }
  350. })
  351. it('escalates to SIGKILL when the runtime traps SIGTERM too', async () => {
  352. const client = processClient(fakeLaunch(
  353. { FAKE_IGNORE_EOF: '1', FAKE_TRAP_SIGTERM: '1' },
  354. { shutdownTimeoutMs: 100, disposeEofGraceMs: 100, disposeGraceMs: 300 },
  355. ))
  356. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  357. // Resolves (does not hang or reject): the SIGKILL rung reaped the child.
  358. await client.close()
  359. })
  360. it('delivers notifications to unfiltered and filtered subscriptions in wire order', async () => {
  361. const client = processClient(fakeLaunch())
  362. cleanups.push(() => client.close())
  363. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  364. const all = client.subscribe()
  365. const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
  366. const firstPending = all.next()
  367. await client.prompt('sub-test', normalizeInput('go'))
  368. const first = await firstPending
  369. expect(first.method).toBe('session.event')
  370. const idle = await idleOnly.next()
  371. expect(idle.method).toBe('session.status')
  372. expect(idleOnly.tryNext()).toBeUndefined()
  373. // A bare unbounded request with omitted params sends `{}` on the wire.
  374. const identity = await client.request('initialize') as { serverInfo: { name: string } }
  375. expect(identity.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
  376. // Async iteration consumes queued items and then parks.
  377. const collected: string[] = []
  378. for await (const notification of all) {
  379. collected.push(notification.method)
  380. if (notification.method === 'session.status' && notification.params.status === 'idle') break
  381. }
  382. expect(collected.at(-1)).toBe('session.status')
  383. all.close()
  384. idleOnly.close()
  385. await expect(all.next()).rejects.toThrow('notification subscription closed')
  386. await client.close()
  387. })
  388. it('contains a throwing filter to its own subscription', async () => {
  389. const client = processClient(fakeLaunch())
  390. cleanups.push(() => client.close())
  391. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  392. const broken = client.subscribe(() => { throw new Error('filter exploded') })
  393. // A non-Error throw is normalized rather than crashing dispatch.
  394. const brokenNonError = client.subscribe(() => { throw 'string boom' })
  395. const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
  396. await client.prompt('filter-contain', normalizeInput('go'))
  397. // The sibling subscription and the read loop are undisturbed.
  398. expect((await healthy.next()).method).toBe('session.status')
  399. // Each broken subscription failed with ITS OWN error and detached.
  400. await expect(broken.next()).rejects.toThrow('filter exploded')
  401. await expect(brokenNonError.next()).rejects.toThrow('string boom')
  402. healthy.close()
  403. await client.close()
  404. })
  405. it('close() drops queued notifications; runtime death keeps them drainable', async () => {
  406. const client = processClient(fakeLaunch())
  407. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  408. const closed = client.subscribe()
  409. const drainable = client.subscribe()
  410. await client.prompt('queue-drop', normalizeInput('go'))
  411. expect(closed.tryNext()).toBeDefined()
  412. closed.close()
  413. // Manual close drops the rest of the queue outright.
  414. expect(closed.tryNext()).toBeUndefined()
  415. await expect(closed.next()).rejects.toThrow('notification subscription closed')
  416. // Runtime teardown, by contrast, only stops FUTURE delivery: what was
  417. // already delivered before close() stays drainable.
  418. await client.close()
  419. expect(drainable.tryNext()).toBeDefined()
  420. })
  421. it('subscriptions created after termination are born failed', async () => {
  422. const client = processClient(fakeLaunch())
  423. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  424. await client.close()
  425. // No producer can ever feed this subscription; next() must not park forever.
  426. await expect(client.subscribe().next()).rejects.toThrow(TransportClosedError)
  427. const dead = processClient(fakeLaunch({ FAKE_EXIT_BEFORE_INIT: '1' }))
  428. cleanups.push(() => dead.close())
  429. await dead.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).catch(() => {})
  430. await expect(dead.subscribe().next()).rejects.toThrow(TransportClosedError)
  431. })
  432. it('closes subscriptions with the runtime and rejects parked waiters', async () => {
  433. const client = processClient(fakeLaunch())
  434. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  435. const subscription = client.subscribe()
  436. const parked = subscription.next()
  437. await client.close()
  438. await expect(parked).rejects.toThrow(TransportClosedError)
  439. })
  440. it('scopes the session tree across multi-hop lineage and ignores foreign sessions', async () => {
  441. const client = processClient(fakeLaunch())
  442. cleanups.push(() => client.close())
  443. await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
  444. const tree = client.subscribeSessionTree('root')
  445. // Lineage edges arrive as subagent.started notifications.
  446. const inject = (method: string, params: Record<string, unknown>): void => {
  447. (client as unknown as { dispatchNotification(n: HarnessNotification): void }).dispatchNotification({ method, params })
  448. }
  449. inject('subagent.started', { parentSessionId: 'root', childSessionId: 'child' })
  450. inject('subagent.started', { parentSessionId: 'child', childSessionId: 'grandchild' })
  451. inject('session.event', { sessionId: 'grandchild', event: { type: 'noop' } })
  452. inject('session.event', { sessionId: 'stranger', event: { type: 'noop' } })
  453. inject('subagent.started', { parentSessionId: 'other-root', childSessionId: 'other-child' })
  454. inject('subagent.finished', { parentSessionId: 'child', childSessionId: 'grandchild' })
  455. // Self-loop and empty edges must not corrupt the lineage map.
  456. inject('subagent.started', { parentSessionId: 'loop', childSessionId: 'loop' })
  457. inject('subagent.started', { parentSessionId: '', childSessionId: 'x' })
  458. inject('subagent.finished', { childSessionId: 'root' })
  459. expect((await tree.next()).method).toBe('subagent.started')
  460. expect((await tree.next()).method).toBe('subagent.started')
  461. expect((await tree.next()).params.sessionId).toBe('grandchild')
  462. expect((await tree.next()).method).toBe('subagent.finished')
  463. // The foreign-root edge and stranger event were filtered; next is the root-child edge.
  464. expect((await tree.next()).params.childSessionId).toBe('root')
  465. tree.close()
  466. await client.close()
  467. })
  468. })
  469. describe('wire payload validation', () => {
  470. it('rejects a non-object session.event envelope as a protocol error', async () => {
  471. const harness = harnessWith({ FAKE_MALFORMED_EVENT: '1' })
  472. await expect(harness.run('bad-event')).rejects.toThrow(SdkProtocolError)
  473. })
  474. it('rejects an assistant/message without a content array as a protocol error', async () => {
  475. const harness = harnessWith({ FAKE_MALFORMED_MESSAGE: '1' })
  476. await expect(harness.run('bad-message')).rejects.toThrow(SdkProtocolError)
  477. })
  478. it('rejects an assistant/message without a data member as a protocol error', async () => {
  479. const harness = harnessWith({ FAKE_MESSAGE_WITHOUT_DATA: '1' })
  480. await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
  481. })
  482. })
  483. describe('stderr tail bound', () => {
  484. it('keeps only the newest lines up to the limit', async () => {
  485. const manyLines = Array.from({ length: 450 }, (_, i) => `line-${i}`).join('\n')
  486. const client = processClient(fakeLaunch({ FAKE_STDERR: manyLines, FAKE_EXIT_BEFORE_INIT: '1' }))
  487. cleanups.push(() => client.close())
  488. const failure = await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' }).then(
  489. () => { throw new Error('initialize unexpectedly succeeded') },
  490. (error: unknown) => error,
  491. )
  492. const text = String(failure)
  493. // The tail is bounded to the newest 400 lines: the oldest are dropped.
  494. expect(text).toContain('line-449')
  495. expect(text).not.toContain('line-0\n')
  496. })
  497. })
  498. describe('pure helpers', () => {
  499. it('normalizeInput wraps strings and passes blocks through', () => {
  500. expect(normalizeInput('x')).toEqual([{ type: 'text', text: 'x' }])
  501. const blocks = [{ type: 'text' as const, text: 'y' }]
  502. expect(normalizeInput(blocks)).toBe(blocks)
  503. })
  504. it('finalResponse reads the last assistant message and tolerates absence', () => {
  505. expect(finalResponse([])).toBe('')
  506. expect(finalResponse([{ type: 'turn/start', seq: 0, time: 0, data: { turn: 0 } } as never])).toBe('')
  507. expect(finalResponse([
  508. { type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'first' }] } } } as never,
  509. { type: 'assistant/message', seq: 1, time: 0, data: { message: { content: [{ type: 'text', text: 'a' }, { type: 'tool-call' }, { type: 'text', text: 'b' }] } } } as never,
  510. ])).toBe('ab')
  511. })
  512. })