tui.snapshot.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { afterAll, describe, expect, it, vi } from 'vitest'
  6. import { Context } from 'cordis'
  7. import { scrubRequestHeaders, tokenizeSessionFixtureCwd } from '@deepseek-ai/dsh-acp-snapshot'
  8. import type { Agent } from '@deepseek-ai/dsh-agent'
  9. import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo'
  10. import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
  11. import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
  12. import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
  13. import CommandService from '@deepseek-ai/dsh-commands'
  14. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  15. import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
  16. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  17. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  18. import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  19. import PlanModeService from '@deepseek-ai/dsh-plan-mode'
  20. import TokenMeterService from '@deepseek-ai/dsh-token-meter'
  21. import { packChunkRuns, SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
  22. import SubagentService from '@deepseek-ai/dsh-subagent'
  23. import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
  24. import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
  25. import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
  26. import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
  27. import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
  28. import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
  29. import { createTuiChat, FILE_REFERENCE_PROMPT, TuiPromptService } from '@deepseek-ai/dsh-tui'
  30. import LocalSpillStore from '@deepseek-ai/dsh-spill-local'
  31. import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
  32. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  33. import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
  34. import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts'
  35. const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  36. // Keep pre-normalization layout widths identical across macOS and Linux.
  37. const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp'
  38. const PROVIDERS = [{ id: 'deepseek-official', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
  39. const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
  40. type SnapshotMode = 'replay' | 'record' | 'refresh'
  41. type Composition = 'native' | 'code' | 'advanced'
  42. type ScenarioInteraction = 'skill-invocation-policy'
  43. interface Scenario {
  44. name: string
  45. composition: Composition
  46. expectedTools: string[]
  47. expectedEventCounts?: Record<string, number>
  48. childSessions?: number
  49. enterPlanMode?: boolean
  50. leavePlanModeAfterFirstTurn?: boolean
  51. recorded: boolean
  52. seedWorkspace?: boolean
  53. /**
  54. * Load the opt-in `todo_write` tool for this scenario. The shipped TUI
  55. * config omits it, so only the todo-plan scenario (the enabled-path proof)
  56. * mounts it; the rest cover the default, todo-free composition.
  57. */
  58. enableTodo?: boolean
  59. /**
  60. * Mount the spill stack (local backend + policy) with this inline cap, as the
  61. * shipped configs do. The dispatch-spill scenario proves the durable
  62. * `tool/code-dispatch` copy of an oversized sub-result is bounded to a
  63. * preview + locator while the program value stays whole.
  64. */
  65. spillMaxInlineBytes?: number
  66. /** Run scenario-specific terminal input instead of replaying recorded user prompts. */
  67. interaction?: ScenarioInteraction
  68. }
  69. const SCENARIOS: Scenario[] = [
  70. {
  71. name: 'multi-turn-conversation',
  72. composition: 'native',
  73. expectedTools: [],
  74. expectedEventCounts: { 'plan/mode': 2 },
  75. enterPlanMode: true,
  76. leavePlanModeAfterFirstTurn: true,
  77. recorded: true,
  78. },
  79. {
  80. name: 'todo-plan',
  81. composition: 'native',
  82. expectedTools: ['todo_write'],
  83. expectedEventCounts: { 'todo/write': 1 },
  84. recorded: true,
  85. enableTodo: true,
  86. },
  87. {
  88. name: 'bash-terminal-card',
  89. composition: 'native',
  90. expectedTools: ['bash'],
  91. recorded: true,
  92. },
  93. {
  94. name: 'parallel-file-reads',
  95. composition: 'native',
  96. expectedTools: ['read', 'read'],
  97. recorded: true,
  98. seedWorkspace: true,
  99. },
  100. {
  101. name: 'skill-invocation-policy',
  102. composition: 'native',
  103. expectedTools: [],
  104. recorded: false,
  105. seedWorkspace: true,
  106. interaction: 'skill-invocation-policy',
  107. },
  108. {
  109. name: 'code-mode',
  110. composition: 'code',
  111. expectedTools: ['run_code'],
  112. expectedEventCounts: { 'tool/code-dispatch': 2 },
  113. recorded: true,
  114. },
  115. {
  116. name: 'code-mode-dispatch-spill',
  117. composition: 'code',
  118. expectedTools: ['run_code'],
  119. expectedEventCounts: { 'tool/code-dispatch-start': 1, 'tool/code-dispatch': 1 },
  120. recorded: true,
  121. spillMaxInlineBytes: 600,
  122. },
  123. {
  124. name: 'dynamic-workflow',
  125. composition: 'native',
  126. expectedTools: ['workflow'],
  127. childSessions: 1,
  128. recorded: true,
  129. },
  130. {
  131. name: 'cordis-dynamic-toolchain',
  132. composition: 'advanced',
  133. expectedTools: ['cordis_mount', 'run_code', 'subagent', 'workflow', 'cordis_unmount'],
  134. expectedEventCounts: { 'tool/code-dispatch': 1 },
  135. childSessions: 2,
  136. recorded: false,
  137. },
  138. ]
  139. function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
  140. if (value === undefined || value === '' || value === 'replay') return 'replay'
  141. if (value === 'record' || value === 'refresh') return value
  142. throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
  143. }
  144. const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
  145. const observedScenarios = new Set<string>()
  146. function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
  147. const rel = relative(cwd, displayPath)
  148. if (rel === '') return displayCwd
  149. if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return displayPath
  150. return `${displayCwd}/${rel.split(sep).join('/')}`
  151. }
  152. function scenarioDir(scenario: Scenario): string {
  153. return join(SNAPSHOTS_DIR, scenario.name)
  154. }
  155. function childFixturePaths(scenario: Scenario): string[] {
  156. return Array.from(
  157. { length: scenario.childSessions ?? 0 },
  158. (_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`),
  159. )
  160. }
  161. function userPrompts(rawLog: string): string[] {
  162. return parseSessionLog(rawLog).flatMap((event) => {
  163. if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
  164. const text = event.data.content
  165. .filter(block => block.type === 'text')
  166. .map(block => block.text)
  167. .join('')
  168. return text.length > 0 ? [text] : []
  169. })
  170. }
  171. function rawSessionLog(session: Session): string {
  172. return [
  173. JSON.stringify({ type: 'session', ...session.header }),
  174. ...packChunkRuns(session.events).map(record => JSON.stringify(record)),
  175. '',
  176. ].join('\n')
  177. }
  178. function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
  179. return snapshot
  180. .split(`/private${cwd}`).join('/workspace/project')
  181. .split(displayCwd).join('/workspace/project')
  182. .split(cwd).join('/workspace/project')
  183. .replace(UUID_RE, '{{uuid}}')
  184. }
  185. async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
  186. let stable = 0
  187. for (let attempt = 0; attempt < 20 && stable < 3; attempt++) {
  188. const before = terminal.frames
  189. await new Promise(resolve => setTimeout(resolve, 10))
  190. await terminal.flush()
  191. stable = terminal.frames === before ? stable + 1 : 0
  192. }
  193. if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms')
  194. }
  195. async function mountScenarioContext(
  196. scenario: Scenario,
  197. cwd: string,
  198. displayCwd: string,
  199. fixtureFile: string,
  200. childFiles: string[],
  201. ): Promise<Context> {
  202. class SnapshotLocalFileSystem extends LocalFileSystem {
  203. override async resolve(
  204. path: string,
  205. opts?: { cwd?: string; signal?: AbortSignal },
  206. ): Promise<Awaited<ReturnType<LocalFileSystem['resolve']>>> {
  207. const target = await super.resolve(path, opts)
  208. return { ...target, displayPath: snapshotDisplayPath(target.displayPath, cwd, displayCwd) }
  209. }
  210. }
  211. const ctx = new Context()
  212. await ctx.plugin(AgentCore, {
  213. agents: [],
  214. dshHome: join(cwd, '.dsh'),
  215. workspaceContext: false,
  216. tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' },
  217. skills: { local: { agentsHome: join(cwd, '.agents') } },
  218. })
  219. await ctx.plugin(TokenMeterService)
  220. await ctx.plugin(LocalSubprocessService)
  221. await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
  222. await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
  223. await ctx.plugin(FsPolicy)
  224. await ctx.plugin(ToolFs)
  225. await ctx.plugin(UserInteractionService)
  226. await ctx.plugin(TuiPromptService)
  227. // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped
  228. // config that omits it. The other scenarios prove the default todo-free composition.
  229. if (scenario.enableTodo === true) await ctx.plugin(ToolTodo)
  230. await ctx.plugin(SubagentService)
  231. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  232. await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false })
  233. await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
  234. await ctx.plugin(ToolWorkflow)
  235. await ctx.plugin(ToolRalph)
  236. await ctx.plugin(CommandService)
  237. if (scenario.enterPlanMode === true) {
  238. await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
  239. }
  240. if (scenario.composition === 'code' || scenario.composition === 'advanced') {
  241. await ctx.plugin(WorkerCodeRuntime, {})
  242. }
  243. if (scenario.spillMaxInlineBytes !== undefined) {
  244. await ctx.plugin(LocalSpillStore, { root: join(cwd, '.spill') })
  245. await ctx.plugin(SpillPolicy, { maxInlineBytes: scenario.spillMaxInlineBytes })
  246. }
  247. if (scenario.composition === 'advanced') await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
  248. if (MODE === 'record' && scenario.recorded) {
  249. await ctx.plugin(LlmDeepSeek)
  250. } else {
  251. installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS })
  252. }
  253. return ctx
  254. }
  255. interface ScenarioResult {
  256. terminal: string
  257. parent: Session
  258. children: Session[]
  259. workflowEvents: string[]
  260. }
  261. async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
  262. const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime())
  263. const dir = scenarioDir(scenario)
  264. const fixtureFile = join(dir, 'session.jsonl')
  265. const childFiles = childFixturePaths(scenario)
  266. const prompts = userPrompts(await readFile(fixtureFile, 'utf8'))
  267. if (scenario.interaction === undefined) {
  268. expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
  269. }
  270. const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
  271. const displayCwd = `/tmp/${basename(cwd)}`
  272. let ctx: Context | undefined
  273. let controller: ReturnType<typeof createTuiChat> | undefined
  274. const terminal = new HeadlessTerminal(100, 36)
  275. try {
  276. if (scenario.seedWorkspace === true) {
  277. const source = join(scenarioDir(scenario), 'workspace')
  278. await cp(source, cwd, { recursive: true })
  279. }
  280. ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
  281. const disposedSessions: Session[] = []
  282. ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
  283. const workflowEvents: string[] = []
  284. for (const name of ['workflow/start', 'workflow/phase', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
  285. ctx.on(name, () => { workflowEvents.push(name) })
  286. }
  287. const handle = await ctx.agents.create({
  288. sessionId: SessionId('main-session'),
  289. meta: { cwd },
  290. agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  291. })
  292. const agent: Agent = handle.agent
  293. controller = createTuiChat(ctx, {
  294. sessionId: 'main-session',
  295. theme: { color: true },
  296. showReasoning: true,
  297. title: 'DSH TUI snapshot',
  298. welcome: `Recorded replay: ${scenario.name}`,
  299. maxToolOutputLines: 8,
  300. }, {
  301. terminal,
  302. exit: () => {},
  303. formatCwd: () => displayCwd,
  304. })
  305. await settleTerminal(terminal)
  306. let interactionSnapshot: string | undefined
  307. if (scenario.interaction === 'skill-invocation-policy') {
  308. terminal.send('/skill')
  309. await settleTerminal(terminal)
  310. const discovery = normalizeTerminalSnapshot(
  311. await terminal.snapshot({ includeScrollback: true }),
  312. cwd,
  313. displayCwd,
  314. )
  315. expect(discovery).toContain('user-only-skill')
  316. expect(discovery).not.toContain('model-only-skill')
  317. terminal.send('\x03')
  318. await settleTerminal(terminal)
  319. const skillContext = ctx
  320. const skillTurnEnded = new Promise<void>((resolve) => {
  321. const detach = skillContext.on('session/event', (session, event) => {
  322. if (session !== agent.session || event.type !== 'turn/end') return
  323. detach()
  324. resolve()
  325. })
  326. })
  327. terminal.send('/skill:user-only-skill')
  328. terminal.send('\r')
  329. await skillTurnEnded
  330. await agent.whenIdle()
  331. await settleTerminal(terminal)
  332. const loaded = normalizeTerminalSnapshot(
  333. await terminal.snapshot({ includeScrollback: true }),
  334. cwd,
  335. displayCwd,
  336. )
  337. expect(loaded).toContain('USER-ONLY SKILL LOADED')
  338. terminal.send('/skill:model-only-skill')
  339. terminal.send('\r')
  340. await settleTerminal(terminal)
  341. const denied = normalizeTerminalSnapshot(
  342. await terminal.snapshot({ includeScrollback: true }),
  343. cwd,
  344. displayCwd,
  345. )
  346. expect(denied).toContain('model-only-skill')
  347. expect(denied).toContain('not available for user invocation.')
  348. expect(denied).not.toContain('MODEL-ONLY BODY MUST NOT LOAD')
  349. interactionSnapshot = [
  350. '=== skill autocomplete ===',
  351. discovery,
  352. '',
  353. '=== loaded exact invocation ===',
  354. loaded,
  355. '',
  356. '=== denied exact invocation ===',
  357. denied,
  358. ].join('\n')
  359. }
  360. let remainingPrompts = prompts
  361. if (scenario.enterPlanMode === true) {
  362. const firstPrompt = prompts[0]!
  363. terminal.send(`/plan ${firstPrompt}`)
  364. terminal.send('\r')
  365. await agent.whenIdle()
  366. await settleTerminal(terminal)
  367. remainingPrompts = prompts.slice(1)
  368. }
  369. if (scenario.leavePlanModeAfterFirstTurn === true) {
  370. terminal.send('/plan off')
  371. terminal.send('\r')
  372. await settleTerminal(terminal)
  373. }
  374. for (const prompt of remainingPrompts) {
  375. terminal.send(prompt)
  376. terminal.send('\r')
  377. await agent.whenIdle()
  378. await settleTerminal(terminal)
  379. }
  380. const events: SessionEvent[] = [...agent.session.events]
  381. const firstHeader = events.find(event => event.type === 'request/header')
  382. expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
  383. .toContain(FILE_REFERENCE_PROMPT)
  384. expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools)
  385. for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
  386. expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
  387. }
  388. if (scenario.enterPlanMode === true) {
  389. expect(ctx.planMode.get(agent)).toEqual({
  390. active: scenario.leavePlanModeAfterFirstTurn !== true,
  391. })
  392. const planMode = events.find(event => event.type === 'plan/mode')
  393. if (planMode === undefined || firstHeader === undefined) {
  394. throw new Error('plan-mode command snapshot needs plan/mode before its first request/header')
  395. }
  396. expect(planMode.seq).toBeLessThan(firstHeader.seq)
  397. expect(firstHeader.data.header.system).toContain('Snapshot plan mode instructions.')
  398. const firstMessage = events.find(event => event.type === 'user/message')
  399. expect(firstMessage?.data.content).toEqual([{ type: 'text', text: prompts[0] }])
  400. }
  401. if (scenario.leavePlanModeAfterFirstTurn === true) {
  402. const planModes = events.filter(event => event.type === 'plan/mode')
  403. expect(planModes.map(event => event.data.active)).toEqual([true, false])
  404. const headers = events.filter(event => event.type === 'request/header')
  405. const exit = planModes[1]
  406. const afterExit = headers[1]
  407. if (exit === undefined || afterExit === undefined) {
  408. throw new Error('active plan exit snapshot needs a committed exit and changed request header')
  409. }
  410. expect(exit.seq).toBeLessThan(afterExit.seq)
  411. expect(afterExit.data.header.system).not.toContain('Snapshot plan mode instructions.')
  412. expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
  413. .toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
  414. }
  415. if (scenario.spillMaxInlineBytes !== undefined) {
  416. // The REAL pipeline ran (tools execute on replay too): the durable
  417. // dispatch copy is bounded to a preview + locator under the run cwd,
  418. // while the outer result still carries the program's whole value.
  419. const dispatch = events.find(event => (event.type as string) === 'tool/code-dispatch')
  420. const content = (dispatch?.data as { content: { type: string; text?: string }[] }).content
  421. const text = content.filter(block => block.type === 'text').map(block => block.text ?? '').join('')
  422. expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(scenario.spillMaxInlineBytes)
  423. expect(text).toContain('Full formatted result stored at:')
  424. expect(text).toContain('.spill')
  425. }
  426. expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.message.content[0].isError)).toBe(true)
  427. expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true)
  428. if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') {
  429. expect(workflowEvents).toEqual([
  430. 'workflow/start',
  431. 'workflow/phase',
  432. 'workflow/agent-start',
  433. 'workflow/agent-end',
  434. 'workflow/end',
  435. ])
  436. }
  437. expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([])
  438. const snapshot = interactionSnapshot ?? normalizeTerminalSnapshot(
  439. await terminal.snapshot({ includeScrollback: true }),
  440. cwd,
  441. displayCwd,
  442. )
  443. await handle.dispose()
  444. const children = disposedSessions
  445. .filter(session => session !== agent.session)
  446. .sort((a, b) => a.header.createdAt - b.header.createdAt)
  447. expect(children).toHaveLength(scenario.childSessions ?? 0)
  448. return { terminal: snapshot, parent: agent.session, children, workflowEvents }
  449. } finally {
  450. await controller?.dispose()
  451. await ctx?.fiber.dispose()
  452. await terminal.dispose()
  453. await rm(cwd, { recursive: true, force: true })
  454. clock.mockRestore()
  455. }
  456. }
  457. async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise<void> {
  458. const dir = scenarioDir(scenario)
  459. await mkdir(dir, { recursive: true })
  460. await writeFile(
  461. join(dir, 'session.jsonl'),
  462. scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(result.parent))),
  463. )
  464. expect(result.children).toHaveLength(scenario.childSessions ?? 0)
  465. for (const [index, child] of result.children.entries()) {
  466. await writeFile(
  467. join(dir, `session.${index + 1}.jsonl`),
  468. scrubRequestHeaders(tokenizeSessionFixtureCwd(rawSessionLog(child))),
  469. )
  470. }
  471. }
  472. describe('TUI recorded-session terminal snapshots', () => {
  473. for (const scenario of SCENARIOS) {
  474. it(scenario.name, async () => {
  475. observedScenarios.add(scenario.name)
  476. const result = await runScenario(scenario)
  477. const terminalFile = join(scenarioDir(scenario), 'terminal.expected.txt')
  478. if (MODE === 'record' || MODE === 'refresh') {
  479. await mkdir(scenarioDir(scenario), { recursive: true })
  480. await writeFile(terminalFile, result.terminal)
  481. }
  482. if (MODE === 'record' && scenario.recorded) await writeRecording(scenario, result)
  483. await expect(result.terminal).toMatchFileSnapshot(terminalFile)
  484. }, 120_000)
  485. }
  486. })
  487. afterAll(async () => {
  488. expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
  489. const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true }))
  490. .filter(entry => entry.isDirectory())
  491. .map(entry => entry.name)
  492. .sort()
  493. expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
  494. for (const scenario of SCENARIOS) {
  495. const expected = [
  496. 'session.jsonl',
  497. 'terminal.expected.txt',
  498. ...scenario.seedWorkspace === true ? ['workspace'] : [],
  499. ...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`),
  500. ].sort()
  501. expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected)
  502. for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) {
  503. const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8')
  504. expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content)
  505. }
  506. }
  507. })