subagent-codex.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092
  1. import { PassThrough } from 'node:stream'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/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 SubagentService from '@deepseek-ai/dsh-subagent'
  9. import type {
  10. SubprocessHandle,
  11. SubprocessOutcome,
  12. } from '@deepseek-ai/dsh-subprocess'
  13. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  14. import * as codex from '../src/index.ts'
  15. import * as invariant from '../src/invariant.ts'
  16. import {
  17. DEFAULT_DISPOSE_GRACE_MS,
  18. disposeCodexChild,
  19. startCodexRun,
  20. textTask,
  21. type CodexRunSpec,
  22. } from '../src/run.ts'
  23. import { CodexAppServerWire } from '../src/wire.ts'
  24. type JsonObject = Record<string, unknown>
  25. const fakeParent = {
  26. id: 'parent',
  27. session: { header: { cwd: process.cwd() } },
  28. } as unknown as Agent
  29. function request(
  30. prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
  31. signal = new AbortController().signal,
  32. ) {
  33. return { prompt, parent: fakeParent, signal }
  34. }
  35. async function nextTask(): Promise<void> {
  36. await new Promise<void>((resolve) => { setImmediate(resolve) })
  37. }
  38. class ProtocolPeer {
  39. private buffer = ''
  40. private readonly frames: JsonObject[] = []
  41. private readonly wakeups = new Set<() => void>()
  42. constructor(
  43. input: PassThrough,
  44. private readonly output: PassThrough,
  45. ) {
  46. input.on('data', (chunk: Buffer | string) => {
  47. this.buffer += chunk.toString()
  48. for (;;) {
  49. const newline = this.buffer.indexOf('\n')
  50. if (newline < 0) break
  51. const line = this.buffer.slice(0, newline)
  52. this.buffer = this.buffer.slice(newline + 1)
  53. if (line.trim().length > 0) this.frames.push(JSON.parse(line) as JsonObject)
  54. }
  55. for (const wake of this.wakeups) wake()
  56. this.wakeups.clear()
  57. })
  58. }
  59. async next(predicate: (frame: JsonObject) => boolean): Promise<JsonObject> {
  60. for (;;) {
  61. const index = this.frames.findIndex(predicate)
  62. if (index >= 0) return this.frames.splice(index, 1)[0]!
  63. await new Promise<void>((resolve) => { this.wakeups.add(resolve) })
  64. }
  65. }
  66. nextMethod(method: string): Promise<JsonObject> {
  67. return this.next(frame => frame.method === method)
  68. }
  69. nextResponse(id: unknown): Promise<JsonObject> {
  70. return this.next(frame => frame.id === id && frame.method === undefined)
  71. }
  72. send(...frames: readonly JsonObject[]): void {
  73. this.output.write(`${frames.map(frame => JSON.stringify(frame)).join('\n')}\n`)
  74. }
  75. respond(requestFrame: JsonObject, result: unknown): void {
  76. this.send({ id: requestFrame.id, result })
  77. }
  78. }
  79. interface FakeChildOptions {
  80. readonly pid?: number
  81. readonly exitOnTerminate?: boolean
  82. readonly doneError?: Error
  83. }
  84. interface FakeChild {
  85. readonly handle: SubprocessHandle
  86. readonly peer: ProtocolPeer
  87. readonly fromChild: PassThrough
  88. readonly toChild: PassThrough
  89. readonly settle: (outcome?: SubprocessOutcome) => void
  90. readonly fail: (error: Error) => void
  91. readonly terminate: () => void
  92. readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
  93. }
  94. function fakeChild(options: FakeChildOptions = {}): FakeChild {
  95. const fromChild = new PassThrough()
  96. const toChild = new PassThrough()
  97. const peer = new ProtocolPeer(toChild, fromChild)
  98. let exited = false
  99. let resolveDone!: (outcome: SubprocessOutcome) => void
  100. let rejectDone!: (error: Error) => void
  101. const done = new Promise<SubprocessOutcome>((resolve, reject) => {
  102. resolveDone = resolve
  103. rejectDone = reject
  104. })
  105. const settle = (
  106. outcome: SubprocessOutcome = { exitCode: 0, signal: null },
  107. ): void => {
  108. if (exited) return
  109. exited = true
  110. resolveDone(outcome)
  111. }
  112. const fail = (error: Error): void => {
  113. if (exited) return
  114. exited = true
  115. rejectDone(error)
  116. }
  117. if (options.doneError !== undefined) fail(options.doneError)
  118. const terminate = vi.fn(() => {
  119. if (options.exitOnTerminate !== false) settle()
  120. })
  121. const waitForExit = vi.fn(async (signal?: AbortSignal) => {
  122. if (exited) return true
  123. if (signal === undefined) {
  124. await done.catch(() => {})
  125. return true
  126. }
  127. return await new Promise<boolean>((resolve) => {
  128. const onAbort = (): void => { resolve(false) }
  129. signal.addEventListener('abort', onAbort, { once: true })
  130. void done.then(
  131. () => {
  132. signal.removeEventListener('abort', onAbort)
  133. resolve(true)
  134. },
  135. () => {
  136. signal.removeEventListener('abort', onAbort)
  137. resolve(true)
  138. },
  139. )
  140. })
  141. })
  142. const handle: SubprocessHandle = {
  143. pid: options.pid ?? 1234,
  144. stdin: toChild,
  145. stdout: fromChild,
  146. stderr: undefined,
  147. collected: {},
  148. done,
  149. terminate,
  150. waitForExit,
  151. }
  152. return {
  153. handle,
  154. peer,
  155. fromChild,
  156. toChild,
  157. settle,
  158. fail,
  159. terminate,
  160. waitForExit,
  161. }
  162. }
  163. function runSpec(
  164. child: FakeChild,
  165. overrides: Partial<CodexRunSpec> = {},
  166. ): CodexRunSpec {
  167. return {
  168. cwd: process.cwd(),
  169. env: {},
  170. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  171. spawn: () => child.handle,
  172. ...overrides,
  173. }
  174. }
  175. async function initializeWire(): Promise<{
  176. readonly child: FakeChild
  177. readonly wire: CodexAppServerWire
  178. }> {
  179. const child = fakeChild()
  180. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  181. wire.start()
  182. const initializing = wire.initialize(new AbortController().signal)
  183. const initialize = await child.peer.nextMethod('initialize')
  184. child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
  185. await initializing
  186. expect(await child.peer.nextMethod('initialized')).toEqual({
  187. jsonrpc: '2.0',
  188. method: 'initialized',
  189. })
  190. const starting = wire.startThread(process.cwd(), new AbortController().signal)
  191. const threadStart = await child.peer.nextMethod('thread/start')
  192. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  193. await starting
  194. return { child, wire }
  195. }
  196. async function publishRun(
  197. child = fakeChild(),
  198. signal = new AbortController().signal,
  199. specOverrides: Partial<CodexRunSpec> = {},
  200. ) {
  201. const starting = startCodexRun(request(undefined, signal), runSpec(child, specOverrides))
  202. const initialize = await child.peer.nextMethod('initialize')
  203. child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
  204. await child.peer.nextMethod('initialized')
  205. const threadStart = await child.peer.nextMethod('thread/start')
  206. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  207. const run = await starting
  208. const turnStart = await child.peer.nextMethod('turn/start')
  209. return { child, run, turnStart }
  210. }
  211. function agentMessage(
  212. text: unknown,
  213. phase: unknown,
  214. turnId = 'turn-1',
  215. threadId = 'thread-1',
  216. ): JsonObject {
  217. return {
  218. method: 'item/completed',
  219. params: {
  220. threadId,
  221. turnId,
  222. item: { type: 'agentMessage', text, phase },
  223. },
  224. }
  225. }
  226. function turnCompleted(
  227. status: unknown,
  228. turnId = 'turn-1',
  229. threadId = 'thread-1',
  230. error: unknown = null,
  231. ): JsonObject {
  232. return {
  233. method: 'turn/completed',
  234. params: {
  235. threadId,
  236. turn: { id: turnId, status, error },
  237. },
  238. }
  239. }
  240. describe('task admission and package contracts', () => {
  241. it('accepts one or more text blocks and rejects empty or non-text tasks', () => {
  242. expect(textTask([
  243. { type: 'text', text: 'one' },
  244. { type: 'text', text: 'two' },
  245. ])).toEqual(['one', 'two'])
  246. expect(() => textTask([])).toThrow('only text blocks')
  247. expect(() => textTask([{ type: 'reasoning', text: 'hidden' }]))
  248. .toThrow('only text blocks')
  249. expect(() => textTask([{ type: 'text', text: ' \n ' }]))
  250. .toThrow('must not be empty')
  251. })
  252. it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
  253. const ctx = new Context()
  254. await ctx.plugin(SubagentService)
  255. await ctx.plugin(LocalSubprocessService)
  256. const fiber = await ctx.plugin(codex, {})
  257. const provider = ctx.subagents.getProvider('codex')!
  258. expect(provider).toMatchObject({
  259. name: 'codex',
  260. capabilities: {
  261. outputSchema: false,
  262. depthLimit: false,
  263. toolFilter: false,
  264. persona: false,
  265. },
  266. inheritsParentContext: false,
  267. })
  268. expect(ctx.subagents.list()).toEqual(['codex'])
  269. await fiber.dispose()
  270. expect(ctx.subagents.list()).toEqual([])
  271. for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
  272. await expect(ctx.plugin(codex, { disposeGraceMs }))
  273. .rejects.toThrow('disposeGraceMs must be a positive finite number')
  274. }
  275. await ctx.fiber.dispose()
  276. })
  277. it('keeps the namespace export shape and package-owned empty invariant', async () => {
  278. expect('default' in codex).toBe(false)
  279. expect(codex.name).toBe('subagent-codex')
  280. expect(codex.inject).toEqual(['subagents', 'subprocess'])
  281. const loader = Object.create(Loader.prototype) as Loader
  282. expect(loader.unwrapExports(codex)).toBe(codex)
  283. const dispose = vi.fn()
  284. const register = vi.fn((
  285. _packageName: string,
  286. _installer: InvariantInstaller,
  287. ) => dispose)
  288. const ctx = { invariants: { register } } as unknown as Context
  289. await expect(invariant.apply(ctx)).resolves.toBe(dispose)
  290. expect(register).toHaveBeenCalledWith(
  291. '@deepseek-ai/dsh-subagent-codex',
  292. expect.any(Function),
  293. )
  294. const install = register.mock.calls[0]![1]
  295. await install(new Context(), (message) => { throw new Error(message) })
  296. expect(invariant.name).toBe('subagent-codex-invariant')
  297. expect(invariant.inject).toEqual(['invariants'])
  298. })
  299. })
  300. describe('CodexAppServerWire', () => {
  301. it('sends the fixed handshake, thread, and turn payloads and keeps final_answer', async () => {
  302. const child = fakeChild()
  303. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  304. expect(wire.collectOutput()).toEqual([])
  305. wire.start()
  306. const initializing = wire.initialize(new AbortController().signal)
  307. const initialize = await child.peer.nextMethod('initialize')
  308. expect(initialize.params).toEqual({
  309. clientInfo: {
  310. name: 'deepseek-harness',
  311. title: 'DeepSeek Harness',
  312. version: '0.0.1',
  313. },
  314. capabilities: {
  315. experimentalApi: false,
  316. requestAttestation: false,
  317. },
  318. })
  319. child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
  320. await initializing
  321. await child.peer.nextMethod('initialized')
  322. const starting = wire.startThread('/workspace', new AbortController().signal)
  323. const threadStart = await child.peer.nextMethod('thread/start')
  324. expect(threadStart.params).toEqual({ cwd: '/workspace', ephemeral: true })
  325. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  326. await starting
  327. const result = wire.runTurn(
  328. ['first', 'second'],
  329. new AbortController().signal,
  330. () => false,
  331. )
  332. const turnStart = await child.peer.nextMethod('turn/start')
  333. expect(turnStart.params).toEqual({
  334. threadId: 'thread-1',
  335. input: [
  336. { type: 'text', text: 'first', text_elements: [] },
  337. { type: 'text', text: 'second', text_elements: [] },
  338. ],
  339. })
  340. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  341. await nextTask()
  342. child.peer.send(
  343. {
  344. method: 'turn/started',
  345. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  346. },
  347. agentMessage('other thread', 'final_answer', 'turn-1', 'thread-2'),
  348. agentMessage('other turn', 'final_answer', 'turn-2'),
  349. {
  350. method: 'item/completed',
  351. params: {
  352. threadId: 'thread-1',
  353. turnId: 'turn-1',
  354. item: { type: 'reasoning', text: 'not output' },
  355. },
  356. },
  357. agentMessage('commentary', 'commentary'),
  358. agentMessage('unphased', null),
  359. agentMessage('first final', 'final_answer'),
  360. agentMessage('last final', 'final_answer'),
  361. turnCompleted('completed'),
  362. )
  363. await expect(result).resolves.toEqual({
  364. output: [{ type: 'text', text: 'last final' }],
  365. stopReason: 'completed',
  366. })
  367. expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'last final' }])
  368. wire.close()
  369. wire.close()
  370. })
  371. it('uses the last nullable-phase answer when no explicit final exists', async () => {
  372. const { child, wire } = await initializeWire()
  373. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  374. const turnStart = await child.peer.nextMethod('turn/start')
  375. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  376. child.peer.send(
  377. agentMessage('first', null),
  378. agentMessage('fallback', null),
  379. turnCompleted('completed'),
  380. )
  381. await expect(result).resolves.toEqual({
  382. output: [{ type: 'text', text: 'fallback' }],
  383. stopReason: 'completed',
  384. })
  385. wire.close()
  386. })
  387. it('maps only an explicit context-window failure to max-tokens', async () => {
  388. const { child, wire } = await initializeWire()
  389. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  390. const turnStart = await child.peer.nextMethod('turn/start')
  391. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  392. child.peer.send(
  393. agentMessage('partial answer', null),
  394. turnCompleted('failed', 'turn-1', 'thread-1', {
  395. message: 'too much context',
  396. codexErrorInfo: 'contextWindowExceeded',
  397. }),
  398. )
  399. await expect(result).resolves.toEqual({
  400. output: [{ type: 'text', text: 'partial answer' }],
  401. stopReason: 'max-tokens',
  402. })
  403. wire.close()
  404. })
  405. it('rejects invalid handshake, thread, and turn response shapes', async () => {
  406. {
  407. const child = fakeChild()
  408. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  409. wire.start()
  410. const pending = wire.initialize(new AbortController().signal)
  411. const frame = await child.peer.nextMethod('initialize')
  412. child.peer.respond(frame, null)
  413. await expect(pending).rejects.toThrow('invalid initialize response')
  414. wire.close()
  415. }
  416. {
  417. const child = fakeChild()
  418. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  419. wire.start()
  420. const pending = wire.startThread('/workspace', new AbortController().signal)
  421. const frame = await child.peer.nextMethod('thread/start')
  422. child.peer.respond(frame, { thread: { id: 'thread-1', ephemeral: false } })
  423. await expect(pending).rejects.toThrow('did not create an ephemeral thread')
  424. wire.close()
  425. }
  426. {
  427. const { child, wire } = await initializeWire()
  428. const pending = wire.runTurn(['task'], new AbortController().signal, () => false)
  429. const frame = await child.peer.nextMethod('turn/start')
  430. child.peer.respond(frame, { turn: { id: '' } })
  431. await expect(pending).rejects.toThrow('turn/start turn id')
  432. wire.close()
  433. }
  434. })
  435. it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
  436. const scenarios: Array<{
  437. readonly frames: JsonObject[]
  438. readonly message: string
  439. }> = [
  440. {
  441. frames: [turnCompleted('completed')],
  442. message: 'without a final answer',
  443. },
  444. {
  445. frames: [
  446. agentMessage('fallback', null),
  447. agentMessage(' \n ', 'final_answer'),
  448. turnCompleted('completed'),
  449. ],
  450. message: 'without a final answer',
  451. },
  452. {
  453. frames: [agentMessage(42, 'final_answer')],
  454. message: 'invalid agent message',
  455. },
  456. {
  457. frames: [agentMessage('answer', 'future_phase')],
  458. message: 'unknown agent message phase',
  459. },
  460. {
  461. frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })],
  462. message: 'status failed',
  463. },
  464. {
  465. frames: [turnCompleted('interrupted')],
  466. message: 'status interrupted',
  467. },
  468. {
  469. frames: [turnCompleted('inProgress')],
  470. message: 'invalid terminal turn status',
  471. },
  472. ]
  473. for (const scenario of scenarios) {
  474. const { child, wire } = await initializeWire()
  475. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  476. const turnStart = await child.peer.nextMethod('turn/start')
  477. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  478. child.peer.send(...scenario.frames)
  479. await expect(result).rejects.toThrow(scenario.message)
  480. wire.close()
  481. }
  482. })
  483. it('keeps an unsupported request authoritative over an early terminal in the same chunk', async () => {
  484. const { child, wire } = await initializeWire()
  485. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  486. const turnStart = await child.peer.nextMethod('turn/start')
  487. child.peer.send(
  488. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  489. { id: 'future-request', method: 'future/request', params: {} },
  490. agentMessage('early answer', 'final_answer'),
  491. turnCompleted('completed'),
  492. )
  493. await expect(result).rejects.toThrow('unsupported app-server request')
  494. wire.close()
  495. })
  496. it('gives local cancellation precedence over a remote completed turn', async () => {
  497. const { child, wire } = await initializeWire()
  498. let cancelled = false
  499. const result = wire.runTurn(
  500. ['task'],
  501. new AbortController().signal,
  502. () => cancelled,
  503. )
  504. const turnStart = await child.peer.nextMethod('turn/start')
  505. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  506. cancelled = true
  507. child.peer.send(agentMessage('late', 'final_answer'), turnCompleted('completed'))
  508. await expect(result).resolves.toEqual({
  509. output: [{ type: 'text', text: 'late' }],
  510. stopReason: 'aborted',
  511. })
  512. wire.close()
  513. })
  514. it('answers all five unattended request classes without granting authority', async () => {
  515. const { child, wire } = await initializeWire()
  516. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  517. const turnStart = await child.peer.nextMethod('turn/start')
  518. child.peer.send({
  519. id: 'command',
  520. method: 'item/commandExecution/requestApproval',
  521. params: {
  522. threadId: 'thread-1',
  523. turnId: 'turn-1',
  524. availableDecisions: ['decline', 'cancel'],
  525. },
  526. })
  527. expect(await child.peer.nextResponse('command')).toMatchObject({
  528. result: { decision: 'cancel' },
  529. })
  530. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  531. await nextTask()
  532. const requests = [
  533. {
  534. id: 'file',
  535. method: 'item/fileChange/requestApproval',
  536. params: {
  537. threadId: 'thread-1',
  538. turnId: 'turn-1',
  539. availableDecisions: ['decline'],
  540. },
  541. result: { decision: 'decline' },
  542. },
  543. {
  544. id: 'file-default',
  545. method: 'item/fileChange/requestApproval',
  546. params: { threadId: 'thread-1', turnId: 'turn-1' },
  547. result: { decision: 'decline' },
  548. },
  549. {
  550. id: 'permissions',
  551. method: 'item/permissions/requestApproval',
  552. params: { threadId: 'thread-1', turnId: 'turn-1' },
  553. result: { permissions: {}, scope: 'turn' },
  554. },
  555. {
  556. id: 'user-input',
  557. method: 'item/tool/requestUserInput',
  558. params: { threadId: 'thread-1', turnId: 'turn-1', questions: [] },
  559. result: { answers: {} },
  560. },
  561. {
  562. id: 'mcp',
  563. method: 'mcpServer/elicitation/request',
  564. params: { threadId: 'thread-1', turnId: null },
  565. result: { action: 'decline', content: null, _meta: null },
  566. },
  567. ] as const
  568. for (const serverRequest of requests) {
  569. child.peer.send(serverRequest)
  570. expect(await child.peer.nextResponse(serverRequest.id)).toMatchObject({
  571. result: serverRequest.result,
  572. })
  573. }
  574. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  575. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  576. wire.close()
  577. })
  578. it('fails the run on unknown requests or wrong request association', async () => {
  579. for (const serverRequest of [
  580. {
  581. id: 'unknown',
  582. method: 'future/request',
  583. params: { threadId: 'thread-1', turnId: 'turn-1' },
  584. },
  585. {
  586. id: 'approval',
  587. method: 'item/commandExecution/requestApproval',
  588. params: {
  589. threadId: 'thread-1',
  590. turnId: 'turn-1',
  591. availableDecisions: ['accept'],
  592. },
  593. },
  594. {
  595. id: 'malformed-approval',
  596. method: 'item/fileChange/requestApproval',
  597. params: {
  598. threadId: 'thread-1',
  599. turnId: 'turn-1',
  600. availableDecisions: 'decline',
  601. },
  602. },
  603. {
  604. id: 'thread',
  605. method: 'item/fileChange/requestApproval',
  606. params: { threadId: 'thread-2', turnId: 'turn-1' },
  607. },
  608. {
  609. id: 'turn',
  610. method: 'item/fileChange/requestApproval',
  611. params: { threadId: 'thread-1', turnId: 'turn-2' },
  612. },
  613. ]) {
  614. const { child, wire } = await initializeWire()
  615. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  616. const turnStart = await child.peer.nextMethod('turn/start')
  617. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  618. await nextTask()
  619. child.peer.send(serverRequest)
  620. const response = await child.peer.nextResponse(serverRequest.id)
  621. expect(response.error).toMatchObject({ code: -32603 })
  622. await expect(result).rejects.toThrow()
  623. wire.close()
  624. }
  625. })
  626. it('rejects conflicting early turn identities before accepting output', async () => {
  627. const { child, wire } = await initializeWire()
  628. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  629. const turnStart = await child.peer.nextMethod('turn/start')
  630. child.peer.send({
  631. method: 'turn/started',
  632. params: { threadId: 'thread-1', turn: { id: 'turn-early' } },
  633. })
  634. child.peer.respond(turnStart, { turn: { id: 'turn-response' } })
  635. await expect(result).rejects.toThrow('did not match the active turn')
  636. wire.close()
  637. })
  638. it('rejects conflicting early notifications and requests before turn/start', async () => {
  639. {
  640. const { child, wire } = await initializeWire()
  641. child.peer.send({
  642. id: 'too-early',
  643. method: 'item/fileChange/requestApproval',
  644. params: { threadId: 'thread-1', turnId: 'turn-1' },
  645. })
  646. const response = await child.peer.nextResponse('too-early')
  647. expect(response.error).toMatchObject({ code: -32603 })
  648. wire.close()
  649. }
  650. {
  651. const { child, wire } = await initializeWire()
  652. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  653. await child.peer.nextMethod('turn/start')
  654. child.peer.send(
  655. {
  656. method: 'turn/started',
  657. params: { threadId: 'thread-1', turn: { id: 'turn-1' } },
  658. },
  659. agentMessage('wrong', 'final_answer', 'turn-2'),
  660. )
  661. await expect(result).rejects.toThrow('conflicting turns')
  662. wire.close()
  663. }
  664. })
  665. it('interrupts only an active open turn and contains remote interrupt failure', async () => {
  666. const { child, wire } = await initializeWire()
  667. wire.interrupt()
  668. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  669. const turnStart = await child.peer.nextMethod('turn/start')
  670. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  671. await nextTask()
  672. wire.interrupt()
  673. const interrupt = await child.peer.nextMethod('turn/interrupt')
  674. expect(interrupt.params).toEqual({ threadId: 'thread-1', turnId: 'turn-1' })
  675. child.peer.send({
  676. id: interrupt.id,
  677. error: { code: -32000, message: 'already done' },
  678. })
  679. child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed'))
  680. await expect(result).resolves.toMatchObject({ stopReason: 'completed' })
  681. wire.close()
  682. wire.interrupt()
  683. })
  684. it('ignores unrelated and out-of-window notifications', async () => {
  685. const { child, wire } = await initializeWire()
  686. child.peer.send(
  687. {
  688. method: 'turn/started',
  689. params: { threadId: 'thread-2', turn: { id: 'turn-other' } },
  690. },
  691. {
  692. method: 'turn/started',
  693. params: { threadId: 'thread-1', turn: { id: 'turn-before' } },
  694. },
  695. agentMessage('before', 'final_answer'),
  696. { method: 'future/notification', params: {} },
  697. turnCompleted('completed'),
  698. turnCompleted('completed', 'turn-other', 'thread-2'),
  699. )
  700. await nextTask()
  701. const result = wire.runTurn(['task'], new AbortController().signal, () => false)
  702. const turnStart = await child.peer.nextMethod('turn/start')
  703. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  704. await nextTask()
  705. child.peer.send(
  706. agentMessage('wrong turn', 'final_answer', 'turn-2'),
  707. turnCompleted('completed', 'turn-2'),
  708. agentMessage('answer', 'final_answer'),
  709. turnCompleted('completed'),
  710. )
  711. await expect(result).resolves.toEqual({
  712. output: [{ type: 'text', text: 'answer' }],
  713. stopReason: 'completed',
  714. })
  715. wire.close()
  716. })
  717. it('rejects pending work on abort, EOF, and stream error', async () => {
  718. {
  719. const child = fakeChild()
  720. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  721. wire.start()
  722. const controller = new AbortController()
  723. controller.abort('pre-aborted')
  724. await expect(wire.initialize(controller.signal))
  725. .rejects.toThrow('app-server request aborted: pre-aborted')
  726. wire.close()
  727. }
  728. {
  729. const child = fakeChild()
  730. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  731. wire.start()
  732. const controller = new AbortController()
  733. const pending = wire.initialize(controller.signal)
  734. await child.peer.nextMethod('initialize')
  735. controller.abort(new Error('cancel initialize'))
  736. await expect(pending).rejects.toThrow('cancel initialize')
  737. wire.close()
  738. }
  739. {
  740. const child = fakeChild()
  741. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  742. wire.start()
  743. const pending = wire.initialize(new AbortController().signal)
  744. await child.peer.nextMethod('initialize')
  745. child.fromChild.end()
  746. await expect(pending).rejects.toThrow(/(?:protocol stream|JSON-RPC input) closed/)
  747. wire.close()
  748. }
  749. {
  750. const child = fakeChild()
  751. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  752. wire.start()
  753. const pending = wire.initialize(new AbortController().signal)
  754. await child.peer.nextMethod('initialize')
  755. child.fromChild.emit('error', new Error('stdout broke'))
  756. await expect(pending).rejects.toThrow('stdout broke')
  757. wire.close()
  758. }
  759. {
  760. const child = fakeChild()
  761. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  762. wire.start()
  763. const pending = wire.initialize(new AbortController().signal)
  764. await child.peer.nextMethod('initialize')
  765. child.toChild.emit('error', new Error('stdin broke'))
  766. await expect(pending).rejects.toThrow('stdin broke')
  767. wire.close()
  768. child.toChild.emit('error', new Error('late stdin close'))
  769. }
  770. })
  771. })
  772. describe('run lifecycle and quiescence', () => {
  773. it('spawns the fixed app-server, publishes after thread creation, and disposes once', async () => {
  774. const child = fakeChild()
  775. const spawn = vi.fn(() => child.handle)
  776. const starting = startCodexRun(
  777. request([{ type: 'text', text: 'task' }]),
  778. runSpec(child, { env: { OPENAI_API_KEY: 'fake' }, spawn }),
  779. )
  780. let published = false
  781. void starting.then(() => { published = true })
  782. const initialize = await child.peer.nextMethod('initialize')
  783. expect(published).toBe(false)
  784. child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
  785. await child.peer.nextMethod('initialized')
  786. const threadStart = await child.peer.nextMethod('thread/start')
  787. expect(published).toBe(false)
  788. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  789. const run = await starting
  790. expect(spawn).toHaveBeenCalledWith({
  791. argv: ['codex', 'app-server', '--stdio'],
  792. cwd: process.cwd(),
  793. stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
  794. graceMs: DEFAULT_DISPOSE_GRACE_MS,
  795. env: { OPENAI_API_KEY: 'fake' },
  796. })
  797. expect(run.localAgent).toBeUndefined()
  798. const turnStart = await child.peer.nextMethod('turn/start')
  799. child.peer.send(
  800. { id: turnStart.id, result: { turn: { id: 'turn-1' } } },
  801. agentMessage('answer', 'final_answer'),
  802. turnCompleted('completed'),
  803. )
  804. await expect(run.result).resolves.toEqual({
  805. output: [{ type: 'text', text: 'answer' }],
  806. stopReason: 'completed',
  807. })
  808. const disposal = run.dispose()
  809. expect(run.dispose()).toBe(disposal)
  810. await disposal
  811. await nextTask()
  812. expect(child.terminate).toHaveBeenCalledTimes(1)
  813. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  814. })
  815. it('settles local cancellation immediately and sends best-effort interrupt', async () => {
  816. const controller = new AbortController()
  817. const { child, run, turnStart } = await publishRun(
  818. fakeChild(),
  819. controller.signal,
  820. )
  821. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  822. await nextTask()
  823. controller.abort(new Error('stop'))
  824. await expect(run.result).resolves.toEqual({
  825. output: [],
  826. stopReason: 'aborted',
  827. })
  828. expect(await child.peer.nextMethod('turn/interrupt')).toMatchObject({
  829. params: { threadId: 'thread-1', turnId: 'turn-1' },
  830. })
  831. await run.dispose()
  832. })
  833. it('flattens child exit and protocol failures after publication', async () => {
  834. const errors: string[] = []
  835. {
  836. const child = fakeChild({ exitOnTerminate: false })
  837. const { run } = await publishRun(child, undefined, {
  838. onError: (error) => { errors.push(error.message) },
  839. })
  840. child.settle({ exitCode: 9, signal: null })
  841. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  842. expect(errors.at(-1)).toContain('code 9')
  843. await run.dispose().catch(() => {})
  844. }
  845. {
  846. const child = fakeChild()
  847. const { run, turnStart } = await publishRun(child, undefined, {
  848. onError: () => { throw new Error('diagnostic sink') },
  849. })
  850. child.peer.respond(turnStart, { turn: { id: 'turn-1' } })
  851. child.fromChild.end()
  852. await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' })
  853. await run.dispose()
  854. }
  855. })
  856. it('rejects before spawn when pre-aborted and rolls back startup failures', async () => {
  857. const controller = new AbortController()
  858. controller.abort()
  859. const spawn = vi.fn()
  860. await expect(startCodexRun(
  861. request(undefined, controller.signal),
  862. {
  863. cwd: process.cwd(),
  864. env: {},
  865. disposeGraceMs: 10,
  866. spawn,
  867. },
  868. )).rejects.toThrow('aborted before app-server startup')
  869. expect(spawn).not.toHaveBeenCalled()
  870. const child = fakeChild()
  871. const starting = startCodexRun(request(), runSpec(child))
  872. const initialize = await child.peer.nextMethod('initialize')
  873. child.peer.respond(initialize, { userAgent: '' })
  874. await expect(starting).rejects.toThrow('initialize userAgent')
  875. expect(child.terminate).toHaveBeenCalledTimes(1)
  876. })
  877. it('rolls back an abort that wins immediately after thread creation', async () => {
  878. const controller = new AbortController()
  879. const child = fakeChild()
  880. const starting = startCodexRun(
  881. request(undefined, controller.signal),
  882. runSpec(child),
  883. )
  884. const initialize = await child.peer.nextMethod('initialize')
  885. child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
  886. await child.peer.nextMethod('initialized')
  887. const threadStart = await child.peer.nextMethod('thread/start')
  888. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  889. controller.abort('startup race')
  890. await expect(starting).rejects.toThrow('aborted before run publication')
  891. expect(child.terminate).toHaveBeenCalledTimes(1)
  892. })
  893. it('rolls back a subprocess done rejection during startup', async () => {
  894. const child = fakeChild({ doneError: new Error('spawn observer failed') })
  895. const error: unknown = await startCodexRun(request(), runSpec(child)).then(
  896. () => undefined,
  897. (failure: unknown) => failure,
  898. )
  899. expect(error).toBeInstanceOf(AggregateError)
  900. if (!(error instanceof AggregateError)) {
  901. throw new Error('expected startup and rollback failures')
  902. }
  903. expect(error.errors).toEqual([
  904. expect.objectContaining({ message: 'spawn observer failed' }),
  905. expect.objectContaining({ message: 'spawn observer failed' }),
  906. ])
  907. expect(child.terminate).toHaveBeenCalledTimes(1)
  908. })
  909. it('keeps overlapping runs isolated', async () => {
  910. const first = fakeChild()
  911. const second = fakeChild()
  912. const runs = await Promise.all([
  913. publishRun(first),
  914. publishRun(second),
  915. ])
  916. for (const [index, entry] of runs.entries()) {
  917. const id = `turn-${index + 1}`
  918. entry.child.peer.send(
  919. { id: entry.turnStart.id, result: { turn: { id } } },
  920. agentMessage(`answer-${index + 1}`, 'final_answer', id),
  921. turnCompleted('completed', id),
  922. )
  923. }
  924. const results = await Promise.all(runs.map(entry => entry.run.result))
  925. expect(results.map(result => result.output)).toEqual([
  926. [{ type: 'text', text: 'answer-1' }],
  927. [{ type: 'text', text: 'answer-2' }],
  928. ])
  929. expect(runs[0].run.id).not.toBe(runs[1].run.id)
  930. await Promise.all(runs.map(entry => entry.run.dispose()))
  931. })
  932. it('uses the registered provider config and logs flattened errors', async () => {
  933. const ctx = new Context()
  934. await ctx.plugin(SubagentService)
  935. await ctx.plugin(LocalSubprocessService)
  936. const child = fakeChild()
  937. const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
  938. const warnings: string[] = []
  939. ctx.logger.warn = ((message: unknown) => {
  940. warnings.push(String(message))
  941. }) as typeof ctx.logger.warn
  942. await ctx.plugin(codex, {
  943. env: { OPENAI_API_KEY: 'fake' },
  944. disposeGraceMs: 25,
  945. })
  946. const starting = ctx.subagents.start('codex', {
  947. prompt: [{ type: 'text', text: 'task' }],
  948. parent: fakeParent,
  949. signal: new AbortController().signal,
  950. })
  951. const initialize = await child.peer.nextMethod('initialize')
  952. child.peer.respond(initialize, { userAgent: 'codex-cli 0.146.0' })
  953. await child.peer.nextMethod('initialized')
  954. const threadStart = await child.peer.nextMethod('thread/start')
  955. child.peer.respond(threadStart, { thread: { id: 'thread-1', ephemeral: true } })
  956. const run = await starting
  957. await child.peer.nextMethod('turn/start')
  958. child.settle({ exitCode: 1, signal: null })
  959. await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
  960. expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
  961. env: { OPENAI_API_KEY: 'fake' },
  962. graceMs: 25,
  963. cwd: process.cwd(),
  964. }))
  965. expect(warnings).toEqual([
  966. expect.stringContaining('subagent-codex: child run failed (error):'),
  967. ])
  968. await run.dispose().catch(() => {})
  969. await ctx.fiber.dispose()
  970. })
  971. })
  972. describe('disposeCodexChild', () => {
  973. it('closes stdin, terminates, and waits for the managed tree', async () => {
  974. const child = fakeChild()
  975. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  976. const end = vi.spyOn(child.toChild, 'end')
  977. await disposeCodexChild(wire, child.handle)
  978. expect(end).toHaveBeenCalled()
  979. expect(child.terminate).toHaveBeenCalledTimes(1)
  980. expect(child.waitForExit).toHaveBeenCalledTimes(1)
  981. expect(child.waitForExit).toHaveBeenCalledWith()
  982. })
  983. it('does not finish disposal before the managed tree exits', async () => {
  984. const child = fakeChild({ exitOnTerminate: false })
  985. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  986. let disposed = false
  987. const disposal = disposeCodexChild(wire, child.handle).then(() => {
  988. disposed = true
  989. })
  990. await new Promise<void>((resolve) => { setImmediate(resolve) })
  991. expect(disposed).toBe(false)
  992. child.settle()
  993. await disposal
  994. expect(disposed).toBe(true)
  995. })
  996. it('contains a concurrently closed stdin error', async () => {
  997. const child = fakeChild()
  998. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  999. vi.spyOn(child.toChild, 'end').mockImplementation(() => {
  1000. throw new Error('already closed')
  1001. })
  1002. await expect(disposeCodexChild(wire, child.handle))
  1003. .resolves.toBeUndefined()
  1004. })
  1005. it('handles a spawn-level failure with no process tree', async () => {
  1006. const child = fakeChild({
  1007. pid: -1,
  1008. doneError: new Error('spawn failed'),
  1009. })
  1010. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1011. await expect(disposeCodexChild(wire, child.handle))
  1012. .resolves.toBeUndefined()
  1013. expect(child.terminate).not.toHaveBeenCalled()
  1014. expect(child.waitForExit).not.toHaveBeenCalled()
  1015. })
  1016. it('reports direct-child observer failure and accepts absent stdin', async () => {
  1017. {
  1018. const child = fakeChild({
  1019. doneError: new Error('close observer failed'),
  1020. })
  1021. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1022. await expect(disposeCodexChild(wire, child.handle))
  1023. .rejects.toThrow('close observer failed')
  1024. }
  1025. {
  1026. const child = fakeChild()
  1027. const handle = { ...child.handle, stdin: undefined }
  1028. const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
  1029. await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
  1030. }
  1031. })
  1032. })