subagent-codex.spec.ts 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058
  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. readonly waitForExitError?: Error
  118. }
  119. interface FakeChild {
  120. readonly handle: SubprocessHandle
  121. readonly peer: ProtocolPeer
  122. readonly fromChild: PassThrough
  123. readonly toChild: PassThrough
  124. readonly stderr: PassThrough
  125. readonly settle: (outcome?: SubprocessOutcome) => void
  126. readonly fail: (error: Error) => void
  127. readonly terminate: () => void
  128. readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
  129. }
  130. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  131. const fromChild = new PassThrough()
  132. const toChild = new PassThrough()
  133. const stderr = new PassThrough()
  134. const peer = new ProtocolPeer(toChild, fromChild)
  135. let exited = false
  136. let resolveDone!: (outcome: SubprocessOutcome) => void
  137. let rejectDone!: (error: Error) => void
  138. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  139. resolveDone = resolve
  140. rejectDone = reject
  141. })
  142. const settle = (
  143. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  144. ): void => {
  145. if (exited) return
  146. exited = true
  147. resolveDone(outcome)
  148. }
  149. const fail = (error: Error): void => {
  150. if (exited) return
  151. exited = true
  152. rejectDone(error)
  153. }
  154. if (options.doneError !== undefined) fail(options.doneError)
  155. const terminate = vi.fn(() => {
  156. if (options.exitOnTerminate !== false) settle()
  157. })
  158. const waitForExit = vi.fn(async (signal?: AbortSignal) => {
  159. if (options.waitForExitError !== undefined) {
  160. throw options.waitForExitError
  161. }
  162. if (exited) return true
  163. if (signal === undefined) {
  164. await done.catch(() => {})
  165. return true
  166. }
  167. return await new Promise<boolean>((resolve) => {
  168. const onAbort = (): void => { resolve(false) }
  169. signal.addEventListener('abort', onAbort, { once: true })
  170. void done.then(
  171. () => {
  172. signal.removeEventListener('abort', onAbort)
  173. resolve(true)
  174. },
  175. () => {
  176. signal.removeEventListener('abort', onAbort)
  177. resolve(true)
  178. },
  179. )
  180. })
  181. })
  182. const handle: SubprocessHandle = {
  183. pid: options.pid ?? 1234,
  184. stdin: toChild,
  185. stdout: fromChild,
  186. stderr,
  187. collected: {},
  188. done,
  189. terminate,
  190. waitForExit,
  191. }
  192. return {
  193. handle,
  194. peer,
  195. fromChild,
  196. toChild,
  197. stderr,
  198. settle,
  199. fail,
  200. terminate,
  201. waitForExit,
  202. }
  203. }
  204. function defaultWire(child: FakeChild): CodexAppServerWire {
  205. return new CodexAppServerWire(
  206. child.handle.stdout!,
  207. child.handle.stdin!,
  208. DEFAULT_CODEX_PERMISSION_MODE,
  209. )
  210. }
  211. function runSpec(
  212. child: FakeChild,
  213. overrides: Partial<CodexRunSpec> = {},
  214. ): CodexRunSpec {
  215. return {
  216. cwd: process.cwd(),
  217. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  218. env: {},
  219. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  220. spawn: () => child.handle,
  221. ...overrides,
  222. }
  223. }
  224. async function initializeWire(): Promise<{
  225. readonly child: FakeChild
  226. readonly wire: CodexAppServerWire
  227. }> {
  228. const child = fakeChild()
  229. const wire = defaultWire(child)
  230. wire.start()
  231. const initializing = wire.initialize(new AbortController().signal)
  232. const initialize = await child.peer.nextMethod('initialize')
  233. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  234. await initializing
  235. expect(await child.peer.nextMethod('initialized')).toEqual({
  236. jsonrpc: '2.0',
  237. method: 'initialized',
  238. })
  239. const starting = wire.startThread(process.cwd(), new AbortController().signal)
  240. const threadStart = await child.peer.nextMethod('thread/start')
  241. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  242. await starting
  243. return { child, wire }
  244. }
  245. async function publishRun(
  246. child = fakeChild(),
  247. signal = new AbortController().signal,
  248. specOverrides: Partial<CodexRunSpec> = {},
  249. ) {
  250. const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
  251. const initialize = await child.peer.nextMethod('initialize')
  252. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  253. await child.peer.nextMethod('initialized')
  254. const threadStart = await child.peer.nextMethod('thread/start')
  255. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  256. const run = await starting
  257. const turnStart = await child.peer.nextMethod('turn/start')
  258. return { child, run, turnStart }
  259. }
  260. function agentMessage(
  261. text: unknown,
  262. phase: unknown,
  263. turnId = 'turn-1',
  264. threadId = 'thread-1',
  265. ): JsonObject {
  266. return {
  267. method: 'item/completed',
  268. params: {
  269. threadId,
  270. turnId,
  271. item: { type: 'agentMessage', text, phase },
  272. },
  273. }
  274. }
  275. function turnCompleted(
  276. status: unknown,
  277. turnId = 'turn-1',
  278. threadId = 'thread-1',
  279. error: unknown = null,
  280. ): JsonObject {
  281. return {
  282. method: 'turn/completed',
  283. params: {
  284. threadId,
  285. turn: { id: turnId, status, error },
  286. },
  287. }
  288. }
  289. function expectedFailureDiagnostic(
  290. stage: 'initialize' | 'thread-start' | 'turn-start' | 'turn' | 'process' | 'teardown',
  291. category: string,
  292. options: {
  293. readonly httpStatus?: number
  294. readonly outcome?: Partial<SubprocessOutcome>
  295. } = {},
  296. ): string {
  297. const fields = [
  298. 'product: Codex',
  299. `stage: ${stage}`,
  300. `category: ${category}`,
  301. ]
  302. if (options.httpStatus !== undefined) {
  303. fields.push(`HTTP status: ${options.httpStatus}`)
  304. }
  305. if (
  306. options.outcome?.exitCode !== null
  307. && options.outcome?.exitCode !== undefined
  308. ) {
  309. fields.push(`exit code: ${options.outcome.exitCode}`)
  310. }
  311. if (
  312. options.outcome?.signal !== null
  313. && options.outcome?.signal !== undefined
  314. ) {
  315. fields.push(`signal: ${options.outcome.signal}`)
  316. }
  317. return `Product subagent failure (${fields.join('; ')})`
  318. }
  319. describe('task admission and package contracts', () => {
  320. it('keeps the app-server command fixed on POSIX and Windows', () => {
  321. expect(codexAppServerArgv('linux')).toEqual([
  322. 'codex', 'app-server', '--stdio',
  323. ])
  324. expect(codexAppServerArgv('win32')).toEqual([
  325. 'cmd.exe',
  326. '/d',
  327. '/s',
  328. '/c',
  329. 'codex',
  330. 'app-server',
  331. '--stdio',
  332. ])
  333. })
  334. it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
  335. expect(textTask([
  336. { type: 'text', text: 'one' },
  337. { type: 'text', text: 'two' },
  338. ])).toEqual(['one', 'two'])
  339. expect(() => textTask([])).toThrow('only text blocks')
  340. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  341. .toThrow('only text blocks')
  342. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  343. .toThrow('must not be empty')
  344. })
  345. it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
  346. const ctx = new Context()
  347. await ctx.plugin(SubagentRuntime)
  348. await ctx.plugin(LocalSubprocessRuntime)
  349. const fiber = await ctx.plugin(codex, {})
  350. const provider = ctx.subagents.getProvider('codex')!
  351. expect(provider).toMatchObject({
  352. name: 'codex',
  353. capabilities: {
  354. outputSchema: false,
  355. depthLimit: false,
  356. toolFilter: false,
  357. persona: false,
  358. },
  359. inheritsParentContext: false,
  360. })
  361. expect(ctx.subagents.list()).toEqual(['codex'])
  362. await fiber.dispose()
  363. expect(ctx.subagents.list()).toEqual([])
  364. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  365. await expect(ctx.plugin(codex, { disposeGraceMs }))
  366. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  367. }
  368. await expect(ctx.plugin(codex, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
  369. .rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
  370. await ctx.fiber.dispose()
  371. })
  372. it('accepts only the three fixed non-interactive permission modes', () => {
  373. expect(codex.Config({}).permissionMode).toBe(DEFAULT_CODEX_PERMISSION_MODE)
  374. for (const permissionMode of CODEX_PERMISSION_MODES) {
  375. expect(codex.Config({ permissionMode }).permissionMode).toBe(permissionMode)
  376. }
  377. for (const permissionMode of ['on-request', 'untrusted', 'future-mode']) {
  378. expect(() => codex.Config({ permissionMode } as never)).toThrow()
  379. }
  380. })
  381. it('resolves the safe permission default when apply is called directly', async () => {
  382. const ctx = new Context()
  383. await ctx.plugin(SubagentRuntime)
  384. await ctx.plugin(LocalSubprocessRuntime)
  385. codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
  386. expect(ctx.subagents.getProvider('codex')).toBeDefined()
  387. await ctx.fiber.dispose()
  388. })
  389. it.each([
  390. ['never', { approvalPolicy: 'never' }],
  391. ['approve-for-me', {
  392. approvalPolicy: 'on-request',
  393. approvalsReviewer: 'auto_review',
  394. sandbox: 'workspace-write',
  395. }],
  396. ['dangerously-bypass-approvals-and-sandbox', {
  397. approvalPolicy: 'never',
  398. sandbox: 'danger-full-access',
  399. }],
  400. ] as const)('maps %s to the official thread/start fields', async (permissionMode, expected) => {
  401. const child = fakeChild()
  402. const wire = new CodexAppServerWire(
  403. child.handle.stdout!,
  404. child.handle.stdin!,
  405. permissionMode,
  406. )
  407. wire.start()
  408. const initializing = wire.initialize(new AbortController().signal)
  409. const initialize = await child.peer.nextMethod('initialize')
  410. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  411. await initializing
  412. await child.peer.nextMethod('initialized')
  413. const starting = wire.startThread('/workspace', new AbortController().signal)
  414. const threadStart = await child.peer.nextMethod('thread/start')
  415. expect(threadStart.params).toEqual({
  416. cwd: '/workspace',
  417. ephemeral: true,
  418. ...expected,
  419. })
  420. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  421. await starting
  422. wire.close()
  423. })
  424. it('requires a parent session cwd without suggesting unsupported config', async () => {
  425. const ctx = new Context()
  426. await ctx.plugin(SubagentRuntime)
  427. await ctx.plugin(LocalSubprocessRuntime)
  428. const spawn = vi.spyOn(ctx.subprocess, 'spawn')
  429. await ctx.plugin(codex, {})
  430. await expect(ctx.subagents.start('codex', {
  431. prompt: [{ type: 'text', text: 'task' }],
  432. parent: {
  433. id: 'parent-without-cwd',
  434. session: { header: {} },
  435. } as unknown as Agent,
  436. signal: new AbortController().signal,
  437. })).rejects.toThrow(
  438. 'subagent-codex: no working directory for the child — delegate from a parent session that has one',
  439. )
  440. expect(spawn).not.toHaveBeenCalled()
  441. await ctx.fiber.dispose()
  442. })
  443. it('keeps the namespace export shape and package-owned empty invariant', async () => {
  444. expect('default' in codex).toBe(false)
  445. expect(codex.name).toBe('subagent-codex')
  446. expect(codex.inject).toEqual(['subagents', 'subprocess'])
  447. const loader = Object.create(Loader.prototype) as Loader
  448. expect(loader.unwrapExports(codex)).toBe(codex)
  449. const dispose = vi.fn()
  450. const register = vi.fn((
  451. _packageName: string,
  452. _installer: InvariantInstaller,
  453. ) => dispose)
  454. const ctx = { invariants: { register } } as unknown as Context
  455. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  456. expect(register).toHaveBeenCalledWith(
  457. '@deepseek-ai/dsh-subagent-codex',
  458. expect.any(Function),
  459. )
  460. const install = register.mock.calls[0]![1]
  461. await install(new Context(), (message) => { throw new Error(message) })
  462. expect(invariant.name).toBe('subagent-codex-invariant')
  463. expect(invariant.inject).toEqual(['invariants'])
  464. })
  465. })
  466. describe('CodexAppServerWire', () => {
  467. it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
  468. const child = fakeChild()
  469. const wire = defaultWire(child)
  470. expect(wire.collectOutput()).toEqual([])
  471. wire.start()
  472. const initializing = wire.initialize(new AbortController().signal)
  473. const initialize = await child.peer.nextMethod('initialize')
  474. expect(initialize.params).toEqual({
  475. clientInfo: {
  476. name: 'deepseek-harness',
  477. title: 'DeepSeek Harness',
  478. version: '0.0.1',
  479. },
  480. capabilities: {
  481. experimentalApi: false,
  482. requestAttestation: false,
  483. },
  484. })
  485. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  486. await initializing
  487. await child.peer.nextMethod('initialized')
  488. const starting = wire.startThread('/workspace', new AbortController().signal)
  489. const threadStart = await child.peer.nextMethod('thread/start')
  490. expect(threadStart.params).toEqual({
  491. cwd: '/workspace',
  492. ephemeral: true,
  493. approvalPolicy: 'never',
  494. })
  495. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  496. await starting
  497. const result = wire.runTurn(
  498. ['first', 'second'],
  499. new AbortController().signal,
  500. )
  501. const turnStart = await child.peer.nextMethod('turn/start')
  502. expect(turnStart.params).toEqual({
  503. threadId: 'thread-1',
  504. input: [
  505. { type: 'text', text: 'first', text_elements: [] },
  506. { type: 'text', text: 'second', text_elements: [] },
  507. ],
  508. })
  509. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  510. await nextTask()
  511. child.peer.send(
  512. {
  513. method: 'turn/started',
  514. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  515. },
  516. agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'),
  517. agentMessage('other turn', 'final_answer', 'turn-2'),
  518. {
  519. method: 'item/completed',
  520. params: {
  521. threadId: 'thread-1',
  522. turnId: 'turn-1',
  523. item: { type: 'reasoning', text: 'not output' },
  524. },
  525. },
  526. agentMessage('commentary', 'commentary'),
  527. agentMessage('unphased', null),
  528. agentMessage('first final', 'final_answer'),
  529. agentMessage('last final', 'final_answer'),
  530. turnCompleted('completed'),
  531. )
  532. await expect(result).resolves.toEqual({
  533. output: [{ type: 'text', text: 'last final' }],
  534. stopReason: 'completed',
  535. })
  536. expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }])
  537. wire.close()
  538. wire.close()
  539. })
  540. it('uses the last nullable-phase answer when no explicit final exists', async () => {
  541. const { child, wire } = await initializeWire()
  542. const result = wire.runTurn(['task'], new AbortController().signal)
  543. const turnStart = await child.peer.nextMethod('turn/start')
  544. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  545. child.peer.send(
  546. agentMessage('first', null),
  547. agentMessage('fallback', null),
  548. turnCompleted('completed'),
  549. )
  550. await expect(result).resolves.toEqual({
  551. output: [{ type: 'text', text: 'fallback' }],
  552. stopReason: 'completed',
  553. })
  554. wire.close()
  555. })
  556. it('maps the complete string error union without changing stop reasons', async () => {
  557. const categories = [
  558. 'contextWindowExceeded',
  559. 'sessionBudgetExceeded',
  560. 'usageLimitExceeded',
  561. 'serverOverloaded',
  562. 'cyberPolicy',
  563. 'internalServerError',
  564. 'unauthorized',
  565. 'badRequest',
  566. 'threadRollbackFailed',
  567. 'sandboxError',
  568. 'other',
  569. ] as const
  570. for (const category of categories) {
  571. const { child, wire } = await initializeWire()
  572. const result = wire.runTurn(['task'], new AbortController().signal)
  573. const turnStart = await child.peer.nextMethod('turn/start')
  574. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  575. child.peer.send(
  576. agentMessage('partial answer', null),
  577. turnCompleted('failed', 'turn-1', 'thread-1', {
  578. message: 'SECRET_TOKEN in /private/secret.txt',
  579. codexErrorInfo: category,
  580. }),
  581. )
  582. if (category === 'contextWindowExceeded') {
  583. await expect(result).resolves.toEqual({
  584. output: [{ type: 'text', text: 'partial answer' }],
  585. stopReason: 'max-tokens',
  586. })
  587. } else {
  588. await expect(result).rejects.toThrow(`status failed: ${category}`)
  589. }
  590. expect(wire.collectFailure()).toEqual({
  591. stage: 'turn',
  592. category,
  593. })
  594. expect(JSON.stringify(wire.collectFailure())).not.toContain('SECRET_TOKEN')
  595. expect(JSON.stringify(wire.collectFailure())).not.toContain('/private/secret.txt')
  596. wire.close()
  597. }
  598. })
  599. it('maps all object error variants and only numeric HTTP status', async () => {
  600. const scenarios = [
  601. ['httpConnectionFailed', { httpStatusCode: 503 }, 503],
  602. ['responseStreamConnectionFailed', { httpStatusCode: null }, undefined],
  603. ['responseStreamDisconnected', {}, undefined],
  604. ['responseTooManyFailedAttempts', { httpStatusCode: '503' }, undefined],
  605. ['activeTurnNotSteerable', { turnKind: 'review' }, undefined],
  606. ] as const
  607. for (const [category, detail, httpStatus] of scenarios) {
  608. const { child, wire } = await initializeWire()
  609. const result = wire.runTurn(['task'], new AbortController().signal)
  610. const turnStart = await child.peer.nextMethod('turn/start')
  611. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  612. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  613. message: 'SECRET_TOKEN in /private/secret.txt',
  614. codexErrorInfo: { [category]: detail },
  615. }))
  616. await expect(result).rejects.toThrow(`status failed: ${category}`)
  617. expect(wire.collectFailure()).toEqual({
  618. stage: 'turn',
  619. category,
  620. ...(httpStatus === undefined ? {} : { httpStatus }),
  621. })
  622. expect(JSON.stringify(wire.collectFailure())).not.toContain('turnKind')
  623. wire.close()
  624. }
  625. })
  626. it('uses unknown for version-external or malformed error info', async () => {
  627. for (const codexErrorInfo of [
  628. 'futureError',
  629. { futureVariant: { message: 'SECRET_TOKEN' } },
  630. {
  631. httpConnectionFailed: { httpStatusCode: 503 },
  632. otherVariant: {},
  633. },
  634. { httpConnectionFailed: null },
  635. ]) {
  636. const { child, wire } = await initializeWire()
  637. const result = wire.runTurn(['task'], new AbortController().signal)
  638. const turnStart = await child.peer.nextMethod('turn/start')
  639. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  640. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  641. message: 'SECRET_TOKEN in /private/secret.txt',
  642. codexErrorInfo,
  643. }))
  644. await expect(result).rejects.toThrow('status failed: unknown')
  645. expect(wire.collectFailure()).toEqual({
  646. stage: 'turn',
  647. category: 'unknown',
  648. })
  649. wire.close()
  650. }
  651. })
  652. it('rejects invalid handshake, thread, and turn response shapes', async () => {
  653. {
  654. const child = fakeChild()
  655. const wire = defaultWire(child)
  656. wire.start()
  657. const pending = wire.initialize(new AbortController().signal)
  658. const frame = await child.peer.nextMethod('initialize')
  659. child.peer.respond(frame, null)
  660. await expect(pending).rejects.toThrow('invalid initialize response')
  661. wire.close()
  662. }
  663. {
  664. const child = fakeChild()
  665. const wire = defaultWire(child)
  666. wire.start()
  667. const pending = wire.startThread('/workspace', new AbortController().signal)
  668. const frame = await child.peer.nextMethod('thread/start')
  669. child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } })
  670. await expect(pending).rejects.toThrow('did not create an ephemeral thread')
  671. wire.close()
  672. }
  673. {
  674. const { child, wire } = await initializeWire()
  675. const pending = wire.runTurn(['task'], new AbortController().signal)
  676. const frame = await child.peer.nextMethod('turn/start')
  677. child.peer.respond(frame, { turn: { id: '' } })
  678. await expect(pending).rejects.toThrow('turn/start turn id')
  679. expect(wire.collectFailure()).toEqual({
  680. stage: 'turn-start',
  681. category: 'unknown',
  682. })
  683. wire.close()
  684. }
  685. })
  686. it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
  687. const scenarios: Array<{
  688. readonly frames: JsonObject[]
  689. readonly message: string
  690. }> = [
  691. {
  692. frames: [turnCompleted('completed')],
  693. message: 'without a final answer',
  694. },
  695. {
  696. frames: [
  697. agentMessage('fallback', null),
  698. agentMessage(' \n ', 'final_answer'),
  699. turnCompleted('completed'),
  700. ],
  701. message: 'without a final answer',
  702. },
  703. {
  704. frames: [agentMessage(42, 'final_answer')],
  705. message: 'invalid agent message',
  706. },
  707. {
  708. frames: [agentMessage('answer', 'future_phase')],
  709. message: 'unknown agent message phase',
  710. },
  711. {
  712. frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
  713. message: 'status failed',
  714. },
  715. {
  716. frames: [turnCompleted('failed', 'turn-1', 'thread-1', 'SECRET_TOKEN')],
  717. message: 'status failed',
  718. },
  719. {
  720. frames: [turnCompleted('interrupted')],
  721. message: 'status interrupted',
  722. },
  723. {
  724. frames: [turnCompleted('inProgress')],
  725. message: 'invalid terminal turn status',
  726. },
  727. ]
  728. for (const scenario of scenarios) {
  729. const { child, wire } = await initializeWire()
  730. const result = wire.runTurn(['task'], new AbortController().signal)
  731. const turnStart = await child.peer.nextMethod('turn/start')
  732. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  733. await nextTask()
  734. child.peer.send(...scenario.frames)
  735. await expect(result).rejects.toThrow(scenario.message)
  736. expect(wire.collectFailure()).toEqual({
  737. stage: 'turn',
  738. category: 'unknown',
  739. })
  740. wire.close()
  741. }
  742. })
  743. it('fails closed when terminal notification params are not an object', async () => {
  744. const { child, wire } = await initializeWire()
  745. const result = wire.runTurn(['task'], new AbortController().signal)
  746. const turnStart = await child.peer.nextMethod('turn/start')
  747. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  748. child.peer.send({ method: 'turn/completed', params: null })
  749. await expect(result).rejects.toThrow('invalid turn/completed thread id')
  750. wire.close()
  751. })
  752. it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
  753. const { child, wire } = await initializeWire()
  754. const result = wire.runTurn(['task'], new AbortController().signal)
  755. const turnStart = await child.peer.nextMethod('turn/start')
  756. child.peer.send(
  757. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  758. { id: 'future-request', method: 'future/request', params: {} },
  759. agentMessage('early answer', 'final_answer'),
  760. turnCompleted('completed'),
  761. )
  762. await expect(result).rejects.toThrow('unsupported app-server request')
  763. wire.close()
  764. })
  765. it('answers all five unattended request classes without granting authority', async () => {
  766. const { child, wire } = await initializeWire()
  767. const result = wire.runTurn(['task'], new AbortController().signal)
  768. const turnStart = await child.peer.nextMethod('turn/start')
  769. child.peer.send({
  770. id: 'command',
  771. method: 'item/commandExecution/requestApproval',
  772. params: {
  773. threadId: 'thread-1',
  774. turnId: 'turn-1',
  775. availableDecisions: ['decline', 'cancel'],
  776. command: 'cat /private/secret.txt',
  777. },
  778. })
  779. expect(await child.peer.nextResponse('command')).toMatchObject({
  780. result: { decision: 'cancel' },
  781. })
  782. expect(wire.collectDiagnostic()).toBeUndefined()
  783. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  784. await nextTask()
  785. expect(wire.collectDiagnostic()).toBe(
  786. 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval',
  787. )
  788. const requests = [
  789. {
  790. id: 'command-decline',
  791. method: 'item/commandExecution/requestApproval',
  792. params: {
  793. threadId: 'thread-1',
  794. turnId: 'turn-1',
  795. availableDecisions: ['decline'],
  796. },
  797. result: { decision: 'decline' },
  798. diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: declined): the provider does not grant interactive approval',
  799. },
  800. {
  801. id: 'file',
  802. method: 'item/fileChange/requestApproval',
  803. params: {
  804. threadId: 'thread-1',
  805. turnId: 'turn-1',
  806. availableDecisions: ['decline'],
  807. },
  808. result: { decision: 'decline' },
  809. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  810. },
  811. {
  812. id: 'file-cancel',
  813. method: 'item/fileChange/requestApproval',
  814. params: {
  815. threadId: 'thread-1',
  816. turnId: 'turn-1',
  817. availableDecisions: ['cancel'],
  818. },
  819. result: { decision: 'cancel' },
  820. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: cancelled): the provider does not grant interactive approval',
  821. },
  822. {
  823. id: 'file-default',
  824. method: 'item/fileChange/requestApproval',
  825. params: { threadId: 'thread-1', turnId: 'turn-1' },
  826. result: { decision: 'decline' },
  827. diagnostic: 'Codex unattended decision (mode: never; request: file approval; decision: declined): the provider does not grant interactive approval',
  828. },
  829. {
  830. id: 'permissions',
  831. method: 'item/permissions/requestApproval',
  832. params: { threadId: 'thread-1', turnId: 'turn-1' },
  833. result: { permissions: {}, scope: 'turn' },
  834. diagnostic: 'Codex unattended decision (mode: never; request: permission grant; decision: denied): the provider grants no additional turn permissions',
  835. },
  836. {
  837. id: 'user-input',
  838. method: 'item/tool/requestUserInput',
  839. params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
  840. result: { answers: {} },
  841. diagnostic: 'Codex unattended decision (mode: never; request: user input; decision: empty response): the provider does not collect interactive answers',
  842. },
  843. {
  844. id: 'mcp',
  845. method: 'mcpServer/elicitation/request',
  846. params: { threadId: 'thread-1', turnId: null },
  847. result: { action: 'decline', content: null, _meta: null },
  848. diagnostic: 'Codex unattended decision (mode: never; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input',
  849. },
  850. ] as const
  851. for (const serverRequest of requests) {
  852. child.peer.send(serverRequest)
  853. expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
  854. result: serverRequest.result,
  855. })
  856. expect(wire.collectDiagnostic()).toBe(serverRequest.diagnostic)
  857. }
  858. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  859. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  860. await expect(result).resolves.toEqual({
  861. output: [{ type: 'text', text: 'answer' }],
  862. stopReason: 'completed',
  863. })
  864. wire.close()
  865. })
  866. it('records only a safe diagnostic for an explicit sandbox failure', async () => {
  867. const { child, wire } = await initializeWire()
  868. const result = wire.runTurn(['task'], new AbortController().signal)
  869. const turnStart = await child.peer.nextMethod('turn/start')
  870. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  871. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  872. message: 'failed at /private/secret.txt with SECRET_TOKEN',
  873. additionalDetails: 'raw command payload',
  874. codexErrorInfo: 'sandboxError',
  875. }))
  876. await expect(result).rejects.toThrow('status failed')
  877. expect(wire.collectDiagnostic()).toBe(
  878. 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure',
  879. )
  880. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  881. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  882. wire.close()
  883. })
  884. it('records declined command and file items without retaining their payloads', async () => {
  885. const { child, wire } = await initializeWire()
  886. const result = wire.runTurn(['task'], new AbortController().signal)
  887. const turnStart = await child.peer.nextMethod('turn/start')
  888. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  889. child.peer.send({
  890. method: 'item/completed',
  891. params: {
  892. threadId: 'thread-1',
  893. turnId: 'turn-1',
  894. item: {
  895. type: 'commandExecution',
  896. status: 'declined',
  897. command: 'cat /private/secret.txt',
  898. },
  899. },
  900. })
  901. await nextTask()
  902. expect(wire.collectDiagnostic()).toBe(
  903. 'Codex unattended decision (mode: never; request: command execution; decision: declined): Codex declined the command under the selected permission mode',
  904. )
  905. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  906. child.peer.send(
  907. {
  908. method: 'item/completed',
  909. params: {
  910. threadId: 'thread-1',
  911. turnId: 'turn-1',
  912. item: {
  913. type: 'fileChange',
  914. status: 'declined',
  915. patch: 'SECRET_TOKEN in /private/secret.txt',
  916. },
  917. },
  918. },
  919. turnCompleted('failed', 'turn-1', 'thread-1', {
  920. message: 'SECRET_TOKEN in /private/secret.txt',
  921. codexErrorInfo: 'other',
  922. }),
  923. )
  924. await expect(result).rejects.toThrow('status failed')
  925. expect(wire.collectDiagnostic()).toBe(
  926. 'Codex unattended decision (mode: never; request: file change; decision: declined): Codex declined the file change under the selected permission mode',
  927. )
  928. expect(wire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  929. expect(wire.collectDiagnostic()).not.toContain('/private/secret.txt')
  930. wire.close()
  931. })
  932. it('recognizes large, split, and ordered stderr signatures without retaining raw text', () => {
  933. const first = fakeChild()
  934. const largeWire = new CodexAppServerWire(
  935. first.handle.stdout!,
  936. first.handle.stdin!,
  937. 'never',
  938. )
  939. largeWire.observeStderr(
  940. `SECRET_TOKEN approval policy is Never; reject command${'x'.repeat(2_048)}`,
  941. )
  942. expect(largeWire.collectDiagnostic()).toBe(
  943. 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  944. )
  945. expect(largeWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  946. const second = fakeChild()
  947. const splitWire = new CodexAppServerWire(
  948. second.handle.stdout!,
  949. second.handle.stdin!,
  950. 'never',
  951. )
  952. splitWire.observeStderr('SECRET_TOKEN approval policy is Ne')
  953. splitWire.observeStderr('ver; reject command — /private/secret.txt')
  954. expect(splitWire.collectDiagnostic()).toBe(
  955. 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval',
  956. )
  957. expect(splitWire.collectDiagnostic()).not.toContain('SECRET_TOKEN')
  958. expect(splitWire.collectDiagnostic()).not.toContain('/private/secret.txt')
  959. const third = fakeChild()
  960. const orderedWire = new CodexAppServerWire(
  961. third.handle.stdout!,
  962. third.handle.stdin!,
  963. 'dangerously-bypass-approvals-and-sandbox',
  964. )
  965. orderedWire.observeStderr(
  966. 'approval policy is Never; reject command; recorded sandbox violation: path=/private/secret.txt',
  967. )
  968. expect(orderedWire.collectDiagnostic()).toBe(
  969. 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: sandbox execution; decision: failed): Codex reported a sandbox violation',
  970. )
  971. expect(orderedWire.collectDiagnostic()).not.toContain('/private/secret.txt')
  972. })
  973. it('does not reapply an old stderr signature after a newer request diagnostic', async () => {
  974. const { child, wire } = await initializeWire()
  975. wire.observeStderr('recorded sandbox violation:')
  976. const result = wire.runTurn(['task'], new AbortController().signal)
  977. const turnStart = await child.peer.nextMethod('turn/start')
  978. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  979. await nextTask()
  980. child.peer.send({
  981. id: 'file-approval',
  982. method: 'item/fileChange/requestApproval',
  983. params: {
  984. threadId: 'thread-1',
  985. turnId: 'turn-1',
  986. availableDecisions: ['decline'],
  987. },
  988. })
  989. await child.peer.nextResponse('file-approval')
  990. expect(wire.collectDiagnostic()).toContain('request: file approval')
  991. wire.observeStderr('later benign stderr')
  992. expect(wire.collectDiagnostic()).toContain('request: file approval')
  993. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  994. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  995. wire.close()
  996. })
  997. it('keeps a newer request diagnostic after replaying an older early item', async () => {
  998. const { child, wire } = await initializeWire()
  999. const result = wire.runTurn(['task'], new AbortController().signal)
  1000. const turnStart = await child.peer.nextMethod('turn/start')
  1001. child.peer.send({
  1002. method: 'item/completed',
  1003. params: {
  1004. threadId: 'thread-1',
  1005. turnId: 'turn-1',
  1006. item: { type: 'fileChange', status: 'declined' },
  1007. },
  1008. })
  1009. await nextTask()
  1010. child.peer.send({
  1011. id: 'newer-command-request',
  1012. method: 'item/commandExecution/requestApproval',
  1013. params: {
  1014. threadId: 'thread-1',
  1015. turnId: 'turn-1',
  1016. availableDecisions: ['cancel'],
  1017. },
  1018. })
  1019. await child.peer.nextResponse('newer-command-request')
  1020. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1021. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1022. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1023. expect(wire.collectDiagnostic()).toContain('request: command approval')
  1024. wire.close()
  1025. })
  1026. it('keeps a newer stderr fact after replaying an older early terminal', async () => {
  1027. hostStderrWrite.capture = true
  1028. hostStderrWrite.chunks.length = 0
  1029. const { child, wire } = await initializeWire()
  1030. const result = wire.runTurn(['task'], new AbortController().signal)
  1031. const turnStart = await child.peer.nextMethod('turn/start')
  1032. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1033. message: 'sandbox failure',
  1034. codexErrorInfo: 'sandboxError',
  1035. }))
  1036. await nextTask()
  1037. wire.observeStderr('approval policy is Never; reject command')
  1038. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1039. await expect(result).rejects.toThrow('sandboxError')
  1040. expect(wire.collectDiagnostic()).toContain('request: command execution')
  1041. wire.close()
  1042. hostStderrWrite.capture = false
  1043. })
  1044. it('fails the run on unknown requests or wrong request association', async () => {
  1045. for (const serverRequest of [
  1046. {
  1047. id: 'unknown',
  1048. method: 'future/request',
  1049. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1050. },
  1051. {
  1052. id: 'approval',
  1053. method: 'item/commandExecution/requestApproval',
  1054. params: {
  1055. threadId: 'thread-1',
  1056. turnId: 'turn-1',
  1057. availableDecisions: ['accept'],
  1058. },
  1059. },
  1060. {
  1061. id: 'malformed-approval',
  1062. method: 'item/fileChange/requestApproval',
  1063. params: {
  1064. threadId: 'thread-1',
  1065. turnId: 'turn-1',
  1066. availableDecisions: 'decline',
  1067. },
  1068. },
  1069. {
  1070. id: 'thread',
  1071. method: 'item/fileChange/requestApproval',
  1072. params: { threadId: 'thread-2', turnId: 'turn-1' },
  1073. },
  1074. {
  1075. id: 'turn',
  1076. method: 'item/fileChange/requestApproval',
  1077. params: { threadId: 'thread-1', turnId: 'turn-2' },
  1078. },
  1079. ]) {
  1080. const { child, wire } = await initializeWire()
  1081. const result = wire.runTurn(['task'], new AbortController().signal)
  1082. const turnStart = await child.peer.nextMethod('turn/start')
  1083. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1084. await nextTask()
  1085. child.peer.send(serverRequest)
  1086. const response = await child.peer.nextResponse(serverRequest.id)
  1087. expect(response.error).toMatchObject({ code: -32603 })
  1088. await expect(result).rejects.toThrow()
  1089. wire.close()
  1090. }
  1091. })
  1092. it('rejects conflicting early turn identities before accepting output', async () => {
  1093. const { child, wire } = await initializeWire()
  1094. const result = wire.runTurn(['task'], new AbortController().signal)
  1095. const turnStart = await child.peer.nextMethod('turn/start')
  1096. child.peer.send({
  1097. method: 'turn/started',
  1098. params: { threadId: 'thread-1', turn: { id: 'turn-early' } },
  1099. })
  1100. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1101. await expect(result).rejects.toThrow('did not match the active turn')
  1102. wire.close()
  1103. })
  1104. it('does not retain a diagnostic from a mismatched early item', async () => {
  1105. const { child, wire } = await initializeWire()
  1106. const result = wire.runTurn(['task'], new AbortController().signal)
  1107. const turnStart = await child.peer.nextMethod('turn/start')
  1108. child.peer.send({
  1109. method: 'item/completed',
  1110. params: {
  1111. threadId: 'thread-1',
  1112. turnId: 'turn-early',
  1113. item: { type: 'fileChange', status: 'declined' },
  1114. },
  1115. })
  1116. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1117. await expect(result).rejects.toThrow('did not match the active turn')
  1118. expect(wire.collectDiagnostic()).toBeUndefined()
  1119. wire.close()
  1120. })
  1121. it('does not retain a diagnostic from a mismatched provisional request', async () => {
  1122. const { child, wire } = await initializeWire()
  1123. const result = wire.runTurn(['task'], new AbortController().signal)
  1124. const turnStart = await child.peer.nextMethod('turn/start')
  1125. child.peer.send({
  1126. id: 'provisional-approval',
  1127. method: 'item/commandExecution/requestApproval',
  1128. params: {
  1129. threadId: 'thread-1',
  1130. turnId: 'turn-early',
  1131. availableDecisions: ['cancel'],
  1132. },
  1133. })
  1134. await child.peer.nextResponse('provisional-approval')
  1135. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  1136. await expect(result).rejects.toThrow('did not match the active turn')
  1137. expect(wire.collectDiagnostic()).toBeUndefined()
  1138. wire.close()
  1139. })
  1140. it('rejects conflicting early notifications and requests before turn/start', async () => {
  1141. {
  1142. const { child, wire } = await initializeWire()
  1143. child.peer.send({
  1144. id: 'too-early',
  1145. method: 'item/fileChange/requestApproval',
  1146. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1147. })
  1148. const response = await child.peer.nextResponse('too-early')
  1149. expect(response.error).toMatchObject({ code: -32603 })
  1150. wire.close()
  1151. }
  1152. {
  1153. const { child, wire } = await initializeWire()
  1154. const result = wire.runTurn(['task'], new AbortController().signal)
  1155. await child.peer.nextMethod('turn/start')
  1156. child.peer.send(
  1157. {
  1158. method: 'turn/started',
  1159. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  1160. },
  1161. agentMessage('wrong', 'final_answer', 'turn-2'),
  1162. )
  1163. await expect(result).rejects.toThrow('conflicting turns')
  1164. wire.close()
  1165. }
  1166. })
  1167. it('interrupts only an active open turn and contains remote interrupt failure', async () => {
  1168. const { child, wire } = await initializeWire()
  1169. wire.interrupt()
  1170. const result = wire.runTurn(['task'], new AbortController().signal)
  1171. const turnStart = await child.peer.nextMethod('turn/start')
  1172. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1173. await nextTask()
  1174. wire.interrupt()
  1175. const interrupt = await child.peer.nextMethod('turn/interrupt')
  1176. expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' })
  1177. child.peer.send({
  1178. id: interrupt.id,
  1179. error: { code: -32000, message: 'already done' },
  1180. })
  1181. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1182. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  1183. wire.close()
  1184. wire.interrupt()
  1185. })
  1186. it('ignores unrelated and out-of-window notifications', async () => {
  1187. const { child, wire } = await initializeWire()
  1188. child.peer.send(
  1189. {
  1190. method: 'turn/started',
  1191. params: { threadId: 'thread-2', turn: { id: 'turn-other' } },
  1192. },
  1193. {
  1194. method: 'turn/started',
  1195. params: { threadId: 'thread-1', turn: { id: 'turn-before' } },
  1196. },
  1197. agentMessage('before', 'final_answer'),
  1198. { method: 'future/notification', params: {} },
  1199. turnCompleted('completed'),
  1200. turnCompleted('completed', 'turn-other', 'thread-2'),
  1201. )
  1202. await nextTask()
  1203. const result = wire.runTurn(['task'], new AbortController().signal)
  1204. const turnStart = await child.peer.nextMethod('turn/start')
  1205. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1206. await nextTask()
  1207. child.peer.send(
  1208. agentMessage('wrong turn', 'final_answer', 'turn-2'),
  1209. turnCompleted('completed', 'turn-2'),
  1210. agentMessage('answer', 'final_answer'),
  1211. turnCompleted('completed'),
  1212. )
  1213. await expect(result).resolves.toEqual({
  1214. output: [{ type: 'text', text: 'answer' }],
  1215. stopReason: 'completed',
  1216. })
  1217. wire.close()
  1218. })
  1219. it('rejects pending work on abort, EOF, and stream error', async () => {
  1220. {
  1221. const child = fakeChild()
  1222. const wire = defaultWire(child)
  1223. wire.start()
  1224. const controller = new AbortController()
  1225. controller.abort('pre-aborted')
  1226. await expect(wire.initialize(controller.signal))
  1227. .rejects.toThrow('app-server request aborted: pre-aborted')
  1228. wire.close()
  1229. }
  1230. {
  1231. const child = fakeChild()
  1232. const wire = defaultWire(child)
  1233. wire.start()
  1234. const controller = new AbortController()
  1235. const pending = wire.initialize(controller.signal)
  1236. await child.peer.nextMethod('initialize')
  1237. controller.abort(new Error('cancel initialize'))
  1238. await expect(pending).rejects.toThrow('cancel initialize')
  1239. wire.close()
  1240. }
  1241. {
  1242. const child = fakeChild()
  1243. const wire = defaultWire(child)
  1244. wire.start()
  1245. const pending = wire.initialize(new AbortController().signal)
  1246. await child.peer.nextMethod('initialize')
  1247. child.fromChild.end()
  1248. await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/)
  1249. wire.close()
  1250. }
  1251. {
  1252. const child = fakeChild()
  1253. const wire = defaultWire(child)
  1254. wire.start()
  1255. const pending = wire.initialize(new AbortController().signal)
  1256. await child.peer.nextMethod('initialize')
  1257. child.fromChild.emit('error', new Error('stdout broke'))
  1258. await expect(pending).rejects.toThrow('stdout broke')
  1259. wire.close()
  1260. }
  1261. {
  1262. const child = fakeChild()
  1263. const wire = defaultWire(child)
  1264. wire.start()
  1265. const pending = wire.initialize(new AbortController().signal)
  1266. await child.peer.nextMethod('initialize')
  1267. child.toChild.emit('error', new Error('stdin broke'))
  1268. await expect(pending).rejects.toThrow('stdin broke')
  1269. wire.close()
  1270. child.toChild.emit('error', new Error('late stdin close'))
  1271. }
  1272. })
  1273. })
  1274. describe('run lifecycle and quiescence', () => {
  1275. it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => {
  1276. const child = fakeChild()
  1277. const spawn = vi.fn(() => child.handle)
  1278. const starting = startCodexRun(
  1279. request([{ type: 'text', text: 'task' }]),
  1280. runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }),
  1281. )
  1282. let published = false
  1283. void starting.then(() => { published = true })
  1284. const initialize = await child.peer.nextMethod('initialize')
  1285. expect(published).toBe(false)
  1286. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1287. await child.peer.nextMethod('initialized')
  1288. const threadStart = await child.peer.nextMethod('thread/start')
  1289. expect(published).toBe(false)
  1290. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1291. const run = await starting
  1292. expect(spawn).toHaveBeenCalledWith({
  1293. argv: codexAppServerArgv(),
  1294. cwd: process.cwd(),
  1295. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
  1296. graceMs: DEFAULT_DISPOSE_GRACE_MS,
  1297. env: { OPENAI_API_KEY: 'fake' },
  1298. })
  1299. expect(run.localAgent).toBeUndefined()
  1300. const turnStart = await child.peer.nextMethod('turn/start')
  1301. child.peer.send(
  1302. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  1303. agentMessage('answer', 'final_answer'),
  1304. turnCompleted('completed'),
  1305. )
  1306. await expect(run.result).resolves.toEqual({
  1307. output: [{ type: 'text', text: 'answer' }],
  1308. stopReason: 'completed',
  1309. })
  1310. const disposal = run.dispose()
  1311. expect(run.dispose()).toBe(disposal)
  1312. await disposal
  1313. await nextTask()
  1314. expect(child.terminate).toHaveBeenCalledTimes(1)
  1315. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1316. })
  1317. it('settles local cancellation immediately and sends best-effort interrupt', async () => {
  1318. const controller = new AbortController()
  1319. const { child, run, turnStart } = await publishRun(
  1320. fakeChild(),
  1321. controller.signal,
  1322. )
  1323. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1324. await nextTask()
  1325. controller.abort(new Error('stop'))
  1326. await expect(run.result).resolves.toEqual({
  1327. output: [],
  1328. stopReason: 'aborted',
  1329. })
  1330. expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({
  1331. params: { threadId: 'thread-1', turnId: 'turn-1' },
  1332. })
  1333. await run.dispose()
  1334. })
  1335. it('reports turn-start failures and omits captured facts after success', async () => {
  1336. {
  1337. const { child, run, turnStart } = await publishRun()
  1338. child.peer.respond(turnStart, { turn: { id: '' } })
  1339. await expect(run.result).resolves.toEqual({
  1340. output: [],
  1341. diagnostic: expectedFailureDiagnostic('turn-start', 'unknown'),
  1342. stopReason: 'error',
  1343. })
  1344. await run.dispose()
  1345. }
  1346. {
  1347. const { child, run, turnStart } = await publishRun()
  1348. child.peer.send({
  1349. id: 'successful-approval',
  1350. method: 'item/commandExecution/requestApproval',
  1351. params: {
  1352. threadId: 'thread-1',
  1353. turnId: 'turn-1',
  1354. availableDecisions: ['cancel'],
  1355. },
  1356. })
  1357. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1358. await child.peer.nextResponse('successful-approval')
  1359. child.peer.send(
  1360. agentMessage('answer', 'final_answer'),
  1361. turnCompleted('completed'),
  1362. )
  1363. await expect(run.result).resolves.toEqual({
  1364. output: [{ type: 'text', text: 'answer' }],
  1365. stopReason: 'completed',
  1366. })
  1367. await run.dispose()
  1368. }
  1369. })
  1370. it('preserves representative terminal categories, HTTP status, and mapping', async () => {
  1371. const scenarios = [
  1372. ['contextWindowExceeded', 'max-tokens', undefined],
  1373. ['sessionBudgetExceeded', 'error', undefined],
  1374. [{ httpConnectionFailed: { httpStatusCode: 503 } }, 'error', 503],
  1375. [{ activeTurnNotSteerable: { turnKind: 'review' } }, 'error', undefined],
  1376. ['futureError', 'error', undefined],
  1377. ] as const
  1378. for (const [codexErrorInfo, stopReason, httpStatus] of scenarios) {
  1379. const { child, run, turnStart } = await publishRun()
  1380. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1381. child.peer.send(
  1382. agentMessage('partial answer', null),
  1383. turnCompleted('failed', 'turn-1', 'thread-1', {
  1384. message: 'SECRET_TOKEN in /private/secret.txt',
  1385. codexErrorInfo,
  1386. }),
  1387. )
  1388. const category = typeof codexErrorInfo === 'string'
  1389. && codexErrorInfo !== 'futureError'
  1390. ? codexErrorInfo
  1391. : typeof codexErrorInfo === 'object'
  1392. ? Object.keys(codexErrorInfo)[0]!
  1393. : 'unknown'
  1394. const result = await run.result
  1395. expect(result).toEqual({
  1396. output: [{ type: 'text', text: 'partial answer' }],
  1397. diagnostic: expectedFailureDiagnostic('turn', category, {
  1398. ...(httpStatus === undefined ? {} : { httpStatus }),
  1399. }),
  1400. stopReason,
  1401. })
  1402. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  1403. expect(result.diagnostic).not.toContain('/private/secret.txt')
  1404. expect(result.diagnostic).not.toContain('turnKind')
  1405. await run.dispose()
  1406. }
  1407. })
  1408. it('uses safe unknown fallbacks when the wire supplies no failure fact', async () => {
  1409. {
  1410. const collectFailure = vi.spyOn(
  1411. CodexAppServerWire.prototype,
  1412. 'collectFailure',
  1413. ).mockReturnValue(undefined)
  1414. const { child, run, turnStart } = await publishRun()
  1415. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1416. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1417. codexErrorInfo: 'contextWindowExceeded',
  1418. }))
  1419. await expect(run.result).resolves.toEqual({
  1420. output: [],
  1421. diagnostic: expectedFailureDiagnostic('turn', 'unknown'),
  1422. stopReason: 'max-tokens',
  1423. })
  1424. collectFailure.mockRestore()
  1425. await run.dispose()
  1426. }
  1427. {
  1428. const runTurn = vi.spyOn(CodexAppServerWire.prototype, 'runTurn')
  1429. .mockRejectedValueOnce(new Error('SECRET_TOKEN wire failure'))
  1430. const child = fakeChild()
  1431. const starting = startCodexRun(request(), runSpec(child))
  1432. const initialize = await child.peer.nextMethod('initialize')
  1433. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1434. await child.peer.nextMethod('initialized')
  1435. const threadStart = await child.peer.nextMethod('thread/start')
  1436. child.peer.respond(threadStart, {
  1437. thread: { id: 'thread-1', ephemeral: true },
  1438. })
  1439. const run = await starting
  1440. const result = await run.result
  1441. expect(result).toEqual({
  1442. output: [],
  1443. diagnostic: expectedFailureDiagnostic('turn', 'unknown'),
  1444. stopReason: 'error',
  1445. })
  1446. expect(result.diagnostic).not.toContain('SECRET_TOKEN')
  1447. runTurn.mockRestore()
  1448. await run.dispose()
  1449. }
  1450. })
  1451. it('flattens child exit and protocol failures after publication', async () => {
  1452. const errors: string[] = []
  1453. const outcomes: SubprocessOutcome[] = [
  1454. { exitCode: 9, signal: null },
  1455. { exitCode: null, signal: 'SIGABRT' },
  1456. { exitCode: 9, signal: 'SIGABRT' },
  1457. { exitCode: null, signal: null },
  1458. ]
  1459. for (const outcome of outcomes) {
  1460. const child = fakeChild({ exitOnTerminate: false })
  1461. const { run } = await publishRun(child, undefined, {
  1462. onError: (error) => { errors.push(error.message) },
  1463. })
  1464. child.settle(outcome)
  1465. await expect(run.result).resolves.toEqual({
  1466. output: [],
  1467. diagnostic: expectedFailureDiagnostic('process', 'process-exit', {
  1468. outcome,
  1469. }),
  1470. stopReason: 'error',
  1471. })
  1472. expect(errors.at(-1)).toBe(
  1473. `subagent-codex: ${expectedFailureDiagnostic('process', 'process-exit', { outcome })}`,
  1474. )
  1475. await run.dispose().catch(() => {})
  1476. }
  1477. {
  1478. const child = fakeChild()
  1479. const { run, turnStart } = await publishRun(child, undefined, {
  1480. onError: () => { throw new Error('diagnostic sink') },
  1481. })
  1482. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1483. child.fromChild.end()
  1484. await expect(run.result).resolves.toEqual({
  1485. output: [],
  1486. diagnostic: expectedFailureDiagnostic('turn', 'unknown'),
  1487. stopReason: 'error',
  1488. })
  1489. await run.dispose()
  1490. }
  1491. {
  1492. const child = fakeChild()
  1493. const { run, turnStart } = await publishRun(child)
  1494. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1495. child.stderr.emit('error', new Error('stderr broke'))
  1496. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  1497. await expect(run.result).resolves.toEqual({
  1498. output: [{ type: 'text', text: 'answer' }],
  1499. stopReason: 'completed',
  1500. })
  1501. await run.dispose()
  1502. expect(child.stderr.listenerCount('error')).toBe(0)
  1503. }
  1504. })
  1505. it('attaches a safe permission diagnostic when a published run fails', async () => {
  1506. const { child, run, turnStart } = await publishRun()
  1507. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1508. await nextTask()
  1509. child.peer.send({
  1510. id: 'approval-diagnostic',
  1511. method: 'item/commandExecution/requestApproval',
  1512. params: {
  1513. threadId: 'thread-1',
  1514. turnId: 'turn-1',
  1515. availableDecisions: ['cancel'],
  1516. command: 'cat /private/secret.txt',
  1517. },
  1518. })
  1519. expect(await child.peer.nextResponse('approval-diagnostic')).toMatchObject({
  1520. result: { decision: 'cancel' },
  1521. })
  1522. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1523. message: 'SECRET_TOKEN in /private/secret.txt',
  1524. codexErrorInfo: 'other',
  1525. }))
  1526. await expect(run.result).resolves.toEqual({
  1527. output: [],
  1528. diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  1529. stopReason: 'error',
  1530. })
  1531. await run.dispose()
  1532. })
  1533. it('drains queued stderr before settling a failed published run', async () => {
  1534. hostStderrWrite.capture = true
  1535. hostStderrWrite.chunks.length = 0
  1536. const { child, run, turnStart } = await publishRun()
  1537. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1538. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1539. message: 'fixture terminal failure',
  1540. codexErrorInfo: 'badRequest',
  1541. }))
  1542. setImmediate(() => {
  1543. child.stderr.write('approval policy is Never; reject command')
  1544. })
  1545. await expect(run.result).resolves.toEqual({
  1546. output: [],
  1547. diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`,
  1548. stopReason: 'error',
  1549. })
  1550. await run.dispose()
  1551. hostStderrWrite.capture = false
  1552. })
  1553. it('forwards stderr while extracting only a fixed safe permission signature', async () => {
  1554. const child = fakeChild()
  1555. hostStderrWrite.capture = true
  1556. hostStderrWrite.chunks.length = 0
  1557. const { run, turnStart } = await publishRun(child)
  1558. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1559. child.stderr.write('SECRET_TOKEN approval policy is Ne')
  1560. child.stderr.write('ver; reject command — /private/secret.txt')
  1561. child.stderr.emit('data', 'string stderr suffix')
  1562. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1563. message: 'fixture terminal failure',
  1564. codexErrorInfo: 'badRequest',
  1565. }))
  1566. await expect(run.result).resolves.toEqual({
  1567. output: [],
  1568. diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`,
  1569. stopReason: 'error',
  1570. })
  1571. expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN')
  1572. expect(hostStderrWrite.chunks).toHaveLength(3)
  1573. await run.dispose()
  1574. expect(child.stderr.listenerCount('data')).toBe(0)
  1575. hostStderrWrite.capture = false
  1576. })
  1577. it('contains host stderr write failures without changing run settlement', async () => {
  1578. const child = fakeChild()
  1579. hostStderrWrite.capture = true
  1580. hostStderrWrite.failNext = true
  1581. const { run, turnStart } = await publishRun(child)
  1582. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1583. child.stderr.write('approval policy is Never; reject command')
  1584. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1585. message: 'fixture terminal failure',
  1586. codexErrorInfo: 'badRequest',
  1587. }))
  1588. await expect(run.result).resolves.toEqual({
  1589. output: [],
  1590. diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`,
  1591. stopReason: 'error',
  1592. })
  1593. await run.dispose()
  1594. hostStderrWrite.capture = false
  1595. })
  1596. it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
  1597. const controller = new AbortController()
  1598. controller.abort()
  1599. const spawn = vi.fn()
  1600. await expect(startCodexRun(
  1601. request(undefined, controller.signal),
  1602. {
  1603. cwd: process.cwd(),
  1604. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1605. env: {},
  1606. disposeGraceMs: 10,
  1607. spawn,
  1608. },
  1609. )).rejects.toThrow('aborted before app-server startup')
  1610. expect(spawn).not.toHaveBeenCalled()
  1611. const spawnFailure = startCodexRun(request(), {
  1612. cwd: process.cwd(),
  1613. permissionMode: DEFAULT_CODEX_PERMISSION_MODE,
  1614. env: {},
  1615. disposeGraceMs: 10,
  1616. spawn: () => { throw new Error('SECRET_TOKEN spawn failure') },
  1617. })
  1618. await expect(spawnFailure)
  1619. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1620. await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN')
  1621. const child = fakeChild()
  1622. const starting = startCodexRun(request(), runSpec(child))
  1623. const initialize = await child.peer.nextMethod('initialize')
  1624. child.peer.respond(initialize, null)
  1625. await expect(starting)
  1626. .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown'))
  1627. await expect(starting).rejects.not.toThrow('invalid initialize response')
  1628. expect(child.terminate).toHaveBeenCalledTimes(1)
  1629. const cleanupRaceAbort = new AbortController()
  1630. const cleanupRaceChild = fakeChild({ exitOnTerminate: false })
  1631. const cleanupRace = startCodexRun(
  1632. request(undefined, cleanupRaceAbort.signal),
  1633. runSpec(cleanupRaceChild),
  1634. )
  1635. const cleanupRaceInitialize = await cleanupRaceChild.peer.nextMethod('initialize')
  1636. cleanupRaceChild.peer.respond(cleanupRaceInitialize, null)
  1637. await nextTask()
  1638. cleanupRaceAbort.abort(new Error('cancelled during cleanup'))
  1639. cleanupRaceChild.settle()
  1640. await expect(cleanupRace)
  1641. .rejects.toThrow('aborted before run publication')
  1642. const threadChild = fakeChild()
  1643. const threadStarting = startCodexRun(request(), runSpec(threadChild))
  1644. const threadInitialize = await threadChild.peer.nextMethod('initialize')
  1645. threadChild.peer.respond(threadInitialize, { userAgent: 'codex-cli 0.147.0' })
  1646. await threadChild.peer.nextMethod('initialized')
  1647. const invalidThread = await threadChild.peer.nextMethod('thread/start')
  1648. threadChild.peer.respond(invalidThread, { thread: { id: '', ephemeral: true } })
  1649. await expect(threadStarting)
  1650. .rejects.toThrow(expectedFailureDiagnostic('thread-start', 'unknown'))
  1651. await expect(threadStarting).rejects.not.toThrow('thread/start thread id')
  1652. const exitedThreadChild = fakeChild({ exitOnTerminate: false })
  1653. const exitedThreadStarting = startCodexRun(
  1654. request(),
  1655. runSpec(exitedThreadChild),
  1656. )
  1657. const exitedThreadInitialize = await exitedThreadChild.peer.nextMethod('initialize')
  1658. exitedThreadChild.peer.respond(exitedThreadInitialize, {
  1659. userAgent: 'codex-cli 0.147.0',
  1660. })
  1661. await exitedThreadChild.peer.nextMethod('initialized')
  1662. await exitedThreadChild.peer.nextMethod('thread/start')
  1663. exitedThreadChild.settle({ exitCode: 17, signal: 'SIGABRT' })
  1664. await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic(
  1665. 'thread-start',
  1666. 'unknown',
  1667. { outcome: { exitCode: 17, signal: 'SIGABRT' } },
  1668. ))
  1669. const stderrChild = fakeChild()
  1670. const stderrStarting = startCodexRun(request(), runSpec(stderrChild))
  1671. const stderrInitialize = await stderrChild.peer.nextMethod('initialize')
  1672. stderrChild.stderr.emit('error', new Error('startup stderr broke'))
  1673. stderrChild.peer.respond(stderrInitialize, { userAgent: 'codex-cli 0.147.0' })
  1674. await stderrChild.peer.nextMethod('initialized')
  1675. const stderrThreadStart = await stderrChild.peer.nextMethod('thread/start')
  1676. stderrChild.peer.respond(stderrThreadStart, {
  1677. thread: { id: 'thread-1', ephemeral: true },
  1678. })
  1679. const stderrRun = await stderrStarting
  1680. const stderrTurnStart = await stderrChild.peer.nextMethod('turn/start')
  1681. stderrChild.peer.send(
  1682. { id: stderrTurnStart.id, result: { turn: { id: 'turn-1' } } },
  1683. agentMessage('answer', 'final_answer'),
  1684. turnCompleted('completed'),
  1685. )
  1686. await expect(stderrRun.result).resolves.toMatchObject({ stopReason: 'completed' })
  1687. await stderrRun.dispose()
  1688. expect(stderrChild.stderr.listenerCount('error')).toBe(0)
  1689. })
  1690. it('rolls back an abort that wins immediately after thread creation', async () => {
  1691. const controller = new AbortController()
  1692. const child = fakeChild()
  1693. const starting = startCodexRun(
  1694. request(undefined, controller.signal),
  1695. runSpec(child),
  1696. )
  1697. const initialize = await child.peer.nextMethod('initialize')
  1698. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1699. await child.peer.nextMethod('initialized')
  1700. const threadStart = await child.peer.nextMethod('thread/start')
  1701. expect(threadStart.params).toEqual({
  1702. cwd: process.cwd(),
  1703. ephemeral: true,
  1704. approvalPolicy: 'never',
  1705. })
  1706. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1707. controller.abort('startup race')
  1708. await expect(starting).rejects.toThrow('aborted before run publication')
  1709. expect(child.terminate).toHaveBeenCalledTimes(1)
  1710. })
  1711. it('rolls back a subprocess done rejection during startup', async () => {
  1712. const child = fakeChild({ doneError: new Error('spawn observer failed') })
  1713. const error: unknown = await startCodexRun(request(), runSpec(child)).then(
  1714. () => undefined,
  1715. (failure: unknown) => failure,
  1716. )
  1717. expect(error).toBeInstanceOf(AggregateError)
  1718. if (!(error instanceof AggregateError)) {
  1719. throw new Error('expected startup and rollback failures')
  1720. }
  1721. expect(error.errors).toEqual([
  1722. expect.objectContaining({
  1723. message: `subagent-codex: ${expectedFailureDiagnostic('initialize', 'unknown')}`,
  1724. }),
  1725. expect.objectContaining({
  1726. message: `subagent-codex: ${expectedFailureDiagnostic('teardown', 'unknown')}`,
  1727. }),
  1728. ])
  1729. expect(child.terminate).toHaveBeenCalledTimes(1)
  1730. })
  1731. it('keeps overlapping runs isolated', async () => {
  1732. const initialStderrListeners = {
  1733. error: process.stderr.listenerCount('error'),
  1734. unpipe: process.stderr.listenerCount('unpipe'),
  1735. close: process.stderr.listenerCount('close'),
  1736. finish: process.stderr.listenerCount('finish'),
  1737. }
  1738. const runs = await Promise.all(
  1739. Array.from({ length: 6 }, () => publishRun(fakeChild())),
  1740. )
  1741. expect({
  1742. error: process.stderr.listenerCount('error'),
  1743. unpipe: process.stderr.listenerCount('unpipe'),
  1744. close: process.stderr.listenerCount('close'),
  1745. finish: process.stderr.listenerCount('finish'),
  1746. }).toEqual(initialStderrListeners)
  1747. for (const [index, entry] of runs.entries()) {
  1748. const id = `turn-${index + 1}`
  1749. entry.child.peer.send(
  1750. { id: entry.turnStart.id, result: { turn: { id } } },
  1751. agentMessage(`answer-${index + 1}`, 'final_answer', id),
  1752. turnCompleted('completed', id),
  1753. )
  1754. }
  1755. const results = await Promise.all(runs.map(entry => entry.run.result))
  1756. expect(results.map(result => result.output)).toEqual(
  1757. Array.from({ length: 6 }, (_, index) => [
  1758. { type: 'text', text: `answer-${index + 1}` },
  1759. ]),
  1760. )
  1761. expect(runs[0]!.run.id).not.toBe(runs[1]!.run.id)
  1762. await Promise.all(runs.map(entry => entry.run.dispose()))
  1763. })
  1764. it('isolates permission modes and diagnostics across overlapping runs', async () => {
  1765. const first = await publishRun(fakeChild(), undefined, {
  1766. permissionMode: 'never',
  1767. })
  1768. const second = await publishRun(fakeChild(), undefined, {
  1769. permissionMode: 'dangerously-bypass-approvals-and-sandbox',
  1770. })
  1771. first.child.peer.respond(first.turnStart, { turn: { id: 'turn-never' } })
  1772. second.child.peer.respond(second.turnStart, { turn: { id: 'turn-bypass' } })
  1773. await nextTask()
  1774. first.child.peer.send({
  1775. id: 'never-approval',
  1776. method: 'item/commandExecution/requestApproval',
  1777. params: {
  1778. threadId: 'thread-1',
  1779. turnId: 'turn-never',
  1780. availableDecisions: ['cancel'],
  1781. },
  1782. })
  1783. second.child.peer.send({
  1784. id: 'bypass-elicitation',
  1785. method: 'mcpServer/elicitation/request',
  1786. params: { threadId: 'thread-1', turnId: null },
  1787. })
  1788. await Promise.all([
  1789. first.child.peer.nextResponse('never-approval'),
  1790. second.child.peer.nextResponse('bypass-elicitation'),
  1791. ])
  1792. first.child.peer.send(turnCompleted('failed', 'turn-never', 'thread-1', {
  1793. message: 'first failure',
  1794. codexErrorInfo: 'other',
  1795. }))
  1796. second.child.peer.send(turnCompleted('failed', 'turn-bypass', 'thread-1', {
  1797. message: 'second failure',
  1798. codexErrorInfo: 'other',
  1799. }))
  1800. await expect(first.run.result).resolves.toEqual({
  1801. output: [],
  1802. diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  1803. stopReason: 'error',
  1804. })
  1805. await expect(second.run.result).resolves.toEqual({
  1806. output: [],
  1807. diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input`,
  1808. stopReason: 'error',
  1809. })
  1810. await Promise.all([first.run.dispose(), second.run.dispose()])
  1811. })
  1812. it('uses the registered provider config and logs flattened errors', async () => {
  1813. const ctx = new Context()
  1814. await ctx.plugin(SubagentRuntime)
  1815. await ctx.plugin(LocalSubprocessRuntime)
  1816. const child = fakeChild()
  1817. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  1818. const warnings: string[] = []
  1819. ctx.logger.warn = ((message: unknown) => {
  1820. warnings.push(String(message))
  1821. }) as typeof ctx.logger.warn
  1822. await ctx.plugin(codex, {
  1823. env: { OPENAI_API_KEY: 'fake' },
  1824. permissionMode: 'approve-for-me',
  1825. disposeGraceMs: 25,
  1826. })
  1827. const starting = ctx.subagents.start('codex', {
  1828. prompt: [{ type: 'text', text: 'task' }],
  1829. parent: fakeParent,
  1830. signal: new AbortController().signal,
  1831. })
  1832. const initialize = await child.peer.nextMethod('initialize')
  1833. child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' })
  1834. await child.peer.nextMethod('initialized')
  1835. const threadStart = await child.peer.nextMethod('thread/start')
  1836. expect(threadStart.params).toEqual({
  1837. cwd: process.cwd(),
  1838. ephemeral: true,
  1839. approvalPolicy: 'on-request',
  1840. approvalsReviewer: 'auto_review',
  1841. sandbox: 'workspace-write',
  1842. })
  1843. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  1844. const run = await starting
  1845. const turnStart = await child.peer.nextMethod('turn/start')
  1846. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  1847. await nextTask()
  1848. child.peer.send({
  1849. id: 'provider-approval',
  1850. method: 'item/commandExecution/requestApproval',
  1851. params: {
  1852. threadId: 'thread-1',
  1853. turnId: 'turn-1',
  1854. availableDecisions: ['cancel'],
  1855. command: 'cat /private/secret.txt',
  1856. },
  1857. })
  1858. await child.peer.nextResponse('provider-approval')
  1859. child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', {
  1860. message: 'SECRET_TOKEN in /private/secret.txt',
  1861. codexErrorInfo: 'other',
  1862. }))
  1863. await expect(run.result).resolves.toEqual({
  1864. output: [],
  1865. diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval`,
  1866. stopReason: 'error',
  1867. })
  1868. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  1869. argv: codexAppServerArgv(),
  1870. env: { OPENAI_API_KEY: 'fake' },
  1871. graceMs: 25,
  1872. cwd: process.cwd(),
  1873. }))
  1874. expect(warnings).toEqual([
  1875. expect.stringContaining(`subagent-codex: child run failed (error): subagent-codex: ${expectedFailureDiagnostic('turn', 'other')}`),
  1876. ])
  1877. expect(warnings.join('\n')).not.toContain('SECRET_TOKEN')
  1878. expect(warnings.join('\n')).not.toContain('/private/secret.txt')
  1879. await run.dispose()
  1880. await ctx.fiber.dispose()
  1881. })
  1882. })
  1883. describe('disposeCodexChild', () => {
  1884. it('closes stdin, terminates, and waits for the managed tree', async () => {
  1885. const child = fakeChild()
  1886. const wire = defaultWire(child)
  1887. const end = vi.spyOn(child.toChild, 'end')
  1888. await disposeCodexChild(wire, child.handle)
  1889. expect(end).toHaveBeenCalled()
  1890. expect(child.terminate).toHaveBeenCalledTimes(1)
  1891. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  1892. expect(child.waitForExit).toHaveBeenCalledWith()
  1893. })
  1894. it('does not finish disposal before the managed tree exits', async () => {
  1895. const child = fakeChild({ exitOnTerminate: false })
  1896. const wire = defaultWire(child)
  1897. let disposed = false
  1898. const disposal = disposeCodexChild(wire, child.handle).then(() => {
  1899. disposed = true
  1900. })
  1901. await new Promise<void>((resolve) => { setImmediate(resolve) })
  1902. expect(disposed).toBe(false)
  1903. child.settle()
  1904. await disposal
  1905. expect(disposed).toBe(true)
  1906. })
  1907. it('contains a concurrently closed stdin error', async () => {
  1908. const child = fakeChild()
  1909. const wire = defaultWire(child)
  1910. vi.spyOn(child.toChild, 'end').mockImplementation(() => {
  1911. throw new Error('already closed')
  1912. })
  1913. await expect(disposeCodexChild(wire, child.handle))
  1914. .resolves.toBeUndefined()
  1915. })
  1916. it('handles a spawn-level failure with no process tree', async () => {
  1917. const child = fakeChild({
  1918. pid: -1,
  1919. doneError: new Error('spawn failed'),
  1920. })
  1921. const wire = defaultWire(child)
  1922. await expect(disposeCodexChild(wire, child.handle))
  1923. .resolves.toBeUndefined()
  1924. expect(child.terminate).not.toHaveBeenCalled()
  1925. expect(child.waitForExit).not.toHaveBeenCalled()
  1926. })
  1927. it('reports direct-child observer failure and accepts absent stdin', async () => {
  1928. {
  1929. const child = fakeChild({
  1930. doneError: new Error('close observer failed'),
  1931. })
  1932. const wire = defaultWire(child)
  1933. await expect(disposeCodexChild(wire, child.handle))
  1934. .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown'))
  1935. }
  1936. {
  1937. const child = fakeChild()
  1938. const handle = { ...child.handle, stdin: undefined }
  1939. const wire = defaultWire(child)
  1940. await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
  1941. }
  1942. })
  1943. it('aggregates wire-close and tree-wait failures with safe teardown facts', async () => {
  1944. const child = fakeChild({
  1945. waitForExitError: new Error('SECRET_TOKEN wait failure'),
  1946. })
  1947. const wire = defaultWire(child)
  1948. vi.spyOn(wire, 'close').mockImplementation(() => {
  1949. throw new Error('/private/secret.txt close failure')
  1950. })
  1951. const disposal = disposeCodexChild(wire, child.handle)
  1952. await expect(disposal).rejects.toBeInstanceOf(AggregateError)
  1953. await expect(disposal).rejects.toThrow(expectedFailureDiagnostic(
  1954. 'teardown',
  1955. 'unknown',
  1956. { outcome: { exitCode: 0, signal: null } },
  1957. ))
  1958. await expect(disposal).rejects.not.toThrow('SECRET_TOKEN')
  1959. await expect(disposal).rejects.not.toThrow('/private/secret.txt')
  1960. })
  1961. })