acp.snapshot.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import { fileURLToPath } from 'node:url'
  2. import { dirname, join } from 'node:path'
  3. import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
  4. /**
  5. * The acp-agent example's snapshot suite: the scenario table for
  6. * `dsh-acp-snapshot`'s suite factory, which owns every compare/guard mechanic
  7. * (expected-output + re-persisted-log diffs, record/refresh write-back, the pinned-header
  8. * uniformity guard, the fixture guards). Fixtures live under `snapshots/<name>/`;
  9. * `pnpm run test:snapshot:record` re-records model transcripts against the real
  10. * API; `pnpm run test:snapshot:refresh` rewrites current replay expected outputs keyless.
  11. * See the package README (packages/support/acp-snapshot) and the snapshot Agent Note,
  12. * .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md.
  13. */
  14. // The dsh-acp-demo bin (the demo:acp entry), this example's cordis.yml, and
  15. // the repo-root tsconfig (four levels up from examples/acp-agent/tests) — all
  16. // ABSOLUTE: the subprocess cwd is a temp dir outside the repo.
  17. const AGENT = {
  18. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  19. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  20. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  21. }
  22. // The Code Mode overlay configs (include-patched variants of cordis.yml; the
  23. // replay swap resolves each one's sibling `*cordis.snapshot.yml`).
  24. const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
  25. const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url))
  26. const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url))
  27. const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url))
  28. const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
  29. const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
  30. const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
  31. function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
  32. switch (value) {
  33. case undefined:
  34. case '':
  35. case 'replay':
  36. return 'replay'
  37. case 'record':
  38. return 'record'
  39. case 'refresh':
  40. return 'refresh'
  41. default:
  42. throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`)
  43. }
  44. }
  45. const SCENARIOS: Scenario[] = [
  46. { name: 'handshake', hasModelTurn: false, recorded: false },
  47. { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false },
  48. // text-turn is the pinned-header scenario: the minimal single text turn.
  49. // Its prompt and tool-schema sidecars pin the composed header.
  50. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
  51. { name: 'tool-call-turn', hasModelTurn: true, recorded: true },
  52. // The fs overlay only adds the spill stack (the sandboxed filesystem tools
  53. // live in the base tree), so these scenarios share the default header class.
  54. {
  55. name: 'parallel-tool-calls',
  56. hasModelTurn: true,
  57. recorded: false,
  58. configPath: FS_CONFIG,
  59. },
  60. { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
  61. { name: 'fs-terminal-card', hasModelTurn: true, recorded: true },
  62. { name: 'todo-plan', hasModelTurn: true, recorded: true },
  63. { name: 'skill-load', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'skill' },
  64. { name: 'workspace-edit', hasModelTurn: true, recorded: true },
  65. { name: 'fs-read', hasModelTurn: true, recorded: true },
  66. { name: 'fs-write', hasModelTurn: true, recorded: true },
  67. { name: 'fs-edit', hasModelTurn: true, recorded: true },
  68. { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
  69. { name: 'fs-read-window', hasModelTurn: true, recorded: true },
  70. { name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
  71. { name: 'multi-turn', hasModelTurn: true, recorded: true },
  72. // ACP exposes the adapter catalog as a session-scoped model select. This
  73. // scenario pins the default flash request, the switch response, and the
  74. // resulting changed request-header snapshot for pro.
  75. {
  76. name: 'model-switching',
  77. hasModelTurn: true,
  78. recorded: true,
  79. pinsHeader: true,
  80. expectedHeaderChanges: 1,
  81. headerClass: 'model-switching',
  82. },
  83. { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
  84. // Keyless, authored (like error-finish/cancel): deterministically forcing a
  85. // LIVE model to repeat one call three times is not a stable recording, so
  86. // the fixture scripts five identical todo_write calls and pins BOTH reminder
  87. // tiers (gentle at 3, detailed at 5) as context/message in transcript and log.
  88. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
  89. // Authored replay: a root AGENTS.md pins the session prefix, then a read in
  90. // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing
  91. // context/message. The scenario-specific config keeps home/root discovery
  92. // hermetic, and the resulting prefix needs its own pinned header class.
  93. {
  94. name: 'workspace-context',
  95. hasModelTurn: true,
  96. recorded: false,
  97. overridden: true,
  98. pinsHeader: true,
  99. headerClass: 'workspace-context',
  100. configPath: WORKSPACE_CONTEXT_CONFIG,
  101. },
  102. { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
  103. { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true },
  104. { name: 'subagent-spawn', hasModelTurn: true, recorded: true },
  105. { name: 'subagent-multi', hasModelTurn: true, recorded: true },
  106. { name: 'subagent-fork', hasModelTurn: true, recorded: true },
  107. { name: 'subagent-mixed', hasModelTurn: true, recorded: true },
  108. {
  109. name: 'subagent-depth-two-rejection',
  110. hasModelTurn: true,
  111. recorded: false,
  112. overridden: true,
  113. configPath: DEPTH_TWO_CONFIG,
  114. },
  115. // The workflow tool: the model writes a one-child orchestration script; the
  116. // child runs as a spawn subagent under the worker-thread engine (its session is the
  117. // child fixture), and the tool result carries the script's return value.
  118. { name: 'workflow-run', hasModelTurn: true, recorded: true },
  119. // Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it
  120. // through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and
  121. // Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
  122. {
  123. name: 'advanced-toolchain',
  124. hasModelTurn: true,
  125. recorded: false,
  126. pinsHeader: true,
  127. headerClass: 'advanced',
  128. configPath: ADVANCED_CONFIG,
  129. },
  130. {
  131. name: 'cordis-inspect-jsdoc',
  132. hasModelTurn: true,
  133. recorded: false,
  134. headerClass: 'advanced',
  135. configPath: ADVANCED_CONFIG,
  136. },
  137. // Prompt-submit blocks are authored keylessly: they persist a rejected turn
  138. // and hook events without starting a model step, so their logs still compare.
  139. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false },
  140. { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false },
  141. // The mid-turn seams fire during a real model turn, so each is recorded with its hook active
  142. // (the model's reaction to a deny/block/force-continue is part of the captured transcript).
  143. // SessionStart/SubagentStart are excluded because detached injection races log
  144. // order; SubagentStop writes no transcript, so an expected output could not prove it ran.
  145. // Unit tests cover those points; the hook-snapshot-matrix Agent Note owns the rationale.
  146. { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true },
  147. { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true },
  148. { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true },
  149. { name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true },
  150. { name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true },
  151. { name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true },
  152. { name: 'hook-codex-promptsubmit-context', hasModelTurn: true, recorded: true },
  153. { name: 'hook-codex-pretool-block', hasModelTurn: true, recorded: true },
  154. { name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true },
  155. { name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true },
  156. { name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
  157. // Code Mode: the registry in `mode: code` — the wire tool list collapses to [run_code], the
  158. // tools:sdk section rides in the prompt, and the program's tool calls land as
  159. // tool/code-dispatch events. Each overlay composes and pins its own header class.
  160. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
  161. // A nested fs dispatch inside run_code discovers workspace instructions. The
  162. // context/message must follow the outer result while retaining workspace
  163. // provenance, which proves Code Mode carries deferred tool context end to end.
  164. {
  165. name: 'code-mode-workspace-context',
  166. hasModelTurn: true,
  167. recorded: true,
  168. pinsHeader: true,
  169. headerClass: 'code-workspace-context',
  170. configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG,
  171. },
  172. { name: 'both-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'both', configPath: BOTH_MODE_CONFIG },
  173. // The default tree also owns the Permissions select. Snapshot mode starts in
  174. // danger-full-access so established fixtures stay runner-independent; these
  175. // policy scenarios switch to workspace-write in their input scripts.
  176. // Real-kernel confinement remains in escalation.e2e.ts and the sandbox
  177. // packages' e2e suites.
  178. { name: 'config-options', hasModelTurn: false, recorded: false, headerClass: 'sandbox' },
  179. { name: 'permission-switching', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'sandbox' },
  180. { name: 'escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' },
  181. { name: 'escalation-rejected', hasModelTurn: true, recorded: true, headerClass: 'sandbox' },
  182. { name: 'fs-escalation-approved', hasModelTurn: true, recorded: true, headerClass: 'sandbox' },
  183. ]
  184. defineAcpSnapshotSuite({
  185. agent: AGENT,
  186. snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
  187. scenarios: SCENARIOS,
  188. mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT),
  189. })