cli-demo.spec.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { mkdtemp } from 'node:fs/promises'
  2. import { randomUUID } from 'node:crypto'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { Context } from 'cordis'
  6. import Loader from '@cordisjs/plugin-loader'
  7. import { agentEvents } from '@deepseek-ai/dsh-agent'
  8. import { SessionId } from '@deepseek-ai/dsh-session'
  9. import { CallId, type Message } from '@deepseek-ai/dsh-llm'
  10. import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
  11. import type { ToolExecution } from '@deepseek-ai/dsh-tools'
  12. import { afterEach, describe, expect, it, vi } from 'vitest'
  13. import * as cliDemo from '../src/index.ts'
  14. const testToolSignal = new AbortController().signal
  15. const contexts: Context[] = []
  16. async function skillConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<cliDemo.Config['skills']>> {
  17. const home = await mkdtemp(join(tmpdir(), 'dsh-cli-demo-skills-'))
  18. return {
  19. local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
  20. ...catalogDescriptionMaxLength === undefined ? {} : { tool: { catalogDescriptionMaxLength } },
  21. }
  22. }
  23. async function mount(config: cliDemo.Config, withBash = false): Promise<Context> {
  24. const ctx = new Context()
  25. if (withBash) {
  26. ctx.provide('bash', {
  27. sandboxMode: undefined,
  28. resolve() { throw new Error('composition test does not execute bash') },
  29. run() { throw new Error('composition test does not execute bash') },
  30. start() { throw new Error('composition test does not execute bash') },
  31. })
  32. }
  33. contexts.push(ctx)
  34. config.persistenceRoot ??= await mkdtemp(join(tmpdir(), 'dsh-cli-demo-persistence-'))
  35. await ctx.plugin(cliDemo, config)
  36. await new Promise(resolve => setTimeout(resolve, 80))
  37. return ctx
  38. }
  39. async function composePrefix(ctx: Context): Promise<Message[]> {
  40. const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
  41. await agentEvents(ctx, agent).serial('agent/step', 1, 1, new AbortController().signal)
  42. return agent.session.deriveMessages()
  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. })