subagent-codex.spec.ts 84 KB

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