stdio-agent.spec.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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 { AgentId, 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 the @deepseek-ai/dsh-stdio-agent app plugin: mounting it
  13. * composes the console logger, the agent-core spine (pre-creating the `main`
  14. * agent from the app config), the JSONL backend, and the readline UI in one
  15. * `ctx.plugin`. The forwarded `model` reaches the pre-created agent and
  16. * `persona` the system-prompt plugin; `persistenceRoot`/`welcome`/
  17. * `resumeSessionId` route to their backends.
  18. *
  19. * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
  20. * plugin the in-process tier cannot import); the keyless echo smoke in
  21. * `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots
  22. * through the real Loader, while the export SHAPE is pinned by this suite's
  23. * explicit `unwrapExports` assertion (an inject-less app would boot past a
  24. * stray default rather than crash). Here we assert the composition + config
  25. * forwarding the unit tier can reach.
  26. */
  27. async function mount(config: stdioAgent.Config): Promise<Context> {
  28. const ctx = new Context()
  29. await ctx.plugin(stdioAgent, config)
  30. // The app mounts its children inside apply() (not awaited there); let their
  31. // fibers settle so the spine services + the pre-created agent are ready.
  32. await new Promise(resolve => setTimeout(resolve, 80))
  33. return ctx
  34. }
  35. async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
  36. const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
  37. return {
  38. local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
  39. ...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
  40. }
  41. }
  42. async function composePrefix(ctx: Context): Promise<Message[]> {
  43. const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
  44. const empty: Message[] = []
  45. return await agentEvents(ctx, agent).waterfall(
  46. 'agent/session-prefix', empty, new AbortController().signal,
  47. () => Promise.resolve(empty),
  48. )
  49. }
  50. async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
  51. const oldDshHome = process.env.DSH_HOME
  52. const oldAgentsHome = process.env.DSH_AGENTS_HOME
  53. const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-default-skills-'))
  54. process.env.DSH_HOME = join(home, '.dsh')
  55. process.env.DSH_AGENTS_HOME = join(home, '.agents')
  56. try {
  57. return await run()
  58. } finally {
  59. if (oldDshHome === undefined) {
  60. delete process.env.DSH_HOME
  61. } else {
  62. process.env.DSH_HOME = oldDshHome
  63. }
  64. if (oldAgentsHome === undefined) {
  65. delete process.env.DSH_AGENTS_HOME
  66. } else {
  67. process.env.DSH_AGENTS_HOME = oldAgentsHome
  68. }
  69. }
  70. }
  71. describe('dsh-stdio-agent app', () => {
  72. it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
  73. const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
  74. // The spine services (brought up by the agent-core bundle) are all present.
  75. expect(ctx.get('agents')).toBeDefined()
  76. expect(ctx.get('agentLoop')).toBeDefined()
  77. expect(ctx.get('sessionPersistence')).toBeDefined()
  78. expect(ctx.get('userInteraction')).toBeDefined()
  79. expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
  80. // The pre-created `main` agent the UI drives.
  81. const agent = ctx.get('agents')?.get(AgentId('main'))
  82. expect(agent).toBeDefined()
  83. expect(agent?.session.header.cwd).toBe(process.cwd())
  84. await ctx.fiber.dispose()
  85. })
  86. it('defaults persistenceRoot and welcome when omitted', async () => {
  87. // Direct apply (NOT via ctx.plugin, which validates+defaults the config
  88. // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on
  89. // apply()'s last two lines are the ones that fire — covering a
  90. // schema-bypassing direct-mount caller.
  91. const ctx = new Context()
  92. // No persona: covers the omitted-persona forwarding branch too.
  93. stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
  94. await new Promise(resolve => setTimeout(resolve, 80))
  95. expect(ctx.get('sessionPersistence')).toBeDefined()
  96. expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
  97. await ctx.fiber.dispose()
  98. })
  99. it('uses default skill config when apply is called directly without skills', async () => {
  100. await withIsolatedSkillHomes(async () => {
  101. const ctx = new Context()
  102. stdioAgent.apply(ctx, { model: 'mock' })
  103. await new Promise(resolve => setTimeout(resolve, 80))
  104. expect(ctx.skills).toBeDefined()
  105. expect(await ctx.skills.list()).toEqual([])
  106. await ctx.fiber.dispose()
  107. })
  108. })
  109. it('forwards resumeSessionId onto the pre-created agent when set', async () => {
  110. // A resume id defers agent creation until persistence loads; with no backing
  111. // session the resume is contained + logged, so no `main` agent registers —
  112. // the branch that maps resumeSessionId through is what this covers.
  113. const ctx = await mount({
  114. model: 'mock',
  115. persona: 'hi',
  116. persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
  117. resumeSessionId: 'no-such-session',
  118. skills: await isolatedSkillsConfig(),
  119. })
  120. expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
  121. await ctx.fiber.dispose()
  122. })
  123. it('forwards skill config into agent-core', async () => {
  124. const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
  125. ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
  126. expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
  127. await ctx.fiber.dispose()
  128. })
  129. it('exposes its name and Config schema', () => {
  130. expect(stdioAgent.name).toBe('stdio-agent')
  131. expect(stdioAgent.Config).toBeDefined()
  132. })
  133. it('forwards toolOrder through agent-core to the system-prompt assembly', async () => {
  134. const ctx = await mount({
  135. model: 'mock',
  136. toolOrder: ['zulu', TOOL_ORDER_REST],
  137. persistenceRoot: '/tmp/dsh-stdio-agent-spec-tool-order',
  138. })
  139. // The bundle's own bash tools pend on the absent `ctx.bash` executor in
  140. // this providerless mount, so register two plain tools to order.
  141. for (const name of ['alpha', 'zulu']) {
  142. ctx.get('tools')!.register({
  143. name,
  144. description: name,
  145. parameters: {},
  146. execute: async () => [],
  147. })
  148. }
  149. const assembly = await ctx.get('systemPrompt')!.assemble()
  150. expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill'])
  151. await ctx.fiber.dispose()
  152. })
  153. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
  154. // Postmortem 0001 guard: a stray `export default apply` makes the Loader's
  155. // `unwrapExports` (`exports.default ?? exports`) collapse the module to the
  156. // bare `apply` function, DROPPING the named `name`/`Config`. This package has
  157. // no `inject` export, so that collapse would NOT crash at load (the keyless
  158. // echo smoke would still boot the tree) — it would silently lose its config
  159. // schema. So guard the shape directly here: assert no `default` export, and
  160. // that the real `unwrapExports` leaves `name`/`Config`/`apply` intact. Adding
  161. // `export default` to src/index.ts fails this test.
  162. expect('default' in stdioAgent).toBe(false)
  163. expect(typeof stdioAgent.apply).toBe('function')
  164. const loader = Object.create(Loader.prototype) as Loader
  165. const unwrapped = loader.unwrapExports(stdioAgent) as Record<string, unknown>
  166. expect(unwrapped).toBe(stdioAgent)
  167. expect(unwrapped.name).toBe('stdio-agent')
  168. expect(unwrapped.Config).toBeDefined()
  169. expect(typeof unwrapped.apply).toBe('function')
  170. })
  171. })