cli-demo.spec.ts 7.3 KB

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