tui-keyless-smoke.e2e.ts 23 KB

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