stdio-agent.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import { describe, it, expect } from 'vitest'
  2. import { mkdtemp } from 'node:fs/promises'
  3. import { join } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import { Context } from 'cordis'
  6. import Loader from '@cordisjs/plugin-loader'
  7. import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  8. import type { Message } from '@deepseek-ai/dsh-llm'
  9. import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
  10. import * as stdioAgent from '../src/index.ts'
  11. /**
  12. * Unit coverage for app composition and config forwarding: pre-created main agent,
  13. * agent-spine-demo spine, JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the
  14. * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise
  15. * survive namespace collapse while silently losing its schema.
  16. */
  17. async function mount(config: stdioAgent.Config, withBash = false): Promise<Context> {
  18. const ctx = new Context()
  19. if (withBash) ctx.provide('bash', { sandboxMode: undefined })
  20. await ctx.plugin(stdioAgent, config)
  21. // The app mounts its children inside apply() (not awaited there); let their
  22. // fibers settle so the spine services + the pre-created agent are ready.
  23. await new Promise(resolve => setTimeout(resolve, 80))
  24. return ctx
  25. }
  26. async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
  27. const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-skills-'))
  28. return {
  29. local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
  30. ...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
  31. }
  32. }
  33. async function composePrefix(ctx: Context): Promise<Message[]> {
  34. const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
  35. const empty: Message[] = []
  36. return await agentEvents(ctx, agent).waterfall(
  37. 'agent/session-prefix', empty, new AbortController().signal,
  38. () => Promise.resolve(empty),
  39. )
  40. }
  41. async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
  42. const oldDshHome = process.env.DSH_HOME
  43. const oldAgentsHome = process.env.DSH_AGENTS_HOME
  44. const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-demo-default-skills-'))
  45. process.env.DSH_HOME = join(home, '.dsh')
  46. process.env.DSH_AGENTS_HOME = join(home, '.agents')
  47. try {
  48. return await run()
  49. } finally {
  50. if (oldDshHome === undefined) {
  51. delete process.env.DSH_HOME
  52. } else {
  53. process.env.DSH_HOME = oldDshHome
  54. }
  55. if (oldAgentsHome === undefined) {
  56. delete process.env.DSH_AGENTS_HOME
  57. } else {
  58. process.env.DSH_AGENTS_HOME = oldAgentsHome
  59. }
  60. }
  61. }
  62. describe('dsh-stdio-demo app', () => {
  63. it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => {
  64. expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline')
  65. expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui')
  66. expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline')
  67. expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui')
  68. expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout')
  69. })
  70. it('binds only the selected terminal package to the app-owned exact session identity', () => {
  71. const calls: Array<{ name: string; config: unknown }> = []
  72. const ctx = {
  73. plugin(plugin: { name?: string }, config?: unknown) {
  74. calls.push({ name: plugin.name ?? '', config })
  75. },
  76. } as unknown as Context
  77. stdioAgent.composeTerminalApp(ctx, {
  78. provider: 'mock',
  79. model: 'mock',
  80. workspaceContext: false,
  81. welcome: 'TUI ready',
  82. ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } },
  83. }, true)
  84. expect(calls.map(call => call.name)).toContain('ui-tui')
  85. expect(calls.map(call => call.name)).not.toContain('ui-stdio')
  86. expect(calls.map(call => call.name)).not.toContain('ConsoleExporter')
  87. const tuiConfig = calls.find(call => call.name === 'ui-tui')?.config as { sessionId: string }
  88. expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
  89. expect(tuiConfig.sessionId).toMatch(/^main-session-/)
  90. const spineConfig = calls.find(call => call.name === 'agent-spine-demo')?.config as {
  91. agents: Array<{ id: string; sessionId?: string; resumeSessionId?: string }>
  92. }
  93. expect(spineConfig.agents[0]).toMatchObject({ id: 'main', sessionId: tuiConfig.sessionId })
  94. calls.length = 0
  95. stdioAgent.composeTerminalApp(ctx, {
  96. provider: 'mock',
  97. model: 'mock',
  98. resumeSessionId: 'persisted-session',
  99. workspaceContext: false,
  100. ui: { mode: 'tui' },
  101. }, true)
  102. expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({
  103. sessionId: 'persisted-session', welcome: 'ready.',
  104. })
  105. expect((calls.find(call => call.name === 'agent-spine-demo')?.config as typeof spineConfig).agents[0])
  106. .toMatchObject({ id: 'main', resumeSessionId: 'persisted-session' })
  107. calls.length = 0
  108. stdioAgent.composeTerminalApp(ctx, {
  109. provider: 'mock', model: 'mock', workspaceContext: false, ui: { mode: 'readline' },
  110. }, false)
  111. expect(calls.map(call => call.name)).toContain('ui-stdio')
  112. expect(calls.map(call => call.name)).toContain('ConsoleExporter')
  113. expect(calls.map(call => call.name)).not.toContain('ui-tui')
  114. })
  115. it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
  116. const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig(), workspaceContext: false })
  117. // The spine services (brought up by the agent-spine-demo bundle) are all present.
  118. expect(ctx.get('agents')).toBeDefined()
  119. expect(ctx.get('agentLoop')).toBeDefined()
  120. expect(ctx.get('sessionPersistence')).toBeDefined()
  121. expect(ctx.get('userInteraction')).toBeDefined()
  122. expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
  123. // The sole pre-created agent the UI drives. `main` is its stable config
  124. // label; each fresh process mints a durable combined agent/session id.
  125. await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
  126. const agent = ctx.get('agents')?.list()[0]
  127. expect(agent).toBeDefined()
  128. expect(agent?.id).toBe(agent?.session.id)
  129. expect(agent?.id).toMatch(/^main-session-/)
  130. expect(agent?.session.header.cwd).toBe(process.cwd())
  131. await ctx.fiber.dispose()
  132. })
  133. it('normalizes an empty resume id to a fresh exact app identity', async () => {
  134. const ctx = await mount({
  135. provider: 'mock',
  136. model: 'mock',
  137. resumeSessionId: '',
  138. persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume',
  139. skills: await isolatedSkillsConfig(),
  140. workspaceContext: false,
  141. })
  142. await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
  143. const agent = ctx.get('agents')?.list()[0]
  144. expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/)
  145. expect(agent?.id).toBe(agent?.session.id)
  146. await ctx.fiber.dispose()
  147. })
  148. it('defaults persistenceRoot and welcome when omitted', async () => {
  149. // Direct apply (NOT via ctx.plugin, which validates+defaults the config
  150. // first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on
  151. // apply()'s last two lines are the ones that fire — covering a
  152. // schema-bypassing direct-mount caller.
  153. const ctx = new Context()
  154. // No persona: covers the omitted-persona forwarding branch too.
  155. stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
  156. await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
  157. expect(ctx.get('sessionPersistence')).toBeDefined()
  158. expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
  159. await ctx.fiber.dispose()
  160. })
  161. it('forwards explicit project-instruction controls to the bundled spine', async () => {
  162. const ctx = await mount({
  163. provider: 'mock',
  164. model: 'mock',
  165. persona: 'hi',
  166. persistenceRoot: '/tmp/dsh-stdio-demo-spec-workspace-context',
  167. workspaceContext: false,
  168. })
  169. await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1)
  170. expect(ctx.get('agents')?.list()[0]?.id).toMatch(/^main-session-/)
  171. await ctx.fiber.dispose()
  172. })
  173. it('uses default skill config when apply is called directly without skills', async () => {
  174. await withIsolatedSkillHomes(async () => {
  175. const ctx = new Context()
  176. stdioAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
  177. await new Promise(resolve => setTimeout(resolve, 80))
  178. expect(ctx.skills).toBeDefined()
  179. expect(await ctx.skills.list()).toEqual([])
  180. await ctx.fiber.dispose()
  181. })
  182. })
  183. it('forwards resumeSessionId onto the pre-created agent when set', async () => {
  184. // A resume id defers agent creation until persistence loads; with no backing
  185. // session the resume is contained + logged, so no agent registers —
  186. // the branch that maps resumeSessionId through is what this covers.
  187. const ctx = await mount({
  188. provider: 'mock',
  189. model: 'mock',
  190. persona: 'hi',
  191. persistenceRoot: '/tmp/dsh-stdio-demo-spec-resume',
  192. resumeSessionId: 'no-such-session',
  193. skills: await isolatedSkillsConfig(),
  194. workspaceContext: false,
  195. })
  196. expect(ctx.get('agents')?.list()).toEqual([])
  197. await ctx.fiber.dispose()
  198. })
  199. it('forwards skill config and dshHome into agent-spine-demo', async () => {
  200. const skills = await isolatedSkillsConfig(6)
  201. const ctx = await mount({ provider: 'mock', model: 'mock', persona: 'hi', dshHome: skills.local!.dshHome!, skills, workspaceContext: false })
  202. ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
  203. expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
  204. await ctx.fiber.dispose()
  205. })
  206. it('forwards maxParallelToolCalls to the bundled agent loop', async () => {
  207. const ctx = await mount({
  208. provider: 'mock',
  209. model: 'mock',
  210. maxParallelToolCalls: 3,
  211. persistenceRoot: '/tmp/dsh-stdio-demo-spec-parallel',
  212. skills: await isolatedSkillsConfig(),
  213. workspaceContext: false,
  214. })
  215. expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
  216. await ctx.fiber.dispose()
  217. })
  218. it('forwards bundled tool config into agent-core', async () => {
  219. const ctx = await mount({
  220. provider: 'mock',
  221. model: 'mock',
  222. workspaceContext: false,
  223. toolBash: { enableRunInBackground: false },
  224. toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
  225. skills: await isolatedSkillsConfig(),
  226. }, true)
  227. const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
  228. expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
  229. .not.toContain('run_in_background')
  230. await ctx.fiber.dispose()
  231. })
  232. it('exposes its name and Config schema', () => {
  233. expect(stdioAgent.name).toBe('stdio-demo')
  234. expect(stdioAgent.Config).toBeDefined()
  235. })
  236. it('forwards toolOrder through agent-spine-demo to the system-prompt assembly', async () => {
  237. const ctx = await mount({
  238. provider: 'mock',
  239. model: 'mock',
  240. toolOrder: ['zulu', TOOL_ORDER_REST],
  241. persistenceRoot: '/tmp/dsh-stdio-demo-spec-tool-order',
  242. workspaceContext: false,
  243. })
  244. // The bundle's own bash tools pend on the absent `ctx.bash` executor in
  245. // this providerless mount, so register two plain tools to order.
  246. for (const name of ['alpha', 'zulu']) {
  247. ctx.get('tools')!.register({
  248. name,
  249. description: name,
  250. parameters: {},
  251. execute: async () => [],
  252. })
  253. }
  254. const assembly = await ctx.get('systemPrompt')!.assemble()
  255. expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill', 'task_kill', 'task_list', 'task_output'])
  256. await ctx.fiber.dispose()
  257. })
  258. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
  259. // A default export would make `unwrapExports` collapse this inject-less namespace and silently
  260. // drop `name`/`Config` while the app still boots. Guard the postmortem-0001 shape directly.
  261. expect('default' in stdioAgent).toBe(false)
  262. expect(typeof stdioAgent.apply).toBe('function')
  263. const loader = Object.create(Loader.prototype) as Loader
  264. const unwrapped = loader.unwrapExports(stdioAgent) as Record<string, unknown>
  265. expect(unwrapped).toBe(stdioAgent)
  266. expect(unwrapped.name).toBe('stdio-demo')
  267. expect(unwrapped.Config).toBeDefined()
  268. expect(typeof unwrapped.apply).toBe('function')
  269. })
  270. })