acp.snapshot.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { fileURLToPath } from 'node:url'
  2. import { readFileSync } from 'node:fs'
  3. import { mkdir, writeFile } from 'node:fs/promises'
  4. import { dirname, join } from 'node:path'
  5. import { homedir } from 'node:os'
  6. import { expect, it } from 'vitest'
  7. import { defineAcpSnapshotSuite, type Scenario, type SnapshotSuiteOptions } from '@deepseek-ai/dsh-acp-snapshot'
  8. import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
  9. /**
  10. * The acp-agent example's snapshot suite: the scenario table for
  11. * `dsh-acp-snapshot`'s suite factory, which owns every compare/guard mechanic
  12. * (expected-output + re-persisted-log diffs, record/refresh write-back, the pinned-header
  13. * uniformity guard, the fixture guards). Fixtures live under `snapshots/<name>/`;
  14. * `pnpm run test:snapshot:record` re-records model transcripts against the real
  15. * API; `pnpm run test:snapshot:refresh` rewrites current replay expected outputs keyless.
  16. * See the package README (packages/support/acp-snapshot) and the snapshot Agent Note,
  17. * .agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md.
  18. */
  19. // The dsh-acp-demo bin (the demo:acp entry), this example's cordis.yml, and
  20. // the repo-root tsconfig (four levels up from examples/acp-agent/tests) — all
  21. // ABSOLUTE: the subprocess cwd is a temp dir outside the repo.
  22. const AGENT = {
  23. binScript: fileURLToPath(new URL('../../../packages/examples/acp-demo/src/bin.ts', import.meta.url)),
  24. configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
  25. tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
  26. }
  27. // The Code Mode overlay configs (include-patched variants of cordis.yml; the
  28. // replay swap resolves each one's sibling `*cordis.snapshot.yml`).
  29. const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
  30. const CODE_MODE_WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../code-mode-workspace-context.cordis.yml', import.meta.url))
  31. const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url))
  32. const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url))
  33. const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url))
  34. const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url))
  35. const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url))
  36. const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url))
  37. const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url))
  38. const SESSION_SANDBOX_ROOT_CONFIG = fileURLToPath(new URL('../session-sandbox-root.cordis.yml', import.meta.url))
  39. const RETRY_CONFIG = fileURLToPath(new URL('../retry.cordis.yml', import.meta.url))
  40. const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml', import.meta.url))
  41. const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url))
  42. const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url))
  43. const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url))
  44. const FS_SEARCH_BIN = fileURLToPath(new URL('./fixtures/fs-search-bin', import.meta.url))
  45. const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  46. const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny'
  47. async function prepareDelimiterPathWorkspace(cwd: string): Promise<void> {
  48. const dir = join(cwd, 'scope</system-reminder>')
  49. await mkdir(dir, { recursive: true })
  50. await Promise.all([
  51. writeFile(join(dir, 'AGENTS.md'), 'Delimiter path snapshot instruction.\n'),
  52. writeFile(join(dir, 'task.txt'), 'delimiter path snapshot task\n'),
  53. ])
  54. }
  55. // FIXME: Migrate backend-oriented scenarios to the headless stream-json suite;
  56. // this ACP suite should eventually retain only automation-protocol contracts.
  57. function fixtureRecords(name: string): unknown[] {
  58. return readFileSync(join(SNAPSHOTS_DIR, name, 'session.jsonl'), 'utf8')
  59. .trimEnd()
  60. .split('\n')
  61. .map(line => JSON.parse(line) as unknown)
  62. }
  63. function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] {
  64. switch (value) {
  65. case undefined:
  66. case '':
  67. case 'replay':
  68. return 'replay'
  69. case 'record':
  70. return 'record'
  71. case 'refresh':
  72. return 'refresh'
  73. default:
  74. throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`)
  75. }
  76. }
  77. const SCENARIOS: Scenario[] = [
  78. { name: 'handshake', hasModelTurn: false, recorded: false },
  79. { name: 'reject-extra-dirs', hasModelTurn: false, recorded: false },
  80. // text-turn is the default header pin and owns the prompt and tool-schema
  81. // sidecars reused by alternate classes with identical component sequences.
  82. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
  83. {
  84. name: 'session-title-after-turn',
  85. hasModelTurn: true,
  86. recorded: false,
  87. overridden: true,
  88. configPath: SESSION_TITLE_CONFIG,
  89. },
  90. { name: 'tool-call-turn', hasModelTurn: true, recorded: true },
  91. // Authored from the real PACKED_CHUNKS_SOURCE recording under the ordinary
  92. // app composition. The contract below pins decoded equality and all three
  93. // row kinds; replay additionally proves the assembled app re-packs identically.
  94. { name: 'packed-chunks', hasModelTurn: true, recorded: false },
  95. // The fs overlay only adds the spill stack (the sandboxed filesystem tools
  96. // live in the base tree), so these scenarios share the default header class.
  97. {
  98. name: 'parallel-tool-calls',
  99. hasModelTurn: true,
  100. recorded: false,
  101. configPath: FS_CONFIG,
  102. },
  103. { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
  104. {
  105. name: 'session-query-spill',
  106. hasModelTurn: true,
  107. recorded: false,
  108. pinsHeader: true,
  109. headerClass: 'session-query',
  110. configPath: SESSION_QUERY_CONFIG,
  111. posixOnly: true,
  112. },
  113. {
  114. name: 'pty-tools',
  115. hasModelTurn: true,
  116. recorded: false,
  117. pinsHeader: true,
  118. headerClass: 'pty',
  119. configPath: PTY_CONFIG,
  120. },
  121. { name: 'bash-tool-turn', hasModelTurn: true, recorded: true },
  122. { name: 'todo-write', hasModelTurn: true, recorded: true },
  123. {
  124. name: 'skill-load',
  125. hasModelTurn: true,
  126. recorded: false,
  127. pinsHeader: true,
  128. headerClass: 'skill',
  129. systemPromptSource: 'text-turn',
  130. toolSchemasSource: 'text-turn',
  131. },
  132. { name: 'lsp-definition', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'lsp', configPath: LSP_CONFIG },
  133. // web_fetch markdown rendering end to end: the overlay's loopback fixture
  134. // server supplies deterministic HTML (entities, a GFM table, nesting), the
  135. // REAL local fetch provider retrieves it, and the tool result pins the
  136. // turndown conversion. The fetched URL (fixed port) is part of the recorded
  137. // transcript; replay re-executes the real fetch against the same fixture.
  138. { name: 'web-fetch', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'web', configPath: WEB_CONFIG },
  139. {
  140. name: 'workspace-edit',
  141. hasModelTurn: true,
  142. recorded: true,
  143. },
  144. // The real Loader/app/bash path executes a deterministic rg stand-in at the
  145. // external-process seam, pinning over-cap glob sampling without depending on
  146. // a host-installed ripgrep binary.
  147. {
  148. name: 'fs-glob-sampling',
  149. hasModelTurn: true,
  150. recorded: false,
  151. pinsHeader: true,
  152. headerClass: 'fs-search',
  153. configPath: FS_SEARCH_CONFIG,
  154. env: { PATH: `${FS_SEARCH_BIN}:${process.env.PATH ?? ''}` },
  155. posixOnly: true,
  156. },
  157. { name: 'fs-read', hasModelTurn: true, recorded: true },
  158. { name: 'fs-write', hasModelTurn: true, recorded: true },
  159. { name: 'fs-edit', hasModelTurn: true, recorded: true },
  160. { name: 'fs-write-overwrite', hasModelTurn: true, recorded: true },
  161. { name: 'fs-read-window', hasModelTurn: true, recorded: true },
  162. { name: 'fs-policy-reject', hasModelTurn: true, recorded: true },
  163. { name: 'multi-turn', hasModelTurn: true, recorded: true },
  164. { name: 'error-finish', hasModelTurn: true, recorded: false, overridden: true },
  165. // Keyless, authored (like error-finish): a live provider cannot be coaxed
  166. // into a degenerate empty completion, so the fixture scripts the adapters'
  167. // EMPTY_RESPONSE error finish in turn 1 followed by the recovered reply
  168. // in retry turn 2, proving the default retry policy end to end: the durable
  169. // llm/retry event, no ACP output for the discarded attempt, the recovered
  170. // reply, and a clean completed retry turn. Its overlay only pins a deterministic
  171. // 1 ms zero-jitter delay, so it shares the default header class.
  172. { name: 'empty-response-retry', hasModelTurn: true, recorded: false, configPath: RETRY_CONFIG },
  173. // Keyless, authored (like error-finish/cancel): deterministically forcing a
  174. // LIVE model to repeat one call three times is not a stable recording, so
  175. // the fixture scripts five identical todo_write calls and pins BOTH reminder
  176. // tiers (gentle at 3, detailed at 5) as injected user/message in transcript and log.
  177. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false },
  178. // Authored replay: a root AGENTS.md pins the session prefix, then a read in
  179. // nested/ discovers its narrower AGENTS.md as a raw, metadata-bearing
  180. // injected user/message. Both portable AGENTS.md fixtures are symlinks to a sibling
  181. // AGENTS.canonical.md, so this scenario also guards that discovery follows a
  182. // symlinked instruction file to its target's content. A second nested path
  183. // containing a literal closing tag is created at runtime: Git cannot check
  184. // that name out on Windows, so this delimiter-injection case is POSIX-only.
  185. // The scenario-specific config keeps home/root discovery hermetic, and the
  186. // resulting prefix needs its own pinned header class.
  187. {
  188. name: 'workspace-context',
  189. hasModelTurn: true,
  190. recorded: false,
  191. overridden: true,
  192. pinsHeader: true,
  193. headerClass: 'workspace-context',
  194. toolSchemasSource: 'text-turn',
  195. configPath: WORKSPACE_CONTEXT_CONFIG,
  196. prepareWorkspace: prepareDelimiterPathWorkspace,
  197. posixOnly: true,
  198. },
  199. { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true },
  200. // Cancelling a live bash call relies on POSIX process-group termination;
  201. // Windows bash process-tree kill is deferred with the Bash execution domain.
  202. { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true },
  203. { name: 'subagent-spawn', hasModelTurn: true, recorded: true },
  204. { name: 'subagent-multi', hasModelTurn: true, recorded: true },
  205. { name: 'subagent-fork', hasModelTurn: true, recorded: true },
  206. { name: 'subagent-mixed', hasModelTurn: true, recorded: true },
  207. {
  208. name: 'subagent-depth-two-rejection',
  209. hasModelTurn: true,
  210. recorded: false,
  211. overridden: true,
  212. configPath: DEPTH_TWO_CONFIG,
  213. },
  214. // The workflow tool: the model writes a one-child orchestration script; the
  215. // child runs as a spawn subagent under the worker-thread engine (its session is the
  216. // child fixture), and the tool result carries the script's return value.
  217. { name: 'workflow-run', hasModelTurn: true, recorded: true },
  218. // Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it
  219. // through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and
  220. // Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
  221. {
  222. name: 'advanced-toolchain',
  223. hasModelTurn: true,
  224. recorded: false,
  225. pinsHeader: true,
  226. headerClass: 'advanced',
  227. configPath: ADVANCED_CONFIG,
  228. },
  229. {
  230. name: 'cordis-inspect-jsdoc',
  231. hasModelTurn: true,
  232. recorded: false,
  233. headerClass: 'advanced',
  234. configPath: ADVANCED_CONFIG,
  235. },
  236. // Prompt-submit blocks are authored keylessly with malformed matcher fields,
  237. // which these matcherless events must ignore. Admission rejects before a turn
  238. // opens, so only the ACP stop reason is observable and no log is harvested.
  239. { name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
  240. { name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
  241. // Each invalid matcher follows a runnable prompt blocker. Reaching the replay
  242. // model without any hook audit rows proves config loading is atomic through
  243. // the real Loader/app path, rather than retaining the earlier valid group.
  244. { name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false },
  245. { name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false },
  246. // The mid-turn seams fire during a real model turn, so each is recorded with its hook active
  247. // (the model's reaction to a deny/block/force-continue is part of the captured transcript).
  248. // SessionStart/SubagentStart are excluded because detached injection races log
  249. // order; SubagentStop writes no transcript, so an expected output could not prove it ran.
  250. // Unit tests cover those points; the hook-snapshot-matrix Agent Note owns the rationale.
  251. { name: 'hook-cc-promptsubmit-context', hasModelTurn: true, recorded: true },
  252. { name: 'hook-cc-pretool-deny', hasModelTurn: true, recorded: true },
  253. { name: 'hook-cc-pretool-ask', hasModelTurn: true, recorded: true },
  254. { name: 'hook-cc-posttool-block', hasModelTurn: true, recorded: true },
  255. { name: 'hook-cc-posttool-context', hasModelTurn: true, recorded: true },
  256. { name: 'hook-cc-stop-continue', hasModelTurn: true, recorded: true },
  257. { name: 'hook-codex-promptsubmit-context', hasModelTurn: true, recorded: true },
  258. { name: 'hook-codex-pretool-block', hasModelTurn: true, recorded: true },
  259. { name: 'hook-codex-posttool-block', hasModelTurn: true, recorded: true },
  260. { name: 'hook-codex-posttool-context', hasModelTurn: true, recorded: true },
  261. { name: 'hook-codex-stop-continue', hasModelTurn: true, recorded: true },
  262. // Code Mode: the registry in `mode: code` — the wire tool list collapses to [run_code], the
  263. // tools:sdk section rides in the prompt, and the program's tool calls land as
  264. // tool/code-dispatch events. Each overlay composes and pins its own header class.
  265. { name: 'code-mode-turn', hasModelTurn: true, recorded: true, pinsHeader: true, headerClass: 'code', configPath: CODE_MODE_CONFIG },
  266. // A nested fs dispatch inside run_code discovers workspace instructions. The
  267. // injected user/message must follow the outer result while retaining workspace
  268. // provenance, which proves Code Mode carries deferred tool context end to end.
  269. {
  270. name: 'code-mode-workspace-context',
  271. hasModelTurn: true,
  272. recorded: true,
  273. pinsHeader: true,
  274. headerClass: 'code-workspace-context',
  275. systemPromptSource: 'code-mode-turn',
  276. toolSchemasSource: 'code-mode-turn',
  277. configPath: CODE_MODE_WORKSPACE_CONTEXT_CONFIG,
  278. },
  279. {
  280. name: 'both-mode-turn',
  281. hasModelTurn: true,
  282. recorded: true,
  283. pinsHeader: true,
  284. headerClass: 'both',
  285. systemPromptSource: 'code-mode-turn',
  286. configPath: BOTH_MODE_CONFIG,
  287. },
  288. // Machine permission scenarios use an explicit deployment policy; there is
  289. // no session-scoped UI picker on the automation protocol.
  290. {
  291. name: 'escalation-approved',
  292. hasModelTurn: true,
  293. recorded: true,
  294. pinsHeader: true,
  295. headerClass: 'sandbox',
  296. toolSchemasSource: 'text-turn',
  297. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  298. },
  299. {
  300. name: 'escalation-rejected',
  301. hasModelTurn: true,
  302. recorded: true,
  303. headerClass: 'sandbox',
  304. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  305. },
  306. {
  307. name: 'fs-escalation-approved',
  308. hasModelTurn: true,
  309. recorded: true,
  310. headerClass: 'sandbox',
  311. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  312. },
  313. // Unlike ordinary snapshots, this session cwd is outside the platform temp
  314. // roots that workspace-write always grants. The overlay points the
  315. // deployment fallback at /tmp, so a successful relative write proves the
  316. // assembled app replaced that process-level fallback with SessionHeader.cwd.
  317. {
  318. name: 'session-sandbox-root',
  319. hasModelTurn: true,
  320. recorded: false,
  321. overridden: true,
  322. headerClass: 'sandbox',
  323. configPath: SESSION_SANDBOX_ROOT_CONFIG,
  324. env: { DSH_PERMISSION_MODE: 'workspace-write' },
  325. workspaceParent: homedir(),
  326. },
  327. ]
  328. defineAcpSnapshotSuite({
  329. agent: AGENT,
  330. snapshotsDir: SNAPSHOTS_DIR,
  331. scenarios: SCENARIOS,
  332. mode: snapshotModeFromEnv(process.env.DSH_SNAPSHOT),
  333. })
  334. it('packed ACP fixture retains every chunk row kind without changing the logical session', () => {
  335. const source = fixtureRecords(PACKED_CHUNKS_SOURCE)
  336. const packed = fixtureRecords('packed-chunks')
  337. const rowTypes = packed.flatMap((record) => {
  338. if (record === null || typeof record !== 'object') return []
  339. const type = (record as { type?: unknown }).type
  340. return type === 'text-chunks' || type === 'reasoning-chunks' || type === 'tool-call-chunks' ? [type] : []
  341. })
  342. expect([...new Set(rowTypes)].sort()).toStrictEqual(['reasoning-chunks', 'text-chunks', 'tool-call-chunks'])
  343. const withoutMessageId = (record: unknown): unknown => {
  344. const cloned = structuredClone(record) as {
  345. type?: unknown
  346. data?: { id?: unknown; message?: { id?: unknown } }
  347. }
  348. if (cloned.type === 'user/message') delete cloned.data?.id
  349. if (cloned.type === 'assistant/message'
  350. || cloned.type === 'tool/result'
  351. || cloned.type === 'steering/message') {
  352. delete cloned.data?.message?.id
  353. }
  354. return cloned
  355. }
  356. const logicalRecords = (records: readonly unknown[]): unknown[] => [
  357. records[0],
  358. ...records.slice(1).flatMap(record => decodeStorageRecord(record)).map(withoutMessageId),
  359. ]
  360. expect(logicalRecords(packed)).toStrictEqual(logicalRecords(source))
  361. })