subagent-codex.spec.ts 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714
  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 SubagentRuntime 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 LocalSubprocessRuntime 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. CODEX_PERMISSION_MODES,
  19. DEFAULT_CODEX_PERMISSION_MODE,
  20. codexAppServerArgv,
  21. DEFAULT_DISPOSE_GRACE_MS,
  22. disposeCodexChild,
  23. startCodexRun,
  24. textTask,
  25. type CodexRunSpec,
  26. } from '../src/run.ts'
  27. import { CodexAppServerWire } from '../src/wire.ts'
  28. const { hostStderrWrite } = vi.hoisted(() => ({
  29. hostStderrWrite: {
  30. capture: false,
  31. failNext: false,
  32. chunks: [] as Buffer[],
  33. },
  34. }))
  35. vi.mock('node:fs', async (importOriginal) => {
  36. const actual = await importOriginal<typeof import('node:fs')>()
  37. return {
  38. ...actual,
  39. writeFileSync(
  40. fd: number,
  41. value: string | Uint8Array,
  42. ): void {
  43. if (fd === 2 && hostStderrWrite.capture) {
  44. if (hostStderrWrite.failNext) {
  45. hostStderrWrite.failNext = false
  46. throw Object.assign(new Error('host stderr broke'), { code: 'EIO' })
  47. }
  48. const bytes = typeof value === 'string'
  49. ? Buffer.from(value)
  50. : Buffer.from(value.buffer, value.byteOffset, value.byteLength)
  51. hostStderrWrite.chunks.push(bytes)
  52. return
  53. }
  54. actual.writeFileSync(fd, value)
  55. },
  56. }
  57. })
  58. type JsonObject = Record<string, unknown>
  59. const fakeParent = {
  60. id: 'parent',
  61. session: { header: { cwd: process.cwd() } },
  62. } as unknown as Agent
  63. function request(
  64. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  65. signal = new AbortController().signal,
  66. ) {
  67. return { prompt, parent: fakeParent, signal }
  68. }
  69. async function nextTask(): Promise<void> {
  70. await new Promise<void>((resolve) => { setImmediate(resolve) })
  71. }
  72. class ProtocolPeer {
  73. private buffer = ''
  74. private readonly frames: JsonObject[] = []
  75. private readonly wakeups = new Set<() => void>()
  76. constructor(
  77. input: PassThrough,
  78. private readonly output: PassThrough,
  79. ) {
  80. input.on('data', (chunk: Buffer | string) => {
  81. this.buffer += chunk.toString()
  82. for (;;) {
  83. const newline = this.buffer.indexOf('\n')
  84. if (newline < 0) break
  85. const line = this.buffer.slice(0, newline)
  86. this.buffer = this.buffer.slice(newline + 1)
  87. if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject)
  88. }
  89. for (const wake of this.wakeups) wake()
  90. this.wakeups.clear()
  91. })
  92. }
  93. async next(predicate: (frame: JsonObject) => boolean): Promise<JsonObject> {
  94. for (;;) {
  95. const index = this.frames.findIndex(predicate)
  96. if (index >= 0) return this.frames.splice(index, 1)[0]!
  97. await new Promise<void>((resolve) => { this.wakeups.add(resolve) })
  98. }
  99. }
  100. nextMethod(method: string): Promise<JsonObject> {
  101. return this.next(frame => frame.method === method)
  102. }
  103. nextResponse(id: unknown): Promise<JsonObject> {
  104. return this.next(frame => frame.id === id && frame.method === undefined)
  105. }
  106. send(...frames: readonly JsonObject[]): void {
  107. this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`)
  108. }
  109. respond(requestFrame: JsonObject, result: unknown): void {
  110. this.send({ id: requestFrame.id, result })
  111. }
  112. }
  113. interface FakeChildOptions {
  114. readonly pid?: number
  115. readonly exitOnTerminate?: boolean
  116. readonly doneError?: Error
  117. }
  118. interface FakeChild {
  119. readonly handle: SubprocessHandle
  120. readonly peer: ProtocolPeer
  121. readonly fromChild: PassThrough
  122. readonly toChild: PassThrough
  123. readonly stderr: PassThrough
  124. readonly settle: (outcome?: SubprocessOutcome) => void
  125. readonly fail: (error: Error) => void
  126. readonly terminate: () => void
  127. readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
  128. }
  129. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  130. const fromChild = new PassThrough()
  131. const toChild = new PassThrough()
  132. const stderr = new PassThrough()
  133. const peer = new ProtocolPeer(toChild, fromChild)
  134. let exited = false
  135. let resolveDone!: (outcome: SubprocessOutcome) => void
  136. let rejectDone!: (error: Error) => void
  137. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  138. resolveDone = resolve
  139. rejectDone = reject
  140. })
  141. const settle = (
  142. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  143. ): void => {
  144. if (exited) return
  145. exited = true
  146. resolveDone(outcome)
  147. }
  148. const fail = (error: Error): void => {
  149. if (exited) return
  150. exited = true
  151. rejectDone(error)
  152. }
  153. if (options.doneError !== undefined) fail(options.doneError)
  154. const terminate = vi.fn(() => {
  155. if (options.exitOnTerminate !== false) settle()
  156. })
  157. const waitForExit = vi.fn(async (signal?: AbortSignal) => {
  158. if (exited) return true
  159. if (signal === undefined) {
  160. await done.catch(() => {})
  161. return true
  162. }
  163. return await new Promise<boolean>((resolve) => {
  164. const onAbort = (): void => { resolve(false) }
  165. signal.addEventListener('abort', onAbort, { once: true })
  166. void done.then(
  167. () => {
  168. signal.removeEventListener('abort', onAbort)
  169. resolve(true)
  170. },
  171. () => {
  172. signal.removeEventListener('abort', onAbort)
  173. resolve(true)
  174. },
  175. )
  176. })
  177. })
  178. const handle: SubprocessHandle = {
  179. pid: options.pid ?? 1234,
  180. stdin: toChild,
  181. stdout: fromChild,
  182. stderr,
  183. collected: {},
  184. done,
  185. terminate,
  186. waitForExit,
  187. }
  188. return {
  189. handle,
  190. peer,
  191. fromChild,
  192. toChild,
  193. stderr,
  194. settle,
  195. fail,
  196. terminate,
  197. waitForExit,
  198. }
  199. }
  200. function defaultWire(child: FakeChild): CodexAppServerWire {
  201. return new CodexAppServerWire(
  202. child.handle.stdout!,
  203. child.handle.stdin!,
  204. DEFAULT_CODEX_PERMISSION_MODE,
  205. )
  206. }
  207. function runSpec(
  208. child: FakeChild,
  209. overrides: Partial<CodexRunSpec> = {},
  210. ): CodexRunSpec {
  211. return {
  212. cwd: process.cwd(),
  213. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  214. env: {},
  215. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  216. spawn: () => child.handle,
  217. ...overrides,
  218. }
  219. }
  220. async function initializeWire(): Promise<{
  221. readonly child: FakeChild
  222. readonly wire: CodexAppServerWire
  223. }> {
  224. const child = fakeChild()
  225. const wire = defaultWire(child)
  226. wire.start()
  227. const initializing = wire.initialize(new AbortController().signal)
  228. const initialize = await child.peer.nextMethod('initialize')
  229. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  230. await initializing
  231. expect(await child.peer.nextMethod('initialized')).toEqual({
  232. jsonrpc: '2.0',
  233. method: 'initialized',
  234. })
  235. const starting = wire.startThread(process.cwd(), new AbortController().signal)
  236. const threadStart = await child.peer.nextMethod('thread/start')
  237. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  238. await starting
  239. return { child, wire }
  240. }
  241. async function publishRun(
  242. child = fakeChild(),
  243. signal = new AbortController().signal,
  244. specOverrides: Partial<CodexRunSpec> = {},
  245. ) {
  246. const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
  247. const initialize = await child.peer.nextMethod('initialize')
  248. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  249. await child.peer.nextMethod('initialized')
  250. const threadStart = await child.peer.nextMethod('thread/start')
  251. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  252. const run = await starting
  253. const turnStart = await child.peer.nextMethod('turn/start')
  254. return { child, run, turnStart }
  255. }
  256. function agentMessage(
  257. text: unknown,
  258. phase: unknown,
  259. turnId = 'turn-1',
  260. threadId = 'thread-1',
  261. ): JsonObject {
  262. return {
  263. method: 'item/completed',
  264. params: {
  265. threadId,
  266. turnId,
  267. item: { type: 'agentMessage', text, phase },
  268. },
  269. }
  270. }
  271. function turnCompleted(
  272. status: unknown,
  273. turnId = 'turn-1',
  274. threadId = 'thread-1',
  275. error: unknown = null,
  276. ): JsonObject {
  277. return {
  278. method: 'turn/completed',
  279. params: {
  280. threadId,
  281. turn: { id: turnId, status, error },
  282. },
  283. }
  284. }
  285. describe('task admission and package contracts', () => {
  286. it('keeps the app-server command fixed on POSIX and Windows', () => {
  287. expect(codexAppServerArgv('linux')).toEqual([
  288. 'codex', 'app-server', '--stdio',
  289. ])
  290. expect(codexAppServerArgv('win32')).toEqual([
  291. 'cmd.exe',
  292. '/d',
  293. '/s',
  294. '/c',
  295. 'codex',
  296. 'app-server',
  297. '--stdio',
  298. ])
  299. })
  300. it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
  301. expect(textTask([
  302. { type: 'text', text: 'one' },
  303. { type: 'text', text: 'two' },
  304. ])).toEqual(['one', 'two'])
  305. expect(() => textTask([])).toThrow('only text blocks')
  306. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  307. .toThrow('only text blocks')
  308. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  309. .toThrow('must not be empty')
  310. })
  311. it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
  312. const ctx = new Context()
  313. await ctx.plugin(SubagentRuntime)
  314. await ctx.plugin(LocalSubprocessRuntime)
  315. const fiber = await ctx.plugin(codex, {})
  316. const provider = ctx.subagents.getProvider('codex')!
  317. expect(provider).toMatchObject({
  318. name: 'codex',
  319. capabilities: {
  320. outputSchema: false,
  321. depthLimit: false,
  322. toolFilter: false,
  323. persona: false,
  324. },
  325. inheritsParentContext: false,
  326. })
  327. expect(ctx.subagents.list()).toEqual(['codex'])
  328. await fiber.dispose()
  329. expect(ctx.subagents.list()).toEqual([])
  330. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  331. await expect(ctx.plugin(codex, { disposeGraceMs }))
  332. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  333. }
  334. await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
  335. .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
  336. await ctx.fiber.dispose()
  337. })
  338. it('accepts only the three fixed non-interactive permission modes', () => {
  339. expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE)
  340. for (const permissionMode of CODEX_PERMISSION_MODES) {
  341. expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode)
  342. }
  343. for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) {
  344. expect(() => codex.Config({ permissionMode } as never)).toThrow()
  345. }
  346. })
  347. it('resolves the safe permission default when apply is called directly', async () => {
  348. const ctx = new Context()
  349. await ctx.plugin(SubagentRuntime)
  350. await ctx.plugin(LocalSubprocessRuntime)
  351. codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  352. expect(ctx.subagents.getProvider('codex')).toBeDefined()
  353. await ctx.fiber.dispose()
  354. })
  355. it.each([
  356. ['never', { approvalPolicy: 'never' }],
  357. ['approve-for-me', {
  358. approvalPolicy: 'on-request',
  359. approvalsReviewer: 'auto_review',
  360. sandbox: 'workspace-write',
  361. }],
  362. ['dangerously-bypass-approvals-and-sandbox', {
  363. approvalPolicy: 'never',
  364. sandbox: 'danger-full-access',
  365. }],
  366. ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => {
  367. const child = fakeChild()
  368. const wire = new CodexAppServerWire(
  369. child.handle.stdout!,
  370. child.handle.stdin!,
  371. permissionMode,
  372. )
  373. wire.start()
  374. const initializing = wire.initialize(new AbortController().signal)
  375. const initialize = await child.peer.nextMethod('initialize')
  376. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  377. await initializing
  378. await child.peer.nextMethod('initialized')
  379. const starting = wire.startThread('/workspace', new AbortController().signal)
  380. const threadStart = await child.peer.nextMethod('thread/start')
  381. expect(threadStart.params).toEqual({
  382. cwd: '/workspace',
  383. ephemeral: true,
  384. ...expected,
  385. })
  386. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  387. await starting
  388. wire.close()
  389. })
  390. it('requires a parent session cwd without suggesting unsupported config', async () => {
  391. const ctx = new Context()
  392. await ctx.plugin(SubagentRuntime)
  393. await ctx.plugin(LocalSubprocessRuntime)
  394. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  395. await ctx.plugin(codex, {})
  396. await expect(ctx.subagents.start('codex', {
  397. prompt: [{ type: 'text', text: 'task' }],
  398. parent: {
  399. id: 'parent-without-cwd',
  400. session: { header: {} },
  401. } as unknown as Agent,
  402. signal: new AbortController().signal,
  403. })).rejects.toThrow(
  404. 'subagent-codex: no working directory for the child — delegate from a parent session that has one',
  405. )
  406. expect(spawn).not.toHaveBeenCalled()
  407. await ctx.fiber.dispose()
  408. })
  409. it('keeps the namespace export shape and package-owned empty invariant', async () => {
  410. expect('default' in codex).toBe(false)
  411. expect(codex.name).toBe('subagent-codex')
  412. expect(codex.inject).toEqual(['subagents', 'subprocess'])
  413. const loader = Object.create(Loader.prototype) as Loader
  414. expect(loader.unwrapExports(codex)).toBe(codex)
  415. const dispose = vi.fn()
  416. const register = vi.fn((
  417. _packageName: string,
  418. _installer: InvariantInstaller,
  419. ) => dispose)
  420. const ctx = { invariants: { register } } as unknown as Context
  421. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  422. expect(register).toHaveBeenCalledWith(
  423. '@deepseek-ai/dsh-subagent-codex',
  424. expect.any(Function),
  425. )
  426. const install = register.mock.calls[0]![1]
  427. await install(new Context(), (message) => { throw new Error(message) })
  428. expect(invariant.name).toBe('subagent-codex-invariant')
  429. expect(invariant.inject).toEqual(['invariants'])
  430. })
  431. })
  432. describe('CodexAppServerWire', () => {
  433. it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
  434. const child = fakeChild()
  435. const wire = defaultWire(child)
  436. expect(wire.collectOutput()).toEqual([])
  437. wire.start()
  438. const initializing = wire.initialize(new AbortController().signal)
  439. const initialize = await child.peer.nextMethod('initialize')
  440. expect(initialize.params).toEqual({
  441. clientInfo: {
  442. name: 'deepseek-harness',
  443. title: 'DeepSeek Harness',
  444. version: '0.0.1',
  445. },
  446. capabilities: {
  447. experimentalApi: false,
  448. requestAttestation: false,
  449. },
  450. })
  451. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  452. await initializing
  453. await child.peer.nextMethod('initialized')
  454. const starting = wire.startThread('/workspace', new AbortController().signal)
  455. const threadStart = await child.peer.nextMethod('thread/start')
  456. expect(threadStart.params).toEqual({
  457. cwd: '/workspace',
  458. ephemeral: true,
  459. approvalPolicy: 'never',
  460. })
  461. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  462. await starting
  463. const result = wire.runTurn(
  464. ['first', 'second'],
  465. new AbortController().signal,
  466. )
  467. const turnStart = await child.peer.nextMethod('turn/start')
  468. expect(turnStart.params).toEqual({
  469. threadId: 'thread-1',
  470. input: [
  471. { type: 'text', text: 'first', text_elements: [] },
  472. { type: 'text', text: 'second', text_elements: [] },
  473. ],
  474. })
  475. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  476. await nextTask()
  477. child.peer.send(
  478. {
  479. method: 'turn/started',
  480. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  481. },
  482. agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'),
  483. agentMessage('other turn', 'final_answer', 'turn-2'),
  484. {
  485. method: 'item/completed',
  486. params: {
  487. threadId: 'thread-1',
  488. turnId: 'turn-1',
  489. item: { type: 'reasoning', text: 'not output' },
  490. },
  491. },
  492. agentMessage('commentary', 'commentary'),
  493. agentMessage('unphased', null),
  494. agentMessage('first final', 'final_answer'),
  495. agentMessage('last final', 'final_answer'),
  496. turnCompleted('completed'),
  497. )
  498. await expect(result).resolves.toEqual({
  499. output: [{ type: 'text', text: 'last final' }],
  500. stopReason: 'completed',
  501. })
  502. expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }])
  503. wire.close()
  504. wire.close()
  505. })
  506. it('uses the last nullable-phase answer when no explicit final exists', async () => {
  507. const { child, wire } = await initializeWire()
  508. const result = wire.runTurn(['task'], new AbortController().signal)
  509. const turnStart = await child.peer.nextMethod('turn/start')
  510. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  511. child.peer.send(
  512. agentMessage('first', null),
  513. agentMessage('fallback', null),
  514. turnCompleted('completed'),
  515. )
  516. await expect(result).resolves.toEqual({
  517. output: [{ type: 'text', text: 'fallback' }],
  518. stopReason: 'completed',
  519. })
  520. wire.close()
  521. })
  522. it('maps only an explicit context-window failure to max-tokens', async () => {
  523. const { child, wire } = await initializeWire()
  524. const result = wire.runTurn(['task'], new AbortController().signal)
  525. const turnStart = await child.peer.nextMethod('turn/start')
  526. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  527. child.peer.send(
  528. agentMessage('partial answer', null),
  529. turnCompleted('failed', 'turn-1', 'thread-1', {
  530. message: 'too much context',
  531. codexErrorInfo: 'contextWindowExceeded',
  532. }),
  533. )
  534. await expect(result).resolves.toEqual({
  535. output: [{ type: 'text', text: 'partial answer' }],
  536. stopReason: 'max-tokens',
  537. })
  538. wire.close()
  539. })
  540. it('rejects invalid handshake, thread, and turn response shapes', async () => {
  541. {
  542. const child = fakeChild()
  543. const wire = defaultWire(child)
  544. wire.start()
  545. const pending = wire.initialize(new AbortController().signal)
  546. const frame = await child.peer.nextMethod('initialize')
  547. child.peer.respond(frame, null)
  548. await expect(pending).rejects.toThrow('invalid initialize response')
  549. wire.close()
  550. }
  551. {
  552. const child = fakeChild()
  553. const wire = defaultWire(child)
  554. wire.start()
  555. const pending = wire.startThread('/workspace', new AbortController().signal)
  556. const frame = await child.peer.nextMethod('thread/start')
  557. child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } })
  558. await expect(pending).rejects.toThrow('did not create an ephemeral thread')
  559. wire.close()
  560. }
  561. {
  562. const { child, wire } = await initializeWire()
  563. const pending = wire.runTurn(['task'], new AbortController().signal)
  564. const frame = await child.peer.nextMethod('turn/start')
  565. child.peer.respond(frame, { turn: { id: '' } })
  566. await expect(pending).rejects.toThrow('turn/start turn id')
  567. wire.close()
  568. }
  569. })
  570. it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
  571. const scenarios: Array<{
  572. readonly frames: JsonObject[]
  573. readonly message: string
  574. }> = [
  575. {
  576. frames: [turnCompleted('completed')],
  577. message: 'without a final answer',
  578. },
  579. {
  580. frames: [
  581. agentMessage('fallback', null),
  582. agentMessage(' \n ', 'final_answer'),
  583. turnCompleted('completed'),
  584. ],
  585. message: 'without a final answer',
  586. },
  587. {
  588. frames: [agentMessage(42, 'final_answer')],
  589. message: 'invalid agent message',
  590. },
  591. {
  592. frames: [agentMessage('answer', 'future_phase')],
  593. message: 'unknown agent message phase',
  594. },
  595. {
  596. frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
  597. message: 'status failed',
  598. },
  599. {
  600. frames: [turnCompleted('interrupted')],
  601. message: 'status interrupted',
  602. },
  603. {
  604. frames: [turnCompleted('inProgress')],
  605. message: 'invalid terminal turn status',
  606. },
  607. ]
  608. for (const scenario of scenarios) {
  609. const { child, wire } = await initializeWire()
  610. const result = wire.runTurn(['task'], new AbortController().signal)
  611. const turnStart = await child.peer.nextMethod('turn/start')
  612. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  613. child.peer.send(...scenario.frames)
  614. await expect(result).rejects.toThrow(scenario.message)
  615. wire.close()
  616. }
  617. })
  618. it('fails closed when terminal notification params are not an object', async () => {
  619. const { child, wire } = await initializeWire()
  620. const result = wire.runTurn(['task'], new AbortController().signal)
  621. const turnStart = await child.peer.nextMethod('turn/start')
  622. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  623. child.peer.send({ method: 'turn/completed', params: null })
  624. await expect(result).rejects.toThrow('invalid turn/completed thread id')
  625. wire.close()
  626. })
  627. it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
  628. const { child, wire } = await initializeWire()
  629. const result = wire.runTurn(['task'], new AbortController().signal)
  630. const turnStart = await child.peer.nextMethod('turn/start')
  631. child.peer.send(
  632. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  633. { id: 'future-request', method: 'future/request', params: {} },
  634. agentMessage('early answer', 'final_answer'),
  635. turnCompleted('completed'),
  636. )
  637. await expect(result).rejects.toThrow('unsupported app-server request')
  638. wire.close()
  639. })
  640. it('answers all five unattended request classes without granting authority', async () => {
  641. const { child, wire } = await initializeWire()
  642. const result = wire.runTurn(['task'], new AbortController().signal)
  643. const turnStart = await child.peer.nextMethod('turn/start')
  644. child.peer.send({
  645. id: 'command',
  646. method: 'item/commandExecution/requestApproval',
  647. params: {
  648. threadId: 'thread-1',
  649. turnId: 'turn-1',
  650. availableDecisions: ['decline', 'cancel'],
  651. command: 'cat /private/secret.txt',
  652. },
  653. })
  654. expect(await child.peer.nextResponse('command')).toMatchObject({
  655. result: { decision: 'cancel' },
  656. })
  657. expect(wire.collectDiagnostic()).toBeUndefined()
  658. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  659. await nextTask()
  660. expect(wire.collectDiagnostic()).toBe(
  661. 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  662. )
  663. const requests = [
  664. {
  665. id: 'command-decline',
  666. method: 'item/commandExecution/requestApproval',
  667. params: {
  668. threadId: 'thread-1',
  669. turnId: 'turn-1',
  670. availableDecisions: ['decline'],
  671. },
  672. result: { decision: 'decline' },
  673. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval',
  674. },
  675. {
  676. id: 'file',
  677. method: 'item/fileChange/requestApproval',
  678. params: {
  679. threadId: 'thread-1',
  680. turnId: 'turn-1',
  681. availableDecisions: ['decline'],
  682. },
  683. result: { decision: 'decline' },
  684. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  685. },
  686. {
  687. id: 'file-cancel',
  688. method: 'item/fileChange/requestApproval',
  689. params: {
  690. threadId: 'thread-1',
  691. turnId: 'turn-1',
  692. availableDecisions: ['cancel'],
  693. },
  694. result: { decision: 'cancel' },
  695. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval',
  696. },
  697. {
  698. id: 'file-default',
  699. method: 'item/fileChange/requestApproval',
  700. params: { threadId: 'thread-1', turnId: 'turn-1' },
  701. result: { decision: 'decline' },
  702. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  703. },
  704. {
  705. id: 'permissions',
  706. method: 'item/permissions/requestApproval',
  707. params: { threadId: 'thread-1', turnId: 'turn-1' },
  708. result: { permissions: {}, scope: 'turn' },
  709. diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions',
  710. },
  711. {
  712. id: 'user-input',
  713. method: 'item/tool/requestUserInput',
  714. params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
  715. result: { answers: {} },
  716. diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers',
  717. },
  718. {
  719. id: 'mcp',
  720. method: 'mcpServer/elicitation/request',
  721. params: { threadId: 'thread-1', turnId: null },
  722. result: { action: 'decline', content: null, _meta: null },
  723. diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  724. },
  725. ] as const
  726. for (const serverRequest of requests) {
  727. child.peer.send(serverRequest)
  728. expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
  729. result: serverRequest.result,
  730. })
  731. expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic)
  732. }
  733. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  734. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  735. await expect(result).resolves.toEqual({
  736. output: [{ type: 'text', text: 'answer' }],
  737. stopReason: 'completed',
  738. })
  739. wire.close()
  740. })
  741. it('records only a safe diagnostic for an explicit sandbox failure', async () => {
  742. const { child, wire } = await initializeWire()
  743. const result = wire.runTurn(['task'], new AbortController().signal)
  744. const turnStart = await child.peer.nextMethod('turn/start')
  745. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  746. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  747. message: 'failed at /private/secret.txt with SECRET_TOKEN',
  748. additionalDetails: 'raw command payload',
  749. codexErrorInfo: 'sandboxError',
  750. }))
  751. await expect(result).rejects.toThrow('status failed')
  752. expect(wire.collectDiagnostic()).toBe(
  753. 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
  754. )
  755. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  756. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  757. wire.close()
  758. })
  759. it('records declined command and file items without retaining their payloads', async () => {
  760. const { child, wire } = await initializeWire()
  761. const result = wire.runTurn(['task'], new AbortController().signal)
  762. const turnStart = await child.peer.nextMethod('turn/start')
  763. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  764. child.peer.send({
  765. method: 'item/completed',
  766. params: {
  767. threadId: 'thread-1',
  768. turnId: 'turn-1',
  769. item: {
  770. type: 'commandExecution',
  771. status: 'declined',
  772. command: 'cat /private/secret.txt',
  773. },
  774. },
  775. })
  776. await nextTask()
  777. expect(wire.collectDiagnostic()).toBe(
  778. 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode',
  779. )
  780. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  781. child.peer.send(
  782. {
  783. method: 'item/completed',
  784. params: {
  785. threadId: 'thread-1',
  786. turnId: 'turn-1',
  787. item: {
  788. type: 'fileChange',
  789. status: 'declined',
  790. patch: 'SECRET_TOKEN in /private/secret.txt',
  791. },
  792. },
  793. },
  794. turnCompleted('failed', 'turn-1', 'thread-1', {
  795. message: 'SECRET_TOKEN in /private/secret.txt',
  796. codexErrorInfo: 'other',
  797. }),
  798. )
  799. await expect(result).rejects.toThrow('status failed')
  800. expect(wire.collectDiagnostic()).toBe(
  801. 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode',
  802. )
  803. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  804. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  805. wire.close()
  806. })
  807. it('recognizes large, split, and ordered stderr signatures without retaining raw text', () => {
  808. const first = fakeChild()
  809. const largeWire = new CodexAppServerWire(
  810. first.handle.stdout!,
  811. first.handle.stdin!,
  812. 'never',
  813. )
  814. largeWire.observeStderr(
  815. `SECRET_TOKEN approval policy is Never; reject command${'x'.repeat(2_048)}`,
  816. )
  817. expect(largeWire.collectDiagnostic()).toBe(
  818. 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  819. )
  820. expect(largeWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  821. const second = fakeChild()
  822. const splitWire = new CodexAppServerWire(
  823. second.handle.stdout!,
  824. second.handle.stdin!,
  825. 'never',
  826. )
  827. splitWire.observeStderr('SECRET_TOKEN approval policy is Ne')
  828. splitWire.observeStderr('ver; reject command — /private/secret.txt')
  829. expect(splitWire.collectDiagnostic()).toBe(
  830. 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  831. )
  832. expect(splitWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  833. expect(splitWire.collectDiagnostic()).not.toContain('/private/secret.txt')
  834. const third = fakeChild()
  835. const orderedWire = new CodexAppServerWire(
  836. third.handle.stdout!,
  837. third.handle.stdin!,
  838. 'dangerously-bypass-approvals-and-sandbox',
  839. )
  840. orderedWire.observeStderr(
  841. 'approval policy is Never; reject command; recorded sandbox violation: path=/private/secret.txt',
  842. )
  843. expect(orderedWire.collectDiagnostic()).toBe(
  844. 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: sandbox execution; decision: failed): Codex reported a sandbox violation',
  845. )
  846. expect(orderedWire.collectDiagnostic()).not.toContain('/private/secret.txt')
  847. })
  848. it('does not reapply an old stderr signature after a newer request diagnostic', async () => {
  849. const { child, wire } = await initializeWire()
  850. wire.observeStderr('recorded sandbox violation:')
  851. const result = wire.runTurn(['task'], new AbortController().signal)
  852. const turnStart = await child.peer.nextMethod('turn/start')
  853. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  854. await nextTask()
  855. child.peer.send({
  856. id: 'file-approval',
  857. method: 'item/fileChange/requestApproval',
  858. params: {
  859. threadId: 'thread-1',
  860. turnId: 'turn-1',
  861. availableDecisions: ['decline'],
  862. },
  863. })
  864. await child.peer.nextResponse('file-approval')
  865. expect(wire.collectDiagnostic()).toContain('request: file approval')
  866. wire.observeStderr('later benign stderr')
  867. expect(wire.collectDiagnostic()).toContain('request: file approval')
  868. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  869. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  870. wire.close()
  871. })
  872. it('keeps a newer request diagnostic after replaying an older early item', async () => {
  873. const { child, wire } = await initializeWire()
  874. const result = wire.runTurn(['task'], new AbortController().signal)
  875. const turnStart = await child.peer.nextMethod('turn/start')
  876. child.peer.send({
  877. method: 'item/completed',
  878. params: {
  879. threadId: 'thread-1',
  880. turnId: 'turn-1',
  881. item: { type: 'fileChange', status: 'declined' },
  882. },
  883. })
  884. await nextTask()
  885. child.peer.send({
  886. id: 'newer-command-request',
  887. method: 'item/commandExecution/requestApproval',
  888. params: {
  889. threadId: 'thread-1',
  890. turnId: 'turn-1',
  891. availableDecisions: ['cancel'],
  892. },
  893. })
  894. await child.peer.nextResponse('newer-command-request')
  895. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  896. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  897. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  898. expect(wire.collectDiagnostic()).toContain('request: command approval')
  899. wire.close()
  900. })
  901. it('keeps a newer stderr fact after replaying an older early terminal', async () => {
  902. hostStderrWrite.capture = true
  903. hostStderrWrite.chunks.length = 0
  904. const { child, wire } = await initializeWire()
  905. const result = wire.runTurn(['task'], new AbortController().signal)
  906. const turnStart = await child.peer.nextMethod('turn/start')
  907. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  908. message: 'sandbox failure',
  909. codexErrorInfo: 'sandboxError',
  910. }))
  911. await nextTask()
  912. wire.observeStderr('approval policy is Never; reject command')
  913. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  914. await expect(result).rejects.toThrow('sandboxError')
  915. expect(wire.collectDiagnostic()).toContain('request: command execution')
  916. wire.close()
  917. hostStderrWrite.capture = false
  918. })
  919. it('fails the run on unknown requests or wrong request association', async () => {
  920. for (const serverRequest of [
  921. {
  922. id: 'unknown',
  923. method: 'future/request',
  924. params: { threadId: 'thread-1', turnId: 'turn-1' },
  925. },
  926. {
  927. id: 'approval',
  928. method: 'item/commandExecution/requestApproval',
  929. params: {
  930. threadId: 'thread-1',
  931. turnId: 'turn-1',
  932. availableDecisions: ['accept'],
  933. },
  934. },
  935. {
  936. id: 'malformed-approval',
  937. method: 'item/fileChange/requestApproval',
  938. params: {
  939. threadId: 'thread-1',
  940. turnId: 'turn-1',
  941. availableDecisions: 'decline',
  942. },
  943. },
  944. {
  945. id: 'thread',
  946. method: 'item/fileChange/requestApproval',
  947. params: { threadId: 'thread-2', turnId: 'turn-1' },
  948. },
  949. {
  950. id: 'turn',
  951. method: 'item/fileChange/requestApproval',
  952. params: { threadId: 'thread-1', turnId: 'turn-2' },
  953. },
  954. ]) {
  955. const { child, wire } = await initializeWire()
  956. const result = wire.runTurn(['task'], new AbortController().signal)
  957. const turnStart = await child.peer.nextMethod('turn/start')
  958. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  959. await nextTask()
  960. child.peer.send(serverRequest)
  961. const response = await child.peer.nextResponse(serverRequest.id)
  962. expect(response.error).toMatchObject({ code: -32603 })
  963. await expect(result).rejects.toThrow()
  964. wire.close()
  965. }
  966. })
  967. it('rejects conflicting early turn identities before accepting output', async () => {
  968. const { child, wire } = await initializeWire()
  969. const result = wire.runTurn(['task'], new AbortController().signal)
  970. const turnStart = await child.peer.nextMethod('turn/start')
  971. child.peer.send({
  972. method: 'turn/started',
  973. params: { threadId: 'thread-1', turn: { id: 'turn-early' } },
  974. })
  975. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  976. await expect(result).rejects.toThrow('did not match the active turn')
  977. wire.close()
  978. })
  979. it('does not retain a diagnostic from a mismatched early item', async () => {
  980. const { child, wire } = await initializeWire()
  981. const result = wire.runTurn(['task'], new AbortController().signal)
  982. const turnStart = await child.peer.nextMethod('turn/start')
  983. child.peer.send({
  984. method: 'item/completed',
  985. params: {
  986. threadId: 'thread-1',
  987. turnId: 'turn-early',
  988. item: { type: 'fileChange', status: 'declined' },
  989. },
  990. })
  991. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  992. await expect(result).rejects.toThrow('did not match the active turn')
  993. expect(wire.collectDiagnostic()).toBeUndefined()
  994. wire.close()
  995. })
  996. it('does not retain a diagnostic from a mismatched provisional request', async () => {
  997. const { child, wire } = await initializeWire()
  998. const result = wire.runTurn(['task'], new AbortController().signal)
  999. const turnStart = await child.peer.nextMethod('turn/start')
  1000. child.peer.send({
  1001. id: 'provisional-approval',
  1002. method: 'item/commandExecution/requestApproval',
  1003. params: {
  1004. threadId: 'thread-1',
  1005. turnId: 'turn-early',
  1006. availableDecisions: ['cancel'],
  1007. },
  1008. })
  1009. await child.peer.nextResponse('provisional-approval')
  1010. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1011. await expect(result).rejects.toThrow('did not match the active turn')
  1012. expect(wire.collectDiagnostic()).toBeUndefined()
  1013. wire.close()
  1014. })
  1015. it('rejects conflicting early notifications and requests before turn/start', async () => {
  1016. {
  1017. const { child, wire } = await initializeWire()
  1018. child.peer.send({
  1019. id: 'too-early',
  1020. method: 'item/fileChange/requestApproval',
  1021. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1022. })
  1023. const response = await child.peer.nextResponse('too-early')
  1024. expect(response.error).toMatchObject({ code: -32603 })
  1025. wire.close()
  1026. }
  1027. {
  1028. const { child, wire } = await initializeWire()
  1029. const result = wire.runTurn(['task'], new AbortController().signal)
  1030. await child.peer.nextMethod('turn/start')
  1031. child.peer.send(
  1032. {
  1033. method: 'turn/started',
  1034. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  1035. },
  1036. agentMessage('wrong', 'final_answer', 'turn-2'),
  1037. )
  1038. await expect(result).rejects.toThrow('conflicting turns')
  1039. wire.close()
  1040. }
  1041. })
  1042. it('interrupts only an active open turn and contains remote interrupt failure', async () => {
  1043. const { child, wire } = await initializeWire()
  1044. wire.interrupt()
  1045. const result = wire.runTurn(['task'], new AbortController().signal)
  1046. const turnStart = await child.peer.nextMethod('turn/start')
  1047. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1048. await nextTask()
  1049. wire.interrupt()
  1050. const interrupt = await child.peer.nextMethod('turn/interrupt')
  1051. expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' })
  1052. child.peer.send({
  1053. id: interrupt.id,
  1054. error: { code: -32000, message: 'already done' },
  1055. })
  1056. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1057. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1058. wire.close()
  1059. wire.interrupt()
  1060. })
  1061. it('ignores unrelated and out-of-window notifications', async () => {
  1062. const { child, wire } = await initializeWire()
  1063. child.peer.send(
  1064. {
  1065. method: 'turn/started',
  1066. params: { threadId: 'thread-2', turn: { id: 'turn-other' } },
  1067. },
  1068. {
  1069. method: 'turn/started',
  1070. params: { threadId: 'thread-1', turn: { id: 'turn-before' } },
  1071. },
  1072. agentMessage('before', 'final_answer'),
  1073. { method: 'future/notification', params: {} },
  1074. turnCompleted('completed'),
  1075. turnCompleted('completed', 'turn-other', 'thread-2'),
  1076. )
  1077. await nextTask()
  1078. const result = wire.runTurn(['task'], new AbortController().signal)
  1079. const turnStart = await child.peer.nextMethod('turn/start')
  1080. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1081. await nextTask()
  1082. child.peer.send(
  1083. agentMessage('wrong turn', 'final_answer', 'turn-2'),
  1084. turnCompleted('completed', 'turn-2'),
  1085. agentMessage('answer', 'final_answer'),
  1086. turnCompleted('completed'),
  1087. )
  1088. await expect(result).resolves.toEqual({
  1089. output: [{ type: 'text', text: 'answer' }],
  1090. stopReason: 'completed',
  1091. })
  1092. wire.close()
  1093. })
  1094. it('rejects pending work on abort, EOF, and stream error', async () => {
  1095. {
  1096. const child = fakeChild()
  1097. const wire = defaultWire(child)
  1098. wire.start()
  1099. const controller = new AbortController()
  1100. controller.abort('pre-aborted')
  1101. await expect(wire.initialize(controller.signal))
  1102. .rejects.toThrow('app-server request aborted: pre-aborted')
  1103. wire.close()
  1104. }
  1105. {
  1106. const child = fakeChild()
  1107. const wire = defaultWire(child)
  1108. wire.start()
  1109. const controller = new AbortController()
  1110. const pending = wire.initialize(controller.signal)
  1111. await child.peer.nextMethod('initialize')
  1112. controller.abort(new Error('cancel initialize'))
  1113. await expect(pending).rejects.toThrow('cancel initialize')
  1114. wire.close()
  1115. }
  1116. {
  1117. const child = fakeChild()
  1118. const wire = defaultWire(child)
  1119. wire.start()
  1120. const pending = wire.initialize(new AbortController().signal)
  1121. await child.peer.nextMethod('initialize')
  1122. child.fromChild.end()
  1123. await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/)
  1124. wire.close()
  1125. }
  1126. {
  1127. const child = fakeChild()
  1128. const wire = defaultWire(child)
  1129. wire.start()
  1130. const pending = wire.initialize(new AbortController().signal)
  1131. await child.peer.nextMethod('initialize')
  1132. child.fromChild.emit('error', new Error('stdout broke'))
  1133. await expect(pending).rejects.toThrow('stdout broke')
  1134. wire.close()
  1135. }
  1136. {
  1137. const child = fakeChild()
  1138. const wire = defaultWire(child)
  1139. wire.start()
  1140. const pending = wire.initialize(new AbortController().signal)
  1141. await child.peer.nextMethod('initialize')
  1142. child.toChild.emit('error', new Error('stdin broke'))
  1143. await expect(pending).rejects.toThrow('stdin broke')
  1144. wire.close()
  1145. child.toChild.emit('error', new Error('late stdin close'))
  1146. }
  1147. })
  1148. })
  1149. describe('run lifecycle and quiescence', () => {
  1150. it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => {
  1151. const child = fakeChild()
  1152. const spawn = vi.fn(() => child.handle)
  1153. const starting = startCodexRun(
  1154. request([{ type: 'text', text: 'task' }]),
  1155. runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }),
  1156. )
  1157. let published = false
  1158. void starting.then(() => { published = true })
  1159. const initialize = await child.peer.nextMethod('initialize')
  1160. expect(published).toBe(false)
  1161. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1162. await child.peer.nextMethod('initialized')
  1163. const threadStart = await child.peer.nextMethod('thread/start')
  1164. expect(published).toBe(false)
  1165. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1166. const run = await starting
  1167. expect(spawn).toHaveBeenCalledWith({
  1168. argv: codexAppServerArgv(),
  1169. cwd: process.cwd(),
  1170. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  1171. graceMs: DEFAULT_DISPOSE_GRACE_MS,
  1172. env: { OPENAI_API_KEY: 'fake' },
  1173. })
  1174. expect(run.localAgent).toBeUndefined()
  1175. const turnStart = await child.peer.nextMethod('turn/start')
  1176. child.peer.send(
  1177. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  1178. agentMessage('answer', 'final_answer'),
  1179. turnCompleted('completed'),
  1180. )
  1181. await expect(run.result).resolves.toEqual({
  1182. output: [{ type: 'text', text: 'answer' }],
  1183. stopReason: 'completed',
  1184. })
  1185. const disposal = run.dispose()
  1186. expect(run.dispose()).toBe(disposal)
  1187. await disposal
  1188. await nextTask()
  1189. expect(child.terminate).toHaveBeenCalledTimes(1)
  1190. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1191. })
  1192. it('settles local cancellation immediately and sends best-effort interrupt', async () => {
  1193. const controller = new AbortController()
  1194. const { child, run, turnStart } = await publishRun(
  1195. fakeChild(),
  1196. controller.signal,
  1197. )
  1198. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1199. await nextTask()
  1200. controller.abort(new Error('stop'))
  1201. await expect(run.result).resolves.toEqual({
  1202. output: [],
  1203. stopReason: 'aborted',
  1204. })
  1205. expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({
  1206. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1207. })
  1208. await run.dispose()
  1209. })
  1210. it('flattens child exit and protocol failures after publication', async () => {
  1211. const errors: string[] = []
  1212. {
  1213. const child = fakeChild({ exitOnTerminate: false })
  1214. const { run } = await publishRun(child, undefined, {
  1215. onError: (error) => { errors.push(error.message) },
  1216. })
  1217. child.settle({ exitCode: 9, signal: null })
  1218. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  1219. expect(errors.at(-1)).toContain('code 9')
  1220. await run.dispose().catch(() => {})
  1221. }
  1222. {
  1223. const child = fakeChild()
  1224. const { run, turnStart } = await publishRun(child, undefined, {
  1225. onError: () => { throw new Error('diagnostic sink') },
  1226. })
  1227. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1228. child.fromChild.end()
  1229. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  1230. await run.dispose()
  1231. }
  1232. {
  1233. const child = fakeChild()
  1234. const { run, turnStart } = await publishRun(child)
  1235. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1236. child.stderr.emit('error', new Error('stderr broke'))
  1237. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1238. await expect(run.result).resolves.toEqual({
  1239. output: [{ type: 'text', text: 'answer' }],
  1240. stopReason: 'completed',
  1241. })
  1242. await run.dispose()
  1243. expect(child.stderr.listenerCount('error')).toBe(0)
  1244. }
  1245. })
  1246. it('attaches a safe permission diagnostic when a published run fails', async () => {
  1247. const { child, run, turnStart } = await publishRun()
  1248. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1249. await nextTask()
  1250. child.peer.send({
  1251. id: 'approval-diagnostic',
  1252. method: 'item/commandExecution/requestApproval',
  1253. params: {
  1254. threadId: 'thread-1',
  1255. turnId: 'turn-1',
  1256. availableDecisions: ['cancel'],
  1257. command: 'cat /private/secret.txt',
  1258. },
  1259. })
  1260. expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({
  1261. result: { decision: 'cancel' },
  1262. })
  1263. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1264. message: 'SECRET_TOKEN in /private/secret.txt',
  1265. codexErrorInfo: 'other',
  1266. }))
  1267. await expect(run.result).resolves.toEqual({
  1268. output: [],
  1269. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1270. stopReason: 'error',
  1271. })
  1272. await run.dispose()
  1273. })
  1274. it('drains queued stderr before settling a failed published run', async () => {
  1275. hostStderrWrite.capture = true
  1276. hostStderrWrite.chunks.length = 0
  1277. const { child, run, turnStart } = await publishRun()
  1278. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1279. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1280. message: 'fixture terminal failure',
  1281. codexErrorInfo: 'badRequest',
  1282. }))
  1283. setImmediate(() => {
  1284. child.stderr.write('approval policy is Never; reject command')
  1285. })
  1286. await expect(run.result).resolves.toEqual({
  1287. output: [],
  1288. diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  1289. stopReason: 'error',
  1290. })
  1291. await run.dispose()
  1292. hostStderrWrite.capture = false
  1293. })
  1294. it('forwards stderr while extracting only a fixed safe permission signature', async () => {
  1295. const child = fakeChild()
  1296. hostStderrWrite.capture = true
  1297. hostStderrWrite.chunks.length = 0
  1298. const { run, turnStart } = await publishRun(child)
  1299. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1300. child.stderr.write('SECRET_TOKEN approval policy is Ne')
  1301. child.stderr.write('ver; reject command — /private/secret.txt')
  1302. child.stderr.emit('data', 'string stderr suffix')
  1303. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1304. message: 'fixture terminal failure',
  1305. codexErrorInfo: 'badRequest',
  1306. }))
  1307. await expect(run.result).resolves.toEqual({
  1308. output: [],
  1309. diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  1310. stopReason: 'error',
  1311. })
  1312. expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN')
  1313. expect(hostStderrWrite.chunks).toHaveLength(3)
  1314. await run.dispose()
  1315. expect(child.stderr.listenerCount('data')).toBe(0)
  1316. hostStderrWrite.capture = false
  1317. })
  1318. it('contains host stderr write failures without changing run settlement', async () => {
  1319. const child = fakeChild()
  1320. hostStderrWrite.capture = true
  1321. hostStderrWrite.failNext = true
  1322. const { run, turnStart } = await publishRun(child)
  1323. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1324. child.stderr.write('approval policy is Never; reject command')
  1325. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1326. message: 'fixture terminal failure',
  1327. codexErrorInfo: 'badRequest',
  1328. }))
  1329. await expect(run.result).resolves.toEqual({
  1330. output: [],
  1331. diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  1332. stopReason: 'error',
  1333. })
  1334. await run.dispose()
  1335. hostStderrWrite.capture = false
  1336. })
  1337. it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
  1338. const controller = new AbortController()
  1339. controller.abort()
  1340. const spawn = vi.fn()
  1341. await expect(startCodexRun(
  1342. request(undefined, controller.signal),
  1343. {
  1344. cwd: process.cwd(),
  1345. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1346. env: {},
  1347. disposeGraceMs: 10,
  1348. spawn,
  1349. },
  1350. )).rejects.toThrow('aborted before app-server startup')
  1351. expect(spawn).not.toHaveBeenCalled()
  1352. const child = fakeChild()
  1353. const starting = startCodexRun(request(), runSpec(child))
  1354. const initialize = await child.peer.nextMethod('initialize')
  1355. child.peer.respond(initialize, null)
  1356. await expect(starting).rejects.toThrow('invalid initialize response')
  1357. expect(child.terminate).toHaveBeenCalledTimes(1)
  1358. const stderrChild = fakeChild()
  1359. const stderrStarting = startCodexRun(request(), runSpec(stderrChild))
  1360. const stderrInitialize = await stderrChild.peer.nextMethod('initialize')
  1361. stderrChild.stderr.emit('error', new Error('startup stderr broke'))
  1362. stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.147.0' })
  1363. await stderrChild.peer.nextMethod('initialized')
  1364. const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start')
  1365. stderrChild.peer.respond(stderrThreadStart, {
  1366. thread: { id: 'thread-1', ephemeral: true },
  1367. })
  1368. const stderrRun = await stderrStarting
  1369. const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start')
  1370. stderrChild.peer.send(
  1371. { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } },
  1372. agentMessage('answer', 'final_answer'),
  1373. turnCompleted('completed'),
  1374. )
  1375. await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' })
  1376. await stderrRun.dispose()
  1377. expect(stderrChild.stderr.listenerCount('error')).toBe(0)
  1378. })
  1379. it('rolls back an abort that wins immediately after thread creation', async () => {
  1380. const controller = new AbortController()
  1381. const child = fakeChild()
  1382. const starting = startCodexRun(
  1383. request(undefined, controller.signal),
  1384. runSpec(child),
  1385. )
  1386. const initialize = await child.peer.nextMethod('initialize')
  1387. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1388. await child.peer.nextMethod('initialized')
  1389. const threadStart = await child.peer.nextMethod('thread/start')
  1390. expect(threadStart.params).toEqual({
  1391. cwd: process.cwd(),
  1392. ephemeral: true,
  1393. approvalPolicy: 'never',
  1394. })
  1395. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1396. controller.abort('startup race')
  1397. await expect(starting).rejects.toThrow('aborted before run publication')
  1398. expect(child.terminate).toHaveBeenCalledTimes(1)
  1399. })
  1400. it('rolls back a subprocess done rejection during startup', async () => {
  1401. const child = fakeChild({ doneError: new Error('spawn observer failed') })
  1402. const error: unknown = await startCodexRun(request(), runSpec(child)).then(
  1403. () => undefined,
  1404. (failure: unknown) => failure,
  1405. )
  1406. expect(error).toBeInstanceOf(AggregateError)
  1407. if (!(error instanceof AggregateError)) {
  1408. throw new Error('expected startup and rollback failures')
  1409. }
  1410. expect(error.errors).toEqual([
  1411. expect.objectContaining({ message: 'spawn observer failed' }),
  1412. expect.objectContaining({ message: 'spawn observer failed' }),
  1413. ])
  1414. expect(child.terminate).toHaveBeenCalledTimes(1)
  1415. })
  1416. it('keeps overlapping runs isolated', async () => {
  1417. const initialStderrListeners = {
  1418. error: process.stderr.listenerCount('error'),
  1419. unpipe: process.stderr.listenerCount('unpipe'),
  1420. close: process.stderr.listenerCount('close'),
  1421. finish: process.stderr.listenerCount('finish'),
  1422. }
  1423. const runs = await Promise.all(
  1424. Array.from({ length: 6 }, () => publishRun(fakeChild())),
  1425. )
  1426. expect({
  1427. error: process.stderr.listenerCount('error'),
  1428. unpipe: process.stderr.listenerCount('unpipe'),
  1429. close: process.stderr.listenerCount('close'),
  1430. finish: process.stderr.listenerCount('finish'),
  1431. }).toEqual(initialStderrListeners)
  1432. for (const [index, entry] of runs.entries()) {
  1433. const id = `turn-${index + 1}`
  1434. entry.child.peer.send(
  1435. { id: entry.turnStart.id, result: { turn: { id } } },
  1436. agentMessage(`answer-${index + 1}`, 'final_answer', id),
  1437. turnCompleted('completed', id),
  1438. )
  1439. }
  1440. const results = await Promise.all(runs.map(entry => entry.run.result))
  1441. expect(results.map(result => result.output)).toEqual(
  1442. Array.from({ length: 6 }, (_, index) => [
  1443. { type: 'text', text: `answer-${index + 1}` },
  1444. ]),
  1445. )
  1446. expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id)
  1447. await Promise.all(runs.map(entry => entry.run.dispose()))
  1448. })
  1449. it('isolates permission modes and diagnostics across overlapping runs', async () => {
  1450. const first = await publishRun(fakeChild(), undefined, {
  1451. permissionMode: 'never',
  1452. })
  1453. const second = await publishRun(fakeChild(), undefined, {
  1454. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  1455. })
  1456. first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } })
  1457. second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } })
  1458. await nextTask()
  1459. first.child.peer.send({
  1460. id: 'never-approval',
  1461. method: 'item/commandExecution/requestApproval',
  1462. params: {
  1463. threadId: 'thread-1',
  1464. turnId: 'turn-never',
  1465. availableDecisions: ['cancel'],
  1466. },
  1467. })
  1468. second.child.peer.send({
  1469. id: 'bypass-elicitation',
  1470. method: 'mcpServer/elicitation/request',
  1471. params: { threadId: 'thread-1', turnId: null },
  1472. })
  1473. await Promise.all([
  1474. first.child.peer.nextResponse('never-approval'),
  1475. second.child.peer.nextResponse('bypass-elicitation'),
  1476. ])
  1477. first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', {
  1478. message: 'first failure',
  1479. codexErrorInfo: 'other',
  1480. }))
  1481. second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', {
  1482. message: 'second failure',
  1483. codexErrorInfo: 'other',
  1484. }))
  1485. await expect(first.run.result).resolves.toEqual({
  1486. output: [],
  1487. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1488. stopReason: 'error',
  1489. })
  1490. await expect(second.run.result).resolves.toEqual({
  1491. output: [],
  1492. diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  1493. stopReason: 'error',
  1494. })
  1495. await Promise.all([first.run.dispose(), second.run.dispose()])
  1496. })
  1497. it('uses the registered provider config and logs flattened errors', async () => {
  1498. const ctx = new Context()
  1499. await ctx.plugin(SubagentRuntime)
  1500. await ctx.plugin(LocalSubprocessRuntime)
  1501. const child = fakeChild()
  1502. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  1503. const warnings: string[] = []
  1504. ctx.logger.warn = ((message: unknown) => {
  1505. warnings.push(String(message))
  1506. }) as typeof ctx.logger.warn
  1507. await ctx.plugin(codex, {
  1508. env: { OPENAI_API_KEY: 'fake' },
  1509. permissionMode: 'approve-for-me',
  1510. disposeGraceMs: 25,
  1511. })
  1512. const starting = ctx.subagents.start('codex', {
  1513. prompt: [{ type: 'text', text: 'task' }],
  1514. parent: fakeParent,
  1515. signal: new AbortController().signal,
  1516. })
  1517. const initialize = await child.peer.nextMethod('initialize')
  1518. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1519. await child.peer.nextMethod('initialized')
  1520. const threadStart = await child.peer.nextMethod('thread/start')
  1521. expect(threadStart.params).toEqual({
  1522. cwd: process.cwd(),
  1523. ephemeral: true,
  1524. approvalPolicy: 'on-request',
  1525. approvalsReviewer: 'auto_review',
  1526. sandbox: 'workspace-write',
  1527. })
  1528. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1529. const run = await starting
  1530. const turnStart = await child.peer.nextMethod('turn/start')
  1531. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1532. await nextTask()
  1533. child.peer.send({
  1534. id: 'provider-approval',
  1535. method: 'item/commandExecution/requestApproval',
  1536. params: {
  1537. threadId: 'thread-1',
  1538. turnId: 'turn-1',
  1539. availableDecisions: ['cancel'],
  1540. command: 'cat /private/secret.txt',
  1541. },
  1542. })
  1543. await child.peer.nextResponse('provider-approval')
  1544. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1545. message: 'SECRET_TOKEN in /private/secret.txt',
  1546. codexErrorInfo: 'other',
  1547. }))
  1548. await expect(run.result).resolves.toEqual({
  1549. output: [],
  1550. diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  1551. stopReason: 'error',
  1552. })
  1553. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  1554. argv: ['codex', 'app-server', '--stdio'],
  1555. env: { OPENAI_API_KEY: 'fake' },
  1556. graceMs: 25,
  1557. cwd: process.cwd(),
  1558. }))
  1559. expect(warnings).toEqual([
  1560. expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'),
  1561. ])
  1562. expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
  1563. expect(warnings.join('\n')).not.toContain('/private/secret.txt')
  1564. await run.dispose()
  1565. await ctx.fiber.dispose()
  1566. })
  1567. })
  1568. describe('disposeCodexChild', () => {
  1569. it('closes stdin, terminates, and waits for the managed tree', async () => {
  1570. const child = fakeChild()
  1571. const wire = defaultWire(child)
  1572. const end = vi.spyOn(child.toChild, 'end')
  1573. await disposeCodexChild(wire, child.handle)
  1574. expect(end).toHaveBeenCalled()
  1575. expect(child.terminate).toHaveBeenCalledTimes(1)
  1576. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1577. expect(child.waitForExit).toHaveBeenCalledWith()
  1578. })
  1579. it('does not finish disposal before the managed tree exits', async () => {
  1580. const child = fakeChild({ exitOnTerminate: false })
  1581. const wire = defaultWire(child)
  1582. let disposed = false
  1583. const disposal = disposeCodexChild(wire, child.handle).then(() => {
  1584. disposed = true
  1585. })
  1586. await new Promise<void>((resolve) => { setImmediate(resolve) })
  1587. expect(disposed).toBe(false)
  1588. child.settle()
  1589. await disposal
  1590. expect(disposed).toBe(true)
  1591. })
  1592. it('contains a concurrently closed stdin error', async () => {
  1593. const child = fakeChild()
  1594. const wire = defaultWire(child)
  1595. vi.spyOn(child.toChild, 'end').mockImplementation(() => {
  1596. throw new Error('already closed')
  1597. })
  1598. await expect(disposeCodexChild(wire, child.handle))
  1599. .resolves.toBeUndefined()
  1600. })
  1601. it('handles a spawn-level failure with no process tree', async () => {
  1602. const child = fakeChild({
  1603. pid: -1,
  1604. doneError: new Error('spawn failed'),
  1605. })
  1606. const wire = defaultWire(child)
  1607. await expect(disposeCodexChild(wire, child.handle))
  1608. .resolves.toBeUndefined()
  1609. expect(child.terminate).not.toHaveBeenCalled()
  1610. expect(child.waitForExit).not.toHaveBeenCalled()
  1611. })
  1612. it('reports direct-child observer failure and accepts absent stdin', async () => {
  1613. {
  1614. const child = fakeChild({
  1615. doneError: new Error('close observer failed'),
  1616. })
  1617. const wire = defaultWire(child)
  1618. await expect(disposeCodexChild(wire, child.handle))
  1619. .rejects.toThrow('close observer failed')
  1620. }
  1621. {
  1622. const child = fakeChild()
  1623. const handle = { ...child.handle, stdin: undefined }
  1624. const wire = defaultWire(child)
  1625. await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
  1626. }
  1627. })
  1628. })