tui.snapshot.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  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 } from 'vitest'
  6. import { Context } from 'cordis'
  7. import { scrubRequestHeaders } 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 WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker'
  12. import CommandService from '@deepseek-ai/dsh-commands'
  13. import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
  14. import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
  15. import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
  16. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  17. import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  18. import PlanModeService from '@deepseek-ai/dsh-plan-mode'
  19. import TokenMeterService from '@deepseek-ai/dsh-token-meter'
  20. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  21. import { SessionId } 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 } from '@deepseek-ai/dsh-tui'
  30. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  31. import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
  32. import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts'
  33. const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  34. // Keep pre-normalization layout widths identical across macOS and Linux.
  35. const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp'
  36. const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }]
  37. const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
  38. type SnapshotMode = 'replay' | 'record' | 'refresh'
  39. type Composition = 'native' | 'code' | 'advanced'
  40. interface Scenario {
  41. name: string
  42. composition: Composition
  43. expectedTools: string[]
  44. expectedEventCounts?: Record<string, number>
  45. childSessions?: number
  46. enterPlanMode?: boolean
  47. leavePlanModeAfterFirstTurn?: boolean
  48. recorded: boolean
  49. seedWorkspace?: boolean
  50. /**
  51. * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent
  52. * config omits it, so only the todo-plan scenario (the enabled-path proof)
  53. * mounts it; the rest cover the default, todo-free composition.
  54. */
  55. enableTodo?: boolean
  56. }
  57. const SCENARIOS: Scenario[] = [
  58. {
  59. name: 'multi-turn-conversation',
  60. composition: 'native',
  61. expectedTools: [],
  62. expectedEventCounts: { 'plan/mode': 2 },
  63. enterPlanMode: true,
  64. leavePlanModeAfterFirstTurn: true,
  65. recorded: true,
  66. },
  67. {
  68. name: 'todo-plan',
  69. composition: 'native',
  70. expectedTools: ['todo_write'],
  71. expectedEventCounts: { 'todo/write': 1 },
  72. recorded: true,
  73. enableTodo: true,
  74. },
  75. {
  76. name: 'bash-terminal-card',
  77. composition: 'native',
  78. expectedTools: ['bash'],
  79. recorded: true,
  80. },
  81. {
  82. name: 'parallel-file-reads',
  83. composition: 'native',
  84. expectedTools: ['read', 'read'],
  85. recorded: true,
  86. seedWorkspace: true,
  87. },
  88. {
  89. name: 'code-mode',
  90. composition: 'code',
  91. expectedTools: ['run_code'],
  92. expectedEventCounts: { 'tool/code-dispatch': 2 },
  93. recorded: true,
  94. },
  95. {
  96. name: 'dynamic-workflow',
  97. composition: 'native',
  98. expectedTools: ['workflow'],
  99. childSessions: 1,
  100. recorded: true,
  101. },
  102. {
  103. name: 'cordis-dynamic-toolchain',
  104. composition: 'advanced',
  105. expectedTools: ['cordis_mount', 'run_code', 'subagent', 'workflow', 'cordis_unmount'],
  106. expectedEventCounts: { 'tool/code-dispatch': 1 },
  107. childSessions: 2,
  108. recorded: false,
  109. },
  110. ]
  111. function snapshotModeFromEnv(value: string | undefined): SnapshotMode {
  112. if (value === undefined || value === '' || value === 'replay') return 'replay'
  113. if (value === 'record' || value === 'refresh') return value
  114. throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
  115. }
  116. const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT)
  117. const observedScenarios = new Set<string>()
  118. function snapshotDisplayPath(displayPath: string, cwd: string, displayCwd: string): string {
  119. const rel = relative(cwd, displayPath)
  120. if (rel === '') return displayCwd
  121. if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return displayPath
  122. return `${displayCwd}/${rel.split(sep).join('/')}`
  123. }
  124. function scenarioDir(scenario: Scenario): string {
  125. return join(SNAPSHOTS_DIR, scenario.name)
  126. }
  127. function childFixturePaths(scenario: Scenario): string[] {
  128. return Array.from(
  129. { length: scenario.childSessions ?? 0 },
  130. (_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`),
  131. )
  132. }
  133. function userPrompts(rawLog: string): string[] {
  134. return parseSessionLog(rawLog).flatMap((event) => {
  135. if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
  136. const text = event.data.content
  137. .filter(block => block.type === 'text')
  138. .map(block => block.text)
  139. .join('')
  140. return text.length > 0 ? [text] : []
  141. })
  142. }
  143. function rawSessionLog(session: Session): string {
  144. return [
  145. JSON.stringify({ type: 'session', ...session.header }),
  146. ...session.events.map(event => JSON.stringify(event)),
  147. '',
  148. ].join('\n')
  149. }
  150. function normalizeTerminalSnapshot(snapshot: string, cwd: string, displayCwd: string): string {
  151. return snapshot
  152. .split(`/private${cwd}`).join('/workspace/project')
  153. .split(displayCwd).join('/workspace/project')
  154. .split(cwd).join('/workspace/project')
  155. .replace(UUID_RE, '{{uuid}}')
  156. }
  157. async function settleTerminal(terminal: HeadlessTerminal): Promise<void> {
  158. let stable = 0
  159. for (let attempt = 0; attempt < 20 && stable < 3; attempt++) {
  160. const before = terminal.frames
  161. await new Promise(resolve => setTimeout(resolve, 10))
  162. await terminal.flush()
  163. stable = terminal.frames === before ? stable + 1 : 0
  164. }
  165. if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms')
  166. }
  167. async function mountScenarioContext(
  168. scenario: Scenario,
  169. cwd: string,
  170. displayCwd: string,
  171. fixtureFile: string,
  172. childFiles: string[],
  173. ): Promise<Context> {
  174. class SnapshotLocalFileSystem extends LocalFileSystem {
  175. override async resolve(
  176. path: string,
  177. opts?: { cwd?: string; signal?: AbortSignal },
  178. ): Promise<Awaited<ReturnType<LocalFileSystem['resolve']>>> {
  179. const target = await super.resolve(path, opts)
  180. return { ...target, displayPath: snapshotDisplayPath(target.displayPath, cwd, displayCwd) }
  181. }
  182. }
  183. const ctx = new Context()
  184. await ctx.plugin(AgentCore, {
  185. agents: [],
  186. dshHome: join(cwd, '.dsh'),
  187. workspaceContext: false,
  188. tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' },
  189. skills: { local: { agentsHome: join(cwd, '.agents') } },
  190. })
  191. await ctx.plugin(TokenMeterService)
  192. await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
  193. await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' })
  194. await ctx.plugin(FsPolicy)
  195. await ctx.plugin(ToolFs)
  196. await ctx.plugin(UserInteractionService)
  197. // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped
  198. // config that omits it. The other scenarios prove the default todo-free composition.
  199. if (scenario.enableTodo === true) await ctx.plugin(ToolTodo)
  200. await ctx.plugin(SubagentService)
  201. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  202. await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false })
  203. await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' })
  204. await ctx.plugin(ToolWorkflow)
  205. await ctx.plugin(ToolRalph)
  206. await ctx.plugin(CommandService)
  207. if (scenario.enterPlanMode === true) {
  208. await ctx.plugin(PlanModeService, { section: 'Snapshot plan mode instructions.' })
  209. }
  210. if (scenario.composition === 'code' || scenario.composition === 'advanced') {
  211. await ctx.plugin(WorkerCodeRuntime, {})
  212. }
  213. if (scenario.composition === 'advanced') await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
  214. if (MODE === 'record' && scenario.recorded) {
  215. await ctx.plugin(LlmDeepSeek)
  216. } else {
  217. installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS })
  218. }
  219. return ctx
  220. }
  221. interface ScenarioResult {
  222. terminal: string
  223. parent: Session
  224. children: Session[]
  225. workflowEvents: string[]
  226. }
  227. async function runScenario(scenario: Scenario): Promise<ScenarioResult> {
  228. const dir = scenarioDir(scenario)
  229. const fixtureFile = join(dir, 'session.jsonl')
  230. const childFiles = childFixturePaths(scenario)
  231. const fixture = await readFile(fixtureFile, 'utf8')
  232. const prompts = userPrompts(fixture)
  233. expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0)
  234. const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`))
  235. const displayCwd = `/tmp/${basename(cwd)}`
  236. let ctx: Context | undefined
  237. let controller: ReturnType<typeof createTuiChat> | undefined
  238. const terminal = new HeadlessTerminal(100, 36)
  239. try {
  240. if (scenario.seedWorkspace === true) {
  241. const source = join(scenarioDir(scenario), 'workspace')
  242. await cp(source, cwd, { recursive: true })
  243. }
  244. ctx = await mountScenarioContext(scenario, cwd, displayCwd, fixtureFile, childFiles)
  245. const disposedSessions: Session[] = []
  246. ctx.on('session/disposed', (session) => { disposedSessions.push(session) })
  247. const workflowEvents: string[] = []
  248. for (const name of ['workflow/start', 'workflow/phase', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) {
  249. ctx.on(name, () => { workflowEvents.push(name) })
  250. }
  251. const handle = await ctx.agents.create({
  252. sessionId: SessionId('main-session'),
  253. meta: { cwd },
  254. agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
  255. })
  256. const agent: Agent = handle.agent
  257. controller = createTuiChat(ctx, {
  258. sessionId: 'main-session',
  259. color: true,
  260. showReasoning: true,
  261. title: 'DSH TUI snapshot',
  262. welcome: `Recorded replay: ${scenario.name}`,
  263. maxToolOutputLines: 8,
  264. }, {
  265. terminal,
  266. exit: () => {},
  267. formatCwd: () => displayCwd,
  268. })
  269. await settleTerminal(terminal)
  270. let remainingPrompts = prompts
  271. if (scenario.enterPlanMode === true) {
  272. const firstPrompt = prompts[0]!
  273. terminal.send(`/plan ${firstPrompt}`)
  274. terminal.send('\r')
  275. await agent.whenIdle()
  276. await settleTerminal(terminal)
  277. remainingPrompts = prompts.slice(1)
  278. }
  279. if (scenario.leavePlanModeAfterFirstTurn === true) {
  280. terminal.send('/plan off')
  281. terminal.send('\r')
  282. await settleTerminal(terminal)
  283. }
  284. for (const prompt of remainingPrompts) {
  285. terminal.send(prompt)
  286. terminal.send('\r')
  287. await agent.whenIdle()
  288. await settleTerminal(terminal)
  289. }
  290. const events: SessionEvent[] = [...agent.session.events]
  291. const firstHeader = events.find(event => event.type === 'request/header')
  292. expect(firstHeader?.type === 'request/header' && firstHeader.data.header.system)
  293. .toContain(FILE_REFERENCE_PROMPT)
  294. expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools)
  295. for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) {
  296. expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count)
  297. }
  298. if (scenario.enterPlanMode === true) {
  299. expect(ctx.planMode.get(agent)).toEqual({
  300. active: scenario.leavePlanModeAfterFirstTurn !== true,
  301. })
  302. const planMode = events.find(event => event.type === 'plan/mode')
  303. if (planMode === undefined || firstHeader === undefined) {
  304. throw new Error('plan-mode command snapshot needs plan/mode before its first request/header')
  305. }
  306. expect(planMode.seq).toBeLessThan(firstHeader.seq)
  307. expect(firstHeader.data.header.system).toContain('Snapshot plan mode instructions.')
  308. const firstMessage = events.find(event => event.type === 'user/message')
  309. expect(firstMessage?.data.content).toEqual([{ type: 'text', text: prompts[0] }])
  310. }
  311. if (scenario.leavePlanModeAfterFirstTurn === true) {
  312. const planModes = events.filter(event => event.type === 'plan/mode')
  313. expect(planModes.map(event => event.data.active)).toEqual([true, false])
  314. const headers = events.filter(event => event.type === 'request/header')
  315. const exit = planModes[1]
  316. const afterExit = headers[1]
  317. if (exit === undefined || afterExit === undefined) {
  318. throw new Error('active plan exit snapshot needs a committed exit and changed request header')
  319. }
  320. expect(exit.seq).toBeLessThan(afterExit.seq)
  321. expect(afterExit.data.header.system).not.toContain('Snapshot plan mode instructions.')
  322. expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content))
  323. .toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }])
  324. }
  325. expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true)
  326. expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true)
  327. if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') {
  328. expect(workflowEvents).toEqual([
  329. 'workflow/start',
  330. 'workflow/phase',
  331. 'workflow/agent-start',
  332. 'workflow/agent-end',
  333. 'workflow/end',
  334. ])
  335. }
  336. expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([])
  337. const snapshot = normalizeTerminalSnapshot(
  338. await terminal.snapshot({ includeScrollback: true }),
  339. cwd,
  340. displayCwd,
  341. )
  342. await handle.dispose()
  343. const children = disposedSessions
  344. .filter(session => session !== agent.session)
  345. .sort((a, b) => a.header.createdAt - b.header.createdAt)
  346. expect(children).toHaveLength(scenario.childSessions ?? 0)
  347. return { terminal: snapshot, parent: agent.session, children, workflowEvents }
  348. } finally {
  349. await controller?.dispose()
  350. await ctx?.fiber.dispose()
  351. await terminal.dispose()
  352. await rm(cwd, { recursive: true, force: true })
  353. }
  354. }
  355. async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise<void> {
  356. const dir = scenarioDir(scenario)
  357. await mkdir(dir, { recursive: true })
  358. await writeFile(join(dir, 'session.jsonl'), scrubRequestHeaders(rawSessionLog(result.parent)))
  359. expect(result.children).toHaveLength(scenario.childSessions ?? 0)
  360. for (const [index, child] of result.children.entries()) {
  361. await writeFile(join(dir, `session.${index + 1}.jsonl`), scrubRequestHeaders(rawSessionLog(child)))
  362. }
  363. }
  364. describe('TUI recorded-session terminal snapshots', () => {
  365. for (const scenario of SCENARIOS) {
  366. it(scenario.name, async () => {
  367. observedScenarios.add(scenario.name)
  368. const result = await runScenario(scenario)
  369. const terminalFile = join(scenarioDir(scenario), 'terminal.expected.txt')
  370. if (MODE === 'record' || MODE === 'refresh') {
  371. await mkdir(scenarioDir(scenario), { recursive: true })
  372. await writeFile(terminalFile, result.terminal)
  373. }
  374. if (MODE === 'record' && scenario.recorded) await writeRecording(scenario, result)
  375. await expect(result.terminal).toMatchFileSnapshot(terminalFile)
  376. }, 120_000)
  377. }
  378. })
  379. afterAll(async () => {
  380. expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
  381. const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true }))
  382. .filter(entry => entry.isDirectory())
  383. .map(entry => entry.name)
  384. .sort()
  385. expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort())
  386. for (const scenario of SCENARIOS) {
  387. const expected = [
  388. 'session.jsonl',
  389. 'terminal.expected.txt',
  390. ...scenario.seedWorkspace === true ? ['workspace'] : [],
  391. ...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`),
  392. ].sort()
  393. expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected)
  394. for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) {
  395. const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8')
  396. expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content)
  397. }
  398. }
  399. })