built-bin.e2e.ts 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003
  1. import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { createInterface } from 'node:readline'
  5. import { Readable, Writable } from 'node:stream'
  6. import { fileURLToPath, pathToFileURL } from 'node:url'
  7. import {
  8. client as createAcpClientApp,
  9. methods,
  10. ndJsonStream,
  11. PROTOCOL_VERSION,
  12. type SessionNotification,
  13. } from '@agentclientprotocol/sdk'
  14. import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
  15. import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
  16. import { execa } from 'execa'
  17. import * as yaml from 'js-yaml'
  18. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  19. /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
  20. const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
  21. // The release version, including a prerelease such as 0.0.1-rc.1: `--version`
  22. // prints what this manifest carries, so no test may pin it to a literal.
  23. const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version
  24. const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
  25. const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url))
  26. async function runBuiltBin(
  27. args: readonly string[] = [],
  28. env: Readonly<Record<string, string | undefined>> = {},
  29. cwd?: string,
  30. ): Promise<{ stdout: string; code: number; stderr: string }> {
  31. const childEnv = Object.fromEntries(
  32. Object.entries({ ...process.env, ...env })
  33. .filter((entry): entry is [string, string] => entry[1] !== undefined),
  34. )
  35. const result = await execa(process.execPath, [dshBin, ...args], {
  36. input: '',
  37. timeout: 25_000,
  38. killSignal: 'SIGKILL',
  39. reject: false,
  40. env: childEnv,
  41. extendEnv: false,
  42. ...cwd === undefined ? {} : { cwd },
  43. })
  44. if (result.timedOut) {
  45. throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
  46. }
  47. return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr }
  48. }
  49. async function waitForFile(file: string): Promise<void> {
  50. const deadline = Date.now() + 20_000
  51. while (!existsSync(file)) {
  52. if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`)
  53. await new Promise(resolve => setTimeout(resolve, 20))
  54. }
  55. }
  56. interface ProfileLifecycleFixture {
  57. home: string
  58. ready: string
  59. settled: string
  60. disposed: string
  61. interrupt: string
  62. }
  63. /**
  64. * A minimal custom profile: one lifecycle-marker plugin bundle listed in
  65. * dsh.profile.bundles, no dsh-base — proving out-of-box composition machinery without
  66. * booting the entire product tree.
  67. */
  68. function createProfileLifecycleFixture(): ProfileLifecycleFixture {
  69. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-lifecycle-'))
  70. const ready = join(home, 'ready')
  71. const settled = join(home, 'settled')
  72. const disposed = join(home, 'disposed')
  73. const interrupt = join(home, 'interrupt')
  74. const bundleDir = join(home, 'lifecycle-bundle')
  75. mkdirSync(bundleDir, { recursive: true })
  76. writeFileSync(join(bundleDir, 'plugin.mjs'), [
  77. "import { existsSync, writeFileSync } from 'node:fs'",
  78. "import { join } from 'node:path'",
  79. "export const name = 'profile-lifecycle-fixture'",
  80. 'export function apply(ctx, config = {}) {',
  81. ' let active = true',
  82. ' // Keep the event loop alive so process lifetime is signal-owned, like a real surface.',
  83. ' // Windows has no deliverable SIGTERM; the marker emits the same process event there.',
  84. ' let interrupted = false',
  85. ' const heartbeat = setInterval(() => {',
  86. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  87. ' interrupted = true',
  88. " process.emit('SIGTERM')",
  89. ' }, 20)',
  90. ' // Echo the mounted generation so the hot-reload e2e can assert both an',
  91. ' // applied override and its removal reverting to this bundle default.',
  92. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  93. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  94. ' void ctx.loader.await().then(() => {',
  95. " if (active) writeFileSync(process.env.RAW_SETTLED_FILE, 'settled')",
  96. ' })',
  97. ' ctx.effect(() => () => {',
  98. ' active = false',
  99. ' clearInterval(heartbeat)',
  100. " writeFileSync(process.env.RAW_DISPOSED_FILE, 'disposed')",
  101. ' })',
  102. '}',
  103. '',
  104. ].join('\n'))
  105. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  106. '- insert:',
  107. ' - id: profile-lifecycle-fixture',
  108. ` name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
  109. '',
  110. ].join('\n'))
  111. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  112. name: 'dsh-lifecycle-bundle',
  113. version: '0.0.0',
  114. type: 'module',
  115. dsh: { bundle: { patch: './cordis.patch.yml' } },
  116. }, undefined, 2))
  117. const profileDir = join(home, 'profiles', 'lifecycle')
  118. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  119. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  120. name: 'dsh-profile-lifecycle',
  121. private: true,
  122. dependencies: {},
  123. dsh: { profile: { bundles: ['dsh-lifecycle-bundle'] } },
  124. }, undefined, 2))
  125. // Hand-place the "installed" bundle where profile resolution finds it.
  126. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  127. const linkTarget = join(profileDir, 'node_modules', 'dsh-lifecycle-bundle')
  128. mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
  129. try {
  130. rmSync(linkTarget, { recursive: true, force: true })
  131. } catch { /* fresh dir */ }
  132. // Copy-free: a package.json redirecting via a relative main is enough for require.resolve.
  133. mkdirSync(linkTarget, { recursive: true })
  134. for (const file of ['package.json', 'cordis.patch.yml', 'plugin.mjs']) {
  135. writeFileSync(join(linkTarget, file), readFileSync(join(bundleDir, file)))
  136. }
  137. return { home, ready, settled, disposed, interrupt }
  138. }
  139. function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) {
  140. return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], {
  141. cwd: fixture.home,
  142. input: '',
  143. reject: false,
  144. env: {
  145. DSH_HOME: fixture.home,
  146. RAW_READY_FILE: fixture.ready,
  147. RAW_SETTLED_FILE: fixture.settled,
  148. RAW_DISPOSED_FILE: fixture.disposed,
  149. RAW_INTERRUPT_FILE: fixture.interrupt,
  150. },
  151. })
  152. }
  153. function requestProfileShutdown(
  154. child: Pick<ReturnType<typeof startProfileLifecycle>, 'kill'>,
  155. fixture: Pick<ProfileLifecycleFixture, 'interrupt'>,
  156. ): void {
  157. if (process.platform === 'win32') {
  158. writeFileSync(fixture.interrupt, 'interrupt')
  159. return
  160. }
  161. child.kill('SIGTERM')
  162. }
  163. function createEnvironmentProbeProfile(home: string, project: string): void {
  164. const pluginFile = join(project, 'environment-probe.mjs')
  165. writeFileSync(pluginFile, [
  166. "export const name = 'environment-probe'",
  167. "export const inject = ['llm']",
  168. 'export function apply(ctx) {',
  169. ' void ctx.loader.await().then(async () => {',
  170. " let text = ''",
  171. ' for await (const chunk of ctx.llm.stream({',
  172. " provider: 'deepseek-official',",
  173. " model: 'deepseek-v4-flash',",
  174. ' messages: [],',
  175. ' maxTokens: 32,',
  176. ' })) {',
  177. " if (chunk.type === 'text-delta') text += chunk.text",
  178. ' }',
  179. ' process.stdout.write(`${text}\\n`)',
  180. " if (process.platform === 'win32') process.emit('SIGTERM')",
  181. " else process.kill(process.pid, 'SIGTERM')",
  182. ' })',
  183. '}',
  184. '',
  185. ].join('\n'))
  186. const profileDir = join(home, 'profiles', 'environment-probe')
  187. mkdirSync(profileDir, { recursive: true })
  188. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  189. name: 'dsh-profile-environment-probe',
  190. private: true,
  191. dependencies: {},
  192. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  193. }, undefined, 2))
  194. writeFileSync(join(profileDir, 'cordis.patch.yml'), [
  195. '- insert:',
  196. ' - id: environment-probe',
  197. ` name: ${pathToFileURL(pluginFile).href}`,
  198. '',
  199. ].join('\n'))
  200. }
  201. interface StartupFixture {
  202. home: string
  203. ready: string
  204. echo: string
  205. interrupt: string
  206. /** An always-running row's echo, used to observe that a user patch reload landed. */
  207. witness: string
  208. }
  209. /**
  210. * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus
  211. * a row that reads its app-owned service through a `!!js` config expression.
  212. * Both plugin modules resolve
  213. * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module
  214. * fallback, exactly as an installed out-of-tree bundle does.
  215. */
  216. function createStartupFixture(): StartupFixture {
  217. const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-'))
  218. const profileDir = join(home, 'profiles', 'startup')
  219. // Written straight into the installed location: a row module resolves its
  220. // own imports from where it is installed, and only inside the profile does
  221. // Node's parent walk reach the installation fallback these plugins need.
  222. const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle')
  223. mkdirSync(bundleDir, { recursive: true })
  224. writeFileSync(join(bundleDir, 'startup.mjs'), [
  225. "import { Command } from 'commander'",
  226. "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'",
  227. "export const name = 'fixture-startup'",
  228. "export const inject = ['cmdlineArgs']",
  229. 'export function apply(ctx) {',
  230. " const program = new Command().name('fixture').option('--generation <value>', 'echoed generation')",
  231. " program.action(() => ctx.provide('fixtureStartup', { generation: program.opts().generation }))",
  232. ' parseCmdline(ctx, program)',
  233. '}',
  234. '',
  235. ].join('\n'))
  236. writeFileSync(join(bundleDir, 'waiting.mjs'), [
  237. "import { existsSync, writeFileSync } from 'node:fs'",
  238. "import { join } from 'node:path'",
  239. "export const name = 'startup-fixture'",
  240. 'export function apply(ctx, config = {}) {',
  241. ' let interrupted = false',
  242. ' const heartbeat = setInterval(() => {',
  243. ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return',
  244. ' interrupted = true',
  245. " process.emit('SIGTERM')",
  246. ' }, 20)',
  247. " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))",
  248. " writeFileSync(process.env.RAW_READY_FILE, 'ready')",
  249. ' ctx.effect(() => () => { clearInterval(heartbeat) })',
  250. '}',
  251. '',
  252. ].join('\n'))
  253. writeFileSync(join(bundleDir, 'witness.mjs'), [
  254. "import { writeFileSync } from 'node:fs'",
  255. "import { join } from 'node:path'",
  256. "export const name = 'reload-witness'",
  257. 'export function apply(ctx, config = {}) {',
  258. " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))",
  259. '}',
  260. '',
  261. ].join('\n'))
  262. writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
  263. '- insert:',
  264. ' - id: startup-fixture',
  265. ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
  266. ' inject: [fixtureStartup]',
  267. ' config:',
  268. // Lazy interpolation runs only after the provider's service is injected.
  269. " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'",
  270. ' - id: fixture-startup',
  271. ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`,
  272. ' - id: reload-witness',
  273. ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`,
  274. '',
  275. ].join('\n'))
  276. writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
  277. name: 'dsh-startup-bundle',
  278. version: '0.0.0',
  279. type: 'module',
  280. dsh: { bundle: { patch: './cordis.patch.yml' } },
  281. }, undefined, 2))
  282. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  283. name: 'dsh-profile-startup',
  284. private: true,
  285. dependencies: {},
  286. dsh: { profile: { bundles: ['dsh-startup-bundle'] } },
  287. }, undefined, 2))
  288. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  289. return {
  290. home,
  291. ready: join(home, 'ready'),
  292. echo: join(home, 'config-echo'),
  293. interrupt: join(home, 'interrupt'),
  294. witness: join(home, 'witness'),
  295. }
  296. }
  297. function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
  298. return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], {
  299. cwd: fixture.home,
  300. input: '',
  301. reject: false,
  302. timeout: 25_000,
  303. killSignal: 'SIGKILL',
  304. env: {
  305. DSH_HOME: fixture.home,
  306. RAW_READY_FILE: fixture.ready,
  307. RAW_INTERRUPT_FILE: fixture.interrupt,
  308. },
  309. })
  310. }
  311. describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
  312. it('requires --profile and rejects removed commands', async () => {
  313. const bare = await runBuiltBin()
  314. expect(bare.code).toBe(1)
  315. expect(bare.stdout).toBe('')
  316. expect(bare.stderr).toContain('--profile <name> is required')
  317. const help = await runBuiltBin(['--help'])
  318. expect(help.code).toBe(0)
  319. expect(help.stdout).toContain('dsh --profile web')
  320. expect(help.stdout).toContain('dsh plugin --profile')
  321. expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu)
  322. for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) {
  323. const result = await runBuiltBin(removed)
  324. expect(result.code).toBe(1)
  325. }
  326. }, 30_000)
  327. it('routes help and usage errors without activating startup-dependent rows', async () => {
  328. const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-'))
  329. try {
  330. const web = await runBuiltBin(['--profile', 'web', '--help'], {
  331. DSH_HOME: home,
  332. DSH_TELEMETRY_DISABLED: '1',
  333. })
  334. expect(web.code).toBe(0)
  335. expect(web.stderr).toBe('')
  336. expect(web.stdout).toContain('Usage: dsh --profile web')
  337. expect(web.stdout).toContain('--port <port>')
  338. expect(web.stdout).not.toContain('dsh web: http://')
  339. const wildcardHost = await runBuiltBin(['web', '--host', '0.0.0.0'], {
  340. DSH_HOME: home,
  341. DSH_TELEMETRY_DISABLED: '1',
  342. })
  343. expect(wildcardHost.code).toBe(1)
  344. expect(wildcardHost.stdout).toBe('')
  345. expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
  346. expect(wildcardHost.stderr).not.toContain('dsh web: http://')
  347. const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], {
  348. DSH_HOME: home,
  349. DSH_TELEMETRY_DISABLED: '1',
  350. })
  351. expect(headlessHelp.code).toBe(0)
  352. expect(headlessHelp.stderr).toBe('')
  353. expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless')
  354. const sdkHelp = await runBuiltBin(['--profile', 'sdk', '--help'], {
  355. DSH_HOME: home,
  356. DSH_TELEMETRY_DISABLED: '1',
  357. })
  358. expect(sdkHelp.code).toBe(0)
  359. expect(sdkHelp.stderr).toBe('')
  360. expect(sdkHelp.stdout).toContain('Usage: dsh --profile sdk')
  361. const acpHelp = await runBuiltBin(['--profile', 'acp', '--help'], {
  362. DSH_HOME: home,
  363. DSH_TELEMETRY_DISABLED: '1',
  364. })
  365. expect(acpHelp.code).toBe(0)
  366. expect(acpHelp.stderr).toBe('')
  367. expect(acpHelp.stdout).toContain('Usage: dsh --profile acp')
  368. const missingTask = await runBuiltBin(['--profile', 'headless'], {
  369. DSH_HOME: home,
  370. DSH_TELEMETRY_DISABLED: '1',
  371. })
  372. expect(missingTask.code).toBe(1)
  373. expect(missingTask.stderr).toContain('a task is required')
  374. } finally {
  375. rmSync(home, { recursive: true, force: true })
  376. }
  377. }, process.platform === 'win32' ? 60_000 : 30_000)
  378. it('reports SDK startup failure when stdin reaches EOF first', async () => {
  379. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-'))
  380. const patch = join(home, 'broken-sdk.cordis.yml')
  381. writeFileSync(patch, [
  382. '- insert:',
  383. ' - id: missing-sdk-startup-plugin',
  384. ' name: "@deepseek-ai/dsh-missing-sdk-startup-plugin"',
  385. '',
  386. ].join('\n'))
  387. try {
  388. const result = await runBuiltBin(['--profile', 'sdk', '--patch', patch], {
  389. DSH_HOME: home,
  390. DSH_TELEMETRY_DISABLED: '1',
  391. DEEPSEEK_API_KEY: 'built-sdk-startup-failure-no-call',
  392. }, home)
  393. expect(result.code).toBe(1)
  394. expect(result.stdout).toBe('')
  395. expect(result.stderr).toContain('plugin tree failed to load')
  396. expect(result.stderr).toContain('@deepseek-ai/dsh-missing-sdk-startup-plugin')
  397. } finally {
  398. rmSync(home, { recursive: true, force: true })
  399. }
  400. }, 30_000)
  401. it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => {
  402. const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
  403. const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], {
  404. cwd: home,
  405. reject: false,
  406. timeout: 25_000,
  407. killSignal: 'SIGKILL',
  408. env: {
  409. ...process.env,
  410. DSH_HOME: home,
  411. DSH_TELEMETRY_DISABLED: '1',
  412. DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
  413. },
  414. extendEnv: false,
  415. })
  416. const stdoutLines = createInterface({ input: child.stdout, crlfDelay: Infinity })[Symbol.asyncIterator]()
  417. let stderr = ''
  418. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
  419. const response = async (id: number): Promise<Record<string, unknown>> => {
  420. for (;;) {
  421. const line = await stdoutLines.next()
  422. if (line.done) throw new Error(`SDK profile stdout closed before response ${String(id)}; stderr=${stderr}`)
  423. let value: Record<string, unknown>
  424. try {
  425. value = JSON.parse(line.value) as Record<string, unknown>
  426. } catch {
  427. throw new Error(`SDK profile wrote non-JSON stdout: ${line.value}`)
  428. }
  429. if (value.id === id) return value
  430. }
  431. }
  432. try {
  433. child.stdin.write(`${JSON.stringify({
  434. jsonrpc: '2.0',
  435. id: 1,
  436. method: 'initialize',
  437. params: { cwd: home, provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  438. })}\n`)
  439. expect(await response(1)).toMatchObject({
  440. jsonrpc: '2.0',
  441. id: 1,
  442. result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
  443. })
  444. child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'shutdown' })}\n`)
  445. expect(await response(2)).toEqual({ jsonrpc: '2.0', id: 2, result: {} })
  446. const result = await child
  447. expect(result.exitCode, `signal=${String(result.signal)}; stderr=${stderr}`).toBe(0)
  448. expect(stderr).toBe('')
  449. } finally {
  450. child.kill('SIGKILL')
  451. await child
  452. rmSync(home, { recursive: true, force: true })
  453. }
  454. }, 30_000)
  455. it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => {
  456. const apiKey = 'built-acp-profile-key'
  457. const server = await startMockLlmServer({
  458. sequence: ['success'],
  459. apiKey,
  460. successText: 'ACP BUILT PROFILE OK',
  461. })
  462. const home = mkdtempSync(join(tmpdir(), 'dsh-built-acp-'))
  463. const child = execa(process.execPath, [dshBin, '--profile', 'acp'], {
  464. cwd: home,
  465. reject: false,
  466. timeout: 25_000,
  467. killSignal: 'SIGKILL',
  468. env: {
  469. ...process.env,
  470. DSH_HOME: home,
  471. DSH_TELEMETRY_DISABLED: '1',
  472. DEEPSEEK_API_KEY: apiKey,
  473. DEEPSEEK_BASE_URL: server.baseURL,
  474. DSH_PERMISSION_MODE: 'danger-full-access',
  475. },
  476. extendEnv: false,
  477. })
  478. const rawOut: string[] = []
  479. const passthrough = new Readable({ read() {} })
  480. child.stdout.on('data', (chunk: Buffer) => {
  481. rawOut.push(chunk.toString('utf8'))
  482. passthrough.push(chunk)
  483. })
  484. child.stdout.on('end', () => { passthrough.push(null) })
  485. const stream = ndJsonStream(
  486. Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
  487. Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
  488. )
  489. const updates: SessionNotification['update'][] = []
  490. const clientApp = createAcpClientApp({ name: 'dsh-built-acp-profile' })
  491. .onNotification(methods.client.session.update, ({ params }) => {
  492. updates.push(params.update)
  493. return Promise.resolve()
  494. })
  495. .onRequest(methods.client.session.requestPermission, () => {
  496. return Promise.resolve({ outcome: { outcome: 'cancelled' } })
  497. })
  498. const client = clientApp.connect(stream).agent
  499. try {
  500. const initialized = await client.request(methods.agent.initialize, {
  501. protocolVersion: PROTOCOL_VERSION,
  502. clientCapabilities: {},
  503. })
  504. expect(initialized.agentInfo).toMatchObject({ name: 'deepseek-harness-acp' })
  505. expect(initialized.agentCapabilities).toEqual({
  506. mcpCapabilities: { http: true },
  507. promptCapabilities: { image: false, audio: false, embeddedContext: false },
  508. sessionCapabilities: { close: {}, list: {}, resume: {} },
  509. })
  510. expect('_meta' in initialized).toBe(false)
  511. const session = await client.request(methods.agent.session.new, { cwd: home, mcpServers: [] })
  512. expect(session.sessionId).toBeTruthy()
  513. expect(await client.request(methods.agent.session.prompt, {
  514. sessionId: session.sessionId,
  515. prompt: [{ type: 'text', text: 'reply from the built ACP profile' }],
  516. })).toEqual({ stopReason: 'end_turn' })
  517. expect(updates).toContainEqual(expect.objectContaining({
  518. sessionUpdate: 'agent_message_chunk',
  519. content: { type: 'text', text: 'ACP BUILT PROFILE OK' },
  520. }))
  521. const message = updates.find(update => update.sessionUpdate === 'agent_message_chunk')
  522. expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true)
  523. expect(server.requests).toHaveLength(1)
  524. child.stdin.end()
  525. const result = await child
  526. expect(result.exitCode, `signal=${String(result.signal)}; stderr=${result.stderr}`).toBe(0)
  527. expect(result.stderr).toBe('')
  528. for (const line of rawOut.join('').split('\n').filter(value => value.trim() !== '')) {
  529. expect(() => JSON.parse(line) as unknown).not.toThrow()
  530. }
  531. } finally {
  532. child.kill('SIGKILL')
  533. await child
  534. await server.close()
  535. rmSync(home, { recursive: true, force: true })
  536. }
  537. }, 30_000)
  538. it('runs the headless profile through its app-owned task positional', async () => {
  539. const apiKey = 'built-dsh-headless-key'
  540. const server = await startMockLlmServer({
  541. sequence: ['reasoning_success'],
  542. apiKey,
  543. reasoningText: 'Inspecting the published entry.',
  544. successText: 'published headless profile reached the mock',
  545. })
  546. const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-'))
  547. try {
  548. const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], {
  549. DSH_HOME: home,
  550. DSH_TELEMETRY_DISABLED: '1',
  551. DEEPSEEK_API_KEY: apiKey,
  552. DEEPSEEK_BASE_URL: server.baseURL,
  553. })
  554. expect(result.code, result.stderr).toBe(0)
  555. expect(result.stdout).toBe('published headless profile reached the mock')
  556. expect(result.stderr).toBe('dsh: reasoning:\nInspecting the published entry.')
  557. expect(server.requests.length).toBeGreaterThan(0)
  558. expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true)
  559. expect(JSON.stringify(server.requests.map(request => request.body))).toContain('answer from the published entry')
  560. } finally {
  561. await server.close()
  562. rmSync(home, { recursive: true, force: true })
  563. }
  564. }, 30_000)
  565. it('does not load a project environment for --version', async () => {
  566. const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-'))
  567. writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n')
  568. try {
  569. const result = await runBuiltBin(['--version'], {}, project)
  570. expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' })
  571. } finally {
  572. rmSync(project, { recursive: true, force: true })
  573. }
  574. })
  575. it('fails loud on a nonexistent profile with the plugin-command hint', async () => {
  576. const home = mkdtempSync(join(tmpdir(), 'dsh-missing-profile-'))
  577. try {
  578. const result = await runBuiltBin(['--profile', 'nope'], { DSH_HOME: home })
  579. expect(result.code).toBe(1)
  580. expect(result.stderr).toContain('profile "nope" does not exist')
  581. expect(result.stderr).toContain('dsh plugin --profile nope add')
  582. } finally {
  583. rmSync(home, { recursive: true, force: true })
  584. }
  585. }, 30_000)
  586. it('uses the launching endpoint and managed credential through the published entry', async () => {
  587. const apiKey = 'built-home-layer-key'
  588. const server = await startMockLlmServer({
  589. sequence: ['success'],
  590. apiKey,
  591. successText: 'launching endpoint reached the mock',
  592. })
  593. const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
  594. const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
  595. writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
  596. createEnvironmentProbeProfile(home, project)
  597. try {
  598. const result = await runBuiltBin(
  599. ['--profile', 'environment-probe'],
  600. {
  601. DSH_HOME: home,
  602. DSH_TELEMETRY_DISABLED: '1',
  603. DEEPSEEK_API_KEY: undefined,
  604. DEEPSEEK_BASE_URL: server.baseURL,
  605. },
  606. project,
  607. )
  608. expect(
  609. result.code,
  610. `${result.stderr}\nstdout:\n${result.stdout}\nmock requests: ${String(server.requests.length)}`,
  611. ).toBe(0)
  612. expect(result.stdout).toBe('launching endpoint reached the mock')
  613. expect(result.stdout).not.toContain(apiKey)
  614. expect(result.stderr).not.toContain(apiKey)
  615. expect(server.requests).toHaveLength(1)
  616. expect(server.requests[0]?.path).toBe('/chat/completions')
  617. expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${apiKey}`)
  618. expect(JSON.stringify(server.requests[0]?.body)).not.toContain(apiKey)
  619. } finally {
  620. await server.close()
  621. rmSync(home, { recursive: true, force: true })
  622. rmSync(project, { recursive: true, force: true })
  623. }
  624. }, 30_000)
  625. it('reports a patch-overlay boot failure without hanging', async () => {
  626. // The HMR main watcher's initial scan once refreshed the include
  627. // mid-initial-apply, deadlocking the failing apply's rollback against the
  628. // refresh drain: dsh exited 13 with no diagnostic instead of settling
  629. // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)).
  630. const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
  631. try {
  632. const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {
  633. DSH_HOME: home,
  634. DEEPSEEK_API_KEY: 'keyless-invalid-config',
  635. DSH_TELEMETRY_DISABLED: '1',
  636. })
  637. expect(result.code).toBe(1)
  638. expect(result.stdout).toBe('')
  639. expect(result.stderr).toContain('llm-pi-ai')
  640. } finally {
  641. rmSync(home, { recursive: true, force: true })
  642. }
  643. }, 30_000)
  644. it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => {
  645. const fixture = createProfileLifecycleFixture()
  646. const child = startProfileLifecycle(fixture, ['--unclaimed'])
  647. try {
  648. await waitForFile(fixture.ready)
  649. requestProfileShutdown(child, fixture)
  650. const result = await child
  651. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  652. expect(result.signal).toBeUndefined()
  653. expect(existsSync(fixture.disposed)).toBe(true)
  654. } finally {
  655. child.kill('SIGKILL')
  656. rmSync(fixture.home, { recursive: true, force: true })
  657. }
  658. }, 30_000)
  659. it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => {
  660. const fixture = createProfileLifecycleFixture()
  661. const child = startProfileLifecycle(fixture)
  662. const profilePatch = join(fixture.home, 'profiles', 'lifecycle', 'cordis.patch.yml')
  663. const configFile = join(fixture.home, 'config-echo')
  664. try {
  665. await waitForFile(fixture.settled)
  666. // The live profile layer: even without an hmr row in the composition,
  667. // the launcher mounts a config-only watcher, so an edited
  668. // cordis.patch.yml lands in the running tree (the reload disposes the
  669. // patched row's old fiber — observable as the disposed marker — and
  670. // mounts the new config, which echoes its generation and re-writes the
  671. // ready marker).
  672. rmSync(fixture.ready)
  673. writeFileSync(profilePatch, [
  674. '- id: profile-lifecycle-fixture',
  675. ' config:',
  676. ' generation: 2',
  677. '',
  678. ].join('\n'))
  679. await waitForFile(fixture.ready)
  680. expect(readFileSync(configFile, 'utf8')).toBe('2')
  681. // Removal reverts: the bundle's inserted row must return to its own
  682. // default config, not keep the removed override — the insert-aliasing
  683. // regression (a shared patch object mutated in place by a former
  684. // generation would make this impossible).
  685. rmSync(fixture.ready)
  686. writeFileSync(profilePatch, '[]\n')
  687. await waitForFile(fixture.ready)
  688. expect(readFileSync(configFile, 'utf8')).toBe('bundle-default')
  689. // The home-level user layer ($DSH_HOME/cordis.patch.yml) is live too
  690. // and outranks the per-profile layer.
  691. rmSync(fixture.ready)
  692. writeFileSync(join(fixture.home, 'cordis.patch.yml'), [
  693. '- id: profile-lifecycle-fixture',
  694. ' config:',
  695. ' generation: home',
  696. '',
  697. ].join('\n'))
  698. await waitForFile(fixture.ready)
  699. expect(readFileSync(configFile, 'utf8')).toBe('home')
  700. requestProfileShutdown(child, fixture)
  701. const result = await child
  702. expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
  703. expect(result.signal).toBeUndefined()
  704. expect(existsSync(fixture.disposed)).toBe(true)
  705. } finally {
  706. child.kill('SIGKILL')
  707. rmSync(fixture.home, { recursive: true, force: true })
  708. }
  709. }, 30_000)
  710. it('hands the app arguments to the profile, which applies them before its rows start', async () => {
  711. const fixture = createStartupFixture()
  712. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  713. try {
  714. await waitForFile(fixture.ready)
  715. // The consumer started once, already carrying the flag value: the
  716. // launcher never saw --generation, and the app provider resolved it first.
  717. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  718. requestProfileShutdown(child, fixture)
  719. expect((await child).exitCode).toBe(0)
  720. } finally {
  721. child.kill('SIGKILL')
  722. rmSync(fixture.home, { recursive: true, force: true })
  723. }
  724. }, 30_000)
  725. it('starts a consumer on its composed value when the invocation carries no app arguments', async () => {
  726. const fixture = createStartupFixture()
  727. const child = startStartupProfile(fixture, [])
  728. try {
  729. await waitForFile(fixture.ready)
  730. expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default')
  731. requestProfileShutdown(child, fixture)
  732. expect((await child).exitCode).toBe(0)
  733. } finally {
  734. child.kill('SIGKILL')
  735. rmSync(fixture.home, { recursive: true, force: true })
  736. }
  737. }, 30_000)
  738. it('keeps the app arguments across a user patch reload', async () => {
  739. // A live edit recomposes every row while the provider service remains
  740. // active, so each config expression reads the same invocation value (a
  741. // served port does not move back to its composed fallback).
  742. const fixture = createStartupFixture()
  743. const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml')
  744. const child = startStartupProfile(fixture, ['--generation', 'flagged'])
  745. try {
  746. // Both rows: the waiting one carries the flag value, and the witness is
  747. // what a reload will re-mount. They start independently, so neither
  748. // marker implies the other.
  749. await waitForFile(fixture.ready)
  750. await waitForFile(fixture.witness)
  751. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  752. // An edit to an unrelated row: the witness re-mounts, which is how this
  753. // test knows the whole tree was recomposed.
  754. rmSync(fixture.witness)
  755. writeFileSync(profilePatch, [
  756. '- id: reload-witness',
  757. ' config:',
  758. ' generation: reloaded',
  759. '',
  760. ].join('\n'))
  761. await waitForFile(fixture.witness)
  762. expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded')
  763. expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged')
  764. requestProfileShutdown(child, fixture)
  765. expect((await child).exitCode).toBe(0)
  766. } finally {
  767. child.kill('SIGKILL')
  768. rmSync(fixture.home, { recursive: true, force: true })
  769. }
  770. }, 30_000)
  771. it("prints the app's own help, starts none of its rows, and exits", async () => {
  772. const fixture = createStartupFixture()
  773. try {
  774. const result = await startStartupProfile(fixture, ['--help'])
  775. expect(result.exitCode).toBe(0)
  776. expect(result.stdout).toContain('Usage: fixture')
  777. expect(result.stdout).toContain('--generation')
  778. expect(existsSync(fixture.ready)).toBe(false)
  779. } finally {
  780. rmSync(fixture.home, { recursive: true, force: true })
  781. }
  782. }, 30_000)
  783. it('anchors a relative add spec to the invoking directory, not the profile', async () => {
  784. // `dsh plugin --profile x add .` from a plugin checkout must install THAT
  785. // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
  786. // would self-link the profile.
  787. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-anchor-'))
  788. const checkout = mkdtempSync(join(tmpdir(), 'dsh-plugin-checkout-'))
  789. try {
  790. writeFileSync(join(checkout, 'package.json'), JSON.stringify({
  791. name: 'anchored-bundle',
  792. version: '1.0.0',
  793. dsh: { bundle: { patch: './cordis.patch.yml' } },
  794. }))
  795. writeFileSync(join(checkout, 'cordis.patch.yml'), '[]\n')
  796. const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], {
  797. cwd: checkout,
  798. input: '',
  799. timeout: 60_000,
  800. killSignal: 'SIGKILL',
  801. reject: false,
  802. env: { DSH_HOME: home },
  803. })
  804. expect(result.exitCode).toBe(0)
  805. const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8')) as {
  806. dependencies: Record<string, string>
  807. dsh: { profile: { bundles: string[] } }
  808. }
  809. expect(Object.keys(manifest.dependencies)).toEqual(['anchored-bundle'])
  810. expect(manifest.dsh.profile.bundles).toContain('anchored-bundle')
  811. const removed = await runBuiltBin(
  812. ['plugin', '--profile', 'anchor', 'remove', 'anchored-bundle'],
  813. { DSH_HOME: home },
  814. checkout,
  815. )
  816. expect(removed.code).toBe(0)
  817. const afterRemove = JSON.parse(
  818. readFileSync(join(home, 'profiles', 'anchor', 'package.json'), 'utf8'),
  819. ) as {
  820. dependencies?: Record<string, string>
  821. dsh: { profile: { bundles: string[] } }
  822. }
  823. expect(Object.keys(afterRemove.dependencies ?? {})).toEqual([])
  824. expect(afterRemove.dsh.profile.bundles).not.toContain('anchored-bundle')
  825. } finally {
  826. rmSync(home, { recursive: true, force: true })
  827. rmSync(checkout, { recursive: true, force: true })
  828. }
  829. }, 90_000)
  830. it('activates a dependency that gained dsh.bundle in a later update', async () => {
  831. // Reconcile runs against the INSTALLED state on every successful pnpm
  832. // run, so `update` (not only `add`) activates a package whose newer
  833. // version declares dsh.bundle. Simulated without a registry: hand-place
  834. // the installed package, flip its manifest, and run a benign pnpm verb.
  835. const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
  836. try {
  837. const profileDir = join(home, 'profiles', 'up')
  838. const installed = join(profileDir, 'node_modules', 'late-bundle')
  839. mkdirSync(installed, { recursive: true })
  840. writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
  841. name: 'dsh-profile-up',
  842. private: true,
  843. dependencies: { 'late-bundle': 'file:./late-bundle' },
  844. dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
  845. }))
  846. writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n')
  847. // v1: no dsh manifest — a plain dependency.
  848. writeFileSync(join(installed, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '1.0.0' }))
  849. const first = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  850. expect(first.code).toBe(0)
  851. let manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  852. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
  853. // v2: the installed package now declares dsh.bundle (an update landed).
  854. writeFileSync(join(installed, 'package.json'), JSON.stringify({
  855. name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
  856. }))
  857. writeFileSync(join(installed, 'cordis.patch.yml'), '[]\n')
  858. const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
  859. expect(second.code).toBe(0)
  860. manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
  861. expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
  862. } finally {
  863. rmSync(home, { recursive: true, force: true })
  864. }
  865. }, 30_000)
  866. describe('config dump', () => {
  867. let home: string
  868. beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-dump-bin-')) })
  869. afterEach(() => { rmSync(home, { recursive: true, force: true }) })
  870. it('prints the web profile bundle layers without a user layer', async () => {
  871. const { stdout, code, stderr } = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  872. expect(code).toBe(0)
  873. expect(stderr).toBe('')
  874. expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-loop'")
  875. expect(stdout).toContain('agents: []')
  876. expect(stdout).toContain('# == @deepseek-ai/dsh-base')
  877. expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'")
  878. expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
  879. }, 30_000)
  880. it('prints the headless profile without Host or browser layers', async () => {
  881. const { stdout, code, stderr } = await runBuiltBin(
  882. ['--profile', 'headless', '--dump-default-config'],
  883. { DSH_HOME: home },
  884. )
  885. expect(code).toBe(0)
  886. expect(stderr).toBe('')
  887. expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'")
  888. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/)
  889. expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'")
  890. expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/)
  891. }, 30_000)
  892. it('prints the exact standalone sdk-minimal tree without dsh-base', async () => {
  893. const { stdout, code, stderr } = await runBuiltBin(
  894. ['--profile', 'sdk-minimal', '--dump-default-config'],
  895. { DSH_HOME: home },
  896. )
  897. expect(code).toBe(0)
  898. expect(stderr).toBe('')
  899. const rows = yaml.load(stdout, { schema: entryListSchema }) as Array<{ id?: string; name?: string }>
  900. expect(rows.map(row => [row.id, row.name])).toEqual([
  901. ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'],
  902. ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'],
  903. ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'],
  904. ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'],
  905. ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'],
  906. ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'],
  907. ['sandbox', '@deepseek-ai/dsh-sandbox-local'],
  908. ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'],
  909. ['subprocess', '@deepseek-ai/dsh-subprocess-local'],
  910. ['pty', '@deepseek-ai/dsh-terminal'],
  911. ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'],
  912. ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'],
  913. ['fs-local', '@deepseek-ai/dsh-fs-local'],
  914. ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'],
  915. ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
  916. ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
  917. ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'],
  918. ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
  919. ])
  920. expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
  921. expect(stdout).not.toContain('@deepseek-ai/dsh-base')
  922. expect(stdout).not.toContain('@deepseek-ai/dsh-web-app')
  923. }, 30_000)
  924. it('composes the profile user layer and a --patch overlay in order', async () => {
  925. // Auto-init the web profile first, then write its user layer.
  926. const init = await runBuiltBin(['--profile', 'web', '--dump-default-config'], { DSH_HOME: home })
  927. expect(init.code).toBe(0)
  928. const profilePatch = join(home, 'profiles', 'web', 'cordis.patch.yml')
  929. writeFileSync(profilePatch, [
  930. '- id: agent-loop',
  931. ' config:',
  932. ' agents:',
  933. ' - id: personal',
  934. ' provider: personal-provider',
  935. ' model: personal-model',
  936. '- id: absent-row',
  937. ' config:',
  938. ' x: 1',
  939. '',
  940. ].join('\n'))
  941. const overlay = join(home, 'overlay.cordis.yml')
  942. writeFileSync(overlay, [
  943. '- id: agent-loop',
  944. ' config:',
  945. ' agents:',
  946. ' - id: configured',
  947. ' provider: configured-provider',
  948. ' model: configured-model',
  949. '',
  950. ].join('\n'))
  951. const { stdout, code, stderr } = await runBuiltBin(
  952. ['--profile', 'web', '--patch', overlay, '--dump-config'],
  953. { DSH_HOME: home },
  954. )
  955. expect(code).toBe(0)
  956. expect(stdout).toContain('provider: configured-provider')
  957. expect(stdout).not.toContain('personal-provider')
  958. // Both layers patched the row; the comment lists them in application order.
  959. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`)
  960. expect(stderr).toContain('patch: entry "absent-row" not found')
  961. }, 30_000)
  962. })
  963. })