tui-keyless-smoke.e2e.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
  2. import { realpathSync } from 'node:fs'
  3. import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
  4. import { dirname, join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { describe, expect, it } from 'vitest'
  7. import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke'
  8. import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session'
  9. import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts'
  10. import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts'
  11. const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
  12. // `--config` layers an overlay over the shared base, so the default surface
  13. // needs no config argument at all; these are the overlays under test.
  14. const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url))
  15. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  16. /**
  17. * Seed the isolated process workspace: ordinary files land in `cwd`, personal
  18. * files in the Harness home (`.dsh`), and skill bundles under the agents
  19. * home's `skills/` root — the same trees `$DSH_HOME` /
  20. * `$DSH_AGENTS_HOME` point the child at.
  21. */
  22. function seedWorkspace(
  23. files: {
  24. workspace?: Record<string, string>
  25. personal?: Record<string, string>
  26. skills?: Record<string, string>
  27. },
  28. ): (cwd: string) => Promise<void> {
  29. return async (cwd) => {
  30. for (const [name, content] of Object.entries(files.workspace ?? {})) {
  31. const file = join(cwd, name)
  32. await mkdir(dirname(file), { recursive: true })
  33. await writeFile(file, content)
  34. }
  35. for (const [name, content] of Object.entries(files.personal ?? {})) {
  36. const file = join(cwd, '.dsh', name)
  37. await mkdir(dirname(file), { recursive: true })
  38. await writeFile(file, content)
  39. }
  40. for (const [name, content] of Object.entries(files.skills ?? {})) {
  41. const file = join(cwd, '.agents', 'skills', name)
  42. await mkdir(dirname(file), { recursive: true })
  43. await writeFile(file, content)
  44. }
  45. }
  46. }
  47. /** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */
  48. async function seedResumeSession(cwd: string): Promise<void> {
  49. const sessionCwd = realpathSync.native(cwd)
  50. const id = SessionId('resume-target')
  51. const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd }
  52. const events: SessionEvent[] = [
  53. { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  54. { type: 'user/message', seq: 1, time: 1_700_000_000_002, data: createUserMessage({
  55. content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' },
  56. }), surfaceOp: 'append' },
  57. { type: 'step/start', seq: 2, time: 1_700_000_000_003, data: { turn: 1, step: 1 } },
  58. { type: 'request/header', seq: 3, time: 1_700_000_000_004, data: { header: { config: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, reason: 'initial' } },
  59. { type: 'assistant/message', seq: 4, time: 1_700_000_000_005, data: {
  60. turn: 1, step: 1,
  61. message: createMessage({
  62. role: 'assistant',
  63. content: [{ type: 'text', text: 'persisted answer' }],
  64. source: {
  65. kind: 'model',
  66. ...{ provider: 'tui-scripted', model: 'tui-scripted-model' },
  67. },
  68. }),
  69. }, surfaceOp: 'append' },
  70. { type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } },
  71. { type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
  72. { type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } },
  73. { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } },
  74. ]
  75. const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none')
  76. await mkdir(dirname(file), { recursive: true })
  77. await writeFile(file, [
  78. JSON.stringify(toHeaderLine(meta)),
  79. ...packChunkRuns(events).map(record => JSON.stringify(record)),
  80. '',
  81. ].join('\n'))
  82. }
  83. /** Model-visible startup context from the first request in the workspace's persisted session log. */
  84. interface LoggedRequestContext {
  85. /** The system prompt string the launcher sends. */
  86. system: string
  87. /** The durable skill-catalog message serialized to text. */
  88. skillCatalog: string
  89. }
  90. async function readLoggedRequestContext(cwd: string): Promise<LoggedRequestContext> {
  91. const sessionsDir = join(cwd, '.sessions')
  92. const entries = await readdir(sessionsDir, { recursive: true })
  93. // A single keyless run writes one session log; the source section is global, so any log carries it.
  94. const logRelPath = entries.find(name => name.endsWith('.jsonl'))
  95. if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`)
  96. const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean)
  97. let skillCatalog = ''
  98. for (const line of lines) {
  99. const event = JSON.parse(line) as SessionEvent
  100. if (
  101. event.type === 'user/message'
  102. && event.data.source.kind === 'plugin'
  103. && event.data.source.plugin === 'dsh-tool-skill'
  104. ) {
  105. skillCatalog = JSON.stringify(event.data.content)
  106. }
  107. if (event.type === 'request/header') {
  108. return {
  109. system: event.data.header.system ?? '',
  110. skillCatalog,
  111. }
  112. }
  113. }
  114. throw new Error(`session log ${logRelPath} has no request/header event`)
  115. }
  116. /**
  117. * Shared defaults: the keyless key and the dsh bin. Each case supplies either
  118. * `configArgs: []` (boot the shipped composition, `base.cordis.yml` +
  119. * `tui.cordis.yml`, with no flags) or `configPath` (an overlay layered over that
  120. * same base through `--config`).
  121. */
  122. function smoke(overrides: Partial<TuiPtySmokeOptions> & { label: string }): Promise<string> {
  123. return runTuiPtySmoke({
  124. tempDirPrefix: 'dsh-tui-smoke-',
  125. binScript: dshBinScript,
  126. tsconfigPath,
  127. // Telemetry now mounts in the shared base: keep fixture sessions from
  128. // POSTing to the production endpoint when run outside CI's workflow env.
  129. env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' },
  130. // Artifact CI builds and smokes concurrently on a contended runner.
  131. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
  132. ...overrides,
  133. })
  134. }
  135. // The scripted conversation switches to the pro model first: the scripted
  136. // adapter proves routing + prompt variables by rejecting tool-ful calls on any
  137. // other route (see fixtures/tui-scripted-llm.ts).
  138. const SELECT_PRO_MODEL = [
  139. { waitFor: 'scripted TUI ready.', send: '/model\r' },
  140. { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' },
  141. ] as const
  142. describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => {
  143. it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => {
  144. // With no configured welcome the borderless banner sweeps in left-to-right;
  145. // the detail line's session id (`main-session-<uuid>`) renders only once
  146. // the sweep reaches it, so it marks a settled banner.
  147. const output = await smoke({
  148. label: 'dsh boot',
  149. configArgs: [],
  150. actions: [
  151. { waitFor: 'main-session-', send: '/plan' },
  152. { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' },
  153. { waitFor: 'Plan mode on. Use /plan off to leave.', send: '/exit\r' },
  154. ],
  155. })
  156. expect(output).toContain('DEEPSEEK')
  157. expect(output).toContain('HARNESS')
  158. expect(output).toContain('main-session-')
  159. expect(output).toContain('[off|message] — Enter or leave plan mode')
  160. expect(output).toContain('Plan mode on. Use /plan off to leave.')
  161. // Borderless: no box-drawing frame around the banner.
  162. expect(output).not.toContain('╭')
  163. expect(output).not.toContain('╮')
  164. expect(output).toContain('\u001B[?2004l')
  165. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  166. it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => {
  167. const output = await smoke({
  168. label: 'dsh conversation',
  169. tempDirPrefix: 'dsh-tui-conversation-',
  170. configPath: scriptedConfigPath,
  171. actions: [
  172. ...SELECT_PRO_MODEL,
  173. { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/plan exercise the TUI\r' },
  174. // The question text first appears in the streamed tool-call card. Wait
  175. // for the dialog's input legend so Enter cannot arrive before it owns
  176. // terminal input when pre-dispatch policy yields.
  177. { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' },
  178. { waitFor: 'Decision received. Scripted TUI run complete.', send: '' },
  179. // Session title: the first user message drives the first-message-llm
  180. // provider's tool-less title call; the scripted adapter answers it, the
  181. // accepted title lands in the log, and the TUI renders the terminal
  182. // window title as `<session title> — <configured title>` via OSC 0.
  183. // Gating /status on it keeps the assertion race-free; the diagnostics
  184. // card is then exercised through the same real Loader/PTY composition.
  185. { waitFor: 'scripted session title — DeepSeek Harness', send: '/plan off\r' },
  186. { waitFor: 'Plan mode off.', send: 'Confirm the scripted run left plan mode.\r' },
  187. { waitFor: 'Default mode confirmed.', send: '/status\r' },
  188. { waitFor: 'Session status', send: '/exit\r' },
  189. ],
  190. })
  191. expect(output).toContain('I need one decision before I continue.')
  192. expect(output).toContain('Reasoning effort: Max.')
  193. expect(output).toContain('Plan mode on. Use /plan off to leave.')
  194. expect(output).toContain('Plan mode off.')
  195. expect(output).toContain('Default mode confirmed.')
  196. expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`)
  197. expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`)
  198. expect(output).toContain(String.raw`\x9b31mMODEL_C1`)
  199. expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007')
  200. expect(output).not.toContain('\u001B[999CMODEL_CURSOR')
  201. expect(output).not.toContain('\u009B31mMODEL_C1')
  202. expect(output).toContain('Safe')
  203. expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007')
  204. expect(output).toContain('Session status')
  205. expect(output).toContain('Title')
  206. expect(output).toContain('scripted session title')
  207. expect(output).toContain('Model')
  208. expect(output).toContain('tui-scripted/tui-scripted-model-pro')
  209. expect(output).toContain('KV cache')
  210. expect(output).toContain('Context')
  211. expect(output).toContain('128,000')
  212. expect(output).toContain('System prompt')
  213. expect(output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.')
  214. expect(output).toContain('Registered tools')
  215. expect(output).toContain('ask_user_question')
  216. expect(output).toContain('\u001B[?2004l')
  217. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  218. it('loads a local skill via /skill: and delivers its body to the model as a user turn', async () => {
  219. // The whole user-only invocation path in one keyless boot: `ctx.get('skills')`
  220. // resolves in the shipped tree, the client-side `/skill:` command parses,
  221. // and the local provider admits a model-disabled skill by the omitted
  222. // `user-invocable` default. The rendered `<skill name="…">` block reaches
  223. // the model — proven by the scripted adapter echoing the fixture's body
  224. // marker only when it arrives.
  225. const output = await smoke({
  226. label: 'dsh skill',
  227. tempDirPrefix: 'dsh-tui-skill-',
  228. configPath: scriptedConfigPath,
  229. prepare: seedWorkspace({
  230. skills: {
  231. 'scripted-skill/SKILL.md': [
  232. '---',
  233. 'name: scripted-skill',
  234. 'description: Keyless PTY proof that the skill command loads a local skill into the conversation.',
  235. 'disable-model-invocation: true',
  236. '---',
  237. '',
  238. 'SCRIPTED SKILL BODY MARKER',
  239. '',
  240. ].join('\n'),
  241. },
  242. }),
  243. actions: [
  244. ...SELECT_PRO_MODEL,
  245. { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: '/skill:scripted-skill\r' },
  246. { waitFor: 'Scripted skill body received.', send: '/exit\r' },
  247. ],
  248. })
  249. expect(output).not.toContain('[instructions]')
  250. expect(output).toContain('Scripted skill body received.')
  251. expect(output).toContain('\u001B[?2004l')
  252. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  253. it('adds a watched local skill to live /skill: autocomplete without restarting', async () => {
  254. const skill = [
  255. '---',
  256. 'name: hot-added-skill',
  257. 'description: HOT_ADDED_COMPLETION_MARKER',
  258. '---',
  259. '',
  260. 'Hot-added body.',
  261. '',
  262. ].join('\n')
  263. const output = await smoke({
  264. label: 'tui-agent hot-added skill autocomplete',
  265. tempDirPrefix: 'tui-agent-hot-skill-',
  266. configPath: scriptedConfigPath,
  267. actions: [
  268. {
  269. waitFor: 'scripted TUI ready.',
  270. writeFile: {
  271. path: '.agents/skills/hot-added-skill/SKILL.md',
  272. content: skill,
  273. },
  274. send: '/skill:hot',
  275. },
  276. { waitFor: 'HOT_ADDED_COMPLETION_MARKER', send: '\x03/exit\r' },
  277. ],
  278. })
  279. expect(output).toContain('HOT_ADDED_COMPLETION_MARKER')
  280. expect(output).toContain('\u001B[?2004l')
  281. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  282. it.skipIf(process.env.DSH_EXAMPLE_MODE === 'lib')('fuzzy-completes an @file path without reading or submitting the file', async () => {
  283. const output = await smoke({
  284. label: 'dsh file autocomplete',
  285. tempDirPrefix: 'dsh-tui-file-autocomplete-',
  286. // Source-plane PTY coverage complements the deterministic package-level
  287. // autocomplete tests. Artifact CI omits this timing-sensitive terminal
  288. // rendering assertion; built boot is covered by the neighboring cases.
  289. configArgs: [],
  290. prepare: seedWorkspace({
  291. workspace: {
  292. 'src/terminal-special-case.ts': 'export const marker = true\n',
  293. 'src/other.ts': 'export const other = true\n',
  294. },
  295. }),
  296. actions: [
  297. { waitFor: 'main-session-', send: '@tsc' },
  298. { waitFor: 'File · terminal-special-case.t', send: '\t' },
  299. { waitFor: '@src/terminal-special-case.ts', send: '\x03/exit\r' },
  300. ],
  301. })
  302. expect(output).toContain('File · terminal-special-case.t')
  303. expect(output).toContain('@src/terminal-special-case.ts')
  304. expect(output).toContain('\u001B[?2004l')
  305. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  306. })
  307. describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
  308. it('exec-replaces the TUI for /resume and restores the same session state', async () => {
  309. const output = await smoke({
  310. label: 'dsh in-place resume',
  311. tempDirPrefix: 'dsh-in-place-resume-',
  312. binScript: dshBinScript,
  313. configPath: scriptedConfigPath,
  314. prepare: seedResumeSession,
  315. actions: [
  316. { waitFor: 'scripted TUI ready.', send: '/resume\r' },
  317. { waitFor: 'Resume selector design', send: 'Resume selector design' },
  318. { waitFor: '⌕ Resume selector design', send: '\r' },
  319. { waitFor: 'Preserve restored state', send: '/exit\r' },
  320. ],
  321. })
  322. const released = output.indexOf('\u001B[?2004l')
  323. const restored = output.indexOf('Resume selector design — DeepSeek Harness')
  324. expect(released).toBeGreaterThanOrEqual(0)
  325. expect(restored).toBeGreaterThan(released)
  326. expect(output).toContain('Preserve restored state')
  327. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  328. it('boots the shipped default config with no arguments and no personal overlay', async () => {
  329. const output = await smoke({
  330. label: 'dsh default boot',
  331. tempDirPrefix: 'dsh-default-boot-',
  332. binScript: dshBinScript,
  333. configArgs: [],
  334. actions: [{ waitFor: 'main-session-', send: '/exit\r' }],
  335. })
  336. expect(output).toContain('DEEPSEEK')
  337. expect(output).toContain('main-session-')
  338. expect(output).not.toContain('╭')
  339. expect(output).not.toContain('╮')
  340. expect(output).toContain('\u001B[?2004l')
  341. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  342. it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
  343. // The whole personal-config chain in one boot, plus the environment layer
  344. // it deliberately excludes. config.yaml patches the `tui` row — a row the
  345. // SURFACE OVERLAY inserted, not one the base declares — proving a later
  346. // patch list reaches a row an earlier one inserted. The single `!!js`
  347. // expression prefers the PERSONAL variable, so the welcome can only render
  348. // the project value while the harness home's .env — the credential store
  349. // of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting
  350. // it would make every stored key read as a read-only launch override on
  351. // the next run and hand it to every subprocess the agent starts.
  352. const output = await smoke({
  353. label: 'dsh personal overlay',
  354. tempDirPrefix: 'dsh-personal-overlay-',
  355. binScript: dshBinScript,
  356. configArgs: [],
  357. prepare: seedWorkspace({
  358. workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' },
  359. personal: {
  360. '.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n',
  361. 'config.yaml': [
  362. '- id: workspace-context',
  363. ' disabled: true',
  364. '- id: tui',
  365. ' config:',
  366. " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'",
  367. ' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME',
  368. '',
  369. ].join('\n'),
  370. },
  371. }),
  372. actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }],
  373. })
  374. expect(output).toContain('PROJECT OVERLAY READY.')
  375. expect(output).not.toContain('HOME ENV LEAKED.')
  376. expect(output).toContain('\u001B[?2004l')
  377. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  378. it('fails loud instead of booting when the personal config.yaml is invalid', async () => {
  379. const output = await smoke({
  380. label: 'dsh invalid personal config',
  381. tempDirPrefix: 'dsh-invalid-personal-',
  382. binScript: dshBinScript,
  383. configArgs: [],
  384. prepare: seedWorkspace({ personal: { 'config.yaml': 'id: not-a-list\n' } }),
  385. expectedExitCode: 1,
  386. })
  387. expect(output).toContain('must be a top-level YAML array of loader patch entries')
  388. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  389. it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => {
  390. // The flag path end to end: apps/cli parses `--resume missing-session`,
  391. // provides it as the launcher-owned identity on the boot context, and the
  392. // resume fails loud — proving the printed hint reaches the app's resume
  393. // intake with no config key and no environment variable.
  394. const output = await smoke({
  395. label: 'dsh resume flag failure',
  396. tempDirPrefix: 'dsh-resume-flag-',
  397. binScript: dshBinScript,
  398. configArgs: ['--resume', 'missing-session'],
  399. expectedExitCode: 1,
  400. })
  401. expect(output).toContain('ui-tui: session "missing-session" failed to start:')
  402. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  403. it('prints the launcher-owned resume command on exit, naming the booted config', async () => {
  404. // The exit line is built by apps/cli from this invocation, so it must carry
  405. // `--config`: a hint that omitted it would resume into the default tree.
  406. const output = await smoke({
  407. label: 'dsh goodbye message',
  408. tempDirPrefix: 'dsh-goodbye-',
  409. binScript: dshBinScript,
  410. configPath: scriptedConfigPath,
  411. actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }],
  412. })
  413. expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/)
  414. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  415. it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => {
  416. // Loader patches replace a targeted `config` key wholesale, so a personal
  417. // overlay repointing the model route drops every identity key the shipped
  418. // row declared. Launcher-owned identity makes that unreachable: agent-loop
  419. // applies the launcher's id over whatever route survives.
  420. const output = await smoke({
  421. label: 'dsh overlay keeps resume',
  422. tempDirPrefix: 'dsh-overlay-resume-',
  423. binScript: dshBinScript,
  424. configArgs: [],
  425. prepare: seedWorkspace({
  426. personal: {
  427. 'config.yaml': [
  428. '- id: workspace-context',
  429. ' disabled: true',
  430. '- id: agent-loop',
  431. ' config:',
  432. ' agents:',
  433. ' - id: main',
  434. ' provider: deepseek-official',
  435. ' model: deepseek-v4-flash',
  436. ' cwd: !!js process.cwd()',
  437. '- id: tui',
  438. ' config:',
  439. " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'",
  440. ' welcome: OVERLAY REPLACED THE CONFIG.',
  441. '',
  442. ].join('\n'),
  443. },
  444. }),
  445. actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }],
  446. })
  447. expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/)
  448. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  449. it('reports a failing bash command exactly once, as the terminal card exit pill', async () => {
  450. // The model-facing result ends in `[exit code: 3]`, which the terminal card
  451. // consumes into its own `[exit 3]` pill. Rendering both would report the same
  452. // exit twice, so the marker must not survive into the card body.
  453. const output = await smoke({
  454. label: 'dsh bash exit pill',
  455. tempDirPrefix: 'dsh-bash-exit-pill-',
  456. configPath: scriptedConfigPath,
  457. actions: [
  458. ...SELECT_PRO_MODEL,
  459. {
  460. waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.',
  461. send: 'Run the failing scripted command.\r',
  462. },
  463. { waitFor: 'Scripted bash failure observed.', send: '/exit\r' },
  464. ],
  465. })
  466. // The command really ran: its stdout is in the card body.
  467. expect(output).toContain('SCRIPTED_BASH_FAILED')
  468. expect(output).toContain('[exit 3]')
  469. expect(output).not.toContain('[exit code: 3]')
  470. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  471. it('tells the model its source path and offers the bundled maintenance skills', async () => {
  472. // The launcher resolves the checkout root three hops up from apps/cli/{src,lib};
  473. // this test file sits an equal depth under the same root, so the same hop applies.
  474. // The source-path line is a system-prompt section; the bundled skills reach the
  475. // model through a durable user message, so each assertion targets its own field.
  476. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url))
  477. let context: LoggedRequestContext = { system: '', skillCatalog: '' }
  478. await smoke({
  479. label: 'dsh source-path prompt',
  480. tempDirPrefix: 'dsh-source-path-',
  481. binScript: dshBinScript,
  482. configPath: scriptedConfigPath,
  483. actions: [
  484. ...SELECT_PRO_MODEL,
  485. { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' },
  486. { waitFor: 'How should the scripted run proceed?', send: '\r' },
  487. { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' },
  488. ],
  489. inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) },
  490. })
  491. expect(context.system).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.`)
  492. expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.")
  493. expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.')
  494. expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.')
  495. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  496. })