harness.spec.ts 62 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432
  1. import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
  2. import * as fsPromises from 'node:fs/promises'
  3. import { once } from 'node:events'
  4. import { tmpdir } from 'node:os'
  5. import { delimiter, join, relative, sep } from 'node:path'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import { afterAll, describe, expect, it, vi, type TestContext } from 'vitest'
  8. import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
  9. import { runScenario, snapshotSpillRoot, type AgentUnderTest, type InputStep } from '../src/harness.ts'
  10. import { launchAcpTestAgent } from '../src/launcher.ts'
  11. const fsControl = vi.hoisted(() => ({
  12. cleanupFailure: undefined as Error | undefined,
  13. spillAllocationFailure: undefined as { error: Error; allocated: string[] } | undefined,
  14. }))
  15. vi.mock('node:fs/promises', async (importOriginal) => {
  16. const actual = await importOriginal<typeof import('node:fs/promises')>()
  17. return {
  18. ...actual,
  19. async mkdtemp(prefix: string): Promise<string> {
  20. const failure = fsControl.spillAllocationFailure
  21. if (prefix.endsWith('acp-snap-spill-') && failure !== undefined) throw failure.error
  22. const path = await actual.mkdtemp(prefix)
  23. failure?.allocated.push(path)
  24. return path
  25. },
  26. async rm(...args: Parameters<typeof actual.rm>): Promise<void> {
  27. if (String(args[0]).includes('acp-snap-cwd-') && fsControl.cleanupFailure !== undefined) {
  28. const failure = fsControl.cleanupFailure
  29. fsControl.cleanupFailure = undefined
  30. await actual.rm(...args)
  31. throw failure
  32. }
  33. await actual.rm(...args)
  34. },
  35. }
  36. })
  37. /**
  38. * Unit tests for the subprocess harness, driven through the REAL spawn path
  39. * (mode-aware launcher, temp cwd, env plumbing) against the scripted fake ACP bin in
  40. * ./fixtures/fake-acp-agent.ts. Each case writes a `behavior.json` next to a
  41. * throwaway fixture path; the fake bin echoes observable facts (env, seeded
  42. * workspace, permission outcomes) into `agent_message_chunk` text, so the
  43. * assertions read plain `rawStdout`.
  44. */
  45. const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url))
  46. const AGENT: AgentUnderTest = {
  47. binScript: fakeAgent,
  48. libBinScript: fakeAgent,
  49. // The fake bin ignores its config argv; any real path documents the shape.
  50. configPath: fakeAgent,
  51. tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
  52. }
  53. async function materializedPatch(root: string, filename: string): Promise<string> {
  54. const entries = await readdir(root, { recursive: true })
  55. const matches = entries.filter(entry => entry.endsWith(filename))
  56. expect(matches).toHaveLength(1)
  57. return join(root, matches[0] as string)
  58. }
  59. /** Temp scenario dirs to drop after the suite. */
  60. const tempDirs: string[] = []
  61. afterAll(async () => {
  62. for (const dir of tempDirs) await rm(dir, { recursive: true, force: true })
  63. })
  64. /** Write a behavior.json into a fresh temp dir; return the sibling fixture path the harness points the bin at. */
  65. async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: string }> {
  66. const dir = await mkdtemp(join(tmpdir(), 'acp-snap-spec-'))
  67. tempDirs.push(dir)
  68. await writeFile(join(dir, 'behavior.json'), JSON.stringify(behavior))
  69. return { dir, fixtureFile: join(dir, 'session.jsonl') }
  70. }
  71. /** Keep immutable-log diagnostics independent of initial filesystem harvest latency. */
  72. function isolateDiagnosticTimeout(onTestFinished: TestContext['onTestFinished']): void {
  73. const waitFor = vi.waitFor
  74. const wait = vi.spyOn(vi, 'waitFor')
  75. onTestFinished(() => { wait.mockRestore() })
  76. wait.mockImplementation(async (callback, options) => {
  77. if (typeof options !== 'object' || options.timeout !== 20) return waitFor(callback, options)
  78. try {
  79. return await callback()
  80. } catch (error) {
  81. return waitFor(() => { throw error }, options)
  82. }
  83. })
  84. }
  85. const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
  86. // A Windows coverage shard can spend more than 20ms harvesting logs before vi.waitFor records the diagnostic error.
  87. const titleDiagnosticTimeoutMs = process.platform === 'win32' ? 5_000 : 20
  88. it('keeps scenario-owned snapshot spill root length stable across platforms', () => {
  89. const fixtureFile = '/fixtures/scenario/session.jsonl'
  90. const posix = snapshotSpillRoot(fixtureFile, 'linux')
  91. const windows = snapshotSpillRoot(fixtureFile, 'win32')
  92. expect(posix).toMatch(/^\/tmp\/dsh-acp-snap-[0-9a-f]{9}$/)
  93. expect(windows).toMatch(/^\/t\/dsh-acp-snap-[0-9a-f]{9}$/)
  94. expect(windows.length + 2).toBe(posix.length)
  95. })
  96. function environmentEcho(rawStdout: string): Record<string, unknown> {
  97. const frames = rawStdout.trim().split('\n')
  98. .map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } })
  99. const text = frames.map(frame => frame.params?.update?.content?.text)
  100. .find(value => typeof value === 'string' && value.startsWith('env:'))
  101. if (typeof text !== 'string') throw new Error('fake ACP agent did not echo its environment')
  102. return JSON.parse(text.slice('env:'.length)) as Record<string, unknown>
  103. }
  104. describe('runScenario', () => {
  105. it('surfaces an asynchronous child spawn failure through startup and close', async () => {
  106. const { dir } = await scenario({})
  107. const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') })
  108. let stdioClosed = false
  109. let clientClosed = false
  110. launched.child.once('close', () => { stdioClosed = true })
  111. void launched.client.closed.then(
  112. () => { clientClosed = true },
  113. () => { clientClosed = true },
  114. )
  115. await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' })
  116. await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' })
  117. expect(stdioClosed).toBe(true)
  118. expect(clientClosed).toBe(true)
  119. })
  120. it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => {
  121. const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' })
  122. const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-'))
  123. tempDirs.push(sessionsRoot)
  124. const launched = launchAcpTestAgent({
  125. agent: AGENT,
  126. cwd: dir,
  127. configPath: AGENT.configPath,
  128. env: {
  129. DSH_SNAPSHOT: 'replay',
  130. DSH_SNAPSHOT_FILE: fixtureFile,
  131. DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
  132. },
  133. })
  134. await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  135. const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] })
  136. const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk')
  137. const laterChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk'
  138. && update.content.type === 'text' && update.content.text === 'never this one')
  139. const predicateFailure = new Error('predicate failed')
  140. const failedPredicate = launched.waitForUpdate(() => { throw predicateFailure })
  141. .catch((error: unknown): unknown => error)
  142. await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
  143. expect(await failedPredicate).toBe(predicateFailure)
  144. expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk')
  145. expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true)
  146. expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}')
  147. expect(launched.stderr()).toContain('launcher stderr')
  148. void laterChunk.catch(() => undefined)
  149. const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/)
  150. await launched.close()
  151. await unmatched
  152. await expect(launched.waitForUpdate(() => true)).rejects.toThrow(/update stream closed/)
  153. await launched.close('SIGKILL')
  154. // The minimal shape needs no environment or config override.
  155. const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  156. await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  157. const childFailure = new Error('child process failed')
  158. let exited = false
  159. minimal.child.once('exit', () => { exited = true })
  160. minimal.child.emit('error', childFailure)
  161. await expect(minimal.close('SIGTERM')).rejects.toBe(childFailure)
  162. // close rejects only after the fallback SIGKILL has produced an exit edge.
  163. expect(exited).toBe(true)
  164. })
  165. it('builds dsh profile argv and rebases relative modules in live and replay patches', async () => {
  166. const { dir, fixtureFile } = await scenario({})
  167. const patchDir = join(dir, 'patches')
  168. const basePatch = join(patchDir, 'base.cordis.yml')
  169. const selectedPatch = join(patchDir, 'selected.cordis.yml')
  170. const replayPatch = join(patchDir, 'selected.cordis.snapshot.yml')
  171. const packageDir = join(dir, 'node_modules', 'example-package')
  172. const scopedPackageDir = join(dir, 'node_modules', '@fixture', 'example-package')
  173. await Promise.all([
  174. mkdir(patchDir, { recursive: true }),
  175. mkdir(packageDir, { recursive: true }),
  176. mkdir(scopedPackageDir, { recursive: true }),
  177. ])
  178. await Promise.all([
  179. writeFile(basePatch, [
  180. '- id: asserted',
  181. ' name: ./plugin.mjs',
  182. ' disabled: true',
  183. '- insert:',
  184. ' - id: relative',
  185. ' name: ./plugin.mjs',
  186. ' - id: package',
  187. ' name: example-package',
  188. ' - id: scoped-package',
  189. " name: '@fixture/example-package/subpath'",
  190. ' - id: builtin',
  191. ' name: fs',
  192. ' - id: group',
  193. ' name: cordis:group',
  194. ' group: true',
  195. ' config:',
  196. ' - id: nested',
  197. ' name: ../nested.mjs',
  198. '',
  199. ].join('\n')),
  200. writeFile(selectedPatch, '[]\n'),
  201. writeFile(replayPatch, '[]\n'),
  202. writeFile(join(patchDir, 'plugin.mjs'), 'export function apply() {}\n'),
  203. writeFile(join(packageDir, 'package.json'), '{"name":"example-package","version":"1.0.0"}\n'),
  204. writeFile(join(scopedPackageDir, 'package.json'), '{"name":"@fixture/example-package","version":"1.0.0"}\n'),
  205. ])
  206. const profileAgent: AgentUnderTest = { ...AGENT, configPath: basePatch, profile: 'acp' }
  207. const live = launchAcpTestAgent({
  208. agent: profileAgent,
  209. cwd: dir,
  210. configPath: selectedPatch,
  211. env: { DSH_SNAPSHOT: 'record', DSH_SNAPSHOT_FILE: fixtureFile },
  212. })
  213. await live.spawned
  214. await live.close()
  215. const materializedRoot = join(dir, '.dsh-profile-patches')
  216. const materialized = await readFile(await materializedPatch(materializedRoot, '0-base.cordis.yml'), 'utf8')
  217. expect(materialized).toContain(pathToFileURL(join(patchDir, 'plugin.mjs')).href)
  218. expect(materialized).toContain(pathToFileURL(join(dir, 'nested.mjs')).href)
  219. expect(materialized).toContain('example-package')
  220. expect(await realpath(join(dir, '.dsh', 'profiles', 'acp', 'node_modules', 'example-package')))
  221. .toBe(await realpath(packageDir))
  222. expect(await realpath(join(dir, '.dsh', 'profiles', 'acp', 'node_modules', '@fixture', 'example-package')))
  223. .toBe(await realpath(scopedPackageDir))
  224. expect(await readFile(await materializedPatch(materializedRoot, '1-selected.cordis.yml'), 'utf8')).toContain('[]')
  225. const replay = launchAcpTestAgent({
  226. agent: profileAgent,
  227. cwd: dir,
  228. configPath: selectedPatch,
  229. env: { DSH_SNAPSHOT: 'replay', DSH_SNAPSHOT_FILE: fixtureFile },
  230. })
  231. await replay.spawned
  232. await replay.close()
  233. expect(await readFile(await materializedPatch(materializedRoot, '1-selected.cordis.snapshot.yml'), 'utf8'))
  234. .toContain('[]')
  235. expect((await readdir(materializedRoot, { withFileTypes: true })).filter(entry => entry.isDirectory()))
  236. .toHaveLength(2)
  237. const conflictPatch = join(dir, 'conflict.cordis.yml')
  238. const conflictPackage = join(dir, 'node_modules', 'conflict-package')
  239. const otherPackage = join(dir, 'other-conflict-package')
  240. const conflictLink = join(dir, '.dsh', 'profiles', 'acp', 'node_modules', 'conflict-package')
  241. await Promise.all([
  242. mkdir(conflictPackage, { recursive: true }),
  243. mkdir(otherPackage, { recursive: true }),
  244. writeFile(conflictPatch, '- id: conflict\n name: conflict-package\n'),
  245. ])
  246. await Promise.all([
  247. writeFile(join(conflictPackage, 'package.json'), '{"name":"conflict-package","version":"1.0.0"}\n'),
  248. writeFile(join(otherPackage, 'package.json'), '{"name":"conflict-package","version":"2.0.0"}\n'),
  249. ])
  250. await symlink(otherPackage, conflictLink, process.platform === 'win32' ? 'junction' : 'dir')
  251. expect(() => launchAcpTestAgent({
  252. agent: { ...profileAgent, configPath: conflictPatch },
  253. cwd: dir,
  254. env: { DSH_SNAPSHOT: 'record', DSH_SNAPSHOT_FILE: fixtureFile },
  255. })).toThrow('snapshot profile package conflict-package resolves to two directories')
  256. const invalidPatch = join(dir, 'invalid.cordis.yml')
  257. await writeFile(invalidPatch, 'not: a-list\n')
  258. expect(() => launchAcpTestAgent({
  259. agent: { ...profileAgent, configPath: invalidPatch },
  260. cwd: dir,
  261. env: { DSH_SNAPSHOT: 'record', DSH_SNAPSHOT_FILE: fixtureFile },
  262. })).toThrow(`snapshot profile patch must be a top-level array: ${invalidPatch}`)
  263. })
  264. it('waits for inherited stdio and buffered ACP parsing after the parent exits', { timeout: 20_000 }, async () => {
  265. const { dir, fixtureFile } = await scenario({ lateInheritedOutput: true })
  266. const launched = launchAcpTestAgent({
  267. agent: AGENT,
  268. cwd: dir,
  269. env: { DSH_SNAPSHOT_FILE: fixtureFile },
  270. })
  271. await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  272. await launched.client.newSession({ cwd: dir, mcpServers: [] })
  273. const lateUpdate = launched.waitForUpdate(update =>
  274. update.sessionUpdate === 'agent_message_chunk'
  275. && update.content.type === 'text'
  276. && update.content.text === 'late inherited stdout')
  277. // Arm rejection handling before close may exhaust the stream; the later assertion still
  278. // observes the original promise and turns a missing inherited frame into the test failure.
  279. void lateUpdate.catch(() => undefined)
  280. await launched.close()
  281. await expect(lateUpdate).resolves.toMatchObject({ sessionUpdate: 'agent_message_chunk' })
  282. expect(launched.rawStdout()).toContain('late inherited stdout')
  283. expect(launched.stderr()).toContain('late inherited stderr')
  284. })
  285. it('rejects promptly when fallback termination is refused', async () => {
  286. const { dir } = await scenario({})
  287. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  288. await launched.spawned
  289. const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' })
  290. const originalKill = launched.child.kill.bind(launched.child)
  291. const kill = vi.spyOn(launched.child, 'kill').mockReturnValue(false)
  292. const closed = new Promise<void>(resolve => launched.child.once('close', () => { resolve() }))
  293. try {
  294. launched.child.emit('error', childFailure)
  295. const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error)
  296. expect(rejection).toBeInstanceOf(AggregateError)
  297. expect(rejection).toMatchObject({
  298. message: 'ACP test agent failed and fallback termination was refused',
  299. errors: [
  300. childFailure,
  301. expect.objectContaining({ message: 'Fallback SIGKILL was not accepted by the child process' }),
  302. ],
  303. })
  304. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  305. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  306. } finally {
  307. kill.mockRestore()
  308. originalKill('SIGKILL')
  309. await closed
  310. }
  311. })
  312. it('preserves the child error when the requested signal sets an exit marker', async () => {
  313. const { dir } = await scenario({})
  314. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  315. await launched.spawned
  316. const childFailure = Object.assign(new Error('signal failed as the child exited'), { code: 'EPERM' })
  317. const originalKill = launched.child.kill.bind(launched.child)
  318. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  319. expect(signal).toBe('SIGTERM')
  320. originalKill('SIGKILL')
  321. Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGTERM' })
  322. return true
  323. })
  324. try {
  325. launched.child.emit('error', childFailure)
  326. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  327. expect(kill).toHaveBeenCalledOnce()
  328. } finally {
  329. kill.mockRestore()
  330. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  331. }
  332. })
  333. it('preserves the child error when the requested signal publishes its exit marker later', async () => {
  334. const { dir } = await scenario({})
  335. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  336. await launched.spawned
  337. const childFailure = Object.assign(new Error('signal failed before the delayed exit marker'), { code: 'EPERM' })
  338. const originalKill = launched.child.kill.bind(launched.child)
  339. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  340. expect(signal).toBe('SIGTERM')
  341. setTimeout(() => { originalKill('SIGKILL') }, 10)
  342. return true
  343. })
  344. try {
  345. launched.child.emit('error', childFailure)
  346. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  347. expect(kill).toHaveBeenCalledOnce()
  348. } finally {
  349. kill.mockRestore()
  350. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  351. }
  352. })
  353. it('preserves the child error when fallback refusal races with an exit marker', async () => {
  354. const { dir } = await scenario({})
  355. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  356. await launched.spawned
  357. const childFailure = Object.assign(new Error('signal failed while the child exited'), { code: 'EPERM' })
  358. const originalKill = launched.child.kill.bind(launched.child)
  359. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  360. if (signal === 'SIGTERM') return true
  361. originalKill('SIGKILL')
  362. Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGKILL' })
  363. return false
  364. })
  365. try {
  366. launched.child.emit('error', childFailure)
  367. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  368. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  369. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  370. } finally {
  371. kill.mockRestore()
  372. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  373. }
  374. })
  375. it('preserves the child error after accepted fallback termination drains', async () => {
  376. const { dir } = await scenario({})
  377. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  378. await launched.spawned
  379. const childFailure = Object.assign(new Error('requested signal failed before fallback'), { code: 'EPERM' })
  380. const originalKill = launched.child.kill.bind(launched.child)
  381. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  382. if (signal === 'SIGTERM') return true
  383. return originalKill('SIGKILL')
  384. })
  385. try {
  386. launched.child.emit('error', childFailure)
  387. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  388. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  389. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  390. } finally {
  391. kill.mockRestore()
  392. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  393. }
  394. })
  395. it('rejects promptly when fallback termination emits an error', async () => {
  396. const { dir } = await scenario({})
  397. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  398. await launched.spawned
  399. const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' })
  400. const fallbackFailure = Object.assign(new Error('fallback signal refused'), { code: 'EPERM' })
  401. const originalKill = launched.child.kill.bind(launched.child)
  402. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  403. if (signal === 'SIGKILL') queueMicrotask(() => launched.child.emit('error', fallbackFailure))
  404. return signal === 'SIGKILL'
  405. })
  406. const closed = new Promise<void>(resolve => launched.child.once('close', () => { resolve() }))
  407. try {
  408. launched.child.emit('error', childFailure)
  409. const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error)
  410. expect(rejection).toBeInstanceOf(AggregateError)
  411. expect(rejection).toMatchObject({
  412. message: 'ACP test agent failed and fallback termination was refused',
  413. errors: [childFailure, fallbackFailure],
  414. })
  415. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  416. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  417. } finally {
  418. kill.mockRestore()
  419. originalKill('SIGKILL')
  420. await closed
  421. }
  422. })
  423. it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => {
  424. const { dir, fixtureFile } = await scenario({ permissionProbe: true })
  425. let releasePermission: (() => void) | undefined
  426. const permissionReleased = new Promise<void>((resolve) => { releasePermission = resolve })
  427. let markPermissionStarted: (() => void) | undefined
  428. const permissionStarted = new Promise<void>((resolve) => { markPermissionStarted = resolve })
  429. let permissionFinished = false
  430. const launched = launchAcpTestAgent({
  431. agent: AGENT,
  432. cwd: dir,
  433. env: { DSH_SNAPSHOT_FILE: fixtureFile },
  434. async requestPermission() {
  435. markPermissionStarted?.()
  436. await permissionReleased
  437. permissionFinished = true
  438. return { outcome: { outcome: 'cancelled' } }
  439. },
  440. })
  441. await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  442. const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] })
  443. void launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => undefined)
  444. await permissionStarted
  445. const childClosed = once(launched.child, 'close')
  446. let closeSettled = false
  447. const closing = launched.close('SIGKILL').then(() => { closeSettled = true })
  448. await childClosed
  449. await launched.client.closed
  450. expect(closeSettled).toBe(false)
  451. releasePermission?.()
  452. await closing
  453. expect(permissionFinished).toBe(true)
  454. })
  455. it('includes agent stderr when the ACP connection closes during startup', { timeout: 20_000 }, async () => {
  456. const { fixtureFile } = await scenario({ failOnBoot: true, stderrNote: 'fake agent requested startup failure' })
  457. await expect(runScenario(
  458. { steps: [{ op: 'initialize' }] },
  459. { agent: AGENT, mode: 'replay', fixtureFile },
  460. )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/)
  461. })
  462. it('preserves launch-resolution errors when no child process exists', async () => {
  463. const { dir, fixtureFile } = await scenario({})
  464. vi.stubEnv('DSH_EXAMPLE_MODE', 'lib')
  465. try {
  466. await expect(runScenario(
  467. { steps: [] },
  468. {
  469. agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined },
  470. mode: 'replay',
  471. fixtureFile,
  472. },
  473. )).rejects.toThrow(/expected a "\/src\/" segment/)
  474. } finally {
  475. vi.unstubAllEnvs()
  476. }
  477. })
  478. it('drives a full turn: initialize, session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {
  479. const { fixtureFile } = await scenario({
  480. permissionProbe: true,
  481. logs: [{
  482. file: 'project/main/session.jsonl',
  483. lines: [
  484. { type: 'session', version: 0, id: '{{SID}}', createdAt: 42, cwd: '{{CWD}}' },
  485. { type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } },
  486. ],
  487. }],
  488. })
  489. const result = await runScenario(
  490. { steps: [{ op: 'initialize' }, { op: 'newSession' }, { op: 'prompt', text: 'go' }] },
  491. { agent: AGENT, mode: 'replay', fixtureFile },
  492. )
  493. expect(result.sessionId).toBeDefined()
  494. // The harness's client answers a permission request with `cancelled`; the
  495. // fake bin echoes the outcome it received back as a chunk.
  496. expect(result.rawStdout).toContain('permission:{\\"outcome\\":\\"cancelled\\"}')
  497. expect(result.sessionLogs).toHaveLength(1)
  498. expect(result.sessionLogs[0]?.id).toBe(result.sessionId)
  499. expect(result.sessionLogs[0]?.createdAt).toBe(42)
  500. expect(result.sessionLogs[0]?.content).toContain('turn/start')
  501. // The harvested log embeds the run's REAL temp cwd (template-substituted).
  502. // The cwd is JSON-encoded in the log line, so compare the parsed field
  503. // rather than substring-matching a raw path (which breaks when the path
  504. // separator is escaped inside JSON text on Windows).
  505. const sessionLine = result.sessionLogs[0]?.content.split('\n').find(l => l.includes('"type":"session"')) ?? '{}'
  506. expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd)
  507. })
  508. it('drives a structured prompt-content step without flattening its wire blocks', { timeout: 20_000 }, async () => {
  509. const { fixtureFile } = await scenario({})
  510. const result = await runScenario(
  511. {
  512. steps: [...boot, {
  513. op: 'promptContent',
  514. content: [
  515. { type: 'text', text: 'before' },
  516. { type: 'image', data: 'AQ==', mimeType: 'image/png' },
  517. { type: 'text', text: 'after' },
  518. ],
  519. }],
  520. },
  521. { agent: AGENT, mode: 'replay', fixtureFile },
  522. )
  523. expect(result.rawStdout).toContain('"stopReason":"end_turn"')
  524. })
  525. it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => {
  526. const { dir, fixtureFile } = await scenario({ echoEnv: true, stderrNote: 'fake bin booted' })
  527. const childFiles = [join(dir, 'session.1.jsonl'), join(dir, 'session.2.jsonl')]
  528. const result = await runScenario(
  529. { steps: [...boot, { op: 'prompt', text: 'env?' }] },
  530. {
  531. agent: AGENT,
  532. mode: 'replay',
  533. fixtureFile,
  534. overrideFile: join(dir, 'replay.override.json'),
  535. childFiles,
  536. // A workspaceDir that does not exist is skipped, not an error.
  537. workspaceDir: join(dir, 'no-such-workspace'),
  538. },
  539. )
  540. expect(result.stderr).toContain('fake bin booted')
  541. expect(result.rawStdout).toContain('replay.override.json')
  542. // Child paths ride one env var, joined with the platform delimiter.
  543. // Parse the fake bin's env-probe chunk rather than substring-matching a
  544. // JSON-encoded path (the escaping breaks raw-substring compares on Windows).
  545. const envChunk = result.rawStdout.split('\n')
  546. .map(l => l.trim())
  547. .filter(l => l.length > 0)
  548. .map(l => JSON.parse(l) as { params?: { update?: { content?: { text?: string } } } })
  549. .find(f => f.params?.update?.content?.text?.startsWith('env:'))
  550. const env = JSON.parse((envChunk?.params?.update?.content?.text ?? 'env:{}').slice('env:'.length)) as {
  551. childFiles: string | null
  552. }
  553. expect(env.childFiles).toBe(childFiles.join(delimiter))
  554. })
  555. it('cleans acquired workspace and session roots when spill allocation fails', async () => {
  556. const { fixtureFile } = await scenario({})
  557. const failure = { error: Object.assign(new Error('spill allocation failed'), { code: 'ENOSPC' }), allocated: [] as string[] }
  558. fsControl.spillAllocationFailure = failure
  559. try {
  560. await expect(runScenario(
  561. { steps: boot },
  562. { agent: AGENT, mode: 'replay', fixtureFile },
  563. )).rejects.toBe(failure.error)
  564. expect(failure.allocated).toHaveLength(2)
  565. for (const root of failure.allocated) {
  566. await expect(readdir(root)).rejects.toMatchObject({ code: 'ENOENT' })
  567. }
  568. } finally {
  569. fsControl.spillAllocationFailure = undefined
  570. await Promise.all(failure.allocated.map(root => rm(root, { recursive: true, force: true })))
  571. }
  572. })
  573. it('gives concurrent runs of the same scenario private temporary spill roots', { timeout: 20_000 }, async () => {
  574. const fixture = await scenario({ echoEnv: true })
  575. const results = await Promise.all([fixture, fixture].map(({ fixtureFile }) => runScenario(
  576. { steps: [...boot, { op: 'prompt', text: 'env?' }] },
  577. { agent: AGENT, mode: 'replay', fixtureFile },
  578. )))
  579. const roots = results.map(result => environmentEcho(result.rawStdout).spillRoot)
  580. expect(roots.every(root => typeof root === 'string')).toBe(true)
  581. expect(new Set(roots).size).toBe(2)
  582. expect((roots[0] as string).length).toBe((roots[1] as string).length)
  583. for (const root of roots as string[]) {
  584. expect(relative(tmpdir(), root)).toMatch(/^acp-snap-spill-[^/\\]+$/)
  585. expect(root).not.toBe(snapshotSpillRoot(fixture.fixtureFile))
  586. await expect(readdir(root)).rejects.toMatchObject({ code: 'ENOENT' })
  587. }
  588. })
  589. it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {
  590. const { dir, fixtureFile } = await scenario({ echoWorkspace: true })
  591. const workspaceDir = join(dir, 'workspace')
  592. await writeFile(join(dir, 'behavior.json'), JSON.stringify({ echoWorkspace: true }))
  593. const { mkdir } = await import('node:fs/promises')
  594. await mkdir(workspaceDir, { recursive: true })
  595. await writeFile(join(workspaceDir, 'seeded.txt'), 'hello')
  596. const result = await runScenario(
  597. { steps: [...boot, { op: 'prompt', text: 'ls' }] },
  598. { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
  599. )
  600. expect(result.rawStdout).toContain('workspace:seeded.txt')
  601. })
  602. it('prepares the generated workspace after copying committed fixtures', { timeout: 20_000 }, async () => {
  603. const { dir, fixtureFile } = await scenario({ echoWorkspace: true })
  604. const workspaceDir = join(dir, 'workspace')
  605. const { mkdir } = await import('node:fs/promises')
  606. await mkdir(workspaceDir, { recursive: true })
  607. await writeFile(join(workspaceDir, 'committed.txt'), 'committed')
  608. const result = await runScenario(
  609. { steps: [...boot, { op: 'prompt', text: 'ls' }] },
  610. {
  611. agent: AGENT,
  612. mode: 'replay',
  613. fixtureFile,
  614. workspaceDir,
  615. prepareWorkspace: async (cwd) => {
  616. expect(await readFile(join(cwd, 'committed.txt'), 'utf8')).toBe('committed')
  617. await writeFile(join(cwd, 'runtime.txt'), 'runtime')
  618. },
  619. },
  620. )
  621. expect(result.rawStdout).toContain('workspace:committed.txt,runtime.txt')
  622. })
  623. it('creates the generated workspace under an explicit parent', { timeout: 20_000 }, async () => {
  624. const { fixtureFile } = await scenario({})
  625. const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-parent-'))
  626. tempDirs.push(workspaceParent)
  627. const result = await runScenario(
  628. { steps: boot },
  629. { agent: AGENT, mode: 'replay', fixtureFile, workspaceParent },
  630. )
  631. const child = relative(workspaceParent, result.cwd)
  632. expect(child).not.toBe('')
  633. expect(child).not.toBe('..')
  634. expect(child.startsWith(`..${sep}`)).toBe(false)
  635. })
  636. it('promptAndCancel waits for the durable turn start, cancels, and settles the prompt', { timeout: 20_000 }, async () => {
  637. const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
  638. const result = await runScenario(
  639. { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }] },
  640. { agent: AGENT, mode: 'replay', fixtureFile },
  641. )
  642. expect(result.rawStdout).toContain('"stopReason":"cancelled"')
  643. // The streamed chunk deterministically precedes the cancelled response.
  644. expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
  645. })
  646. it('promptAndCancel can wait for cwd-relative readiness before cancelling', { timeout: 20_000 }, async () => {
  647. const { dir, fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
  648. const workspaceDir = join(dir, 'workspace')
  649. const { mkdir } = await import('node:fs/promises')
  650. await mkdir(workspaceDir, { recursive: true })
  651. await writeFile(join(workspaceDir, 'started.txt'), 'started')
  652. const result = await runScenario(
  653. {
  654. steps: [...boot, {
  655. op: 'promptAndCancel',
  656. text: 'hang',
  657. waitForFile: { path: 'started.txt' },
  658. }],
  659. },
  660. { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
  661. )
  662. expect(result.rawStdout).toContain('"stopReason":"cancelled"')
  663. const missing = await scenario({ prompt: 'hang-until-cancel' })
  664. await expect(runScenario(
  665. {
  666. steps: [...boot, {
  667. op: 'promptAndCancel',
  668. text: 'hang',
  669. waitForFile: { path: 'never.txt', timeoutMs: 20 },
  670. }],
  671. },
  672. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  673. )).rejects.toThrow(/workspace file "never\.txt" did not appear within 20ms/)
  674. })
  675. it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => {
  676. const { fixtureFile } = await scenario({ prompt: 'respond' })
  677. const result = await runScenario(
  678. {
  679. steps: [...boot, {
  680. op: 'promptAndWaitForAgentMessage',
  681. text: 'go',
  682. waitForText: 'thinking about it',
  683. }],
  684. },
  685. { agent: AGENT, mode: 'replay', fixtureFile, configPath: AGENT.configPath },
  686. )
  687. expect(result.rawStdout).toContain('thinking about it')
  688. })
  689. it('waitForTurnEnd holds cancellation open through the persisted closing boundary', { timeout: 20_000 }, async () => {
  690. const { fixtureFile } = await scenario({
  691. prompt: 'hang-until-cancel',
  692. persistLogsOnCancel: true,
  693. logs: [{
  694. file: 'project/main/session.jsonl',
  695. lines: [
  696. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  697. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  698. ],
  699. }],
  700. })
  701. const result = await runScenario(
  702. { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForTurnEnd' }] },
  703. { agent: AGENT, mode: 'replay', fixtureFile },
  704. )
  705. expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"')
  706. })
  707. it('waitForInboxMessage holds the app through a matching durable insertion', { timeout: 20_000 }, async () => {
  708. const { fixtureFile } = await scenario({
  709. prompt: 'hang-until-cancel',
  710. persistLogsOnCancel: true,
  711. logs: [{
  712. file: 'project/main/session.jsonl',
  713. lines: [
  714. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  715. {
  716. type: 'agent/inbox/spliced',
  717. seq: 0,
  718. time: 2,
  719. data: {
  720. target: 'next-turn',
  721. start: 0,
  722. inserted: [{ role: 'user', content: [{ type: 'text', text: 'durable marker' }] }],
  723. },
  724. },
  725. ],
  726. }],
  727. })
  728. const result = await runScenario(
  729. { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'marker' }] },
  730. { agent: AGENT, mode: 'replay', fixtureFile },
  731. )
  732. expect(result.sessionLogs[0]?.content).toContain('durable marker')
  733. })
  734. it('waitForInboxMessage times out when the session log or matching insertion is absent', { timeout: 20_000 }, async () => {
  735. const absent = await scenario({ prompt: 'hang-until-cancel', persistLogsOnCancel: true })
  736. await expect(runScenario(
  737. { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] },
  738. { agent: AGENT, mode: 'replay', fixtureFile: absent.fixtureFile },
  739. )).rejects.toThrow(/did not persist expected inbox message within 20ms/)
  740. const unmatched = await scenario({
  741. prompt: 'hang-until-cancel',
  742. persistLogsOnCancel: true,
  743. logs: [{
  744. file: 'project/main/session.jsonl',
  745. lines: [{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }],
  746. }],
  747. })
  748. await expect(runScenario(
  749. { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] },
  750. { agent: AGENT, mode: 'replay', fixtureFile: unmatched.fixtureFile },
  751. )).rejects.toThrow(/did not persist expected inbox message within 20ms/)
  752. })
  753. it('waitForTitleAfterTurnEnd holds the app through a standalone durable title', { timeout: 20_000 }, async () => {
  754. const { fixtureFile } = await scenario({
  755. prompt: 'hang-until-cancel',
  756. persistLogsOnCancel: true,
  757. logs: [{
  758. file: 'project/main/session.jsonl',
  759. lines: [
  760. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  761. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  762. { type: 'session/title', seq: 2, time: 3, data: { title: 'Late title' } },
  763. ],
  764. }],
  765. })
  766. const result = await runScenario(
  767. {
  768. steps: [
  769. ...boot,
  770. { op: 'promptAndCancel', text: 'hang' },
  771. { op: 'waitForTitleAfterTurnEnd' },
  772. ],
  773. },
  774. { agent: AGENT, mode: 'replay', fixtureFile },
  775. )
  776. expect(result.sessionLogs[0]?.content).toMatch(/"turn\/end"[\s\S]*"session\/title"/)
  777. })
  778. it('waitForTurnStart can require a later durable turn before continuing', { timeout: 20_000 }, async () => {
  779. const { fixtureFile } = await scenario({
  780. prompt: 'hang-until-cancel',
  781. persistLogsOnCancel: true,
  782. logs: [{
  783. file: 'project/main/session.jsonl',
  784. lines: [
  785. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  786. { type: 'turn/start', seq: 0, time: 1, data: { turn: 3 } },
  787. ],
  788. }],
  789. })
  790. const result = await runScenario(
  791. {
  792. steps: [
  793. ...boot,
  794. { op: 'promptAndCancel', text: 'hang' },
  795. { op: 'waitForTurnStart', minimumTurn: 3 },
  796. ],
  797. },
  798. { agent: AGENT, mode: 'replay', fixtureFile },
  799. )
  800. expect(result.sessionLogs[0]?.content).toContain('"turn":3')
  801. })
  802. it('waitForTurnStart rejects missing, earlier, and malformed durable turns', { timeout: 20_000 }, async () => {
  803. const missing = await scenario({})
  804. await expect(runScenario(
  805. { steps: [...boot, { op: 'waitForTurnStart', timeoutMs: 200 }] },
  806. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  807. )).rejects.toThrow(/did not persist turn\/start within 200ms/)
  808. const earlier = await scenario({
  809. prompt: 'hang-until-cancel',
  810. persistLogsOnCancel: true,
  811. logs: [{
  812. file: 'project/main/session.jsonl',
  813. lines: [
  814. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  815. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  816. ],
  817. }],
  818. })
  819. await expect(runScenario(
  820. {
  821. steps: [
  822. ...boot,
  823. { op: 'promptAndCancel', text: 'hang' },
  824. { op: 'waitForTurnStart', minimumTurn: 3, timeoutMs: 200 },
  825. ],
  826. },
  827. { agent: AGENT, mode: 'replay', fixtureFile: earlier.fixtureFile },
  828. )).rejects.toThrow(/turn\/start at or beyond turn 3 within 200ms/)
  829. const closed = await scenario({
  830. prompt: 'hang-until-cancel',
  831. persistLogsOnCancel: true,
  832. logs: [{
  833. file: 'project/main/session.jsonl',
  834. lines: [
  835. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  836. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  837. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'stop' } } },
  838. ],
  839. }],
  840. })
  841. await expect(runScenario(
  842. {
  843. steps: [
  844. ...boot,
  845. { op: 'promptAndCancel', text: 'hang' },
  846. { op: 'waitForTurnStart', timeoutMs: 200 },
  847. ],
  848. },
  849. { agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile },
  850. )).rejects.toThrow(/did not persist turn\/start within 200ms/)
  851. for (const turn of [undefined, 0]) {
  852. const malformed = await scenario({
  853. prompt: 'hang-until-cancel',
  854. persistLogsOnCancel: true,
  855. logs: [{
  856. file: 'project/main/session.jsonl',
  857. lines: [
  858. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  859. { type: 'turn/start', seq: 0, time: 1, data: turn === undefined ? {} : { turn } },
  860. ],
  861. }],
  862. })
  863. await expect(runScenario(
  864. {
  865. steps: [
  866. ...boot,
  867. { op: 'promptAndCancel', text: 'hang' },
  868. { op: 'waitForTurnStart' },
  869. ],
  870. },
  871. { agent: AGENT, mode: 'replay', fixtureFile: malformed.fixtureFile },
  872. )).rejects.toThrow('invalid persisted turn/start record')
  873. }
  874. })
  875. it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => {
  876. const missing = await scenario({})
  877. await expect(runScenario(
  878. { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 200 }] },
  879. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  880. )).rejects.toThrow(/did not persist turn\/end within 200ms/)
  881. const open = await scenario({
  882. prompt: 'hang-until-cancel',
  883. persistLogsOnCancel: true,
  884. logs: [{
  885. file: 'project/main/session.jsonl',
  886. lines: [
  887. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  888. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  889. ],
  890. }],
  891. })
  892. await expect(runScenario(
  893. {
  894. steps: [
  895. ...boot,
  896. { op: 'promptAndCancel', text: 'hang' },
  897. { op: 'waitForTurnEnd', timeoutMs: 200 },
  898. ],
  899. },
  900. { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile },
  901. )).rejects.toThrow(/did not persist turn\/end within 200ms/)
  902. })
  903. it('waitForGoalPhase requires the requested durable goal phase', { timeout: 20_000 }, async () => {
  904. const reached = await scenario({
  905. prompt: 'hang-until-cancel',
  906. persistLogsOnCancel: true,
  907. logs: [{
  908. file: 'project/main/session.jsonl',
  909. lines: [
  910. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  911. { type: 'goal/change', seq: 1, time: 2, data: {} },
  912. { type: 'goal/change', seq: 2, time: 3, data: { goal: { phase: 'active' } } },
  913. ],
  914. }],
  915. })
  916. const result = await runScenario(
  917. {
  918. steps: [
  919. ...boot,
  920. { op: 'promptAndCancel', text: 'hang' },
  921. { op: 'waitForGoalPhase', phase: 'active' },
  922. ],
  923. },
  924. { agent: AGENT, mode: 'replay', fixtureFile: reached.fixtureFile },
  925. )
  926. expect(result.sessionLogs[0]?.content).toContain('"phase":"active"')
  927. const missing = await scenario({})
  928. await expect(runScenario(
  929. { steps: [...boot, { op: 'waitForGoalPhase', phase: 'blocked', timeoutMs: 20 }] },
  930. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  931. )).rejects.toThrow(/did not persist goal phase "blocked" within 20ms/)
  932. })
  933. it('identifies the child wait when its first log harvest outlasts the deadline', async () => {
  934. const { fixtureFile } = await scenario({})
  935. const reading = Promise.withResolvers<undefined>()
  936. const release = Promise.withResolvers<undefined>()
  937. let pendingRead: Promise<unknown> | undefined
  938. const originalReaddir = readdir
  939. const spy = vi.spyOn(fsPromises, 'readdir').mockImplementation(async (...args) => {
  940. if (pendingRead === undefined && String(args[0]).includes('acp-snap-sessions-')) {
  941. const read = release.promise.then(() => originalReaddir(...args))
  942. pendingRead = read
  943. reading.resolve(undefined)
  944. return await read
  945. }
  946. return await originalReaddir(...args)
  947. })
  948. const run = runScenario(
  949. { steps: [...boot, { op: 'waitForSubagentTurnEnd', child: 2, timeoutMs: 20 }] },
  950. { agent: AGENT, mode: 'replay', fixtureFile },
  951. )
  952. const rejected = expect(run).rejects.toThrow(/subagent child #2 did not persist closed turn 1 within 20ms/)
  953. try {
  954. await Promise.race([reading.promise, rejected])
  955. expect(pendingRead).toBeDefined()
  956. await rejected
  957. } finally {
  958. release.resolve(undefined)
  959. await Promise.allSettled([pendingRead, run, rejected])
  960. spy.mockRestore()
  961. }
  962. })
  963. it('waitForSubagentTurnEnd requires a closed child work turn', { timeout: 20_000 }, async ({ onTestFinished }) => {
  964. isolateDiagnosticTimeout(onTestFinished)
  965. const closed = await scenario({
  966. prompt: 'hang-until-cancel',
  967. persistLogsOnCancel: true,
  968. logs: [
  969. {
  970. file: 'project/main/session.jsonl',
  971. lines: [
  972. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  973. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  974. ],
  975. },
  976. {
  977. file: 'project/child/session.jsonl',
  978. lines: [
  979. { type: 'session', version: 0, id: 'child-1', createdAt: 2, parentSession: '{{SID}}', delegationDepth: 1 },
  980. { type: 'subagent/descriptor', seq: 0, time: 1, data: {} },
  981. { type: 'turn/start', seq: 1, time: 2, data: { turn: 1 } },
  982. { type: 'request/header', seq: 2, time: 3, data: { header: {}, reason: 'initial' } },
  983. { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
  984. ],
  985. },
  986. ],
  987. })
  988. const result = await runScenario(
  989. {
  990. steps: [
  991. ...boot,
  992. { op: 'promptAndCancel', text: 'hang' },
  993. { op: 'waitForSubagentTurnEnd' },
  994. ],
  995. },
  996. { agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile },
  997. )
  998. expect(result.sessionLogs[1]?.parentSession).toBe(result.sessionId)
  999. await expect(runScenario(
  1000. {
  1001. steps: [
  1002. ...boot,
  1003. { op: 'promptAndCancel', text: 'hang' },
  1004. { op: 'waitForSubagentTurnEnd', minimumTurn: 2, timeoutMs: 20 },
  1005. ],
  1006. },
  1007. { agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile },
  1008. )).rejects.toThrow(/subagent child #1 did not persist closed turn 2 within 20ms/)
  1009. const seedOnly = await scenario({
  1010. prompt: 'hang-until-cancel',
  1011. persistLogsOnCancel: true,
  1012. logs: [
  1013. {
  1014. file: 'project/main/session.jsonl',
  1015. lines: [
  1016. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  1017. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  1018. ],
  1019. },
  1020. {
  1021. file: 'project/child/session.jsonl',
  1022. lines: [
  1023. { type: 'session', version: 0, id: 'child-1', createdAt: 2, parentSession: '{{SID}}', delegationDepth: 1 },
  1024. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' } } },
  1025. { type: 'request/header', seq: 1, time: 2, data: { header: {}, reason: 'initial' } },
  1026. { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
  1027. { type: 'subagent/descriptor', seq: 3, time: 4, data: {} },
  1028. ],
  1029. },
  1030. ],
  1031. })
  1032. await expect(runScenario(
  1033. {
  1034. steps: [
  1035. ...boot,
  1036. { op: 'promptAndCancel', text: 'hang' },
  1037. { op: 'waitForSubagentTurnEnd', timeoutMs: 20 },
  1038. ],
  1039. },
  1040. { agent: AGENT, mode: 'replay', fixtureFile: seedOnly.fixtureFile },
  1041. )).rejects.toThrow(/subagent child #1 did not persist closed turn 1 within 20ms/)
  1042. const missing = await scenario({})
  1043. await expect(runScenario(
  1044. { steps: [...boot, { op: 'waitForSubagentTurnEnd', child: 2, timeoutMs: 20 }] },
  1045. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  1046. )).rejects.toThrow(/subagent child #2 did not persist closed turn 1 within 20ms/)
  1047. })
  1048. it('waitForTitleAfterTurnEnd times out when the title precedes the boundary', { timeout: 20_000 }, async () => {
  1049. const { fixtureFile } = await scenario({
  1050. prompt: 'hang-until-cancel',
  1051. persistLogsOnCancel: true,
  1052. logs: [{
  1053. file: 'project/main/session.jsonl',
  1054. lines: [
  1055. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  1056. { type: 'session/title', seq: 1, time: 1, data: { title: 'Early title' } },
  1057. { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  1058. ],
  1059. }],
  1060. })
  1061. await expect(runScenario(
  1062. {
  1063. steps: [
  1064. ...boot,
  1065. { op: 'promptAndCancel', text: 'hang' },
  1066. { op: 'waitForTitleAfterTurnEnd', timeoutMs: titleDiagnosticTimeoutMs },
  1067. ],
  1068. },
  1069. { agent: AGENT, mode: 'replay', fixtureFile },
  1070. )).rejects.toThrow(new RegExp(`did not persist session/title after turn/end within ${titleDiagnosticTimeoutMs}ms`))
  1071. })
  1072. it('waitForEventAfterTurnEnd holds the app for a typed post-boundary record and times out otherwise', { timeout: 20_000 }, async ({ onTestFinished }) => {
  1073. isolateDiagnosticTimeout(onTestFinished)
  1074. const late = await scenario({
  1075. prompt: 'hang-until-cancel',
  1076. persistLogsOnCancel: true,
  1077. logs: [{
  1078. file: 'project/main/session.jsonl',
  1079. lines: [
  1080. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  1081. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  1082. { type: 'user/message', seq: 2, time: 3, data: { content: [{ type: 'text', text: 'late goal state' }], source: { kind: 'user' } } },
  1083. ],
  1084. }],
  1085. })
  1086. const result = await runScenario(
  1087. {
  1088. steps: [
  1089. ...boot,
  1090. { op: 'promptAndCancel', text: 'hang' },
  1091. { op: 'waitForEventAfterTurnEnd', type: 'user/message' },
  1092. ],
  1093. },
  1094. { agent: AGENT, mode: 'replay', fixtureFile: late.fixtureFile },
  1095. )
  1096. expect(result.sessionLogs[0]?.content).toMatch(/"turn\/end"[\s\S]*"user\/message"/)
  1097. const early = await scenario({
  1098. prompt: 'hang-until-cancel',
  1099. persistLogsOnCancel: true,
  1100. logs: [{
  1101. file: 'project/main/session.jsonl',
  1102. lines: [
  1103. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  1104. { type: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' } } },
  1105. { type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  1106. ],
  1107. }],
  1108. })
  1109. await expect(runScenario(
  1110. {
  1111. steps: [
  1112. ...boot,
  1113. { op: 'promptAndCancel', text: 'hang' },
  1114. { op: 'waitForEventAfterTurnEnd', type: 'user/message', timeoutMs: 20 },
  1115. ],
  1116. },
  1117. { agent: AGENT, mode: 'replay', fixtureFile: early.fixtureFile },
  1118. )).rejects.toThrow(/did not persist user\/message after turn\/end within 20ms/)
  1119. })
  1120. it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => {
  1121. const { fixtureFile } = await scenario({ prompt: 'error' })
  1122. const result = await runScenario(
  1123. { steps: [...boot, { op: 'promptExpectError', text: 'boom' }] },
  1124. { agent: AGENT, mode: 'replay', fixtureFile },
  1125. )
  1126. expect(result.rawStdout).toContain('model exploded')
  1127. })
  1128. it('promptExpectError throws when the prompt unexpectedly succeeds (and teardown kills the live child)', { timeout: 20_000 }, async () => {
  1129. const { fixtureFile } = await scenario({ prompt: 'respond' })
  1130. await expect(runScenario(
  1131. { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] },
  1132. { agent: AGENT, mode: 'replay', fixtureFile },
  1133. )).rejects.toThrow(/expected the prompt to fail/)
  1134. })
  1135. it('reports scenario and cleanup failures together', { timeout: 20_000 }, async () => {
  1136. const { fixtureFile } = await scenario({ prompt: 'respond' })
  1137. const cleanupFailure = new Error('cleanup failed')
  1138. fsControl.cleanupFailure = cleanupFailure
  1139. const failure = await runScenario(
  1140. { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] },
  1141. { agent: AGENT, mode: 'replay', fixtureFile },
  1142. ).catch((error: unknown): unknown => error)
  1143. expect(failure).toBeInstanceOf(AggregateError)
  1144. const failures = (failure as AggregateError).errors as unknown[]
  1145. expect(failures).toHaveLength(2)
  1146. expect(failures[0]).toBeInstanceOf(Error)
  1147. expect((failures[0] as Error).message).toMatch(/expected the prompt to fail/)
  1148. expect(failures[1]).toBe(cleanupFailure)
  1149. })
  1150. it('reports cleanup failure after an otherwise successful scenario', { timeout: 20_000 }, async () => {
  1151. const { fixtureFile } = await scenario({})
  1152. const cleanupFailure = new Error('cleanup failed')
  1153. fsControl.cleanupFailure = cleanupFailure
  1154. const failure = await runScenario(
  1155. { steps: boot },
  1156. { agent: AGENT, mode: 'replay', fixtureFile },
  1157. ).catch((error: unknown): unknown => error)
  1158. expect(failure).toBeInstanceOf(AggregateError)
  1159. expect((failure as AggregateError).message).toBe('snapshot cleanup failed')
  1160. expect((failure as AggregateError).errors as unknown[]).toEqual([cleanupFailure])
  1161. })
  1162. it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => {
  1163. const { fixtureFile } = await scenario({ rejectExtraDirs: true })
  1164. const result = await runScenario(
  1165. { steps: [{ op: 'initialize' }, { op: 'newSessionExpectError', additionalDirectories: ['/elsewhere'] }] },
  1166. { agent: AGENT, mode: 'replay', fixtureFile },
  1167. )
  1168. // No session was created, so no id and no logs.
  1169. expect(result.sessionId).toBeUndefined()
  1170. expect(result.sessionLogs).toHaveLength(0)
  1171. const rejectAll = await scenario({ rejectNewSession: true })
  1172. const second = await runScenario(
  1173. { steps: [{ op: 'initialize' }, { op: 'newSessionExpectError' }] },
  1174. { agent: AGENT, mode: 'replay', fixtureFile: rejectAll.fixtureFile },
  1175. )
  1176. expect(second.rawStdout).toContain('unsupported workspace scope')
  1177. })
  1178. it('newSessionExpectError throws when session/new unexpectedly succeeds', { timeout: 20_000 }, async () => {
  1179. const { fixtureFile } = await scenario({})
  1180. await expect(runScenario(
  1181. { steps: [{ op: 'initialize' }, { op: 'newSessionExpectError' }] },
  1182. { agent: AGENT, mode: 'replay', fixtureFile },
  1183. )).rejects.toThrow(/expected session\/new to be rejected/)
  1184. })
  1185. it('a plain cancel step is forwarded (and ignored by an idle agent)', { timeout: 20_000 }, async () => {
  1186. const { fixtureFile } = await scenario({})
  1187. const result = await runScenario(
  1188. { steps: [...boot, { op: 'cancel' }] },
  1189. { agent: AGENT, mode: 'replay', fixtureFile },
  1190. )
  1191. expect(result.sessionId).toBeDefined()
  1192. })
  1193. it('a standalone cancel can wait for cwd-relative readiness', { timeout: 20_000 }, async () => {
  1194. const { dir, fixtureFile } = await scenario({})
  1195. const workspaceDir = join(dir, 'workspace')
  1196. const { mkdir } = await import('node:fs/promises')
  1197. await mkdir(workspaceDir, { recursive: true })
  1198. await writeFile(join(workspaceDir, 'ready'), '')
  1199. const result = await runScenario(
  1200. { steps: [...boot, { op: 'cancel', waitForFile: { path: 'ready' } }] },
  1201. { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
  1202. )
  1203. expect(result.sessionId).toBeDefined()
  1204. })
  1205. it('waitForFile holds the next input step behind cwd-relative readiness', { timeout: 20_000 }, async () => {
  1206. const { dir, fixtureFile } = await scenario({})
  1207. const workspaceDir = join(dir, 'workspace')
  1208. const { mkdir } = await import('node:fs/promises')
  1209. await mkdir(workspaceDir, { recursive: true })
  1210. await writeFile(join(workspaceDir, 'ready'), '')
  1211. const result = await runScenario(
  1212. { steps: [...boot, { op: 'waitForFile', path: 'ready' }, { op: 'cancel' }] },
  1213. { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
  1214. )
  1215. expect(result.sessionId).toBeDefined()
  1216. })
  1217. it.each([
  1218. [{ op: 'prompt', text: 'x' }, /prompt before newSession/],
  1219. [{ op: 'promptContent', content: [{ type: 'text', text: 'x' }] }, /promptContent before newSession/],
  1220. [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
  1221. [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
  1222. [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
  1223. [{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/],
  1224. [{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/],
  1225. [{ op: 'waitForGoalPhase', phase: 'active' }, /waitForGoalPhase before newSession/],
  1226. [{ op: 'waitForInboxMessage', text: 'marker' }, /waitForInboxMessage before newSession/],
  1227. [{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/],
  1228. [{ op: 'waitForEventAfterTurnEnd', type: 'user/message' }, /waitForEventAfterTurnEnd before newSession/],
  1229. [{ op: 'cancel' }, /cancel before newSession/],
  1230. ] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
  1231. const { fixtureFile } = await scenario({})
  1232. await expect(runScenario(
  1233. { steps: [{ op: 'initialize' }, step] },
  1234. { agent: AGENT, mode: 'replay', fixtureFile },
  1235. )).rejects.toThrow(message)
  1236. })
  1237. it('rejects an unknown input op', { timeout: 20_000 }, async () => {
  1238. const { fixtureFile } = await scenario({})
  1239. const bogus = { op: 'reticulate' } as unknown as InputStep
  1240. await expect(runScenario(
  1241. { steps: [bogus] },
  1242. { agent: AGENT, mode: 'replay', fixtureFile },
  1243. )).rejects.toThrow(/unknown input op/)
  1244. })
  1245. it('harvests all logs primary-first, children by createdAt then id, skipping filesystem noise', { timeout: 20_000 }, async () => {
  1246. const { fixtureFile } = await scenario({
  1247. strayRootFile: true,
  1248. strayBucketFile: true,
  1249. logs: [
  1250. // File names chosen so readdir feeds the sort children-first AND
  1251. // parent-in-the-middle: the comparator then sees a parent on both
  1252. // sides of a pair, plus the same-createdAt (localeCompare) tiebreak.
  1253. { file: 'b1/aa-child-c/session.jsonl', lines: [{ type: 'session', version: 0, id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
  1254. { file: 'b1/bb-parent/session.jsonl', lines: [{ type: 'session', version: 0, id: 'superseded-parent', createdAt: 1 }] },
  1255. { file: 'b1/bb-parent/session.v1.jsonl', lines: [{ type: 'session', version: 1, id: '{{SID}}', createdAt: 900 }] },
  1256. { file: 'b1/cc-child-a/session.jsonl', lines: [{ type: 'session', version: 0, id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
  1257. // Missing id/createdAt fall back to ''/0; earliest child by createdAt.
  1258. { file: 'b2/orphan/session.jsonl', lines: [{ type: 'session', version: 0, parentSession: '{{SID}}' }] },
  1259. ],
  1260. })
  1261. const result = await runScenario(
  1262. { steps: [...boot, { op: 'prompt', text: 'go' }] },
  1263. { agent: AGENT, mode: 'replay', fixtureFile },
  1264. )
  1265. expect(result.sessionLogs.map(l => [l.id, l.createdAt])).toEqual([
  1266. [result.sessionId, 900],
  1267. ['', 0],
  1268. ['aaaaaaaa-0000-4000-8000-000000000000', 500],
  1269. ['cccccccc-0000-4000-8000-000000000000', 500],
  1270. ])
  1271. expect(result.sessionLogs[1]?.parentSession).toBe(result.sessionId)
  1272. })
  1273. it('rejects an empty persisted generation', { timeout: 20_000 }, async () => {
  1274. const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty/session.jsonl', lines: [] }] })
  1275. await expect(runScenario(
  1276. { steps: boot },
  1277. { agent: AGENT, mode: 'replay', fixtureFile },
  1278. )).rejects.toThrow('session.jsonl: session fixture is empty')
  1279. })
  1280. it('rejects a persisted filename/header generation mismatch', { timeout: 20_000 }, async () => {
  1281. const { fixtureFile } = await scenario({
  1282. logs: [{ file: 'b/mismatch/session.v1.jsonl', lines: [{ type: 'session', version: 0 }] }],
  1283. })
  1284. await expect(runScenario(
  1285. { steps: boot },
  1286. { agent: AGENT, mode: 'replay', fixtureFile },
  1287. )).rejects.toThrow('filename declares Session format v1, header declares v0')
  1288. })
  1289. it('yields no logs when the sessions root vanished', { timeout: 20_000 }, async () => {
  1290. const { fixtureFile } = await scenario({ deleteSessionsRoot: true })
  1291. const result = await runScenario(
  1292. { steps: boot },
  1293. { agent: AGENT, mode: 'replay', fixtureFile },
  1294. )
  1295. expect(result.sessionLogs).toHaveLength(0)
  1296. })
  1297. it('answers permission requests from the scripted queue by option kind, falling back to cancelled', { timeout: 20_000 }, async () => {
  1298. const { fixtureFile } = await scenario({ permissionProbe: true })
  1299. // Two prompts → two permission round-trips; one scripted answer, so the
  1300. // second request exercises the exhausted-queue fallback.
  1301. const result = await runScenario(
  1302. {
  1303. steps: [...boot, { op: 'prompt', text: 'one' }, { op: 'prompt', text: 'two' }],
  1304. permissionAnswers: [{ kind: 'allow_once' }],
  1305. },
  1306. { agent: AGENT, mode: 'replay', fixtureFile },
  1307. )
  1308. const first = result.rawStdout.indexOf('permission:{\\"outcome\\":\\"selected\\",\\"optionId\\":\\"opt-allow\\"}')
  1309. const second = result.rawStdout.indexOf('permission:{\\"outcome\\":\\"cancelled\\"}')
  1310. expect(first).toBeGreaterThanOrEqual(0)
  1311. expect(second).toBeGreaterThan(first)
  1312. })
  1313. it('selects a non-first offered option by kind', { timeout: 20_000 }, async () => {
  1314. const { fixtureFile } = await scenario({ permissionProbe: true })
  1315. const result = await runScenario(
  1316. { steps: [...boot, { op: 'prompt', text: 'deny it' }], permissionAnswers: [{ kind: 'reject_once' }] },
  1317. { agent: AGENT, mode: 'replay', fixtureFile },
  1318. )
  1319. expect(result.rawStdout).toContain('permission:{\\"outcome\\":\\"selected\\",\\"optionId\\":\\"opt-reject\\"}')
  1320. })
  1321. it('rejects the run on a scripted permission kind the agent never offered', { timeout: 20_000 }, async () => {
  1322. const { fixtureFile } = await scenario({ permissionProbe: true })
  1323. // The fake bin offers allow_once/reject_once; scripting allow_always is a
  1324. // scenario bug. The agent is answered `cancelled` (it must not be able to
  1325. // absorb the bug as an error-means-denial), and the RUN fails: a callback
  1326. // throw would only reach the agent as a JSON-RPC error response, letting
  1327. // a tolerant agent carry on and the scenario pass — or record.
  1328. await expect(runScenario(
  1329. { steps: [...boot, { op: 'prompt', text: 'impossible click' }], permissionAnswers: [{ kind: 'allow_always' }] },
  1330. { agent: AGENT, mode: 'replay', fixtureFile },
  1331. )).rejects.toThrow(/allow_always not among the offered options \[allow_once, reject_once\]/)
  1332. })
  1333. })