acp.snapshot.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import { fileURLToPath } from 'node:url'
  2. import { readFileSync } from 'node:fs'
  3. import { spawnSync } from 'node:child_process'
  4. import { mkdir, utimes, writeFile } from 'node:fs/promises'
  5. import { dirname, join } from 'node:path'
  6. import { homedir } from 'node:os'
  7. import { expect, it } from 'vitest'
  8. import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
  9. import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
  10. import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
  11. /**
  12. * The acp-agent example's snapshot suite: the scenario table for
  13. * `dsh-acp-snapshot`'s suite factory, which owns every compare/guard mechanic
  14. * (expected-output + re-persisted-log diffs, record/refresh write-back, the pinned-header
  15. * uniformity guard, the fixture guards). Fixtures live under `snapshots/<name>/`;
  16. * `pnpm run test:snapshot:record` re-records model transcripts against the real
  17. * API; `pnpm run test:snapshot:refresh` rewrites current replay expected outputs keyless.
  18. * See the package README (packages/support/acp-snapshot) and the snapshot Agent Note,
  19. * .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md.
  20. */
  21. // The dsh-acp-demo bin (the demo:acp entry), this example's cordis.yml, and
  22. // the repo-root tsconfig (four levels up from examples/acp-agent/tests) — all
  23. // ABSOLUTE: the subprocess cwd is a temp dir outside the repo.
  24. const AGENT = {
  25. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  26. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  27. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  28. }
  29. // The Code Mode overlay configs (include-patched variants of cordis.yml; the
  30. // replay swap resolves each one's sibling `*cordis.snapshot.yml`).
  31. const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
  32. const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url))
  33. const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url))
  34. const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url))
  35. const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
  36. const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
  37. const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url))
  38. const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
  39. const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
  40. const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url))
  41. const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url))
  42. const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url))
  43. const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath(
  44. new URL('../subagent-durability-failure.cordis.yml', import.meta.url),
  45. )
  46. const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
  47. const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
  48. const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url))
  49. const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url))
  50. const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url))
  51. const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  52. const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
  53. async function prepareDelimiterPathWorkspace(cwd: string): Promise<void> {
  54. const dir = join(cwd, 'scope</system-reminder>')
  55. await mkdir(dir, { recursive: true })
  56. await Promise.all([
  57. writeFile(join(dir, 'AGENTS.md'), 'Delimiter path snapshot instruction.\n'),
  58. writeFile(join(dir, 'task.txt'), 'delimiter path snapshot task\n'),
  59. ])
  60. }
  61. /**
  62. * Seed the over-cap glob fixture: eight files under `tree/` with fixed mtimes,
  63. * so the packaged ripgrep's `--sort=modified` order is deterministic — three
  64. * files under `archive/`, one each under `docs/`, `src/`, and `test/`, plus
  65. * two flat files (six top-level entries). Scoping the search to `tree/` keeps
  66. * the harness's own session artifacts out of the listing.
  67. */
  68. async function prepareFsSearchWorkspace(cwd: string): Promise<void> {
  69. const tree = join(cwd, 'tree')
  70. const files: Array<[relative: string, mtime: Date]> = [
  71. [join('archive', 'a.ts'), new Date(2000, 0, 1, 0, 0, 0, 1)],
  72. [join('archive', 'b.ts'), new Date(2000, 0, 1, 0, 0, 0, 2)],
  73. [join('archive', 'c.ts'), new Date(2000, 0, 1, 0, 0, 0, 3)],
  74. [join('docs', 'guide.md'), new Date(2000, 0, 1, 0, 0, 0, 4)],
  75. [join('src', 'index.ts'), new Date(2000, 0, 1, 0, 0, 0, 5)],
  76. [join('test', 'spec.ts'), new Date(2000, 0, 1, 0, 0, 0, 6)],
  77. ['top.txt', new Date(2000, 0, 1, 0, 0, 0, 7)],
  78. ['notes.md', new Date(2000, 0, 1, 0, 0, 0, 8)],
  79. ]
  80. for (const [relative, mtime] of files) {
  81. const target = join(tree, relative)
  82. await mkdir(dirname(target), { recursive: true })
  83. await writeFile(target, 'fixture\n')
  84. await utimes(target, mtime, mtime)
  85. }
  86. }
  87. // FIXME: Migrate backend-oriented scenarios to the headless stream-json suite;
  88. // this ACP suite should eventually retain only automation-protocol contracts.
  89. function fixtureRecords(name: string): unknown[] {
  90. return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8')
  91. .trimEnd()
  92. .split('\n')
  93. .map(line => JSON.parse(line) as unknown)
  94. }
  95. function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
  96. switch (value) {
  97. case undefined:
  98. case '':
  99. case 'replay':
  100. return 'replay'
  101. case 'record':
  102. return 'record'
  103. case 'refresh':
  104. return 'refresh'
  105. default:
  106. throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`)
  107. }
  108. }
  109. const SCENARIOS: Scenario[] = [
  110. { name: 'handshake', hasModelTurn: false, recorded: false },
  111. { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false },
  112. // text-turn is the default header pin and owns the prompt and tool-schema
  113. // sidecars reused by alternate classes with identical component sequences.
  114. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
  115. {
  116. name: 'session-title-after-turn',
  117. hasModelTurn: true,
  118. recorded: false,
  119. overridden: true,
  120. configPath: SESSION_TITLE_CONFIG,
  121. },
  122. { name: 'tool-call-turn', hasModelTurn: true, recorded: true },
  123. // Authored from the real PACKED_CHUNKS_SOURCE recording under the ordinary
  124. // app composition. The contract below pins decoded equality and all three
  125. // row kinds; replay additionally proves the assembled app re-packs identically.
  126. { name: 'packed-chunks', hasModelTurn: true, recorded: false },
  127. // The fs overlay only adds the spill stack (the sandboxed filesystem tools
  128. // live in the base tree), so these scenarios share the default header class.
  129. {
  130. name: 'parallel-tool-calls',
  131. hasModelTurn: true,
  132. recorded: false,
  133. configPath: FS_CONFIG,
  134. },
  135. { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
  136. {
  137. name: 'session-query-spill',
  138. hasModelTurn: true,
  139. recorded: false,
  140. overridden: true,
  141. pinsHeader: true,
  142. headerClass: 'session-query',
  143. configPath: SESSION_QUERY_CONFIG,
  144. posixOnly: true,
  145. },
  146. {
  147. name: 'pty-tools',
  148. hasModelTurn: true,
  149. recorded: false,
  150. pinsHeader: true,
  151. headerClass: 'pty',
  152. configPath: PTY_CONFIG,
  153. },
  154. { name: 'bash-tool-turn', hasModelTurn: true, recorded: true },
  155. // The pwsh overlay (pwsh.cordis.yml / pwsh.cordis.snapshot.yml) swaps the
  156. // bundle's bash tool for the PowerShell twin, so its header class pins its
  157. // own prompt/tool sidecars and a recorded transcript.
  158. {
  159. name: 'pwsh-tool-turn',
  160. hasModelTurn: true,
  161. recorded: true,
  162. pinsHeader: true,
  163. headerClass: 'pwsh',
  164. configPath: PWSH_CONFIG,
  165. // The composition boots the real pwsh executor; hosts without a `pwsh`
  166. // binary skip the run (fixtures stay guarded). The recorded turn writes
  167. // PWSH_OK via [Console]::Out.Write so the fixture carries no platform
  168. // newline and one recording replays on every host.
  169. pwshOnly: true,
  170. },
  171. // Authored keyless replay through a test-only partial-Landlock provider:
  172. // the exact compatibility notice must stay ordinary stderr when the wrapped
  173. // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE.
  174. {
  175. name: 'partial-landlock-child-failure',
  176. hasModelTurn: true,
  177. recorded: false,
  178. headerClass: 'sandbox',
  179. configPath: PARTIAL_LANDLOCK_CONFIG,
  180. env: { DSH_PERMISSION_MODE: 'read-only' },
  181. posixOnly: true,
  182. },
  183. // A valid cwd plus a missing provider executable exercises the assembled
  184. // foreground error and background task marker without a platform runner.
  185. {
  186. name: 'missing-sandbox-runner',
  187. hasModelTurn: true,
  188. recorded: false,
  189. headerClass: 'sandbox',
  190. configPath: PARTIAL_LANDLOCK_CONFIG,
  191. env: {
  192. DSH_PERMISSION_MODE: 'read-only',
  193. DSH_SNAPSHOT_MISSING_SANDBOX_RUNNER: '1',
  194. },
  195. posixOnly: true,
  196. },
  197. { name: 'todo-write', hasModelTurn: true, recorded: true },
  198. {
  199. name: 'skill-load',
  200. hasModelTurn: true,
  201. recorded: false,
  202. pinsHeader: true,
  203. headerClass: 'skill',
  204. systemPromptSource: 'text-turn',
  205. toolSchemasSource: 'text-turn',
  206. },
  207. { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG },
  208. // web_fetch markdown rendering end to end: the overlay's loopback fixture
  209. // server supplies deterministic HTML (entities, a GFM table, nesting), the
  210. // REAL local fetch provider retrieves it, and the tool result pins the
  211. // turndown conversion. The fetched URL (fixed port) is part of the recorded
  212. // transcript; replay re-executes the real fetch against the same fixture.
  213. { name: 'web-fetch', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'web', configPath: WEB_CONFIG },
  214. {
  215. name: 'workspace-edit',
  216. hasModelTurn: true,
  217. recorded: true,
  218. },
  219. // The real Loader/app/subprocess path executes the PACKAGED ripgrep binary
  220. // against a prepared workspace whose fixed mtimes pin the
  221. // `--sort=modified` order, pinning over-cap glob sampling without depending
  222. // on a host-installed ripgrep binary or a PATH stand-in. POSIX-only because
  223. // the displayed paths carry `/` separators the session-log comparison
  224. // cannot normalize. Recorded (not authored): the assistant turn is a real
  225. // model transcript; re-record with `test:snapshot:record -t fs-glob-sampling`
  226. // and then `migrate:packed-session-fixtures`, which canonicalizes the live
  227. // log's eager-drain-packed rows into the maximal-run layout replay produces.
  228. // The recorded fixture's `request/header` config and `request/context` are
  229. // normalized to the replay-produced minimal shape (the live adapter logs
  230. // model capabilities like maxTokens/reasoningEffort that llm-replay has no
  231. // data for), and its tool-result paths are canonicalized to `/` separators.
  232. {
  233. name: 'fs-glob-sampling',
  234. hasModelTurn: true,
  235. recorded: true,
  236. posixOnly: true,
  237. pinsHeader: true,
  238. headerClass: 'fs-search',
  239. configPath: FS_SEARCH_CONFIG,
  240. prepareWorkspace: prepareFsSearchWorkspace,
  241. },
  242. { name: 'fs-read', hasModelTurn: true, recorded: true },
  243. { name: 'fs-write', hasModelTurn: true, recorded: true },
  244. { name: 'fs-edit', hasModelTurn: true, recorded: true },
  245. { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
  246. { name: 'fs-read-window', hasModelTurn: true, recorded: true },
  247. { name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
  248. { name: 'multi-turn', hasModelTurn: true, recorded: true },
  249. { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
  250. // Keyless, authored (like error-finish): a live provider cannot be coaxed
  251. // into a degenerate empty completion, so the fixture scripts the adapters'
  252. // EMPTY_RESPONSE error finish in turn 1 followed by the recovered reply
  253. // in retry turn 2, proving the default retry policy end to end: the durable
  254. // llm/retry event, no ACP output for the discarded attempt, the recovered
  255. // reply, and a clean completed retry turn. Its overlay only pins a deterministic
  256. // 1 ms zero-jitter delay, so it shares the default header class.
  257. { name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG },
  258. // Keyless, authored (like error-finish/cancel): deterministically forcing a
  259. // LIVE model to repeat one call three times is not a stable recording, so
  260. // the fixture scripts five identical todo_write calls and pins BOTH reminder
  261. // tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log.
  262. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
  263. // Authored replay: a root AGENTS.md pins the session prefix, then a read in
  264. // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing
  265. // injected user/message. Both portable AGENTS.md fixtures are symlinks to a sibling
  266. // AGENTS.canonical.md, so this scenario also guards that discovery follows a
  267. // symlinked instruction file to its target's content. A second nested path
  268. // containing a literal closing tag is created at runtime: Git cannot check
  269. // that name out on Windows, so this delimiter-injection case is POSIX-only.
  270. // The scenario-specific config keeps home/root discovery hermetic, and the
  271. // resulting prefix needs its own pinned header class.
  272. {
  273. name: 'workspace-context',
  274. hasModelTurn: true,
  275. recorded: false,
  276. overridden: true,
  277. pinsHeader: true,
  278. headerClass: 'workspace-context',
  279. toolSchemasSource: 'text-turn',
  280. configPath: WORKSPACE_CONTEXT_CONFIG,
  281. prepareWorkspace: prepareDelimiterPathWorkspace,
  282. posixOnly: true,
  283. },
  284. { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
  285. // Cancelling a live bash call relies on POSIX process-group termination;
  286. // Windows bash process-tree kill is deferred with the Bash execution domain.
  287. { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
  288. { name: 'subagent-spawn', hasModelTurn: true, recorded: true },
  289. { name: 'subagent-multi', hasModelTurn: true, recorded: true },
  290. { name: 'subagent-fork', hasModelTurn: true, recorded: true },
  291. { name: 'subagent-mixed', hasModelTurn: true, recorded: true },
  292. // Authored continuable-subagent transcript: a background delegation returns
  293. // only the durable subagent id, two send_message calls queue as later FIFO
  294. // turns on that same child (the parent is never woken with their output),
  295. // send_message to an unknown subagent id fails without delivering, and the
  296. // child's retained handle is disposed child-first at teardown despite a
  297. // failed final durability confirmation.
  298. {
  299. name: 'subagent-continuable',
  300. hasModelTurn: true,
  301. recorded: false,
  302. pinsChildToolSchemas: [1],
  303. configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG,
  304. },
  305. // The in-process child is published before its first follow-up fails. The
  306. // foreground tool retains both that run-result failure and an independent
  307. // published-handle disposal failure.
  308. {
  309. name: 'subagent-published-run-failure',
  310. env: { DSH_SUBAGENT_PUBLISHED_FAILURE: '1' },
  311. hasModelTurn: true,
  312. recorded: false,
  313. overridden: true,
  314. configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG,
  315. },
  316. // Authored child-to-parent transcript: the child calls its scope-local
  317. // `report`, quiet delivery reaches the idle parent without waking it, and a
  318. // later parent turn consumes the logged report.
  319. {
  320. name: 'subagent-report',
  321. hasModelTurn: true,
  322. recorded: false,
  323. pinsChildToolSchemas: [1],
  324. },
  325. // Authored durable-catalog transcript: the snapshot-only lifecycle marker
  326. // fences the second parent turn behind the child's Activation end, so
  327. // `list_agents` deterministically reads the persisted child as complete.
  328. // The tool itself executes for real against the control service, session
  329. // query, and JSONL persistence; the marker is not model-visible.
  330. {
  331. name: 'subagent-list-agents',
  332. hasModelTurn: true,
  333. recorded: false,
  334. pinsChildToolSchemas: [1],
  335. },
  336. {
  337. name: 'subagent-depth-two-rejection',
  338. hasModelTurn: true,
  339. recorded: false,
  340. overridden: true,
  341. configPath: DEPTH_TWO_CONFIG,
  342. },
  343. // The workflow tool: the model writes a one-child orchestration script; the
  344. // child runs as a spawn subagent under the worker-thread engine (its session is the
  345. // child fixture), and the tool result carries the script's return value.
  346. { name: 'workflow-run', hasModelTurn: true, recorded: true },
  347. // Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it
  348. // through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and
  349. // Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
  350. {
  351. name: 'advanced-toolchain',
  352. hasModelTurn: true,
  353. recorded: false,
  354. pinsHeader: true,
  355. headerClass: 'advanced',
  356. configPath: ADVANCED_CONFIG,
  357. },
  358. {
  359. name: 'cordis-inspect-jsdoc',
  360. hasModelTurn: true,
  361. recorded: false,
  362. headerClass: 'advanced',
  363. configPath: ADVANCED_CONFIG,
  364. },
  365. // Prompt-submit blocks are authored keylessly with malformed matcher fields,
  366. // which these matcherless events must ignore. Admission rejects before a turn
  367. // opens, so only the ACP stop reason is observable and no log is harvested.
  368. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
  369. { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
  370. // Each invalid matcher follows a runnable prompt blocker. Reaching the replay
  371. // model without any hook audit rows proves config loading is atomic through
  372. // the real Loader/app path, rather than retaining the earlier valid group.
  373. { name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false },
  374. { name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false },
  375. // The mid-turn seams fire during a real model turn, so each is recorded with its hook active
  376. // (the model's reaction to a deny/block/force-continue is part of the captured transcript).
  377. // SessionStart/SubagentStart are excluded because detached injection races log
  378. // order; SubagentStop writes no transcript, so an expected output could not prove it ran.
  379. // Unit tests cover those points; the hook-snapshot-matrix Agent Note owns the rationale.
  380. { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true },
  381. { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true },
  382. { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true },
  383. { name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true },
  384. { name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true },
  385. { name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true },
  386. { name: 'hook-codex-promptsubmit-context', hasModelTurn: true, recorded: true },
  387. { name: 'hook-codex-pretool-block', hasModelTurn: true, recorded: true },
  388. { name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true },
  389. { name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true },
  390. { name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
  391. // Code Mode: the registry in `mode: code` — the wire tool list collapses to [run_code], the
  392. // tools:sdk section rides in the prompt, and the program's tool calls land as
  393. // tool/code-dispatch events. Each overlay composes and pins its own header class.
  394. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
  395. // A nested fs dispatch inside run_code discovers workspace instructions. The
  396. // projection enters the inbox after the outer result and becomes model-visible
  397. // on the following step, retaining workspace provenance end to end.
  398. {
  399. name: 'code-mode-workspace-context',
  400. hasModelTurn: true,
  401. recorded: false,
  402. overridden: true,
  403. pinsHeader: true,
  404. headerClass: 'code-workspace-context',
  405. systemPromptSource: 'code-mode-turn',
  406. toolSchemasSource: 'code-mode-turn',
  407. configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG,
  408. },
  409. {
  410. name: 'both-mode-turn',
  411. hasModelTurn: true,
  412. recorded: true,
  413. pinsHeader: true,
  414. headerClass: 'both',
  415. systemPromptSource: 'code-mode-turn',
  416. configPath: BOTH_MODE_CONFIG,
  417. },
  418. // Machine permission scenarios use an explicit deployment policy; there is
  419. // no session-scoped UI picker on the automation protocol.
  420. {
  421. name: 'escalation-approved',
  422. hasModelTurn: true,
  423. recorded: true,
  424. pinsHeader: true,
  425. headerClass: 'sandbox',
  426. systemPromptSource: 'text-turn',
  427. toolSchemasSource: 'text-turn',
  428. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  429. },
  430. {
  431. name: 'escalation-rejected',
  432. hasModelTurn: true,
  433. recorded: true,
  434. headerClass: 'sandbox',
  435. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  436. },
  437. {
  438. name: 'fs-escalation-approved',
  439. hasModelTurn: true,
  440. recorded: true,
  441. headerClass: 'sandbox',
  442. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  443. },
  444. // Unlike ordinary snapshots, this session cwd is outside the platform temp
  445. // roots that workspace-write always grants. The overlay points the
  446. // deployment fallback at /tmp, so a successful relative write proves the
  447. // assembled app replaced that process-level fallback with SessionHeader.cwd.
  448. {
  449. name: 'session-sandbox-root',
  450. hasModelTurn: true,
  451. recorded: false,
  452. overridden: true,
  453. headerClass: 'sandbox',
  454. configPath: SESSION_SANDBOX_ROOT_CONFIG,
  455. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  456. workspaceParent: homedir(),
  457. },
  458. ]
  459. // Hosts without a usable PowerShell skip the pwsh-tool-turn run (its fixtures
  460. // stay guarded); the probe follows the executor's own resolution so a Windows
  461. // host with only an install-location pwsh still runs the scenario.
  462. const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
  463. defineAcpSnapshotSuite({
  464. agent: AGENT,
  465. snapshotsDir: SNAPSHOTS_DIR,
  466. scenarios: SCENARIOS,
  467. mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT),
  468. hasPwsh,
  469. })
  470. it('packed ACP fixture retains every chunk row kind without changing the logical session', () => {
  471. const source = fixtureRecords(PACKED_CHUNKS_SOURCE)
  472. const packed = fixtureRecords('packed-chunks')
  473. const rowTypes = packed.flatMap((record) => {
  474. if (record === null || typeof record !== 'object') return []
  475. const type = (record as { type?: unknown }).type
  476. return type === 'text-chunks' || type === 'reasoning-chunks' || type === 'tool-call-chunks' ? [type] : []
  477. })
  478. expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks'])
  479. const withoutMessageId = (record: unknown): unknown => {
  480. const cloned = structuredClone(record) as {
  481. time?: unknown
  482. type?: unknown
  483. data?: {
  484. durationMs?: unknown
  485. id?: unknown
  486. inserted?: Array<{ id?: unknown }>
  487. message?: { id?: unknown }
  488. }
  489. }
  490. delete cloned.time
  491. if (cloned.type === 'agent/inbox/spliced') {
  492. for (const message of cloned.data?.inserted ?? []) delete message.id
  493. }
  494. if (cloned.type === 'user/message') delete cloned.data?.id
  495. if (cloned.type === 'assistant/message'
  496. || cloned.type === 'tool/result') {
  497. delete cloned.data?.message?.id
  498. }
  499. if (cloned.type === 'hook/result') delete cloned.data?.durationMs
  500. return cloned
  501. }
  502. const logicalRecords = (records: readonly unknown[]): unknown[] => [
  503. records[0],
  504. ...records.slice(1).flatMap(record => decodeStorageRecord(record)).map(withoutMessageId),
  505. ]
  506. expect(logicalRecords(packed)).toStrictEqual(logicalRecords(source))
  507. })