tui-keyless-smoke.e2e.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises'
  2. import { dirname, join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import { describe, expect, it } from 'vitest'
  5. import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
  6. import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
  7. import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
  8. import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
  9. const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url))
  10. const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url))
  11. const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
  12. const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url))
  13. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
  14. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  15. /**
  16. * Seed the isolated process workspace: ordinary files land in `cwd`, personal
  17. * files in the Harness home (`.dsh`), and skill bundles under the agents
  18. * home's `skills/` root — the same trees `$DSH_HOME` /
  19. * `$DSH_AGENTS_HOME` point the child at.
  20. */
  21. function seedWorkspace(
  22. files: {
  23. workspace?: Record<string, string>
  24. personal?: Record<string, string>
  25. skills?: Record<string, string>
  26. },
  27. ): (cwd: string) => Promise<void> {
  28. return async (cwd) => {
  29. for (const [name, content] of Object.entries(files.workspace ?? {})) {
  30. const file = join(cwd, name)
  31. await mkdir(dirname(file), { recursive: true })
  32. await writeFile(file, content)
  33. }
  34. for (const [name, content] of Object.entries(files.personal ?? {})) {
  35. const file = join(cwd, '.dsh', name)
  36. await mkdir(dirname(file), { recursive: true })
  37. await writeFile(file, content)
  38. }
  39. for (const [name, content] of Object.entries(files.skills ?? {})) {
  40. const file = join(cwd, '.agents', 'skills', name)
  41. await mkdir(dirname(file), { recursive: true })
  42. await writeFile(file, content)
  43. }
  44. }
  45. }
  46. /** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
  47. async function seedResumeSession(cwd: string): Promise<void> {
  48. const sessionCwd = await realpath(cwd)
  49. const id = SessionId('resume-target')
  50. const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd }
  51. const events: SessionEvent[] = [
  52. { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  53. { type: 'user/message', seq: 1, time: 1_700_000_000_002, data: { content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' } }, surfaceOp: 'append' },
  54. { type: 'step/start', seq: 2, time: 1_700_000_000_003, data: { turn: 1, step: 1 } },
  55. { type: 'request/header', seq: 3, time: 1_700_000_000_004, data: { header: { config: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, reason: 'initial' } },
  56. { type: 'assistant/message', seq: 4, time: 1_700_000_000_005, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'persisted answer' }], provenance: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, surfaceOp: 'append' },
  57. { type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } },
  58. { type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
  59. { type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } },
  60. { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } },
  61. ]
  62. const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none')
  63. await mkdir(dirname(file), { recursive: true })
  64. await writeFile(file, [
  65. JSON.stringify(toHeaderLine(meta)),
  66. ...events.map(event => JSON.stringify(event)),
  67. '',
  68. ].join('\n'))
  69. }
  70. /** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */
  71. async function readLoggedSystemPrompt(cwd: string): Promise<string> {
  72. const sessionsDir = join(cwd, '.sessions')
  73. const entries = await readdir(sessionsDir, { recursive: true })
  74. // A single keyless run writes one session log; the source section is global, so any log carries it.
  75. const logRelPath = entries.find(name => name.endsWith('.jsonl'))
  76. if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
  77. const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
  78. for (const line of lines) {
  79. const event = JSON.parse(line) as { type: string; data: { header?: { system?: string } } }
  80. if (event.type === 'request/header') return event.data.header?.system ?? ''
  81. }
  82. throw new Error(`session log ${logRelPath} has no request/header event`)
  83. }
  84. /** Shared defaults: the keyless key, the tui-demo bin, and the live cordis.yml. */
  85. function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
  86. return runTuiPtySmoke({
  87. tempDirPrefix: 'tui-agent-smoke-',
  88. binScript,
  89. configPath,
  90. tsconfigPath,
  91. env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
  92. ...overrides,
  93. })
  94. }
  95. // The scripted conversation switches to the pro model first: the scripted
  96. // adapter proves routing + prompt variables by rejecting tool-ful calls on any
  97. // other route (see fixtures/tui-scripted-llm.ts).
  98. const SELECT_PRO_MODEL = [
  99. { waitFor: 'scripted TUI ready.', send: '/model\r' },
  100. { waitFor: 'Select model', send: '\x1b[B\r' },
  101. ] as const
  102. describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => {
  103. it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => {
  104. // With no configured welcome the borderless banner sweeps in left-to-right;
  105. // the detail line's session id (`main-session-<uuid>`) renders only once
  106. // the sweep reaches it, so it marks a settled banner.
  107. const output = await smoke({
  108. label: 'tui-agent boot',
  109. actions: [
  110. { waitFor: 'main-session-', send: '/plan' },
  111. { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' },
  112. { waitFor: 'Entering plan mode (applies from the next step). Use /plan off to leave.', send: '/exit\r' },
  113. ],
  114. })
  115. expect(output).toContain('DEEPSEEK')
  116. expect(output).toContain('HARNESS')
  117. expect(output).toContain('main-session-')
  118. expect(output).toContain('[off|message] — Enter or leave plan mode')
  119. expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.')
  120. // Borderless: no box-drawing frame around the banner.
  121. expect(output).not.toContain('╭')
  122. expect(output).not.toContain('╮')
  123. expect(output).toContain('\u001B[?2004l')
  124. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  125. it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
  126. const output = await smoke({
  127. label: 'tui-agent conversation',
  128. tempDirPrefix: 'tui-agent-conversation-',
  129. configPath: scriptedConfigPath,
  130. actions: [
  131. ...SELECT_PRO_MODEL,
  132. { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan exercise the TUI\r' },
  133. // The question text first appears in the streamed tool-call card. Wait
  134. // for the dialog's input legend so Enter cannot arrive before it owns
  135. // terminal input when pre-dispatch policy yields.
  136. { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' },
  137. { waitFor: 'Decision received. Scripted TUI run complete.', send: '' },
  138. // Session title: the first user message drives the first-message-llm
  139. // provider's tool-less title call; the scripted adapter answers it, the
  140. // accepted title lands in the log, and the TUI renders the terminal
  141. // window title as `<session title> — <configured title>` via OSC 0.
  142. // Gating /status on it keeps the assertion race-free; the diagnostics
  143. // card is then exercised through the same real Loader/PTY composition.
  144. { waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' },
  145. { waitFor: 'Leaving plan mode (applies from the next step).', send: 'Confirm the scripted run left plan mode.\r' },
  146. { waitFor: 'Default mode confirmed.', send: '/status\r' },
  147. { waitFor: 'Session status', send: '/exit\r' },
  148. ],
  149. })
  150. expect(output).toContain('I need one decision before I continue.')
  151. expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.')
  152. expect(output).toContain('Leaving plan mode (applies from the next step).')
  153. expect(output).toContain('Default mode confirmed.')
  154. expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
  155. expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
  156. expect(output).toContain(String.raw`\x9b31mMODEL_C1`)
  157. expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007')
  158. expect(output).not.toContain('\u001B[999CMODEL_CURSOR')
  159. expect(output).not.toContain('\u009B31mMODEL_C1')
  160. expect(output).toContain('Safe')
  161. expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007')
  162. expect(output).toContain('Session status')
  163. expect(output).toContain('Title')
  164. expect(output).toContain('scripted session title')
  165. expect(output).toContain('Model')
  166. expect(output).toContain('tui-scripted/tui-scripted-model-pro')
  167. expect(output).toContain('KV cache')
  168. expect(output).toContain('Context')
  169. expect(output).toContain('128,000')
  170. expect(output).toContain('\u001B[?2004l')
  171. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  172. it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
  173. // The whole manual-invocation path in one keyless boot: `ctx.get('skills')`
  174. // resolves in the shipped tree, the client-side `/skill:` command parses,
  175. // the local provider loads `scripted-skill` from the agents home, and the
  176. // rendered `<skill name="…">` block reaches the model — proven by the
  177. // scripted adapter echoing the fixture's body marker only when it arrives.
  178. const output = await smoke({
  179. label: 'tui-agent skill',
  180. tempDirPrefix: 'tui-agent-skill-',
  181. configPath: scriptedConfigPath,
  182. prepare: seedWorkspace({
  183. skills: {
  184. 'scripted-skill/SKILL.md': [
  185. '---',
  186. 'name: scripted-skill',
  187. 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
  188. '---',
  189. '',
  190. 'SCRIPTED SKILL BODY MARKER',
  191. '',
  192. ].join('\n'),
  193. },
  194. }),
  195. actions: [
  196. ...SELECT_PRO_MODEL,
  197. { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/skill:scripted-skill\r' },
  198. { waitFor: 'Scripted skill body received.', send: '/exit\r' },
  199. ],
  200. })
  201. expect(output).toContain('Scripted skill body received.')
  202. expect(output).toContain('\u001B[?2004l')
  203. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  204. it('fuzzy-completes an @file path without reading or submitting the file', async () => {
  205. const output = await smoke({
  206. label: 'tui-agent file autocomplete',
  207. tempDirPrefix: 'tui-agent-file-autocomplete-',
  208. prepare: seedWorkspace({
  209. workspace: {
  210. 'src/terminal-special-case.ts': 'export const marker = true\n',
  211. 'src/other.ts': 'export const other = true\n',
  212. },
  213. }),
  214. actions: [
  215. { waitFor: 'main-session-', send: '@tsc' },
  216. { waitFor: 'File · terminal-special-case.t', send: '\t' },
  217. { waitFor: '@src/terminal-special-case.ts', send: '\x03/exit\r' },
  218. ],
  219. })
  220. expect(output).toContain('File · terminal-special-case.t')
  221. expect(output).toContain('@src/terminal-special-case.ts')
  222. expect(output).toContain('\u001B[?2004l')
  223. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  224. it('boots the Code Mode overlay tree, renders its banner, and exits cleanly', async () => {
  225. // The overlay's only keyless composition proof: the include+patch tree,
  226. // worker code runtime, and one-tool registry all mount before the banner.
  227. const output = await smoke({
  228. label: 'tui-agent code mode',
  229. tempDirPrefix: 'tui-agent-code-mode-',
  230. configPath: codeModeConfigPath,
  231. actions: [{ waitFor: 'TUI Code Mode ready.', send: '/exit\r' }],
  232. })
  233. expect(output).toContain('TUI Code Mode ready.')
  234. expect(output).toContain('\u001B[?2004l')
  235. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  236. it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => {
  237. const output = await smoke({
  238. label: 'tui-agent resume failure',
  239. tempDirPrefix: 'tui-agent-resume-',
  240. env: {
  241. DEEPSEEK_API_KEY: 'keyless-tui-no-call',
  242. RESUME_SESSION_ID: 'missing-session',
  243. },
  244. expectedExitCode: 1,
  245. })
  246. expect(output).toContain('ui-tui: session "missing-session" failed to start:')
  247. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  248. })
  249. describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
  250. it('exec-replaces the TUI for /resume and restores the same session state', async () => {
  251. const output = await smoke({
  252. label: 'dsh in-place resume',
  253. tempDirPrefix: 'dsh-in-place-resume-',
  254. binScript: dshBinScript,
  255. configArgs: [scriptedConfigPath],
  256. prepare: seedResumeSession,
  257. actions: [
  258. { waitFor: 'scripted TUI ready.', send: '/resume\r' },
  259. { waitFor: 'Resume selector design', send: 'Resume selector design' },
  260. { waitFor: '⌕ Resume selector design', send: '\r' },
  261. { waitFor: 'Preserve restored state', send: '/exit\r' },
  262. ],
  263. })
  264. const released = output.indexOf('\u001B[?2004l')
  265. const restored = output.indexOf('Resume selector design — DeepSeek Harness')
  266. expect(released).toBeGreaterThanOrEqual(0)
  267. expect(restored).toBeGreaterThan(released)
  268. expect(output).toContain('Preserve restored state')
  269. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  270. it('boots the shipped default config with no arguments and no personal overlay', async () => {
  271. const output = await smoke({
  272. label: 'dsh default boot',
  273. tempDirPrefix: 'dsh-default-boot-',
  274. binScript: dshBinScript,
  275. configArgs: [],
  276. actions: [{ waitFor: 'main-session-', send: '/exit\r' }],
  277. })
  278. expect(output).toContain('DEEPSEEK')
  279. expect(output).toContain('main-session-')
  280. expect(output).not.toContain('╭')
  281. expect(output).not.toContain('╮')
  282. expect(output).toContain('\u001B[?2004l')
  283. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  284. it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => {
  285. // The whole personal-config chain in one boot: the personal .env supplies
  286. // the variable, config.yaml patches the tui-agent entry with a `!!js`
  287. // reference to it, and the banner renders the patched welcome verbatim.
  288. const output = await smoke({
  289. label: 'dsh personal overlay',
  290. tempDirPrefix: 'dsh-personal-overlay-',
  291. binScript: dshBinScript,
  292. configArgs: [],
  293. prepare: seedWorkspace({
  294. personal: {
  295. '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
  296. 'config.yaml': [
  297. '- id: tui-agent',
  298. " name: '@deepseek-ai/dsh-tui-demo'",
  299. ' config:',
  300. ' provider: deepseek',
  301. ' model: deepseek-v4-flash',
  302. ' workspaceContext: false',
  303. ' welcome: !!js process.env.DSH_PERSONAL_WELCOME',
  304. '',
  305. ].join('\n'),
  306. },
  307. }),
  308. actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }],
  309. })
  310. expect(output).toContain('PERSONAL OVERLAY READY.')
  311. expect(output).toContain('\u001B[?2004l')
  312. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  313. it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
  314. const output = await smoke({
  315. label: 'dsh invalid personal config',
  316. tempDirPrefix: 'dsh-invalid-personal-',
  317. binScript: dshBinScript,
  318. configArgs: [],
  319. prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }),
  320. expectedExitCode: 1,
  321. })
  322. expect(output).toContain('must be a top-level YAML array of loader patch entries')
  323. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  324. it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => {
  325. // The flag path end to end: apps/cli parses `--resume missing-session` and
  326. // sets RESUME_SESSION_ID, the shipped config's `!!js` reads it, and the
  327. // resume fails loud — proving the printed `dsh --resume <id>` hint reaches
  328. // the same intake as the env var.
  329. const output = await smoke({
  330. label: 'dsh resume flag failure',
  331. tempDirPrefix: 'dsh-resume-flag-',
  332. binScript: dshBinScript,
  333. configArgs: ['--resume', 'missing-session'],
  334. expectedExitCode: 1,
  335. })
  336. expect(output).toContain('ui-tui: session "missing-session" failed to start:')
  337. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  338. it('tells the model where its own source lives, in the system prompt it sends', async () => {
  339. // The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
  340. // this test file sits an equal depth under the same root, so the same hop applies.
  341. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
  342. let loggedSystem = ''
  343. await smoke({
  344. label: 'dsh source-path prompt',
  345. tempDirPrefix: 'dsh-source-path-',
  346. binScript: dshBinScript,
  347. configArgs: [scriptedConfigPath],
  348. actions: [
  349. ...SELECT_PRO_MODEL,
  350. { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
  351. { waitFor: 'How should the scripted run proceed?', send: '\r' },
  352. { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
  353. ],
  354. inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) },
  355. })
  356. expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`)
  357. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  358. })