acp.snapshot.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. import { fileURLToPath } from 'node:url'
  2. import { readFileSync } from 'node:fs'
  3. import { dirname, join } from 'node:path'
  4. import { homedir } from 'node:os'
  5. import { expect, it } from 'vitest'
  6. import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
  7. import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
  8. /**
  9. * The acp-agent example's snapshot suite: the scenario table for
  10. * `dsh-acp-snapshot`'s suite factory, which owns every compare/guard mechanic
  11. * (expected-output + re-persisted-log diffs, record/refresh write-back, the pinned-header
  12. * uniformity guard, the fixture guards). Fixtures live under `snapshots/<name>/`;
  13. * `pnpm run test:snapshot:record` re-records model transcripts against the real
  14. * API; `pnpm run test:snapshot:refresh` rewrites current replay expected outputs keyless.
  15. * See the package README (packages/support/acp-snapshot) and the snapshot Agent Note,
  16. * .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md.
  17. */
  18. // The dsh-acp-demo bin (the demo:acp entry), this example's cordis.yml, and
  19. // the repo-root tsconfig (four levels up from examples/acp-agent/tests) — all
  20. // ABSOLUTE: the subprocess cwd is a temp dir outside the repo.
  21. const AGENT = {
  22. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  23. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  24. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  25. }
  26. // The Code Mode overlay configs (include-patched variants of cordis.yml; the
  27. // replay swap resolves each one's sibling `*cordis.snapshot.yml`).
  28. const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
  29. const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url))
  30. const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url))
  31. const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url))
  32. const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
  33. const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
  34. const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
  35. const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
  36. const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url))
  37. const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url))
  38. const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
  39. const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  40. const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
  41. function fixtureRecords(name: string): unknown[] {
  42. return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8')
  43. .trimEnd()
  44. .split('\n')
  45. .map(line => JSON.parse(line) as unknown)
  46. }
  47. function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
  48. switch (value) {
  49. case undefined:
  50. case '':
  51. case 'replay':
  52. return 'replay'
  53. case 'record':
  54. return 'record'
  55. case 'refresh':
  56. return 'refresh'
  57. default:
  58. throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`)
  59. }
  60. }
  61. const SCENARIOS: Scenario[] = [
  62. { name: 'handshake', hasModelTurn: false, recorded: false },
  63. { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false },
  64. // Direct command dispatch reports goal state without spending a model turn.
  65. { name: 'goal-command-status', hasModelTurn: false, recorded: false },
  66. // Protocol-only (keyless, authored): session/new advertises the mode picker,
  67. // session/set_mode acknowledges a valid selection, and an unknown mode id
  68. // fails loudly. With no model turn, its membership in the plan header class
  69. // is vacuous; the class still needs one explicit pin below.
  70. { name: 'modes-advertise', hasModelTurn: false, recorded: false, headerClass: 'plan' },
  71. // The plan header pin covers the full arc: setMode(plan), a real read under
  72. // the independently configured sandbox, plan review through exit_plan_mode,
  73. // an approved boundary flip back to default, and a real edit in the next
  74. // step. Leaving plan removes the policy section and exit tool, producing one
  75. // changed request header.
  76. { name: 'plan-mode', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'plan', expectedHeaderChanges: 1 },
  77. // Free-text review feedback returns as a corrective error and leaves the
  78. // session in plan mode, so this scenario shares the pinned plan header.
  79. { name: 'plan-mode-reject', hasModelTurn: true, recorded: true, headerClass: 'plan' },
  80. // text-turn is the pinned-header scenario: the minimal single text turn.
  81. // Its prompt and tool-schema sidecars pin the composed header.
  82. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
  83. { name: 'tool-call-turn', hasModelTurn: true, recorded: true },
  84. // Authored from the real PACKED_CHUNKS_SOURCE recording under the same app
  85. // composition. The contract below pins decoded equality and all three row
  86. // kinds; replay additionally proves the assembled app re-packs identically.
  87. { name: 'packed-chunks', hasModelTurn: true, recorded: false, configPath: PACKED_CHUNKS_CONFIG },
  88. // The fs overlay only adds the spill stack (the sandboxed filesystem tools
  89. // live in the base tree), so these scenarios share the default header class.
  90. {
  91. name: 'parallel-tool-calls',
  92. hasModelTurn: true,
  93. recorded: false,
  94. configPath: FS_CONFIG,
  95. },
  96. { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
  97. {
  98. name: 'pty-tools',
  99. hasModelTurn: true,
  100. recorded: false,
  101. pinsHeader: true,
  102. headerClass: 'pty',
  103. configPath: PTY_CONFIG,
  104. },
  105. { name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
  106. { name: 'todo-plan', hasModelTurn: true, recorded: true },
  107. { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' },
  108. { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG },
  109. {
  110. name: 'workspace-edit',
  111. hasModelTurn: true,
  112. recorded: true,
  113. pinsNativeWindowsStdout: true,
  114. },
  115. { name: 'fs-read', hasModelTurn: true, recorded: true },
  116. { name: 'fs-write', hasModelTurn: true, recorded: true },
  117. { name: 'fs-edit', hasModelTurn: true, recorded: true },
  118. { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
  119. { name: 'fs-read-window', hasModelTurn: true, recorded: true },
  120. { name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
  121. { name: 'multi-turn', hasModelTurn: true, recorded: true },
  122. // ACP exposes the adapter catalog as a session-scoped model select. This
  123. // scenario pins the default flash request, the switch response, and the
  124. // resulting changed request-header snapshot for pro.
  125. {
  126. name: 'model-switching',
  127. hasModelTurn: true,
  128. recorded: true,
  129. pinsHeader: true,
  130. expectedHeaderChanges: 1,
  131. headerClass: 'model-switching',
  132. },
  133. { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
  134. // Keyless, authored (like error-finish/cancel): deterministically forcing a
  135. // LIVE model to repeat one call three times is not a stable recording, so
  136. // the fixture scripts five identical todo_write calls and pins BOTH reminder
  137. // tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log.
  138. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
  139. // Authored replay: a root AGENTS.md pins the session prefix, then a read in
  140. // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing
  141. // injected user/message. Both AGENTS.md fixtures are symlinks to a sibling
  142. // AGENTS.canonical.md, so this scenario also guards that discovery follows a
  143. // symlinked instruction file to its target's content. The scenario-specific
  144. // config keeps home/root discovery hermetic, and the resulting prefix needs
  145. // its own pinned header class.
  146. {
  147. name: 'workspace-context',
  148. hasModelTurn: true,
  149. recorded: false,
  150. overridden: true,
  151. pinsHeader: true,
  152. headerClass: 'workspace-context',
  153. configPath: WORKSPACE_CONTEXT_CONFIG,
  154. },
  155. { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
  156. // Cancelling a live bash call relies on POSIX process-group termination;
  157. // Windows bash process-tree kill is deferred with the Bash execution domain.
  158. { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
  159. { name: 'subagent-spawn', hasModelTurn: true, recorded: true },
  160. { name: 'subagent-multi', hasModelTurn: true, recorded: true },
  161. { name: 'subagent-fork', hasModelTurn: true, recorded: true },
  162. { name: 'subagent-mixed', hasModelTurn: true, recorded: true },
  163. {
  164. name: 'subagent-depth-two-rejection',
  165. hasModelTurn: true,
  166. recorded: false,
  167. overridden: true,
  168. configPath: DEPTH_TWO_CONFIG,
  169. },
  170. // The workflow tool: the model writes a one-child orchestration script; the
  171. // child runs as a spawn subagent under the worker-thread engine (its session is the
  172. // child fixture), and the tool result carries the script's return value.
  173. { name: 'workflow-run', hasModelTurn: true, recorded: true },
  174. // Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it
  175. // through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and
  176. // Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
  177. {
  178. name: 'advanced-toolchain',
  179. hasModelTurn: true,
  180. recorded: false,
  181. pinsHeader: true,
  182. headerClass: 'advanced',
  183. configPath: ADVANCED_CONFIG,
  184. },
  185. {
  186. name: 'cordis-inspect-jsdoc',
  187. hasModelTurn: true,
  188. recorded: false,
  189. headerClass: 'advanced',
  190. configPath: ADVANCED_CONFIG,
  191. },
  192. // Prompt-submit blocks are authored keylessly. Admission rejects before a
  193. // turn opens, so only the ACP stop reason is observable and no log is harvested.
  194. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
  195. { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
  196. // The mid-turn seams fire during a real model turn, so each is recorded with its hook active
  197. // (the model's reaction to a deny/block/force-continue is part of the captured transcript).
  198. // SessionStart/SubagentStart are excluded because detached injection races log
  199. // order; SubagentStop writes no transcript, so an expected output could not prove it ran.
  200. // Unit tests cover those points; the hook-snapshot-matrix Agent Note owns the rationale.
  201. { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true },
  202. { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true },
  203. { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true },
  204. { name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true },
  205. { name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true },
  206. { name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true },
  207. { name: 'hook-codex-promptsubmit-context', hasModelTurn: true, recorded: true },
  208. { name: 'hook-codex-pretool-block', hasModelTurn: true, recorded: true },
  209. { name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true },
  210. { name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true },
  211. { name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
  212. // Code Mode: the registry in `mode: code` — the wire tool list collapses to [run_code], the
  213. // tools:sdk section rides in the prompt, and the program's tool calls land as
  214. // tool/code-dispatch events. Each overlay composes and pins its own header class.
  215. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
  216. // A nested fs dispatch inside run_code discovers workspace instructions. The
  217. // injected user/message must follow the outer result while retaining workspace
  218. // provenance, which proves Code Mode carries deferred tool context end to end.
  219. {
  220. name: 'code-mode-workspace-context',
  221. hasModelTurn: true,
  222. recorded: true,
  223. pinsHeader: true,
  224. headerClass: 'code-workspace-context',
  225. configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG,
  226. },
  227. { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG },
  228. // The default tree also owns the Permissions select. Snapshot mode starts in
  229. // danger-full-access so established fixtures stay runner-independent; these
  230. // policy scenarios switch to workspace-write in their input scripts.
  231. // Real-kernel confinement remains in escalation.e2e.ts and the sandbox
  232. // packages' e2e suites.
  233. { name: 'config-options', hasModelTurn: false, recorded: false, headerClass: 'sandbox' },
  234. { name: 'permission-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'sandbox' },
  235. { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' },
  236. { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox' },
  237. { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' },
  238. // Unlike ordinary snapshots, this session cwd is outside the platform temp
  239. // roots that workspace-write always grants. The overlay points the
  240. // deployment fallback at /tmp, so a successful relative write proves the
  241. // assembled app replaced that process-level fallback with SessionHeader.cwd.
  242. {
  243. name: 'session-sandbox-root',
  244. hasModelTurn: true,
  245. recorded: false,
  246. overridden: true,
  247. headerClass: 'sandbox',
  248. configPath: SESSION_SANDBOX_ROOT_CONFIG,
  249. workspaceParent: homedir(),
  250. },
  251. ]
  252. defineAcpSnapshotSuite({
  253. agent: AGENT,
  254. snapshotsDir: SNAPSHOTS_DIR,
  255. scenarios: SCENARIOS,
  256. mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT),
  257. })
  258. it('packed ACP fixture retains every chunk row kind without changing the logical session', () => {
  259. const source = fixtureRecords(PACKED_CHUNKS_SOURCE)
  260. const packed = fixtureRecords('packed-chunks')
  261. const rowTypes = packed.flatMap((record) => {
  262. if (record === null || typeof record !== 'object') return []
  263. const type = (record as { type?: unknown }).type
  264. return type === 'text-chunks' || type === 'reasoning-chunks' || type === 'tool-call-chunks' ? [type] : []
  265. })
  266. expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks'])
  267. expect([packed[0], ...packed.slice(1).flatMap(record => decodeStorageRecord(record))]).toStrictEqual(source)
  268. })