agent-core.spec.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import { describe, expect, it } from 'vitest'
  2. import { mkdir, mkdtemp, writeFile } 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 { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
  8. import * as agentCore from '../src/index.ts'
  9. import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  10. import type { Message } from '@deepseek-ai/dsh-llm'
  11. async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
  12. const agent = { session: { header: { cwd } } } as unknown as Agent
  13. const empty: Message[] = []
  14. return await agentEvents(ctx, agent).waterfall(
  15. 'agent/session-prefix', empty, new AbortController().signal,
  16. () => Promise.resolve(empty),
  17. )
  18. }
  19. /**
  20. * Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
  21. * up the whole default spine in one `ctx.plugin`, and the forwarded
  22. * `agents` config reaches the loop (default `[]`, or a pre-created agent).
  23. *
  24. * The bundle is exercised through `ctx.plugin(agentCore, …)` — the NAMESPACE
  25. * import, the same shape the Loader builds from `unwrapExports`. The real
  26. * Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
  27. * bin smokes; here we assert the composition + config forwarding.
  28. */
  29. async function mount(config?: agentCore.Config): Promise<Context> {
  30. const oldDshHome = process.env.DSH_HOME
  31. const oldAgentsHome = process.env.DSH_AGENTS_HOME
  32. process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
  33. process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
  34. const ctx = new Context()
  35. try {
  36. await ctx.plugin(agentCore, config)
  37. // The bundle mounts its children inside apply() (not awaited there); let their
  38. // fibers settle so the spine services and any pre-created agent are ready.
  39. await new Promise(resolve => setTimeout(resolve, 50))
  40. return ctx
  41. } finally {
  42. if (oldDshHome === undefined) {
  43. delete process.env.DSH_HOME
  44. } else {
  45. process.env.DSH_HOME = oldDshHome
  46. }
  47. if (oldAgentsHome === undefined) {
  48. delete process.env.DSH_AGENTS_HOME
  49. } else {
  50. process.env.DSH_AGENTS_HOME = oldAgentsHome
  51. }
  52. }
  53. }
  54. async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
  55. const oldDshHome = process.env.DSH_HOME
  56. const oldAgentsHome = process.env.DSH_AGENTS_HOME
  57. process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-home-'))
  58. process.env.DSH_AGENTS_HOME = await mkdtemp(join(tmpdir(), 'dsh-agent-core-agents-'))
  59. try {
  60. return await run()
  61. } finally {
  62. if (oldDshHome === undefined) {
  63. delete process.env.DSH_HOME
  64. } else {
  65. process.env.DSH_HOME = oldDshHome
  66. }
  67. if (oldAgentsHome === undefined) {
  68. delete process.env.DSH_AGENTS_HOME
  69. } else {
  70. process.env.DSH_AGENTS_HOME = oldAgentsHome
  71. }
  72. }
  73. }
  74. describe('dsh-agent-core bundle', () => {
  75. it('brings up the full default spine', async () => {
  76. const ctx = await mount()
  77. // One service from each layer of the spine proves the children loaded.
  78. expect(ctx.get('timer')).toBeDefined()
  79. expect(ctx.get('llm')).toBeDefined()
  80. expect(ctx.get('sessions')).toBeDefined()
  81. expect(ctx.get('systemPrompt')).toBeDefined()
  82. expect(ctx.get('tools')).toBeDefined()
  83. expect(ctx.get('skills')).toBeDefined()
  84. expect(ctx.get('agents')).toBeDefined()
  85. expect(ctx.get('agentLoop')).toBeDefined()
  86. await ctx.fiber.dispose()
  87. })
  88. it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
  89. const ctx = await mount()
  90. expect(ctx.skills).toBeDefined()
  91. expect(ctx.tools.schemas().map(tool => tool.name)).toContain('skill')
  92. expect(await ctx.skills.list()).toEqual([])
  93. await ctx.fiber.dispose()
  94. })
  95. it('defaults the agents list to empty (no pre-created agents)', async () => {
  96. const ctx = await mount()
  97. expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
  98. await ctx.fiber.dispose()
  99. })
  100. it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => {
  101. const ctx = await mount({
  102. agents: [{ id: AgentId('main'), model: 'mock' }],
  103. persona: 'You are main.',
  104. })
  105. expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
  106. const assembly = await ctx.get('systemPrompt')!.assemble()
  107. expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.')
  108. await ctx.fiber.dispose()
  109. })
  110. it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => {
  111. // ctx.plugin validates + defaults the bundle config first; a direct apply
  112. // skips the schema, so the forwarding `?? []` / `?? ''` are what fire.
  113. const ctx = new Context()
  114. agentCore.apply(ctx, {})
  115. await new Promise(resolve => setTimeout(resolve, 50))
  116. expect(ctx.get('agentLoop')).toBeDefined()
  117. expect(ctx.get('agents')?.list()).toHaveLength(0)
  118. const assembly = await ctx.get('systemPrompt')!.assemble()
  119. expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('')
  120. await ctx.fiber.dispose()
  121. })
  122. it('forwards skill config to the registry, local provider, and model-facing consumer', async () => {
  123. const home = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-home-'))
  124. const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-agents-'))
  125. const custom = await mkdtemp(join(tmpdir(), 'dsh-agent-core-skill-custom-'))
  126. await mkdir(custom, { recursive: true })
  127. await writeFile(join(custom, 'custom-skill.md'), '---\nname: custom-skill\ndescription: Custom skill\n---\n\nCustom body.\n')
  128. const ctx = await mount({
  129. agents: [],
  130. skills: {
  131. registry: { collectCacheMaxEntries: 4 },
  132. local: {
  133. dshHome: join(home, '.dsh'),
  134. agentsHome: join(agentsHome, '.agents'),
  135. customSkillDirs: [custom],
  136. },
  137. tool: { catalogDescriptionMaxLength: 6 },
  138. },
  139. })
  140. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['custom-skill'])
  141. expect(JSON.stringify(await composePrefix(ctx, '/tmp'))).toContain('- `custom-skill`: Cus...')
  142. await ctx.fiber.dispose()
  143. })
  144. it('uses the default skill config when apply is called directly without skills', async () => {
  145. await withIsolatedSkillHomes(async () => {
  146. const ctx = new Context()
  147. agentCore.apply(ctx, { agents: [] })
  148. await new Promise(resolve => setTimeout(resolve, 50))
  149. expect(ctx.skills).toBeDefined()
  150. expect(await ctx.skills.list()).toEqual([])
  151. await ctx.fiber.dispose()
  152. })
  153. })
  154. it('forwards toolOrder to the system-prompt assembly', async () => {
  155. const ctx = await mount({ toolOrder: ['zulu', TOOL_ORDER_REST] })
  156. // The bundle's own bash tools pend on the absent `ctx.bash` executor in
  157. // this providerless mount, so register two plain tools to order.
  158. for (const name of ['alpha', 'zulu']) {
  159. ctx.get('tools')!.register({
  160. name,
  161. description: name,
  162. parameters: {},
  163. execute: async () => [],
  164. })
  165. }
  166. const assembly = await ctx.get('systemPrompt')!.assemble()
  167. expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
  168. await ctx.fiber.dispose()
  169. })
  170. it('re-exports the loop config schema as its own', () => {
  171. expect(agentCore.Config).toBeDefined()
  172. expect(agentCore.name).toBe('agent-core')
  173. })
  174. it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
  175. // A default export would make `unwrapExports` collapse this inject-less namespace and silently
  176. // drop `name`/`Config`. Apps import the bundle directly, so this is its Loader-shape guard.
  177. expect('default' in agentCore).toBe(false)
  178. expect(typeof agentCore.apply).toBe('function')
  179. const loader = Object.create(Loader.prototype) as Loader
  180. const unwrapped = loader.unwrapExports(agentCore) as Record<string, unknown>
  181. expect(unwrapped).toBe(agentCore)
  182. expect(unwrapped.name).toBe('agent-core')
  183. expect(unwrapped.Config).toBeDefined()
  184. expect(typeof unwrapped.apply).toBe('function')
  185. })
  186. })