subagent-codex.spec.ts 84 KB

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