cli-demo.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { mkdtemp } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { Context } from 'cordis'
  5. import Loader from '@cordisjs/plugin-loader'
  6. import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  7. import { CallId, type Message } from '@deepseek-ai/dsh-llm'
  8. import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
  9. import type { ToolExecution } from '@deepseek-ai/dsh-tools'
  10. import { afterEach, describe, expect, it, vi } from 'vitest'
  11. import * as cliDemo from '../src/index.ts'
  12. const contexts: Context[] = []
  13. async function skillConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<cliDemo.Config['skills']>> {
  14. const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-skills-'))
  15. return {
  16. local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
  17. ...catalogDescriptionMaxLength === undefined ? {} : { tool: { catalogDescriptionMaxLength } },
  18. }
  19. }
  20. async function mount(config: cliDemo.Config, withBash = false): Promise<Context> {
  21. const ctx = new Context()
  22. if (withBash) ctx.provide('bash', { sandboxMode: undefined })
  23. contexts.push(ctx)
  24. await ctx.plugin(cliDemo, config)
  25. await new Promise(resolve => setTimeout(resolve, 80))
  26. return ctx
  27. }
  28. async function composePrefix(ctx: Context): Promise<Message[]> {
  29. const agent = { session: { header: { cwd: '/tmp' } } } as unknown as Agent
  30. const empty: Message[] = []
  31. return await agentEvents(ctx, agent).waterfall(
  32. 'agent/session-prefix', empty, new AbortController().signal,
  33. () => Promise.resolve(empty),
  34. )
  35. }
  36. afterEach(async () => {
  37. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  38. })
  39. describe('dsh-cli-demo app composition', () => {
  40. it('composes the UI-less spine, JSONL persistence, and a main agent', async () => {
  41. const root = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-compose-'))
  42. const ctx = await mount({
  43. provider: 'mock',
  44. model: 'mock',
  45. persona: 'Headless.',
  46. tools: { mode: 'native' },
  47. persistenceRoot: root,
  48. persistenceCompression: 'none',
  49. skills: await skillConfig(),
  50. workspaceContext: false,
  51. })
  52. const [agent] = ctx.get('agents')?.roots() ?? []
  53. expect(ctx.get('agentLoop')).toBeDefined()
  54. expect(ctx.get('sessionPersistence')).toBeDefined()
  55. expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
  56. expect(agent?.session.header.cwd).toBe(process.cwd())
  57. expect(ctx.get('userInteraction')).toBeUndefined()
  58. expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
  59. })
  60. it('covers direct-apply defaults and forwards skill and tool-order config', async () => {
  61. const oldDshHome = process.env.DSH_HOME
  62. const oldAgentsHome = process.env.DSH_AGENTS_HOME
  63. const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-defaults-'))
  64. process.env.DSH_HOME = join(home, '.dsh')
  65. process.env.DSH_AGENTS_HOME = join(home, '.agents')
  66. try {
  67. const ctx = new Context()
  68. contexts.push(ctx)
  69. cliDemo.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
  70. await new Promise(resolve => setTimeout(resolve, 80))
  71. expect(ctx.get('sessionPersistence')).toBeDefined()
  72. const [agent] = ctx.get('agents')?.roots() ?? []
  73. expect(agent?.session.id).toMatch(/^main-session-/)
  74. expect(await ctx.skills.list()).toEqual([])
  75. } finally {
  76. if (oldDshHome === undefined) delete process.env.DSH_HOME
  77. else process.env.DSH_HOME = oldDshHome
  78. if (oldAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
  79. else process.env.DSH_AGENTS_HOME = oldAgentsHome
  80. }
  81. const ctx = await mount({
  82. provider: 'mock',
  83. model: 'mock',
  84. toolOrder: ['zulu', TOOL_ORDER_REST],
  85. skills: await skillConfig(6),
  86. workspaceContext: false,
  87. })
  88. ctx.skills.register({ name: 'cli-skill', description: 'CLI skill', source: 'runtime', content: 'body' })
  89. for (const name of ['alpha', 'zulu']) {
  90. ctx.tools.register({ name, description: name, parameters: {}, execute: async () => [] })
  91. }
  92. expect(JSON.stringify(await composePrefix(ctx))).toContain('- `cli-skill`: CLI...')
  93. expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual([
  94. 'zulu',
  95. 'alpha',
  96. 'skill',
  97. 'task_kill',
  98. 'task_list',
  99. 'task_output',
  100. ])
  101. })
  102. it('forwards the complete shared spine configuration', async () => {
  103. const dshHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-home-'))
  104. const agentsHome = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-agents-'))
  105. const ctx = await mount({
  106. provider: 'mock',
  107. model: 'mock',
  108. maxParallelToolCalls: 3,
  109. dshHome,
  110. skills: { local: { agentsHome } },
  111. toolBash: { enableRunInBackground: false },
  112. toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 },
  113. workspaceContext: false,
  114. }, true)
  115. expect(ctx.get('agentLoop')?.config.maxParallelToolCalls).toBe(3)
  116. const execution: ToolExecution = {
  117. token: Symbol('cli-demo-dsh-home-test') as ToolExecution['token'],
  118. callId: CallId('cli-demo-dsh-home'),
  119. name: 'bash',
  120. arguments: { command: 'true' },
  121. }
  122. expect(ctx.bashEnv.collect(execution)).toMatchObject({ DSH_HOME: dshHome })
  123. const bash = ctx.tools.schemas().find(tool => tool.name === 'bash')
  124. expect(Object.keys((bash!.parameters as { properties: Record<string, unknown> }).properties))
  125. .not.toContain('run_in_background')
  126. const id = ctx.tasks.start({
  127. kind: 'bash',
  128. label: 'config forwarding probe',
  129. run: () => ({ cancel: () => {}, done: Promise.resolve({ status: 'completed' }) }),
  130. })
  131. const wait = vi.spyOn(ctx.tasks, 'wait')
  132. await ctx.tools.execute({
  133. callId: CallId('cli-demo-task-config'),
  134. name: 'task_output',
  135. arguments: { task_id: id, wait: true },
  136. })
  137. expect(wait).toHaveBeenCalledWith(id, 7, undefined, undefined)
  138. })
  139. it('accepts false to keep task services without model-facing task controls', async () => {
  140. const ctx = await mount({
  141. provider: 'mock',
  142. model: 'mock',
  143. skills: { enabled: false },
  144. toolTasks: false,
  145. workspaceContext: false,
  146. })
  147. expect(ctx.get('tasks')).toBeDefined()
  148. expect(ctx.get('tools')?.get('task_output')).toBeUndefined()
  149. expect(ctx.get('tools')?.get('task_list')).toBeUndefined()
  150. expect(ctx.get('tools')?.get('task_kill')).toBeUndefined()
  151. })
  152. it('exposes the Loader-safe namespace plugin shape and schema', () => {
  153. expect(cliDemo.name).toBe('cli-demo')
  154. expect(cliDemo.Config).toBeDefined()
  155. expect('default' in cliDemo).toBe(false)
  156. const loader = Object.create(Loader.prototype) as Loader
  157. const unwrapped = loader.unwrapExports(cliDemo) as Record<string, unknown>
  158. expect(unwrapped).toBe(cliDemo)
  159. expect(unwrapped.name).toBe('cli-demo')
  160. expect(typeof unwrapped.apply).toBe('function')
  161. })
  162. })