stdio-agent.spec.ts 7.2 KB

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