subagent-acp.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import Loader from '@cordisjs/plugin-loader'
  4. import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join, resolve } from 'node:path'
  7. import { fileURLToPath } from 'node:url'
  8. import SubagentService from '@deepseek-ai/dsh-subagent'
  9. import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess'
  10. import type { Agent } from '@deepseek-ai/dsh-agent'
  11. import * as acp from '../src/index.ts'
  12. import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
  13. /**
  14. * Keyless integration tests for the ACP subagent backend. Each spawns a REAL
  15. * subprocess — the scripted mock ACP server (tests/mock-acp-server.ts) — and
  16. * drives it through the REAL backend over real ACP JSON-RPC stdio, so the
  17. * connection setup, the client callbacks, the prompt round-trip, the stop-reason
  18. * mapping, cancellation, and quiescent disposal are all exercised end to end.
  19. * No model, no key.
  20. */
  21. const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url))
  22. /** A parent Agent stub. The ACP backend reads exactly one thing off it: the session header's cwd (the workspace its child inherits). */
  23. const fakeParent = { id: 'parent', session: { header: { cwd: process.cwd() } } } as unknown as Agent
  24. function request(text = 'p', signal = new AbortController().signal) {
  25. return { prompt: [{ type: 'text' as const, text }], parent: fakeParent, signal }
  26. }
  27. interface SetupEnv {
  28. /** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */
  29. [key: string]: string
  30. }
  31. /**
  32. * Mount the ACP backend pointed at the mock server, scripted by `mockEnv`.
  33. * `permission` selects the backend's auto-answer policy.
  34. */
  35. async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') {
  36. const ctx = new Context()
  37. await ctx.plugin(SubagentService)
  38. await ctx.plugin(acp, {
  39. providerName: 'acp',
  40. command: process.execPath,
  41. args: [mockServer],
  42. permission,
  43. env: mockEnv,
  44. })
  45. return ctx
  46. }
  47. function text(blocks: { type: string; text?: string }[]): string {
  48. return blocks.filter(b => b.type === 'text').map(b => b.text).join('')
  49. }
  50. /**
  51. * Poll until `file` exists (the mock touches it once its prompt is in flight),
  52. * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
  53. * subprocess cold-start is variable, and a fixed sleep both flakes and
  54. * slows the suite. Fails loud if the child never signals readiness.
  55. */
  56. async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
  57. const deadline = Date.now() + timeoutMs
  58. while (!existsSync(file)) {
  59. if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`)
  60. await new Promise(r => setTimeout(r, 10))
  61. }
  62. }
  63. describe('acpStopReason', () => {
  64. it('maps each ACP stop reason to the harness vocabulary', () => {
  65. expect(acpStopReason('end_turn')).toBe('completed')
  66. expect(acpStopReason('max_tokens')).toBe('max-tokens')
  67. expect(acpStopReason('refusal')).toBe('refusal')
  68. expect(acpStopReason('cancelled')).toBe('aborted')
  69. expect(acpStopReason('max_turn_requests')).toBe('error')
  70. })
  71. it('treats an unknown terminal reason as an error', () => {
  72. expect(acpStopReason('something-new' as never)).toBe('error')
  73. })
  74. })
  75. describe('acpContentText / toAcpPrompt', () => {
  76. it('extracts text from a text content block, empty for non-text', () => {
  77. expect(acpContentText({ type: 'text', text: 'hi' })).toBe('hi')
  78. // A non-text ACP content block (e.g. an image) contributes no text.
  79. expect(acpContentText({ type: 'image', data: 'x', mimeType: 'image/png' })).toBe('')
  80. })
  81. it('keeps text prompt blocks and drops non-text ones', () => {
  82. expect(toAcpPrompt([{ type: 'text', text: 'a' }])).toEqual([{ type: 'text', text: 'a' }])
  83. // A non-text harness block (e.g. reasoning) is dropped from the ACP prompt.
  84. expect(toAcpPrompt([{ type: 'text', text: 'a' }, { type: 'reasoning', text: 'think' }]))
  85. .toEqual([{ type: 'text', text: 'a' }])
  86. })
  87. })
  88. describe('buildChildEnv', () => {
  89. it('drops credential-shaped ambient vars but keeps the explicit extras', () => {
  90. process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me'
  91. try {
  92. const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' })
  93. // The credential-shaped ambient var is scrubbed.
  94. expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined()
  95. // The explicitly-supplied key survives (an opt-in for the child's creds).
  96. expect(env.DEEPSEEK_API_KEY).toBe('explicit')
  97. // A normal ambient var is forwarded.
  98. expect(env.PATH).toBe(process.env.PATH)
  99. } finally {
  100. delete process.env.DSH_ACP_TEST_SECRET_TOKEN
  101. }
  102. })
  103. })
  104. describe('cwd resolution', () => {
  105. it('falls back to the parent session cwd for the child process AND its ACP session', async () => {
  106. // realpath: on macOS `tmpdir()` sits behind a symlink (/var → /private/var),
  107. // and the child reports its REAL process.cwd() — compare canonical paths.
  108. const workdir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
  109. try {
  110. const ctx = await setup({ MOCK_ECHO_CWD: '1' })
  111. const parent = { id: 'parent', session: { header: { cwd: workdir } } } as unknown as Agent
  112. const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
  113. const result = await run.result
  114. await run.dispose()
  115. // Line 1: where the child process actually ran; line 2: the workspace the
  116. // backend announced in `session/new`. Both must be the parent's workspace.
  117. expect(text(result.output)).toBe(`${workdir}\n${workdir}`)
  118. } finally {
  119. rmSync(workdir, { recursive: true, force: true })
  120. }
  121. })
  122. it('rejects before spawning when neither config.cwd nor the parent session provides one', async () => {
  123. const tmp = mkdtempSync(join(tmpdir(), 'acp-no-cwd-'))
  124. const sentinel = join(tmp, 'spawned')
  125. try {
  126. const ctx = new Context()
  127. await ctx.plugin(SubagentService)
  128. // A command that would create the sentinel if the child were ever spawned.
  129. await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
  130. const parent = { id: 'parent', session: { header: {} } } as unknown as Agent
  131. await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
  132. .rejects.toThrow('no working directory')
  133. // Resolution failed BEFORE the process boundary — nothing was launched.
  134. expect(existsSync(sentinel)).toBe(false)
  135. } finally {
  136. rmSync(tmp, { recursive: true, force: true })
  137. }
  138. })
  139. it('prefers the configured cwd override to the parent session cwd', async () => {
  140. const configured = realpathSync(mkdtempSync(join(tmpdir(), 'acp-cfg-cwd-')))
  141. const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
  142. try {
  143. const ctx = new Context()
  144. await ctx.plugin(SubagentService)
  145. await ctx.plugin(acp, {
  146. providerName: 'acp',
  147. command: process.execPath,
  148. args: [mockServer],
  149. cwd: configured,
  150. permission: 'reject',
  151. env: { MOCK_ECHO_CWD: '1' },
  152. })
  153. const parent = { id: 'parent', session: { header: { cwd: parentDir } } } as unknown as Agent
  154. const run = await ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal })
  155. const result = await run.result
  156. await run.dispose()
  157. expect(text(result.output)).toBe(`${configured}\n${configured}`)
  158. } finally {
  159. rmSync(configured, { recursive: true, force: true })
  160. rmSync(parentDir, { recursive: true, force: true })
  161. }
  162. })
  163. it('resolves a relative config cwd against the launch directory at load', async () => {
  164. // The child process AND its announced ACP session cwd must both get the
  165. // ABSOLUTE form — DSH's own ACP server rejects a relative session cwd, and
  166. // deferring resolution to spawn would hide the launch-dir dependency.
  167. const relative = 'packages/subagent/subagent-acp'
  168. const absolute = resolve(relative)
  169. const ctx = new Context()
  170. await ctx.plugin(SubagentService)
  171. await ctx.plugin(acp, {
  172. providerName: 'acp',
  173. command: process.execPath,
  174. args: [mockServer],
  175. cwd: relative,
  176. permission: 'reject',
  177. env: { MOCK_ECHO_CWD: '1' },
  178. })
  179. const run = await ctx.subagents.start('acp', request())
  180. const result = await run.result
  181. await run.dispose()
  182. expect(text(result.output)).toBe(`${realpathSync(absolute)}\n${absolute}`)
  183. })
  184. it('rejects an empty config cwd at load', async () => {
  185. // `path.resolve('')` is the process cwd, so an empty string would silently
  186. // reintroduce the launch-directory fallback this resolution removed.
  187. const ctx = new Context()
  188. await ctx.plugin(SubagentService)
  189. await expect(ctx.plugin(acp, {
  190. providerName: 'acp',
  191. command: 'true',
  192. args: [],
  193. cwd: '',
  194. permission: 'reject',
  195. env: {},
  196. })).rejects.toThrow('config cwd must not be empty')
  197. await ctx.fiber.dispose()
  198. })
  199. // Windows ACLs do not expose the POSIX directory search-bit state this fixture creates.
  200. it.skipIf(process.platform === 'win32')('rejects a config cwd directory without search permission at load', async () => {
  201. // statSync().isDirectory() is true for a mode-600 directory, but a
  202. // subprocess cwd needs SEARCH permission — spawn would fail EACCES.
  203. const tmp = mkdtempSync(join(tmpdir(), 'acp-noexec-'))
  204. chmodSync(tmp, 0o600)
  205. try {
  206. const ctx = new Context()
  207. await ctx.plugin(SubagentService)
  208. await expect(ctx.plugin(acp, {
  209. providerName: 'acp',
  210. command: 'true',
  211. args: [],
  212. cwd: tmp,
  213. permission: 'reject',
  214. env: {},
  215. })).rejects.toThrow('not an accessible directory')
  216. await ctx.fiber.dispose()
  217. } finally {
  218. chmodSync(tmp, 0o700)
  219. rmSync(tmp, { recursive: true, force: true })
  220. }
  221. })
  222. it('rejects a config cwd that is not an accessible directory at load', async () => {
  223. const ctx = new Context()
  224. await ctx.plugin(SubagentService)
  225. await expect(ctx.plugin(acp, {
  226. providerName: 'acp',
  227. command: 'true',
  228. args: [],
  229. cwd: '/nonexistent/acp-child-workspace',
  230. permission: 'reject',
  231. env: {},
  232. })).rejects.toThrow('not an accessible directory')
  233. await ctx.fiber.dispose()
  234. })
  235. it('rejects a parent session cwd that is not absolute', async () => {
  236. // SessionHeader documents cwd as absolute; a relative value here is a broken
  237. // header, and resolving it against the server process cwd would silently
  238. // re-introduce the launch-directory dependency this resolution removes.
  239. const ctx = await setup({})
  240. const parent = { id: 'parent', session: { header: { cwd: 'relative/workspace' } } } as unknown as Agent
  241. await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
  242. .rejects.toThrow('must be an absolute path')
  243. })
  244. it('rejects a parent session cwd that names a FILE, not a directory', async () => {
  245. const tmp = mkdtempSync(join(tmpdir(), 'acp-file-cwd-'))
  246. const file = join(tmp, 'a-file')
  247. writeFileSync(file, 'x')
  248. try {
  249. const ctx = await setup({})
  250. const parent = { id: 'parent', session: { header: { cwd: file } } } as unknown as Agent
  251. await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
  252. .rejects.toThrow('not an accessible directory')
  253. } finally {
  254. rmSync(tmp, { recursive: true, force: true })
  255. }
  256. })
  257. it('rejects a parent session cwd that is not an accessible directory, before spawning', async () => {
  258. const tmp = mkdtempSync(join(tmpdir(), 'acp-bad-parent-cwd-'))
  259. const sentinel = join(tmp, 'spawned')
  260. try {
  261. const ctx = new Context()
  262. await ctx.plugin(SubagentService)
  263. await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
  264. const parent = { id: 'parent', session: { header: { cwd: join(tmp, 'vanished') } } } as unknown as Agent
  265. await expect(ctx.subagents.start('acp', { prompt: [{ type: 'text' as const, text: 'p' }], parent, signal: new AbortController().signal }))
  266. .rejects.toThrow('not an accessible directory')
  267. expect(existsSync(sentinel)).toBe(false)
  268. } finally {
  269. rmSync(tmp, { recursive: true, force: true })
  270. }
  271. })
  272. })
  273. describe('dsh-subagent-acp', () => {
  274. it('drives child processes with parent-unique run ids and returns streamed output', async () => {
  275. const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' })
  276. const run = await ctx.subagents.start('acp', request('do X'))
  277. expect(run.id).not.toBe('acp-child-session')
  278. const result = await run.result
  279. expect(result.stopReason).toBe('completed')
  280. expect(text(result.output)).toBe('hello from acp child')
  281. const disposal = run.dispose()
  282. expect(run.dispose()).toBe(disposal)
  283. await disposal
  284. const nextRun = await ctx.subagents.start('acp', request('do X again'))
  285. expect(nextRun.id).not.toBe(run.id)
  286. expect(nextRun.id).not.toBe('acp-child-session')
  287. await nextRun.result
  288. await nextRun.dispose()
  289. })
  290. it('maps a max_tokens stop reason', async () => {
  291. const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' })
  292. const run = await ctx.subagents.start('acp', request())
  293. const result = await run.result
  294. expect(result.stopReason).toBe('max-tokens')
  295. await run.dispose()
  296. })
  297. it('maps a refusal stop reason', async () => {
  298. const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' })
  299. const run = await ctx.subagents.start('acp', request())
  300. const result = await run.result
  301. expect(result.stopReason).toBe('refusal')
  302. await run.dispose()
  303. })
  304. it('aborting the required signal cancels a running child', async () => {
  305. const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
  306. const readyFile = join(tmp, 'ready')
  307. try {
  308. const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
  309. const controller = new AbortController()
  310. const run = await ctx.subagents.start('acp', request('p', controller.signal))
  311. // Wait until the child's prompt is in flight (condition, not a sleep),
  312. // then cancel — so we exercise the mid-run session/cancel path.
  313. await waitForFile(readyFile)
  314. controller.abort('test')
  315. const result = await run.result
  316. expect(result.stopReason).toBe('aborted')
  317. await run.dispose()
  318. } finally {
  319. rmSync(tmp, { recursive: true, force: true })
  320. }
  321. })
  322. it('rejects WITHOUT spawning the child when the signal is already aborted', async () => {
  323. // A pre-aborted request must not even launch the configured binary. Point
  324. // the command at one that would create a sentinel file if it ever ran, and
  325. // assert the sentinel never appears.
  326. const tmp = mkdtempSync(join(tmpdir(), 'acp-preabort-'))
  327. const sentinel = join(tmp, 'spawned')
  328. try {
  329. const controller = new AbortController()
  330. controller.abort()
  331. await expect(startAcpRun(
  332. request('p', controller.signal),
  333. // `touch <sentinel>` — runs only if the process is actually spawned.
  334. { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
  335. )).rejects.toThrow('aborted before the ACP child started')
  336. // The binary was never launched — no sentinel.
  337. expect(existsSync(sentinel)).toBe(false)
  338. } finally {
  339. rmSync(tmp, { recursive: true, force: true })
  340. }
  341. })
  342. it('reaps a child whose session/new response omits the session id', async () => {
  343. const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-'))
  344. const flushed = join(tmp, 'flushed')
  345. try {
  346. await expect(startAcpRun(request(), {
  347. command: process.execPath,
  348. args: [mockServer],
  349. cwd: process.cwd(),
  350. permission: 'reject',
  351. env: {
  352. MOCK_MISSING_SESSION_ID: '1',
  353. MOCK_FLUSH_ON_EOF: flushed,
  354. MOCK_FLUSH_DELAY_MS: '20',
  355. },
  356. disposeEofGraceMs: 1000,
  357. disposeGraceMs: 100,
  358. })).rejects.toThrow('ACP child published without a session id')
  359. // Startup rejects only after its private child reaches quiescence. The
  360. // marker proves rollback closed stdin and allowed the child's EOF flush.
  361. expect(existsSync(flushed)).toBe(true)
  362. } finally {
  363. rmSync(tmp, { recursive: true, force: true })
  364. }
  365. })
  366. it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
  367. // The child traps SIGTERM and keeps its event loop alive, so a graceful
  368. // term alone would hang dispose forever. With a short grace, dispose must
  369. // escalate to SIGKILL and return once the process is actually gone.
  370. const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
  371. const ready = join(tmp, 'trap-armed')
  372. try {
  373. const spec: AcpRunSpec = {
  374. command: process.execPath,
  375. args: [mockServer],
  376. cwd: process.cwd(),
  377. permission: 'reject',
  378. env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
  379. // Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must
  380. // burn the EOF window, then the SIGTERM window, then SIGKILL — keep each
  381. // small so the whole ladder finishes well within the 4000ms bound.
  382. disposeEofGraceMs: 150,
  383. disposeGraceMs: 150,
  384. }
  385. const run = await startAcpRun(request(), spec)
  386. // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
  387. // sleep) — otherwise SIGTERM races the trap install and the default handler
  388. // terminates the child, never exercising the escalation.
  389. await waitForFile(ready)
  390. // Don't await result (the child hangs). Dispose must still return promptly
  391. // via the SIGKILL escalation — bound it so a regression (no escalation)
  392. // fails loud instead of hanging the suite.
  393. await expect(Promise.race([
  394. run.dispose(),
  395. new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — no SIGKILL escalation')) }, 4000) }),
  396. ])).resolves.toBeUndefined()
  397. } finally {
  398. rmSync(tmp, { recursive: true, force: true })
  399. }
  400. })
  401. it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
  402. // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
  403. // down on connection close, NOT on a signal) — and it has no SIGTERM handler.
  404. // Its EOF teardown can itself await a signal-trapping grandchild (a bash
  405. // subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window
  406. // must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value.
  407. // The mock models a flush that takes LONGER than the SIGTERM grace but well
  408. // under the EOF grace: it lands only because tier 1 waits eofGraceMs, not
  409. // graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the
  410. // round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.)
  411. const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-'))
  412. const ready = join(tmp, 'ready')
  413. const flushed = join(tmp, 'flushed')
  414. try {
  415. const spec: AcpRunSpec = {
  416. command: process.execPath,
  417. args: [mockServer],
  418. cwd: process.cwd(),
  419. permission: 'reject',
  420. // MOCK_HANG so the prompt never resolves on its own — we tear down a live
  421. // child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits
  422. // the 2000ms EOF grace; the marker lands iff the EOF tier honored its own
  423. // wider grace.
  424. env: {
  425. MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready,
  426. MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400',
  427. },
  428. disposeEofGraceMs: 2000,
  429. disposeGraceMs: 50,
  430. }
  431. const run = await startAcpRun(request(), spec)
  432. // Wait until the child is fully booted with its prompt in flight (its ACP
  433. // stdin reader is attached), so dispose's stdin EOF reaches a live child.
  434. await waitForFile(ready)
  435. await run.dispose()
  436. // dispose returned via the natural-exit tier — the EOF-driven flush landed
  437. // despite taking longer than the SIGTERM grace.
  438. expect(existsSync(flushed)).toBe(true)
  439. } finally {
  440. rmSync(tmp, { recursive: true, force: true })
  441. }
  442. })
  443. it('terminates a child that ignores EOF using the host platform semantics', async () => {
  444. // POSIX uses the catchable SIGTERM tier and records the marker. Windows has
  445. // no distinct graceful signal, so disposal skips directly to forced exit.
  446. const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
  447. const ready = join(tmp, 'ready')
  448. const sigterm = join(tmp, 'sigterm')
  449. try {
  450. const spec: AcpRunSpec = {
  451. command: process.execPath,
  452. args: [mockServer],
  453. cwd: process.cwd(),
  454. permission: 'reject',
  455. env: {
  456. MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x',
  457. MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm,
  458. },
  459. // Tiny EOF grace so the ignored-EOF window elapses quickly.
  460. disposeEofGraceMs: 150,
  461. disposeGraceMs: 2000,
  462. }
  463. const run = await startAcpRun(request(), spec)
  464. await waitForFile(ready)
  465. // Bound it so a hang fails loud rather than stalling the suite.
  466. await expect(Promise.race([
  467. run.dispose(),
  468. new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }),
  469. ])).resolves.toBeUndefined()
  470. expect(existsSync(sigterm)).toBe(process.platform !== 'win32')
  471. } finally {
  472. rmSync(tmp, { recursive: true, force: true })
  473. }
  474. })
  475. it('rejects after cleanup when the signal aborts during newSession', async () => {
  476. // Gate the child at newSession: it signals `ready` and blocks until `go`.
  477. // We cancel WHILE newSession is pending (sessionId still undefined, so the
  478. // backend cannot send session/cancel) — the `cancelled` flag alone must
  479. // settle the run aborted after newSession resolves, never issuing the prompt.
  480. const tmp = mkdtempSync(join(tmpdir(), 'acp-early-'))
  481. const ready = join(tmp, 'ready')
  482. const go = join(tmp, 'go')
  483. try {
  484. const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
  485. const controller = new AbortController()
  486. const starting = ctx.subagents.start('acp', request('p', controller.signal))
  487. await waitForFile(ready) // newSession is now in flight, sessionId undefined
  488. controller.abort('early')
  489. writeFileSync(go, 'go') // let newSession resolve
  490. await expect(starting).rejects.toThrow('aborted before the ACP child started')
  491. } finally {
  492. rmSync(tmp, { recursive: true, force: true })
  493. }
  494. })
  495. it('bridges the request signal to a session/cancel mid-run', async () => {
  496. const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-'))
  497. const readyFile = join(tmp, 'ready')
  498. try {
  499. const controller = new AbortController()
  500. const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
  501. const run = await ctx.subagents.start('acp', request('p', controller.signal))
  502. await waitForFile(readyFile)
  503. controller.abort()
  504. const result = await run.result
  505. expect(result.stopReason).toBe('aborted')
  506. await run.dispose()
  507. } finally {
  508. rmSync(tmp, { recursive: true, force: true })
  509. }
  510. })
  511. it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => {
  512. const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject')
  513. const run = await ctx.subagents.start('acp', request())
  514. const result = await run.result
  515. // The child asked permission, the backend rejected, the child returned cancelled.
  516. expect(result.stopReason).toBe('aborted')
  517. await run.dispose()
  518. })
  519. it('auto-approves a permission prompt under the allow policy', async () => {
  520. const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow')
  521. const run = await ctx.subagents.start('acp', request())
  522. const result = await run.result
  523. expect(result.stopReason).toBe('completed')
  524. expect(text(result.output)).toBe('approved answer')
  525. await run.dispose()
  526. })
  527. it('falls back to cancelled under the allow policy when the child offers no allow option', async () => {
  528. // The child asks permission but offers ONLY reject-shaped options, so an
  529. // allow-policy client finds nothing to select and must answer cancelled.
  530. const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow')
  531. const run = await ctx.subagents.start('acp', request())
  532. const result = await run.result
  533. expect(result.stopReason).toBe('aborted')
  534. await run.dispose()
  535. })
  536. it('consumes a non-message update (a thought) without adding it to the output', async () => {
  537. // The child streams an agent_thought_chunk before its answer; the backend
  538. // must consume it but NOT include it in the result output.
  539. const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' })
  540. const run = await ctx.subagents.start('acp', request())
  541. const result = await run.result
  542. expect(result.stopReason).toBe('completed')
  543. // Only the message text, NOT the thought.
  544. expect(text(result.output)).toBe('final answer')
  545. await run.dispose()
  546. })
  547. it('rejects a spawn failure after provider-owned cleanup', async () => {
  548. await expect(startAcpRun(
  549. request(),
  550. { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS },
  551. )).rejects.toThrow()
  552. })
  553. it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
  554. // Same trap scenario as the direct startAcpRun escalation test, but the
  555. // graces arrive via the PLUGIN CONFIG through the registered provider — so a
  556. // regression that stops threading config into AcpRunSpec (falling back to
  557. // the 6s/3s defaults) blows past the 4000ms bound and fails loud.
  558. const tmp = mkdtempSync(join(tmpdir(), 'acp-cfg-trap-'))
  559. const ready = join(tmp, 'trap-armed')
  560. try {
  561. const ctx = new Context()
  562. await ctx.plugin(SubagentService)
  563. await ctx.plugin(acp, {
  564. providerName: 'acp',
  565. command: process.execPath,
  566. args: [mockServer],
  567. permission: 'reject',
  568. env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
  569. disposeEofGraceMs: 150,
  570. disposeGraceMs: 150,
  571. })
  572. const run = await ctx.subagents.start('acp', request())
  573. await waitForFile(ready)
  574. await expect(Promise.race([
  575. run.dispose(),
  576. new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }),
  577. ])).resolves.toBeUndefined()
  578. await ctx.fiber.dispose()
  579. } finally {
  580. rmSync(tmp, { recursive: true, force: true })
  581. }
  582. })
  583. it('rejects a non-positive dispose grace at load', async () => {
  584. for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) {
  585. const ctx = new Context()
  586. await ctx.plugin(SubagentService)
  587. await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad }))
  588. .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/)
  589. await ctx.fiber.dispose()
  590. }
  591. })
  592. it('rejects a startup failure via the provider load path', async () => {
  593. const ctx = new Context()
  594. await ctx.plugin(SubagentService)
  595. await ctx.plugin(acp, {
  596. providerName: 'acp',
  597. command: '/nonexistent/acp-agent-binary',
  598. args: [],
  599. permission: 'reject',
  600. env: {},
  601. })
  602. await expect(ctx.subagents.start('acp', request())).rejects.toThrow()
  603. })
  604. it('reports a flattened child failure through onError (preserved, not silently lost)', async () => {
  605. // The seam forbids `result` rejecting, so a child-level failure is flattened
  606. // to a stop reason — onError must still surface the original error so a real
  607. // fault is logged, not swallowed. The child exits after its session is
  608. // published but while prompt is in flight.
  609. const errors: { message: string; stopReason: string }[] = []
  610. const run = await startAcpRun(
  611. request(),
  612. {
  613. command: process.execPath,
  614. args: [mockServer],
  615. cwd: process.cwd(),
  616. permission: 'reject',
  617. env: { MOCK_CRASH_ON_PROMPT: '1' },
  618. disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
  619. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  620. onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) },
  621. },
  622. )
  623. const result = await run.result
  624. expect(result.stopReason).toBe('error')
  625. expect(errors).toHaveLength(1)
  626. expect(errors[0]!.stopReason).toBe('error')
  627. expect(errors[0]!.message.length).toBeGreaterThan(0)
  628. await run.dispose()
  629. })
  630. it('logs a flattened child failure through the registered provider', async () => {
  631. const ctx = await setup({ MOCK_CRASH_ON_PROMPT: '1' })
  632. const warnings: string[] = []
  633. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  634. const run = await ctx.subagents.start('acp', request())
  635. const result = await run.result
  636. expect(result.stopReason).toBe('error')
  637. expect(warnings).toEqual([
  638. expect.stringContaining('subagent-acp "acp": child run failed (error):'),
  639. ])
  640. await run.dispose()
  641. })
  642. it('resolves error (never rejects) even when the onError sink itself throws', async () => {
  643. // onError is a caller-supplied callback boundary: its own exception must be
  644. // contained, or it would reject `result` and break the seam's "result never
  645. // rejects" contract that the flattening above exists to uphold.
  646. const run = await startAcpRun(
  647. request(),
  648. {
  649. command: process.execPath,
  650. args: [mockServer],
  651. cwd: process.cwd(),
  652. permission: 'reject',
  653. env: { MOCK_CRASH_ON_PROMPT: '1' },
  654. disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS,
  655. disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
  656. onError: () => { throw new Error('sink boom') },
  657. },
  658. )
  659. const result = await run.result
  660. expect(result.stopReason).toBe('error')
  661. await run.dispose()
  662. })
  663. it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
  664. // The child hangs, we cancel, and instead of answering the child exits hard
  665. // — the pending prompt RPC rejects. With a cancel already requested, the
  666. // backend's catch path must settle `aborted` (the failure is the cancel
  667. // surfacing as a torn pipe), not `error`.
  668. const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-'))
  669. const ready = join(tmp, 'ready')
  670. try {
  671. const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
  672. const controller = new AbortController()
  673. const run = await ctx.subagents.start('acp', request('p', controller.signal))
  674. await waitForFile(ready)
  675. controller.abort('crash it')
  676. const result = await run.result
  677. expect(result.stopReason).toBe('aborted')
  678. await run.dispose()
  679. } finally {
  680. rmSync(tmp, { recursive: true, force: true })
  681. }
  682. })
  683. it('settles aborted on signal even when the child IGNORES session/cancel', async () => {
  684. // The signal contract requires `result` to settle `aborted`. A child that hangs
  685. // its prompt AND ignores session/cancel must not wedge the parent — the
  686. // backend's own cancel-settle path resolves `aborted` without the child's
  687. // cooperation, and dispose() still reaps the process.
  688. const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-'))
  689. const ready = join(tmp, 'ready')
  690. try {
  691. const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
  692. const controller = new AbortController()
  693. const run = await ctx.subagents.start('acp', request('p', controller.signal))
  694. await waitForFile(ready)
  695. controller.abort('test')
  696. // Bound it: a regression (cancel only notifies the child, which ignores it)
  697. // would hang result forever — fail loud instead of stalling the suite.
  698. const result = await Promise.race([
  699. run.result,
  700. new Promise<never>((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }),
  701. ])
  702. expect(result.stopReason).toBe('aborted')
  703. await run.dispose()
  704. } finally {
  705. rmSync(tmp, { recursive: true, force: true })
  706. }
  707. })
  708. it('advertises no start-time capabilities (out-of-process child)', async () => {
  709. const ctx = await setup()
  710. const provider = ctx.subagents.getProvider('acp')!
  711. expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false, persona: false })
  712. })
  713. it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
  714. const ctx = new Context()
  715. await ctx.plugin(SubagentService)
  716. const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} })
  717. expect(ctx.subagents.list()).toEqual(['acp'])
  718. await fiber.dispose()
  719. expect(ctx.subagents.list()).toEqual([])
  720. })
  721. it('has the namespace-plugin export shape (no stray default)', () => {
  722. expect('default' in acp).toBe(false)
  723. expect(acp.name).toBe('subagent-acp')
  724. expect(acp.inject).toEqual(['subagents'])
  725. const loader = Object.create(Loader.prototype) as Loader
  726. const unwrapped = loader.unwrapExports(acp) as Record<string, unknown>
  727. expect(unwrapped).toBe(acp)
  728. expect(unwrapped.name).toBe('subagent-acp')
  729. expect(typeof unwrapped.apply).toBe('function')
  730. })
  731. })