subagent-codex.spec.ts 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120
  1. import { PassThrough } from 'node:stream'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import Loader from '@deepseek-ai/cordis-plugin-loader'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import type { Agent } from '@deepseek-ai/dsh-agent'
  6. import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
  7. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  8. import SubagentService from '@deepseek-ai/dsh-subagent'
  9. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  10. import type {
  11. SubprocessHandle,
  12. SubprocessOutcome,
  13. } from '@deepseek-ai/dsh-subprocess'
  14. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  15. import * as codex from '../src/index.ts'
  16. import * as invariant from '../src/invariant.ts'
  17. import {
  18. codexAppServerArgv,
  19. DEFAULT_DISPOSE_GRACE_MS,
  20. disposeCodexChild,
  21. startCodexRun,
  22. textTask,
  23. type CodexRunSpec,
  24. } from '../src/run.ts'
  25. import { CodexAppServerWire } from '../src/wire.ts'
  26. type JsonObject = Record<string, unknown>
  27. const fakeParent = {
  28. id: 'parent',
  29. session: { header: { cwd: process.cwd() } },
  30. } as unknown as Agent
  31. function request(
  32. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  33. signal = new AbortController().signal,
  34. ) {
  35. return { prompt, parent: fakeParent, signal }
  36. }
  37. async function nextTask(): Promise<void> {
  38. await new Promise<void>((resolve) => { setImmediate(resolve) })
  39. }
  40. class ProtocolPeer {
  41. private buffer = ''
  42. private readonly frames: JsonObject[] = []
  43. private readonly wakeups = new Set<() => void>()
  44. constructor(
  45. input: PassThrough,
  46. private readonly output: PassThrough,
  47. ) {
  48. input.on('data', (chunk: Buffer | string) => {
  49. this.buffer += chunk.toString()
  50. for (;;) {
  51. const newline = this.buffer.indexOf('\n')
  52. if (newline < 0) break
  53. const line = this.buffer.slice(0, newline)
  54. this.buffer = this.buffer.slice(newline + 1)
  55. if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject)
  56. }
  57. for (const wake of this.wakeups) wake()
  58. this.wakeups.clear()
  59. })
  60. }
  61. async next(predicate: (frame: JsonObject) => boolean): Promise<JsonObject> {
  62. for (;;) {
  63. const index = this.frames.findIndex(predicate)
  64. if (index >= 0) return this.frames.splice(index, 1)[0]!
  65. await new Promise<void>((resolve) => { this.wakeups.add(resolve) })
  66. }
  67. }
  68. nextMethod(method: string): Promise<JsonObject> {
  69. return this.next(frame => frame.method === method)
  70. }
  71. nextResponse(id: unknown): Promise<JsonObject> {
  72. return this.next(frame => frame.id === id && frame.method === undefined)
  73. }
  74. send(...frames: readonly JsonObject[]): void {
  75. this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`)
  76. }
  77. respond(requestFrame: JsonObject, result: unknown): void {
  78. this.send({ id: requestFrame.id, result })
  79. }
  80. }
  81. interface FakeChildOptions {
  82. readonly pid?: number
  83. readonly exitOnTerminate?: boolean
  84. readonly doneError?: Error
  85. }
  86. interface FakeChild {
  87. readonly handle: SubprocessHandle
  88. readonly peer: ProtocolPeer
  89. readonly fromChild: PassThrough
  90. readonly toChild: PassThrough
  91. readonly settle: (outcome?: SubprocessOutcome) => void
  92. readonly fail: (error: Error) => void
  93. readonly terminate: () => void
  94. readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
  95. }
  96. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  97. const fromChild = new PassThrough()
  98. const toChild = new PassThrough()
  99. const peer = new ProtocolPeer(toChild, fromChild)
  100. let exited = false
  101. let resolveDone!: (outcome: SubprocessOutcome) => void
  102. let rejectDone!: (error: Error) => void
  103. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  104. resolveDone = resolve
  105. rejectDone = reject
  106. })
  107. const settle = (
  108. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  109. ): void => {
  110. if (exited) return
  111. exited = true
  112. resolveDone(outcome)
  113. }
  114. const fail = (error: Error): void => {
  115. if (exited) return
  116. exited = true
  117. rejectDone(error)
  118. }
  119. if (options.doneError !== undefined) fail(options.doneError)
  120. const terminate = vi.fn(() => {
  121. if (options.exitOnTerminate !== false) settle()
  122. })
  123. const waitForExit = vi.fn(async (signal?: AbortSignal) => {
  124. if (exited) return true
  125. if (signal === undefined) {
  126. await done.catch(() => {})
  127. return true
  128. }
  129. return await new Promise<boolean>((resolve) => {
  130. const onAbort = (): void => { resolve(false) }
  131. signal.addEventListener('abort', onAbort, { once: true })
  132. void done.then(
  133. () => {
  134. signal.removeEventListener('abort', onAbort)
  135. resolve(true)
  136. },
  137. () => {
  138. signal.removeEventListener('abort', onAbort)
  139. resolve(true)
  140. },
  141. )
  142. })
  143. })
  144. const handle: SubprocessHandle = {
  145. pid: options.pid ?? 1234,
  146. stdin: toChild,
  147. stdout: fromChild,
  148. stderr: undefined,
  149. collected: {},
  150. done,
  151. terminate,
  152. waitForExit,
  153. }
  154. return {
  155. handle,
  156. peer,
  157. fromChild,
  158. toChild,
  159. settle,
  160. fail,
  161. terminate,
  162. waitForExit,
  163. }
  164. }
  165. function runSpec(
  166. child: FakeChild,
  167. overrides: Partial<CodexRunSpec> = {},
  168. ): CodexRunSpec {
  169. return {
  170. cwd: process.cwd(),
  171. env: {},
  172. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  173. spawn: () => child.handle,
  174. ...overrides,
  175. }
  176. }
  177. async function initializeWire(): Promise<{
  178. readonly child: FakeChild
  179. readonly wire: CodexAppServerWire
  180. }> {
  181. const child = fakeChild()
  182. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  183. wire.start()
  184. const initializing = wire.initialize(new AbortController().signal)
  185. const initialize = await child.peer.nextMethod('initialize')
  186. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  187. await initializing
  188. expect(await child.peer.nextMethod('initialized')).toEqual({
  189. jsonrpc: '2.0',
  190. method: 'initialized',
  191. })
  192. const starting = wire.startThread(process.cwd(), new AbortController().signal)
  193. const threadStart = await child.peer.nextMethod('thread/start')
  194. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  195. await starting
  196. return { child, wire }
  197. }
  198. async function publishRun(
  199. child = fakeChild(),
  200. signal = new AbortController().signal,
  201. specOverrides: Partial<CodexRunSpec> = {},
  202. ) {
  203. const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
  204. const initialize = await child.peer.nextMethod('initialize')
  205. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  206. await child.peer.nextMethod('initialized')
  207. const threadStart = await child.peer.nextMethod('thread/start')
  208. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  209. const run = await starting
  210. const turnStart = await child.peer.nextMethod('turn/start')
  211. return { child, run, turnStart }
  212. }
  213. function agentMessage(
  214. text: unknown,
  215. phase: unknown,
  216. turnId = 'turn-1',
  217. threadId = 'thread-1',
  218. ): JsonObject {
  219. return {
  220. method: 'item/completed',
  221. params: {
  222. threadId,
  223. turnId,
  224. item: { type: 'agentMessage', text, phase },
  225. },
  226. }
  227. }
  228. function turnCompleted(
  229. status: unknown,
  230. turnId = 'turn-1',
  231. threadId = 'thread-1',
  232. error: unknown = null,
  233. ): JsonObject {
  234. return {
  235. method: 'turn/completed',
  236. params: {
  237. threadId,
  238. turn: { id: turnId, status, error },
  239. },
  240. }
  241. }
  242. describe('task admission and package contracts', () => {
  243. it('resolves the fixed app-server command through the Windows npm shim boundary', () => {
  244. expect(codexAppServerArgv('win32')).toEqual([
  245. 'cmd.exe',
  246. '/d',
  247. '/s',
  248. '/c',
  249. 'codex',
  250. 'app-server',
  251. '--stdio',
  252. ])
  253. expect(codexAppServerArgv('linux')).toEqual(['codex', 'app-server', '--stdio'])
  254. })
  255. it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
  256. expect(textTask([
  257. { type: 'text', text: 'one' },
  258. { type: 'text', text: 'two' },
  259. ])).toEqual(['one', 'two'])
  260. expect(() => textTask([])).toThrow('only text blocks')
  261. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  262. .toThrow('only text blocks')
  263. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  264. .toThrow('must not be empty')
  265. })
  266. it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
  267. const ctx = new Context()
  268. await ctx.plugin(SubagentService)
  269. await ctx.plugin(LocalSubprocessService)
  270. const fiber = await ctx.plugin(codex, {})
  271. const provider = ctx.subagents.getProvider('codex')!
  272. expect(provider).toMatchObject({
  273. name: 'codex',
  274. capabilities: {
  275. outputSchema: false,
  276. depthLimit: false,
  277. toolFilter: false,
  278. persona: false,
  279. },
  280. inheritsParentContext: false,
  281. })
  282. expect(ctx.subagents.list()).toEqual(['codex'])
  283. await fiber.dispose()
  284. expect(ctx.subagents.list()).toEqual([])
  285. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  286. await expect(ctx.plugin(codex, { disposeGraceMs }))
  287. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  288. }
  289. await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
  290. .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
  291. await ctx.fiber.dispose()
  292. })
  293. it('requires a parent session cwd without suggesting unsupported config', async () => {
  294. const ctx = new Context()
  295. await ctx.plugin(SubagentService)
  296. await ctx.plugin(LocalSubprocessService)
  297. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  298. await ctx.plugin(codex, {})
  299. await expect(ctx.subagents.start('codex', {
  300. prompt: [{ type: 'text', text: 'task' }],
  301. parent: {
  302. id: 'parent-without-cwd',
  303. session: { header: {} },
  304. } as unknown as Agent,
  305. signal: new AbortController().signal,
  306. })).rejects.toThrow(
  307. 'subagent-codex: no working directory for the child — delegate from a parent session that has one',
  308. )
  309. expect(spawn).not.toHaveBeenCalled()
  310. await ctx.fiber.dispose()
  311. })
  312. it('keeps the namespace export shape and package-owned empty invariant', async () => {
  313. expect('default' in codex).toBe(false)
  314. expect(codex.name).toBe('subagent-codex')
  315. expect(codex.inject).toEqual(['subagents', 'subprocess'])
  316. const loader = Object.create(Loader.prototype) as Loader
  317. expect(loader.unwrapExports(codex)).toBe(codex)
  318. const dispose = vi.fn()
  319. const register = vi.fn((
  320. _packageName: string,
  321. _installer: InvariantInstaller,
  322. ) => dispose)
  323. const ctx = { invariants: { register } } as unknown as Context
  324. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  325. expect(register).toHaveBeenCalledWith(
  326. '@deepseek-ai/dsh-subagent-codex',
  327. expect.any(Function),
  328. )
  329. const install = register.mock.calls[0]![1]
  330. await install(new Context(), (message) => { throw new Error(message) })
  331. expect(invariant.name).toBe('subagent-codex-invariant')
  332. expect(invariant.inject).toEqual(['invariants'])
  333. })
  334. })
  335. describe('CodexAppServerWire', () => {
  336. it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
  337. const child = fakeChild()
  338. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  339. expect(wire.collectOutput()).toEqual([])
  340. wire.start()
  341. const initializing = wire.initialize(new AbortController().signal)
  342. const initialize = await child.peer.nextMethod('initialize')
  343. expect(initialize.params).toEqual({
  344. clientInfo: {
  345. name: 'deepseek-harness',
  346. title: 'DeepSeek Harness',
  347. version: '0.0.1',
  348. },
  349. capabilities: {
  350. experimentalApi: false,
  351. requestAttestation: false,
  352. },
  353. })
  354. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  355. await initializing
  356. await child.peer.nextMethod('initialized')
  357. const starting = wire.startThread('/workspace', new AbortController().signal)
  358. const threadStart = await child.peer.nextMethod('thread/start')
  359. expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true })
  360. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  361. await starting
  362. const result = wire.runTurn(
  363. ['first', 'second'],
  364. new AbortController().signal,
  365. )
  366. const turnStart = await child.peer.nextMethod('turn/start')
  367. expect(turnStart.params).toEqual({
  368. threadId: 'thread-1',
  369. input: [
  370. { type: 'text', text: 'first', text_elements: [] },
  371. { type: 'text', text: 'second', text_elements: [] },
  372. ],
  373. })
  374. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  375. await nextTask()
  376. child.peer.send(
  377. {
  378. method: 'turn/started',
  379. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  380. },
  381. agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'),
  382. agentMessage('other turn', 'final_answer', 'turn-2'),
  383. {
  384. method: 'item/completed',
  385. params: {
  386. threadId: 'thread-1',
  387. turnId: 'turn-1',
  388. item: { type: 'reasoning', text: 'not output' },
  389. },
  390. },
  391. agentMessage('commentary', 'commentary'),
  392. agentMessage('unphased', null),
  393. agentMessage('first final', 'final_answer'),
  394. agentMessage('last final', 'final_answer'),
  395. turnCompleted('completed'),
  396. )
  397. await expect(result).resolves.toEqual({
  398. output: [{ type: 'text', text: 'last final' }],
  399. stopReason: 'completed',
  400. })
  401. expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }])
  402. wire.close()
  403. wire.close()
  404. })
  405. it('uses the last nullable-phase answer when no explicit final exists', async () => {
  406. const { child, wire } = await initializeWire()
  407. const result = wire.runTurn(['task'], new AbortController().signal)
  408. const turnStart = await child.peer.nextMethod('turn/start')
  409. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  410. child.peer.send(
  411. agentMessage('first', null),
  412. agentMessage('fallback', null),
  413. turnCompleted('completed'),
  414. )
  415. await expect(result).resolves.toEqual({
  416. output: [{ type: 'text', text: 'fallback' }],
  417. stopReason: 'completed',
  418. })
  419. wire.close()
  420. })
  421. it('maps only an explicit context-window failure to max-tokens', async () => {
  422. const { child, wire } = await initializeWire()
  423. const result = wire.runTurn(['task'], new AbortController().signal)
  424. const turnStart = await child.peer.nextMethod('turn/start')
  425. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  426. child.peer.send(
  427. agentMessage('partial answer', null),
  428. turnCompleted('failed', 'turn-1', 'thread-1', {
  429. message: 'too much context',
  430. codexErrorInfo: 'contextWindowExceeded',
  431. }),
  432. )
  433. await expect(result).resolves.toEqual({
  434. output: [{ type: 'text', text: 'partial answer' }],
  435. stopReason: 'max-tokens',
  436. })
  437. wire.close()
  438. })
  439. it('rejects invalid handshake, thread, and turn response shapes', async () => {
  440. {
  441. const child = fakeChild()
  442. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  443. wire.start()
  444. const pending = wire.initialize(new AbortController().signal)
  445. const frame = await child.peer.nextMethod('initialize')
  446. child.peer.respond(frame, null)
  447. await expect(pending).rejects.toThrow('invalid initialize response')
  448. wire.close()
  449. }
  450. {
  451. const child = fakeChild()
  452. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  453. wire.start()
  454. const pending = wire.startThread('/workspace', new AbortController().signal)
  455. const frame = await child.peer.nextMethod('thread/start')
  456. child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } })
  457. await expect(pending).rejects.toThrow('did not create an ephemeral thread')
  458. wire.close()
  459. }
  460. {
  461. const { child, wire } = await initializeWire()
  462. const pending = wire.runTurn(['task'], new AbortController().signal)
  463. const frame = await child.peer.nextMethod('turn/start')
  464. child.peer.respond(frame, { turn: { id: '' } })
  465. await expect(pending).rejects.toThrow('turn/start turn id')
  466. wire.close()
  467. }
  468. })
  469. it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
  470. const scenarios: Array<{
  471. readonly frames: JsonObject[]
  472. readonly message: string
  473. }> = [
  474. {
  475. frames: [turnCompleted('completed')],
  476. message: 'without a final answer',
  477. },
  478. {
  479. frames: [
  480. agentMessage('fallback', null),
  481. agentMessage(' \n ', 'final_answer'),
  482. turnCompleted('completed'),
  483. ],
  484. message: 'without a final answer',
  485. },
  486. {
  487. frames: [agentMessage(42, 'final_answer')],
  488. message: 'invalid agent message',
  489. },
  490. {
  491. frames: [agentMessage('answer', 'future_phase')],
  492. message: 'unknown agent message phase',
  493. },
  494. {
  495. frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
  496. message: 'status failed',
  497. },
  498. {
  499. frames: [turnCompleted('interrupted')],
  500. message: 'status interrupted',
  501. },
  502. {
  503. frames: [turnCompleted('inProgress')],
  504. message: 'invalid terminal turn status',
  505. },
  506. ]
  507. for (const scenario of scenarios) {
  508. const { child, wire } = await initializeWire()
  509. const result = wire.runTurn(['task'], new AbortController().signal)
  510. const turnStart = await child.peer.nextMethod('turn/start')
  511. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  512. child.peer.send(...scenario.frames)
  513. await expect(result).rejects.toThrow(scenario.message)
  514. wire.close()
  515. }
  516. })
  517. it('fails closed when terminal notification params are not an object', async () => {
  518. const { child, wire } = await initializeWire()
  519. const result = wire.runTurn(['task'], new AbortController().signal)
  520. const turnStart = await child.peer.nextMethod('turn/start')
  521. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  522. child.peer.send({ method: 'turn/completed', params: null })
  523. await expect(result).rejects.toThrow('invalid turn/completed thread id')
  524. wire.close()
  525. })
  526. it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
  527. const { child, wire } = await initializeWire()
  528. const result = wire.runTurn(['task'], new AbortController().signal)
  529. const turnStart = await child.peer.nextMethod('turn/start')
  530. child.peer.send(
  531. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  532. { id: 'future-request', method: 'future/request', params: {} },
  533. agentMessage('early answer', 'final_answer'),
  534. turnCompleted('completed'),
  535. )
  536. await expect(result).rejects.toThrow('unsupported app-server request')
  537. wire.close()
  538. })
  539. it('answers all five unattended request classes without granting authority', async () => {
  540. const { child, wire } = await initializeWire()
  541. const result = wire.runTurn(['task'], new AbortController().signal)
  542. const turnStart = await child.peer.nextMethod('turn/start')
  543. child.peer.send({
  544. id: 'command',
  545. method: 'item/commandExecution/requestApproval',
  546. params: {
  547. threadId: 'thread-1',
  548. turnId: 'turn-1',
  549. availableDecisions: ['decline', 'cancel'],
  550. },
  551. })
  552. expect(await child.peer.nextResponse('command')).toMatchObject({
  553. result: { decision: 'cancel' },
  554. })
  555. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  556. await nextTask()
  557. const requests = [
  558. {
  559. id: 'file',
  560. method: 'item/fileChange/requestApproval',
  561. params: {
  562. threadId: 'thread-1',
  563. turnId: 'turn-1',
  564. availableDecisions: ['decline'],
  565. },
  566. result: { decision: 'decline' },
  567. },
  568. {
  569. id: 'file-default',
  570. method: 'item/fileChange/requestApproval',
  571. params: { threadId: 'thread-1', turnId: 'turn-1' },
  572. result: { decision: 'decline' },
  573. },
  574. {
  575. id: 'permissions',
  576. method: 'item/permissions/requestApproval',
  577. params: { threadId: 'thread-1', turnId: 'turn-1' },
  578. result: { permissions: {}, scope: 'turn' },
  579. },
  580. {
  581. id: 'user-input',
  582. method: 'item/tool/requestUserInput',
  583. params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
  584. result: { answers: {} },
  585. },
  586. {
  587. id: 'mcp',
  588. method: 'mcpServer/elicitation/request',
  589. params: { threadId: 'thread-1', turnId: null },
  590. result: { action: 'decline', content: null, _meta: null },
  591. },
  592. ] as const
  593. for (const serverRequest of requests) {
  594. child.peer.send(serverRequest)
  595. expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
  596. result: serverRequest.result,
  597. })
  598. }
  599. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  600. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  601. wire.close()
  602. })
  603. it('fails the run on unknown requests or wrong request association', async () => {
  604. for (const serverRequest of [
  605. {
  606. id: 'unknown',
  607. method: 'future/request',
  608. params: { threadId: 'thread-1', turnId: 'turn-1' },
  609. },
  610. {
  611. id: 'approval',
  612. method: 'item/commandExecution/requestApproval',
  613. params: {
  614. threadId: 'thread-1',
  615. turnId: 'turn-1',
  616. availableDecisions: ['accept'],
  617. },
  618. },
  619. {
  620. id: 'malformed-approval',
  621. method: 'item/fileChange/requestApproval',
  622. params: {
  623. threadId: 'thread-1',
  624. turnId: 'turn-1',
  625. availableDecisions: 'decline',
  626. },
  627. },
  628. {
  629. id: 'thread',
  630. method: 'item/fileChange/requestApproval',
  631. params: { threadId: 'thread-2', turnId: 'turn-1' },
  632. },
  633. {
  634. id: 'turn',
  635. method: 'item/fileChange/requestApproval',
  636. params: { threadId: 'thread-1', turnId: 'turn-2' },
  637. },
  638. ]) {
  639. const { child, wire } = await initializeWire()
  640. const result = wire.runTurn(['task'], new AbortController().signal)
  641. const turnStart = await child.peer.nextMethod('turn/start')
  642. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  643. await nextTask()
  644. child.peer.send(serverRequest)
  645. const response = await child.peer.nextResponse(serverRequest.id)
  646. expect(response.error).toMatchObject({ code: -32603 })
  647. await expect(result).rejects.toThrow()
  648. wire.close()
  649. }
  650. })
  651. it('rejects conflicting early turn identities before accepting output', async () => {
  652. const { child, wire } = await initializeWire()
  653. const result = wire.runTurn(['task'], new AbortController().signal)
  654. const turnStart = await child.peer.nextMethod('turn/start')
  655. child.peer.send({
  656. method: 'turn/started',
  657. params: { threadId: 'thread-1', turn: { id: 'turn-early' } },
  658. })
  659. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  660. await expect(result).rejects.toThrow('did not match the active turn')
  661. wire.close()
  662. })
  663. it('rejects conflicting early notifications and requests before turn/start', async () => {
  664. {
  665. const { child, wire } = await initializeWire()
  666. child.peer.send({
  667. id: 'too-early',
  668. method: 'item/fileChange/requestApproval',
  669. params: { threadId: 'thread-1', turnId: 'turn-1' },
  670. })
  671. const response = await child.peer.nextResponse('too-early')
  672. expect(response.error).toMatchObject({ code: -32603 })
  673. wire.close()
  674. }
  675. {
  676. const { child, wire } = await initializeWire()
  677. const result = wire.runTurn(['task'], new AbortController().signal)
  678. await child.peer.nextMethod('turn/start')
  679. child.peer.send(
  680. {
  681. method: 'turn/started',
  682. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  683. },
  684. agentMessage('wrong', 'final_answer', 'turn-2'),
  685. )
  686. await expect(result).rejects.toThrow('conflicting turns')
  687. wire.close()
  688. }
  689. })
  690. it('interrupts only an active open turn and contains remote interrupt failure', async () => {
  691. const { child, wire } = await initializeWire()
  692. wire.interrupt()
  693. const result = wire.runTurn(['task'], new AbortController().signal)
  694. const turnStart = await child.peer.nextMethod('turn/start')
  695. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  696. await nextTask()
  697. wire.interrupt()
  698. const interrupt = await child.peer.nextMethod('turn/interrupt')
  699. expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' })
  700. child.peer.send({
  701. id: interrupt.id,
  702. error: { code: -32000, message: 'already done' },
  703. })
  704. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  705. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  706. wire.close()
  707. wire.interrupt()
  708. })
  709. it('ignores unrelated and out-of-window notifications', async () => {
  710. const { child, wire } = await initializeWire()
  711. child.peer.send(
  712. {
  713. method: 'turn/started',
  714. params: { threadId: 'thread-2', turn: { id: 'turn-other' } },
  715. },
  716. {
  717. method: 'turn/started',
  718. params: { threadId: 'thread-1', turn: { id: 'turn-before' } },
  719. },
  720. agentMessage('before', 'final_answer'),
  721. { method: 'future/notification', params: {} },
  722. turnCompleted('completed'),
  723. turnCompleted('completed', 'turn-other', 'thread-2'),
  724. )
  725. await nextTask()
  726. const result = wire.runTurn(['task'], new AbortController().signal)
  727. const turnStart = await child.peer.nextMethod('turn/start')
  728. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  729. await nextTask()
  730. child.peer.send(
  731. agentMessage('wrong turn', 'final_answer', 'turn-2'),
  732. turnCompleted('completed', 'turn-2'),
  733. agentMessage('answer', 'final_answer'),
  734. turnCompleted('completed'),
  735. )
  736. await expect(result).resolves.toEqual({
  737. output: [{ type: 'text', text: 'answer' }],
  738. stopReason: 'completed',
  739. })
  740. wire.close()
  741. })
  742. it('rejects pending work on abort, EOF, and stream error', async () => {
  743. {
  744. const child = fakeChild()
  745. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  746. wire.start()
  747. const controller = new AbortController()
  748. controller.abort('pre-aborted')
  749. await expect(wire.initialize(controller.signal))
  750. .rejects.toThrow('app-server request aborted: pre-aborted')
  751. wire.close()
  752. }
  753. {
  754. const child = fakeChild()
  755. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  756. wire.start()
  757. const controller = new AbortController()
  758. const pending = wire.initialize(controller.signal)
  759. await child.peer.nextMethod('initialize')
  760. controller.abort(new Error('cancel initialize'))
  761. await expect(pending).rejects.toThrow('cancel initialize')
  762. wire.close()
  763. }
  764. {
  765. const child = fakeChild()
  766. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  767. wire.start()
  768. const pending = wire.initialize(new AbortController().signal)
  769. await child.peer.nextMethod('initialize')
  770. child.fromChild.end()
  771. await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/)
  772. wire.close()
  773. }
  774. {
  775. const child = fakeChild()
  776. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  777. wire.start()
  778. const pending = wire.initialize(new AbortController().signal)
  779. await child.peer.nextMethod('initialize')
  780. child.fromChild.emit('error', new Error('stdout broke'))
  781. await expect(pending).rejects.toThrow('stdout broke')
  782. wire.close()
  783. }
  784. {
  785. const child = fakeChild()
  786. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  787. wire.start()
  788. const pending = wire.initialize(new AbortController().signal)
  789. await child.peer.nextMethod('initialize')
  790. child.toChild.emit('error', new Error('stdin broke'))
  791. await expect(pending).rejects.toThrow('stdin broke')
  792. wire.close()
  793. child.toChild.emit('error', new Error('late stdin close'))
  794. }
  795. })
  796. })
  797. describe('run lifecycle and quiescence', () => {
  798. it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => {
  799. const child = fakeChild()
  800. const spawn = vi.fn(() => child.handle)
  801. const starting = startCodexRun(
  802. request([{ type: 'text', text: 'task' }]),
  803. runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }),
  804. )
  805. let published = false
  806. void starting.then(() => { published = true })
  807. const initialize = await child.peer.nextMethod('initialize')
  808. expect(published).toBe(false)
  809. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  810. await child.peer.nextMethod('initialized')
  811. const threadStart = await child.peer.nextMethod('thread/start')
  812. expect(published).toBe(false)
  813. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  814. const run = await starting
  815. expect(spawn).toHaveBeenCalledWith({
  816. argv: codexAppServerArgv(),
  817. cwd: process.cwd(),
  818. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  819. graceMs: DEFAULT_DISPOSE_GRACE_MS,
  820. env: { OPENAI_API_KEY: 'fake' },
  821. })
  822. expect(run.localAgent).toBeUndefined()
  823. const turnStart = await child.peer.nextMethod('turn/start')
  824. child.peer.send(
  825. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  826. agentMessage('answer', 'final_answer'),
  827. turnCompleted('completed'),
  828. )
  829. await expect(run.result).resolves.toEqual({
  830. output: [{ type: 'text', text: 'answer' }],
  831. stopReason: 'completed',
  832. })
  833. const disposal = run.dispose()
  834. expect(run.dispose()).toBe(disposal)
  835. await disposal
  836. await nextTask()
  837. expect(child.terminate).toHaveBeenCalledTimes(1)
  838. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  839. })
  840. it('settles local cancellation immediately and sends best-effort interrupt', async () => {
  841. const controller = new AbortController()
  842. const { child, run, turnStart } = await publishRun(
  843. fakeChild(),
  844. controller.signal,
  845. )
  846. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  847. await nextTask()
  848. controller.abort(new Error('stop'))
  849. await expect(run.result).resolves.toEqual({
  850. output: [],
  851. stopReason: 'aborted',
  852. })
  853. expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({
  854. params: { threadId: 'thread-1', turnId: 'turn-1' },
  855. })
  856. await run.dispose()
  857. })
  858. it('flattens child exit and protocol failures after publication', async () => {
  859. const errors: string[] = []
  860. {
  861. const child = fakeChild({ exitOnTerminate: false })
  862. const { run } = await publishRun(child, undefined, {
  863. onError: (error) => { errors.push(error.message) },
  864. })
  865. child.settle({ exitCode: 9, signal: null })
  866. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  867. expect(errors.at(-1)).toContain('code 9')
  868. await run.dispose().catch(() => {})
  869. }
  870. {
  871. const child = fakeChild()
  872. const { run, turnStart } = await publishRun(child, undefined, {
  873. onError: () => { throw new Error('diagnostic sink') },
  874. })
  875. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  876. child.fromChild.end()
  877. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  878. await run.dispose()
  879. }
  880. })
  881. it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
  882. const controller = new AbortController()
  883. controller.abort()
  884. const spawn = vi.fn()
  885. await expect(startCodexRun(
  886. request(undefined, controller.signal),
  887. {
  888. cwd: process.cwd(),
  889. env: {},
  890. disposeGraceMs: 10,
  891. spawn,
  892. },
  893. )).rejects.toThrow('aborted before app-server startup')
  894. expect(spawn).not.toHaveBeenCalled()
  895. const child = fakeChild()
  896. const starting = startCodexRun(request(), runSpec(child))
  897. const initialize = await child.peer.nextMethod('initialize')
  898. child.peer.respond(initialize, null)
  899. await expect(starting).rejects.toThrow('invalid initialize response')
  900. expect(child.terminate).toHaveBeenCalledTimes(1)
  901. })
  902. it('rolls back an abort that wins immediately after thread creation', async () => {
  903. const controller = new AbortController()
  904. const child = fakeChild()
  905. const starting = startCodexRun(
  906. request(undefined, controller.signal),
  907. runSpec(child),
  908. )
  909. const initialize = await child.peer.nextMethod('initialize')
  910. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  911. await child.peer.nextMethod('initialized')
  912. const threadStart = await child.peer.nextMethod('thread/start')
  913. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  914. controller.abort('startup race')
  915. await expect(starting).rejects.toThrow('aborted before run publication')
  916. expect(child.terminate).toHaveBeenCalledTimes(1)
  917. })
  918. it('rolls back a subprocess done rejection during startup', async () => {
  919. const child = fakeChild({ doneError: new Error('spawn observer failed') })
  920. const error: unknown = await startCodexRun(request(), runSpec(child)).then(
  921. () => undefined,
  922. (failure: unknown) => failure,
  923. )
  924. expect(error).toBeInstanceOf(AggregateError)
  925. if (!(error instanceof AggregateError)) {
  926. throw new Error('expected startup and rollback failures')
  927. }
  928. expect(error.errors).toEqual([
  929. expect.objectContaining({ message: 'spawn observer failed' }),
  930. expect.objectContaining({ message: 'spawn observer failed' }),
  931. ])
  932. expect(child.terminate).toHaveBeenCalledTimes(1)
  933. })
  934. it('keeps overlapping runs isolated', async () => {
  935. const first = fakeChild()
  936. const second = fakeChild()
  937. const runs = await Promise.all([
  938. publishRun(first),
  939. publishRun(second),
  940. ])
  941. for (const [index, entry] of runs.entries()) {
  942. const id = `turn-${index + 1}`
  943. entry.child.peer.send(
  944. { id: entry.turnStart.id, result: { turn: { id } } },
  945. agentMessage(`answer-${index + 1}`, 'final_answer', id),
  946. turnCompleted('completed', id),
  947. )
  948. }
  949. const results = await Promise.all(runs.map(entry => entry.run.result))
  950. expect(results.map(result => result.output)).toEqual([
  951. [{ type: 'text', text: 'answer-1' }],
  952. [{ type: 'text', text: 'answer-2' }],
  953. ])
  954. expect(runs[0].run.id).not.toBe(runs[1].run.id)
  955. await Promise.all(runs.map(entry => entry.run.dispose()))
  956. })
  957. it('uses the registered provider config and logs flattened errors', async () => {
  958. const ctx = new Context()
  959. await ctx.plugin(SubagentService)
  960. await ctx.plugin(LocalSubprocessService)
  961. const child = fakeChild()
  962. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  963. const warnings: string[] = []
  964. ctx.logger.warn = ((message: unknown) => {
  965. warnings.push(String(message))
  966. }) as typeof ctx.logger.warn
  967. await ctx.plugin(codex, {
  968. env: { OPENAI_API_KEY: 'fake' },
  969. disposeGraceMs: 25,
  970. })
  971. const starting = ctx.subagents.start('codex', {
  972. prompt: [{ type: 'text', text: 'task' }],
  973. parent: fakeParent,
  974. signal: new AbortController().signal,
  975. })
  976. const initialize = await child.peer.nextMethod('initialize')
  977. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  978. await child.peer.nextMethod('initialized')
  979. const threadStart = await child.peer.nextMethod('thread/start')
  980. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  981. const run = await starting
  982. await child.peer.nextMethod('turn/start')
  983. child.settle({ exitCode: 1, signal: null })
  984. await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
  985. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  986. env: { OPENAI_API_KEY: 'fake' },
  987. graceMs: 25,
  988. cwd: process.cwd(),
  989. }))
  990. expect(warnings).toEqual([
  991. expect.stringContaining('subagent-codex: child run failed (error):'),
  992. ])
  993. await run.dispose().catch(() => {})
  994. await ctx.fiber.dispose()
  995. })
  996. })
  997. describe('disposeCodexChild', () => {
  998. it('closes stdin, terminates, and waits for the managed tree', async () => {
  999. const child = fakeChild()
  1000. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1001. const end = vi.spyOn(child.toChild, 'end')
  1002. await disposeCodexChild(wire, child.handle)
  1003. expect(end).toHaveBeenCalled()
  1004. expect(child.terminate).toHaveBeenCalledTimes(1)
  1005. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1006. expect(child.waitForExit).toHaveBeenCalledWith()
  1007. })
  1008. it('does not finish disposal before the managed tree exits', async () => {
  1009. const child = fakeChild({ exitOnTerminate: false })
  1010. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1011. let disposed = false
  1012. const disposal = disposeCodexChild(wire, child.handle).then(() => {
  1013. disposed = true
  1014. })
  1015. await new Promise<void>((resolve) => { setImmediate(resolve) })
  1016. expect(disposed).toBe(false)
  1017. child.settle()
  1018. await disposal
  1019. expect(disposed).toBe(true)
  1020. })
  1021. it('contains a concurrently closed stdin error', async () => {
  1022. const child = fakeChild()
  1023. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1024. vi.spyOn(child.toChild, 'end').mockImplementation(() => {
  1025. throw new Error('already closed')
  1026. })
  1027. await expect(disposeCodexChild(wire, child.handle))
  1028. .resolves.toBeUndefined()
  1029. })
  1030. it('handles a spawn-level failure with no process tree', async () => {
  1031. const child = fakeChild({
  1032. pid: -1,
  1033. doneError: new Error('spawn failed'),
  1034. })
  1035. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1036. await expect(disposeCodexChild(wire, child.handle))
  1037. .resolves.toBeUndefined()
  1038. expect(child.terminate).not.toHaveBeenCalled()
  1039. expect(child.waitForExit).not.toHaveBeenCalled()
  1040. })
  1041. it('reports direct-child observer failure and accepts absent stdin', async () => {
  1042. {
  1043. const child = fakeChild({
  1044. doneError: new Error('close observer failed'),
  1045. })
  1046. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1047. await expect(disposeCodexChild(wire, child.handle))
  1048. .rejects.toThrow('close observer failed')
  1049. }
  1050. {
  1051. const child = fakeChild()
  1052. const handle = { ...child.handle, stdin: undefined }
  1053. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1054. await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
  1055. }
  1056. })
  1057. })