subagent-codex.spec.ts 58 KB

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