cli-demo.spec.ts 7.7 KB

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