subagent-codex.spec.ts 85 KB

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