harness.spec.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  2. import { once } from 'node:events'
  3. import { tmpdir } from 'node:os'
  4. import { delimiter, join, relative, sep } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { afterAll, describe, expect, it, vi } from 'vitest'
  7. import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
  8. import { runScenario, snapshotSpillRoot, type AgentUnderTest, type InputStep } from '../src/harness.ts'
  9. import { launchAcpTestAgent } from '../src/launcher.ts'
  10. const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined }))
  11. vi.mock('node:fs/promises', async (importOriginal) => {
  12. const actual = await importOriginal<typeof import('node:fs/promises')>()
  13. return {
  14. ...actual,
  15. async rm(...args: Parameters<typeof actual.rm>): Promise<void> {
  16. if (String(args[0]).includes('acp-snap-cwd-') && fsControl.cleanupFailure !== undefined) {
  17. const failure = fsControl.cleanupFailure
  18. fsControl.cleanupFailure = undefined
  19. await actual.rm(...args)
  20. throw failure
  21. }
  22. await actual.rm(...args)
  23. },
  24. }
  25. })
  26. /**
  27. * Unit tests for the subprocess harness, driven through the REAL spawn path
  28. * (mode-aware launcher, temp cwd, env plumbing) against the scripted fake ACP bin in
  29. * ./fixtures/fake-acp-agent.ts. Each case writes a `behavior.json` next to a
  30. * throwaway fixture path; the fake bin echoes observable facts (env, seeded
  31. * workspace, permission outcomes) into `agent_message_chunk` text, so the
  32. * assertions read plain `rawStdout`.
  33. */
  34. const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url))
  35. const AGENT: AgentUnderTest = {
  36. binScript: fakeAgent,
  37. libBinScript: fakeAgent,
  38. // The fake bin ignores its config argv; any real path documents the shape.
  39. configPath: fakeAgent,
  40. tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
  41. }
  42. /** Temp scenario dirs to drop after the suite. */
  43. const tempDirs: string[] = []
  44. afterAll(async () => {
  45. for (const dir of tempDirs) await rm(dir, { recursive: true, force: true })
  46. })
  47. /** Write a behavior.json into a fresh temp dir; return the sibling fixture path the harness points the bin at. */
  48. async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: string }> {
  49. const dir = await mkdtemp(join(tmpdir(), 'acp-snap-spec-'))
  50. tempDirs.push(dir)
  51. await writeFile(join(dir, 'behavior.json'), JSON.stringify(behavior))
  52. return { dir, fixtureFile: join(dir, 'session.jsonl') }
  53. }
  54. const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
  55. it('keeps scenario-owned snapshot spill root length stable across platforms', () => {
  56. const fixtureFile = '/fixtures/scenario/session.jsonl'
  57. const posix = snapshotSpillRoot(fixtureFile, 'linux')
  58. const windows = snapshotSpillRoot(fixtureFile, 'win32')
  59. expect(posix).toMatch(/^\/tmp\/dsh-acp-snap-[0-9a-f]{9}$/)
  60. expect(windows).toMatch(/^\/t\/dsh-acp-snap-[0-9a-f]{9}$/)
  61. expect(windows.length + 2).toBe(posix.length)
  62. })
  63. function environmentEcho(rawStdout: string): Record<string, unknown> {
  64. const frames = rawStdout.trim().split('\n')
  65. .map(line => JSON.parse(line) as { params?: { update?: { content?: { text?: unknown } } } })
  66. const text = frames.map(frame => frame.params?.update?.content?.text)
  67. .find(value => typeof value === 'string' && value.startsWith('env:'))
  68. if (typeof text !== 'string') throw new Error('fake ACP agent did not echo its environment')
  69. return JSON.parse(text.slice('env:'.length)) as Record<string, unknown>
  70. }
  71. describe('runScenario', () => {
  72. it('surfaces an asynchronous child spawn failure through startup and close', async () => {
  73. const { dir } = await scenario({})
  74. const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') })
  75. let stdioClosed = false
  76. let clientClosed = false
  77. launched.child.once('close', () => { stdioClosed = true })
  78. void launched.client.closed.then(
  79. () => { clientClosed = true },
  80. () => { clientClosed = true },
  81. )
  82. await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' })
  83. await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' })
  84. expect(stdioClosed).toBe(true)
  85. expect(clientClosed).toBe(true)
  86. })
  87. it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => {
  88. const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' })
  89. const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-'))
  90. tempDirs.push(sessionsRoot)
  91. const launched = launchAcpTestAgent({
  92. agent: AGENT,
  93. cwd: dir,
  94. configPath: AGENT.configPath,
  95. env: {
  96. DSH_SNAPSHOT: 'replay',
  97. DSH_SNAPSHOT_FILE: fixtureFile,
  98. DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
  99. },
  100. })
  101. await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  102. const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] })
  103. const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk')
  104. const laterChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk'
  105. && update.content.type === 'text' && update.content.text === 'never this one')
  106. const predicateFailure = new Error('predicate failed')
  107. const failedPredicate = launched.waitForUpdate(() => { throw predicateFailure })
  108. .catch((error: unknown): unknown => error)
  109. await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
  110. expect(await failedPredicate).toBe(predicateFailure)
  111. expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk')
  112. expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true)
  113. expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}')
  114. expect(launched.stderr()).toContain('launcher stderr')
  115. void laterChunk.catch(() => undefined)
  116. const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/)
  117. await launched.close()
  118. await unmatched
  119. await expect(launched.waitForUpdate(() => true)).rejects.toThrow(/update stream closed/)
  120. await launched.close('SIGKILL')
  121. // The minimal shape needs no environment or config override.
  122. const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  123. await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  124. const childFailure = new Error('child process failed')
  125. let exited = false
  126. minimal.child.once('exit', () => { exited = true })
  127. minimal.child.emit('error', childFailure)
  128. await expect(minimal.close('SIGTERM')).rejects.toBe(childFailure)
  129. // close rejects only after the fallback SIGKILL has produced an exit edge.
  130. expect(exited).toBe(true)
  131. })
  132. it('waits for inherited stdio and buffered ACP parsing after the parent exits', { timeout: 20_000 }, async () => {
  133. const { dir, fixtureFile } = await scenario({ lateInheritedOutput: true })
  134. const launched = launchAcpTestAgent({
  135. agent: AGENT,
  136. cwd: dir,
  137. env: { DSH_SNAPSHOT_FILE: fixtureFile },
  138. })
  139. await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  140. await launched.client.newSession({ cwd: dir, mcpServers: [] })
  141. const lateUpdate = launched.waitForUpdate(update =>
  142. update.sessionUpdate === 'agent_message_chunk'
  143. && update.content.type === 'text'
  144. && update.content.text === 'late inherited stdout')
  145. // Arm rejection handling before close may exhaust the stream; the later assertion still
  146. // observes the original promise and turns a missing inherited frame into the test failure.
  147. void lateUpdate.catch(() => undefined)
  148. await launched.close()
  149. await expect(lateUpdate).resolves.toMatchObject({ sessionUpdate: 'agent_message_chunk' })
  150. expect(launched.rawStdout()).toContain('late inherited stdout')
  151. expect(launched.stderr()).toContain('late inherited stderr')
  152. })
  153. it('rejects promptly when fallback termination is refused', async () => {
  154. const { dir } = await scenario({})
  155. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  156. await launched.spawned
  157. const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' })
  158. const originalKill = launched.child.kill.bind(launched.child)
  159. const kill = vi.spyOn(launched.child, 'kill').mockReturnValue(false)
  160. const closed = new Promise<void>(resolve => launched.child.once('close', () => { resolve() }))
  161. try {
  162. launched.child.emit('error', childFailure)
  163. const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error)
  164. expect(rejection).toBeInstanceOf(AggregateError)
  165. expect(rejection).toMatchObject({
  166. message: 'ACP test agent failed and fallback termination was refused',
  167. errors: [
  168. childFailure,
  169. expect.objectContaining({ message: 'Fallback SIGKILL was not accepted by the child process' }),
  170. ],
  171. })
  172. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  173. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  174. } finally {
  175. kill.mockRestore()
  176. originalKill('SIGKILL')
  177. await closed
  178. }
  179. })
  180. it('preserves the child error when the requested signal sets an exit marker', async () => {
  181. const { dir } = await scenario({})
  182. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  183. await launched.spawned
  184. const childFailure = Object.assign(new Error('signal failed as the child exited'), { code: 'EPERM' })
  185. const originalKill = launched.child.kill.bind(launched.child)
  186. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  187. expect(signal).toBe('SIGTERM')
  188. originalKill('SIGKILL')
  189. Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGTERM' })
  190. return true
  191. })
  192. try {
  193. launched.child.emit('error', childFailure)
  194. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  195. expect(kill).toHaveBeenCalledOnce()
  196. } finally {
  197. kill.mockRestore()
  198. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  199. }
  200. })
  201. it('preserves the child error when the requested signal publishes its exit marker later', async () => {
  202. const { dir } = await scenario({})
  203. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  204. await launched.spawned
  205. const childFailure = Object.assign(new Error('signal failed before the delayed exit marker'), { code: 'EPERM' })
  206. const originalKill = launched.child.kill.bind(launched.child)
  207. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  208. expect(signal).toBe('SIGTERM')
  209. setTimeout(() => { originalKill('SIGKILL') }, 10)
  210. return true
  211. })
  212. try {
  213. launched.child.emit('error', childFailure)
  214. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  215. expect(kill).toHaveBeenCalledOnce()
  216. } finally {
  217. kill.mockRestore()
  218. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  219. }
  220. })
  221. it('preserves the child error when fallback refusal races with an exit marker', async () => {
  222. const { dir } = await scenario({})
  223. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  224. await launched.spawned
  225. const childFailure = Object.assign(new Error('signal failed while the child exited'), { code: 'EPERM' })
  226. const originalKill = launched.child.kill.bind(launched.child)
  227. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  228. if (signal === 'SIGTERM') return true
  229. originalKill('SIGKILL')
  230. Object.defineProperty(launched.child, 'signalCode', { configurable: true, enumerable: true, writable: true, value: 'SIGKILL' })
  231. return false
  232. })
  233. try {
  234. launched.child.emit('error', childFailure)
  235. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  236. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  237. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  238. } finally {
  239. kill.mockRestore()
  240. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  241. }
  242. })
  243. it('preserves the child error after accepted fallback termination drains', async () => {
  244. const { dir } = await scenario({})
  245. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  246. await launched.spawned
  247. const childFailure = Object.assign(new Error('requested signal failed before fallback'), { code: 'EPERM' })
  248. const originalKill = launched.child.kill.bind(launched.child)
  249. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  250. if (signal === 'SIGTERM') return true
  251. return originalKill('SIGKILL')
  252. })
  253. try {
  254. launched.child.emit('error', childFailure)
  255. await expect(launched.close('SIGTERM')).rejects.toBe(childFailure)
  256. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  257. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  258. } finally {
  259. kill.mockRestore()
  260. if (launched.child.exitCode === null && launched.child.signalCode === null) originalKill('SIGKILL')
  261. }
  262. })
  263. it('rejects promptly when fallback termination emits an error', async () => {
  264. const { dir } = await scenario({})
  265. const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir })
  266. await launched.spawned
  267. const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' })
  268. const fallbackFailure = Object.assign(new Error('fallback signal refused'), { code: 'EPERM' })
  269. const originalKill = launched.child.kill.bind(launched.child)
  270. const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => {
  271. if (signal === 'SIGKILL') queueMicrotask(() => launched.child.emit('error', fallbackFailure))
  272. return signal === 'SIGKILL'
  273. })
  274. const closed = new Promise<void>(resolve => launched.child.once('close', () => { resolve() }))
  275. try {
  276. launched.child.emit('error', childFailure)
  277. const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error)
  278. expect(rejection).toBeInstanceOf(AggregateError)
  279. expect(rejection).toMatchObject({
  280. message: 'ACP test agent failed and fallback termination was refused',
  281. errors: [childFailure, fallbackFailure],
  282. })
  283. expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM')
  284. expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL')
  285. } finally {
  286. kill.mockRestore()
  287. originalKill('SIGKILL')
  288. await closed
  289. }
  290. })
  291. it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => {
  292. const { dir, fixtureFile } = await scenario({ permissionProbe: true })
  293. let releasePermission: (() => void) | undefined
  294. const permissionReleased = new Promise<void>((resolve) => { releasePermission = resolve })
  295. let markPermissionStarted: (() => void) | undefined
  296. const permissionStarted = new Promise<void>((resolve) => { markPermissionStarted = resolve })
  297. let permissionFinished = false
  298. const launched = launchAcpTestAgent({
  299. agent: AGENT,
  300. cwd: dir,
  301. env: { DSH_SNAPSHOT_FILE: fixtureFile },
  302. async requestPermission() {
  303. markPermissionStarted?.()
  304. await permissionReleased
  305. permissionFinished = true
  306. return { outcome: { outcome: 'cancelled' } }
  307. },
  308. })
  309. await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
  310. const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] })
  311. void launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => undefined)
  312. await permissionStarted
  313. const childClosed = once(launched.child, 'close')
  314. let closeSettled = false
  315. const closing = launched.close('SIGKILL').then(() => { closeSettled = true })
  316. await childClosed
  317. await launched.client.closed
  318. expect(closeSettled).toBe(false)
  319. releasePermission?.()
  320. await closing
  321. expect(permissionFinished).toBe(true)
  322. })
  323. it('includes agent stderr when the ACP connection closes during startup', { timeout: 20_000 }, async () => {
  324. const { fixtureFile } = await scenario({ failOnBoot: true, stderrNote: 'fake agent requested startup failure' })
  325. await expect(runScenario(
  326. { steps: [{ op: 'initialize' }] },
  327. { agent: AGENT, mode: 'replay', fixtureFile },
  328. )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/)
  329. })
  330. it('preserves launch-resolution errors when no child process exists', async () => {
  331. const { dir, fixtureFile } = await scenario({})
  332. vi.stubEnv('DSH_EXAMPLE_MODE', 'lib')
  333. try {
  334. await expect(runScenario(
  335. { steps: [] },
  336. {
  337. agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined },
  338. mode: 'replay',
  339. fixtureFile,
  340. },
  341. )).rejects.toThrow(/expected a "\/src\/" segment/)
  342. } finally {
  343. vi.unstubAllEnvs()
  344. }
  345. })
  346. it('drives a full turn: initialize, session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {
  347. const { fixtureFile } = await scenario({
  348. permissionProbe: true,
  349. logs: [{
  350. file: 'project/main/session.jsonl',
  351. lines: [
  352. { type: 'session', id: '{{SID}}', createdAt: 42, cwd: '{{CWD}}' },
  353. { type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } },
  354. ],
  355. }],
  356. })
  357. const result = await runScenario(
  358. { steps: [{ op: 'initialize' }, { op: 'newSession' }, { op: 'prompt', text: 'go' }] },
  359. { agent: AGENT, mode: 'replay', fixtureFile },
  360. )
  361. expect(result.sessionId).toBeDefined()
  362. // The harness's client answers a permission request with `cancelled`; the
  363. // fake bin echoes the outcome it received back as a chunk.
  364. expect(result.rawStdout).toContain('permission:{\\"outcome\\":\\"cancelled\\"}')
  365. expect(result.sessionLogs).toHaveLength(1)
  366. expect(result.sessionLogs[0]?.id).toBe(result.sessionId)
  367. expect(result.sessionLogs[0]?.createdAt).toBe(42)
  368. expect(result.sessionLogs[0]?.content).toContain('turn/start')
  369. // The harvested log embeds the run's REAL temp cwd (template-substituted).
  370. // The cwd is JSON-encoded in the log line, so compare the parsed field
  371. // rather than substring-matching a raw path (which breaks when the path
  372. // separator is escaped inside JSON text on Windows).
  373. const sessionLine = result.sessionLogs[0]?.content.split('\n').find(l => l.includes('"type":"session"')) ?? '{}'
  374. expect((JSON.parse(sessionLine) as { cwd?: string }).cwd).toBe(result.cwd)
  375. })
  376. it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => {
  377. const { dir, fixtureFile } = await scenario({ echoEnv: true, stderrNote: 'fake bin booted' })
  378. const childFiles = [join(dir, 'session.1.jsonl'), join(dir, 'session.2.jsonl')]
  379. const result = await runScenario(
  380. { steps: [...boot, { op: 'prompt', text: 'env?' }] },
  381. {
  382. agent: AGENT,
  383. mode: 'replay',
  384. fixtureFile,
  385. overrideFile: join(dir, 'replay.override.json'),
  386. childFiles,
  387. // A workspaceDir that does not exist is skipped, not an error.
  388. workspaceDir: join(dir, 'no-such-workspace'),
  389. },
  390. )
  391. expect(result.stderr).toContain('fake bin booted')
  392. expect(result.rawStdout).toContain('replay.override.json')
  393. // Child paths ride one env var, joined with the platform delimiter.
  394. // Parse the fake bin's env-probe chunk rather than substring-matching a
  395. // JSON-encoded path (the escaping breaks raw-substring compares on Windows).
  396. const envChunk = result.rawStdout.split('\n')
  397. .map(l => l.trim())
  398. .filter(l => l.length > 0)
  399. .map(l => JSON.parse(l) as { params?: { update?: { content?: { text?: string } } } })
  400. .find(f => f.params?.update?.content?.text?.startsWith('env:'))
  401. const env = JSON.parse((envChunk?.params?.update?.content?.text ?? 'env:{}').slice('env:'.length)) as {
  402. childFiles: string | null
  403. }
  404. expect(env.childFiles).toBe(childFiles.join(delimiter))
  405. })
  406. it('gives concurrent scenarios distinct equal-length spill roots', { timeout: 20_000 }, async () => {
  407. const [first, second] = await Promise.all([scenario({ echoEnv: true }), scenario({ echoEnv: true })])
  408. const results = await Promise.all([first, second].map(({ fixtureFile }) => runScenario(
  409. { steps: [...boot, { op: 'prompt', text: 'env?' }] },
  410. { agent: AGENT, mode: 'replay', fixtureFile },
  411. )))
  412. const roots = results.map(result => environmentEcho(result.rawStdout).spillRoot)
  413. expect(roots.every(root => typeof root === 'string')).toBe(true)
  414. expect(new Set(roots).size).toBe(2)
  415. expect((roots[0] as string).length).toBe((roots[1] as string).length)
  416. expect(roots).toEqual([
  417. snapshotSpillRoot(first.fixtureFile),
  418. snapshotSpillRoot(second.fixtureFile),
  419. ])
  420. })
  421. it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {
  422. const { dir, fixtureFile } = await scenario({ echoWorkspace: true })
  423. const workspaceDir = join(dir, 'workspace')
  424. await writeFile(join(dir, 'behavior.json'), JSON.stringify({ echoWorkspace: true }))
  425. const { mkdir } = await import('node:fs/promises')
  426. await mkdir(workspaceDir, { recursive: true })
  427. await writeFile(join(workspaceDir, 'seeded.txt'), 'hello')
  428. const result = await runScenario(
  429. { steps: [...boot, { op: 'prompt', text: 'ls' }] },
  430. { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
  431. )
  432. expect(result.rawStdout).toContain('workspace:seeded.txt')
  433. })
  434. it('creates the generated workspace under an explicit parent', { timeout: 20_000 }, async () => {
  435. const { fixtureFile } = await scenario({})
  436. const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-parent-'))
  437. tempDirs.push(workspaceParent)
  438. const result = await runScenario(
  439. { steps: boot },
  440. { agent: AGENT, mode: 'replay', fixtureFile, workspaceParent },
  441. )
  442. const child = relative(workspaceParent, result.cwd)
  443. expect(child).not.toBe('')
  444. expect(child).not.toBe('..')
  445. expect(child.startsWith(`..${sep}`)).toBe(false)
  446. })
  447. it('promptAndCancel waits for the durable turn start, cancels, and settles the prompt', { timeout: 20_000 }, async () => {
  448. const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
  449. const result = await runScenario(
  450. { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }] },
  451. { agent: AGENT, mode: 'replay', fixtureFile },
  452. )
  453. expect(result.rawStdout).toContain('"stopReason":"cancelled"')
  454. // The streamed chunk deterministically precedes the cancelled response.
  455. expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
  456. })
  457. it('promptAndCancel can wait for cwd-relative readiness before cancelling', { timeout: 20_000 }, async () => {
  458. const { dir, fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
  459. const workspaceDir = join(dir, 'workspace')
  460. const { mkdir } = await import('node:fs/promises')
  461. await mkdir(workspaceDir, { recursive: true })
  462. await writeFile(join(workspaceDir, 'started.txt'), 'started')
  463. const result = await runScenario(
  464. {
  465. steps: [...boot, {
  466. op: 'promptAndCancel',
  467. text: 'hang',
  468. waitForFile: { path: 'started.txt' },
  469. }],
  470. },
  471. { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
  472. )
  473. expect(result.rawStdout).toContain('"stopReason":"cancelled"')
  474. const missing = await scenario({ prompt: 'hang-until-cancel' })
  475. await expect(runScenario(
  476. {
  477. steps: [...boot, {
  478. op: 'promptAndCancel',
  479. text: 'hang',
  480. waitForFile: { path: 'never.txt', timeoutMs: 20 },
  481. }],
  482. },
  483. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  484. )).rejects.toThrow(/workspace file "never\.txt" did not appear within 20ms/)
  485. })
  486. it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => {
  487. const { fixtureFile } = await scenario({ prompt: 'respond' })
  488. const result = await runScenario(
  489. {
  490. steps: [...boot, {
  491. op: 'promptAndWaitForAgentMessage',
  492. text: 'go',
  493. waitForText: 'thinking about it',
  494. }],
  495. },
  496. { agent: AGENT, mode: 'replay', fixtureFile },
  497. )
  498. expect(result.rawStdout).toContain('thinking about it')
  499. })
  500. it('waitForTurnEnd holds cancellation open through the persisted closing boundary', { timeout: 20_000 }, async () => {
  501. const { fixtureFile } = await scenario({
  502. prompt: 'hang-until-cancel',
  503. persistLogsOnCancel: true,
  504. logs: [{
  505. file: 'project/main/session.jsonl',
  506. lines: [
  507. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  508. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
  509. ],
  510. }],
  511. })
  512. const result = await runScenario(
  513. { steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForTurnEnd' }] },
  514. { agent: AGENT, mode: 'replay', fixtureFile },
  515. )
  516. expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"')
  517. })
  518. it('waitForTurnStart can require a later durable turn before continuing', { timeout: 20_000 }, async () => {
  519. const { fixtureFile } = await scenario({
  520. prompt: 'hang-until-cancel',
  521. persistLogsOnCancel: true,
  522. logs: [{
  523. file: 'project/main/session.jsonl',
  524. lines: [
  525. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  526. { type: 'turn/start', seq: 0, time: 1, data: { turn: 3 } },
  527. ],
  528. }],
  529. })
  530. const result = await runScenario(
  531. {
  532. steps: [
  533. ...boot,
  534. { op: 'promptAndCancel', text: 'hang' },
  535. { op: 'waitForTurnStart', minimumTurn: 3 },
  536. ],
  537. },
  538. { agent: AGENT, mode: 'replay', fixtureFile },
  539. )
  540. expect(result.sessionLogs[0]?.content).toContain('"turn":3')
  541. })
  542. it('waitForTurnStart rejects missing, earlier, and malformed durable turns', { timeout: 20_000 }, async () => {
  543. const missing = await scenario({})
  544. await expect(runScenario(
  545. { steps: [...boot, { op: 'waitForTurnStart', timeoutMs: 20 }] },
  546. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  547. )).rejects.toThrow(/did not persist turn\/start within 20ms/)
  548. const earlier = await scenario({
  549. prompt: 'hang-until-cancel',
  550. persistLogsOnCancel: true,
  551. logs: [{
  552. file: 'project/main/session.jsonl',
  553. lines: [
  554. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  555. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  556. ],
  557. }],
  558. })
  559. await expect(runScenario(
  560. {
  561. steps: [
  562. ...boot,
  563. { op: 'promptAndCancel', text: 'hang' },
  564. { op: 'waitForTurnStart', minimumTurn: 3, timeoutMs: 20 },
  565. ],
  566. },
  567. { agent: AGENT, mode: 'replay', fixtureFile: earlier.fixtureFile },
  568. )).rejects.toThrow(/turn\/start at or beyond turn 3 within 20ms/)
  569. const closed = await scenario({
  570. prompt: 'hang-until-cancel',
  571. persistLogsOnCancel: true,
  572. logs: [{
  573. file: 'project/main/session.jsonl',
  574. lines: [
  575. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  576. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  577. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'stop' } } },
  578. ],
  579. }],
  580. })
  581. await expect(runScenario(
  582. {
  583. steps: [
  584. ...boot,
  585. { op: 'promptAndCancel', text: 'hang' },
  586. { op: 'waitForTurnStart', timeoutMs: 20 },
  587. ],
  588. },
  589. { agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile },
  590. )).rejects.toThrow(/did not persist turn\/start within 20ms/)
  591. for (const turn of [undefined, 0]) {
  592. const malformed = await scenario({
  593. prompt: 'hang-until-cancel',
  594. persistLogsOnCancel: true,
  595. logs: [{
  596. file: 'project/main/session.jsonl',
  597. lines: [
  598. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  599. { type: 'turn/start', seq: 0, time: 1, data: turn === undefined ? {} : { turn } },
  600. ],
  601. }],
  602. })
  603. await expect(runScenario(
  604. {
  605. steps: [
  606. ...boot,
  607. { op: 'promptAndCancel', text: 'hang' },
  608. { op: 'waitForTurnStart' },
  609. ],
  610. },
  611. { agent: AGENT, mode: 'replay', fixtureFile: malformed.fixtureFile },
  612. )).rejects.toThrow('invalid persisted turn/start record')
  613. }
  614. })
  615. it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => {
  616. const missing = await scenario({})
  617. await expect(runScenario(
  618. { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] },
  619. { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
  620. )).rejects.toThrow(/did not persist turn\/end within 20ms/)
  621. const open = await scenario({
  622. prompt: 'hang-until-cancel',
  623. persistLogsOnCancel: true,
  624. logs: [{
  625. file: 'project/main/session.jsonl',
  626. lines: [
  627. { type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
  628. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  629. ],
  630. }],
  631. })
  632. await expect(runScenario(
  633. {
  634. steps: [
  635. ...boot,
  636. { op: 'promptAndCancel', text: 'hang' },
  637. { op: 'waitForTurnEnd', timeoutMs: 20 },
  638. ],
  639. },
  640. { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile },
  641. )).rejects.toThrow(/did not persist turn\/end within 20ms/)
  642. })
  643. it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => {
  644. const { fixtureFile } = await scenario({ prompt: 'error' })
  645. const result = await runScenario(
  646. { steps: [...boot, { op: 'promptExpectError', text: 'boom' }] },
  647. { agent: AGENT, mode: 'replay', fixtureFile },
  648. )
  649. expect(result.rawStdout).toContain('model exploded')
  650. })
  651. it('promptExpectError throws when the prompt unexpectedly succeeds (and teardown kills the live child)', { timeout: 20_000 }, async () => {
  652. const { fixtureFile } = await scenario({ prompt: 'respond' })
  653. await expect(runScenario(
  654. { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] },
  655. { agent: AGENT, mode: 'replay', fixtureFile },
  656. )).rejects.toThrow(/expected the prompt to fail/)
  657. })
  658. it('reports scenario and cleanup failures together', { timeout: 20_000 }, async () => {
  659. const { fixtureFile } = await scenario({ prompt: 'respond' })
  660. const cleanupFailure = new Error('cleanup failed')
  661. fsControl.cleanupFailure = cleanupFailure
  662. const failure = await runScenario(
  663. { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] },
  664. { agent: AGENT, mode: 'replay', fixtureFile },
  665. ).catch((error: unknown): unknown => error)
  666. expect(failure).toBeInstanceOf(AggregateError)
  667. const failures = (failure as AggregateError).errors as unknown[]
  668. expect(failures).toHaveLength(2)
  669. expect(failures[0]).toBeInstanceOf(Error)
  670. expect((failures[0] as Error).message).toMatch(/expected the prompt to fail/)
  671. expect(failures[1]).toBe(cleanupFailure)
  672. })
  673. it('reports cleanup failure after an otherwise successful scenario', { timeout: 20_000 }, async () => {
  674. const { fixtureFile } = await scenario({})
  675. const cleanupFailure = new Error('cleanup failed')
  676. fsControl.cleanupFailure = cleanupFailure
  677. const failure = await runScenario(
  678. { steps: boot },
  679. { agent: AGENT, mode: 'replay', fixtureFile },
  680. ).catch((error: unknown): unknown => error)
  681. expect(failure).toBeInstanceOf(AggregateError)
  682. expect((failure as AggregateError).message).toBe('snapshot cleanup failed')
  683. expect((failure as AggregateError).errors as unknown[]).toEqual([cleanupFailure])
  684. })
  685. it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => {
  686. const { fixtureFile } = await scenario({ rejectExtraDirs: true })
  687. const result = await runScenario(
  688. { steps: [{ op: 'initialize' }, { op: 'newSessionExpectError', additionalDirectories: ['/elsewhere'] }] },
  689. { agent: AGENT, mode: 'replay', fixtureFile },
  690. )
  691. // No session was created, so no id and no logs.
  692. expect(result.sessionId).toBeUndefined()
  693. expect(result.sessionLogs).toHaveLength(0)
  694. const rejectAll = await scenario({ rejectNewSession: true })
  695. const second = await runScenario(
  696. { steps: [{ op: 'initialize' }, { op: 'newSessionExpectError' }] },
  697. { agent: AGENT, mode: 'replay', fixtureFile: rejectAll.fixtureFile },
  698. )
  699. expect(second.rawStdout).toContain('unsupported workspace scope')
  700. })
  701. it('newSessionExpectError throws when session/new unexpectedly succeeds', { timeout: 20_000 }, async () => {
  702. const { fixtureFile } = await scenario({})
  703. await expect(runScenario(
  704. { steps: [{ op: 'initialize' }, { op: 'newSessionExpectError' }] },
  705. { agent: AGENT, mode: 'replay', fixtureFile },
  706. )).rejects.toThrow(/expected session\/new to be rejected/)
  707. })
  708. it('a plain cancel step is forwarded (and ignored by an idle agent)', { timeout: 20_000 }, async () => {
  709. const { fixtureFile } = await scenario({})
  710. const result = await runScenario(
  711. { steps: [...boot, { op: 'cancel' }] },
  712. { agent: AGENT, mode: 'replay', fixtureFile },
  713. )
  714. expect(result.sessionId).toBeDefined()
  715. })
  716. it('a standalone cancel can wait for cwd-relative readiness', { timeout: 20_000 }, async () => {
  717. const { dir, fixtureFile } = await scenario({})
  718. const workspaceDir = join(dir, 'workspace')
  719. const { mkdir } = await import('node:fs/promises')
  720. await mkdir(workspaceDir, { recursive: true })
  721. await writeFile(join(workspaceDir, 'ready'), '')
  722. const result = await runScenario(
  723. { steps: [...boot, { op: 'cancel', waitForFile: { path: 'ready' } }] },
  724. { agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
  725. )
  726. expect(result.sessionId).toBeDefined()
  727. })
  728. it.each([
  729. [{ op: 'prompt', text: 'x' }, /prompt before newSession/],
  730. [{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
  731. [{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
  732. [{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
  733. [{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/],
  734. [{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/],
  735. [{ op: 'cancel' }, /cancel before newSession/],
  736. ] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
  737. const { fixtureFile } = await scenario({})
  738. await expect(runScenario(
  739. { steps: [{ op: 'initialize' }, step] },
  740. { agent: AGENT, mode: 'replay', fixtureFile },
  741. )).rejects.toThrow(message)
  742. })
  743. it('rejects an unknown input op', { timeout: 20_000 }, async () => {
  744. const { fixtureFile } = await scenario({})
  745. const bogus = { op: 'reticulate' } as unknown as InputStep
  746. await expect(runScenario(
  747. { steps: [bogus] },
  748. { agent: AGENT, mode: 'replay', fixtureFile },
  749. )).rejects.toThrow(/unknown input op/)
  750. })
  751. it('harvests all logs primary-first, children by createdAt then id, skipping filesystem noise', { timeout: 20_000 }, async () => {
  752. const { fixtureFile } = await scenario({
  753. strayRootFile: true,
  754. strayBucketFile: true,
  755. logs: [
  756. // File names chosen so readdir feeds the sort children-first AND
  757. // parent-in-the-middle: the comparator then sees a parent on both
  758. // sides of a pair, plus the same-createdAt (localeCompare) tiebreak.
  759. { file: 'b1/aa-child-c/session.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
  760. { file: 'b1/bb-parent/session.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] },
  761. { file: 'b1/cc-child-a/session.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
  762. // Missing id/createdAt fall back to ''/0; earliest child by createdAt.
  763. { file: 'b2/orphan/session.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] },
  764. ],
  765. })
  766. const result = await runScenario(
  767. { steps: [...boot, { op: 'prompt', text: 'go' }] },
  768. { agent: AGENT, mode: 'replay', fixtureFile },
  769. )
  770. expect(result.sessionLogs.map(l => [l.id, l.createdAt])).toEqual([
  771. [result.sessionId, 900],
  772. ['', 0],
  773. ['aaaaaaaa-0000-4000-8000-000000000000', 500],
  774. ['cccccccc-0000-4000-8000-000000000000', 500],
  775. ])
  776. expect(result.sessionLogs[1]?.parentSession).toBe(result.sessionId)
  777. })
  778. it('treats an empty log file as a header-less primary with default fields', { timeout: 20_000 }, async () => {
  779. const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty/session.jsonl', lines: [] }] })
  780. const result = await runScenario(
  781. { steps: boot },
  782. { agent: AGENT, mode: 'replay', fixtureFile },
  783. )
  784. expect(result.sessionLogs.map(l => [l.id, l.createdAt, l.parentSession])).toEqual([['', 0, undefined]])
  785. })
  786. it('yields no logs when the sessions root vanished', { timeout: 20_000 }, async () => {
  787. const { fixtureFile } = await scenario({ deleteSessionsRoot: true })
  788. const result = await runScenario(
  789. { steps: boot },
  790. { agent: AGENT, mode: 'replay', fixtureFile },
  791. )
  792. expect(result.sessionLogs).toHaveLength(0)
  793. })
  794. it('answers permission requests from the scripted queue by option kind, falling back to cancelled', { timeout: 20_000 }, async () => {
  795. const { fixtureFile } = await scenario({ permissionProbe: true })
  796. // Two prompts → two permission round-trips; one scripted answer, so the
  797. // second request exercises the exhausted-queue fallback.
  798. const result = await runScenario(
  799. {
  800. steps: [...boot, { op: 'prompt', text: 'one' }, { op: 'prompt', text: 'two' }],
  801. permissionAnswers: [{ kind: 'allow_once' }],
  802. },
  803. { agent: AGENT, mode: 'replay', fixtureFile },
  804. )
  805. const first = result.rawStdout.indexOf('permission:{\\"outcome\\":\\"selected\\",\\"optionId\\":\\"opt-allow\\"}')
  806. const second = result.rawStdout.indexOf('permission:{\\"outcome\\":\\"cancelled\\"}')
  807. expect(first).toBeGreaterThanOrEqual(0)
  808. expect(second).toBeGreaterThan(first)
  809. })
  810. it('selects a non-first offered option by kind', { timeout: 20_000 }, async () => {
  811. const { fixtureFile } = await scenario({ permissionProbe: true })
  812. const result = await runScenario(
  813. { steps: [...boot, { op: 'prompt', text: 'deny it' }], permissionAnswers: [{ kind: 'reject_once' }] },
  814. { agent: AGENT, mode: 'replay', fixtureFile },
  815. )
  816. expect(result.rawStdout).toContain('permission:{\\"outcome\\":\\"selected\\",\\"optionId\\":\\"opt-reject\\"}')
  817. })
  818. it('rejects the run on a scripted permission kind the agent never offered', { timeout: 20_000 }, async () => {
  819. const { fixtureFile } = await scenario({ permissionProbe: true })
  820. // The fake bin offers allow_once/reject_once; scripting allow_always is a
  821. // scenario bug. The agent is answered `cancelled` (it must not be able to
  822. // absorb the bug as an error-means-denial), and the RUN fails: a callback
  823. // throw would only reach the agent as a JSON-RPC error response, letting
  824. // a tolerant agent carry on and the scenario pass — or record.
  825. await expect(runScenario(
  826. { steps: [...boot, { op: 'prompt', text: 'impossible click' }], permissionAnswers: [{ kind: 'allow_always' }] },
  827. { agent: AGENT, mode: 'replay', fixtureFile },
  828. )).rejects.toThrow(/allow_always not among the offered options \[allow_once, reject_once\]/)
  829. })
  830. })