composition.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. /** Loader composition with a real stdio child and a scripted external model. */
  2. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath, pathToFileURL } from 'node:url'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { Context, FiberState } from '@deepseek-ai/cordis'
  8. import Loader from '@deepseek-ai/cordis-plugin-loader'
  9. import Include from '@deepseek-ai/cordis-plugin-include'
  10. import ComputerUse from '@deepseek-ai/dsh-computer-use'
  11. import { ComputerUseProviderName } from '@deepseek-ai/dsh-computer-use/brand'
  12. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  13. import ToolRuntime from '@deepseek-ai/dsh-tools'
  14. import LlmRuntime, { LlmAdapter, ToolCallId, createUserMessage } from '@deepseek-ai/dsh-llm'
  15. import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
  16. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  17. import AgentRegistry from '@deepseek-ai/dsh-agent'
  18. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  19. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  20. import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
  21. import * as Provider from '../src/index.ts'
  22. const TOOL = 'mcp__cua-driver-mcp__screenshot'
  23. const fixture = fileURLToPath(new URL('./fixtures/driver.mjs', import.meta.url))
  24. const roots: string[] = []
  25. const contexts: Context[] = []
  26. afterEach(async () => {
  27. await Promise.all(contexts.splice(0).map(async ctx => ctx.fiber.dispose()))
  28. await Promise.all(roots.splice(0).map(async root => rm(root, { recursive: true, force: true })))
  29. })
  30. class ScreenshotModel extends LlmAdapter {
  31. readonly requests: GenerateOptions[] = []
  32. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  33. return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'] })
  34. }
  35. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  36. this.requests.push(options)
  37. if (this.requests.length === 1) {
  38. const id = ToolCallId('screenshot-call')
  39. const args = '{"display":0}'
  40. yield { type: 'block-start', index: 0, blockType: 'tool-call' }
  41. yield { type: 'tool-call-delta', index: 0, id, name: TOOL, argumentsDelta: args }
  42. yield { type: 'block-end', index: 0, block: { type: 'tool-call', id, name: TOOL, arguments: args } }
  43. yield { type: 'finish', reason: { kind: 'tool-calls' } }
  44. } else {
  45. yield { type: 'block-start', index: 0, blockType: 'text' }
  46. yield { type: 'text-delta', index: 0, text: 'The display is visible.' }
  47. yield { type: 'block-end', index: 0, block: { type: 'text', text: 'The display is visible.' } }
  48. yield { type: 'finish', reason: { kind: 'stop' } }
  49. }
  50. }
  51. }
  52. async function load(mode?: string): Promise<{ ctx: Context; root: string; model: ScreenshotModel }> {
  53. const root = await mkdtemp(join(tmpdir(), 'dsh-computer-use-mcp-'))
  54. roots.push(root)
  55. const model = new ScreenshotModel()
  56. const modules = new Map<string, unknown>([
  57. ['@deepseek-ai/dsh-computer-use', ComputerUse],
  58. ['@deepseek-ai/dsh-system-prompt', SystemPrompt],
  59. ['@deepseek-ai/dsh-tools', ToolRuntime],
  60. ['@deepseek-ai/dsh-llm', LlmRuntime],
  61. ['@deepseek-ai/dsh-session', SessionStore],
  62. ['@deepseek-ai/dsh-agent', AgentRegistry],
  63. ['@deepseek-ai/dsh-agent-loop', AgentLoop],
  64. ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
  65. ['@deepseek-ai/dsh-attachment-local', LocalAttachmentStore],
  66. ['@fixture/model', { inject: ['llm'], apply(ctx: Context) { ctx.effect(() => ctx.llm.registerAdapter(['fixture'], model)) } }],
  67. ['@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp', Provider],
  68. ])
  69. const configPath = join(root, 'cordis.yml')
  70. await writeFile(configPath, JSON.stringify([...modules.keys()].map(name => ({
  71. id: name === '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp' ? 'computer-use-driver' : undefined,
  72. name,
  73. config: name === '@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp'
  74. ? {
  75. command: process.execPath,
  76. args: [fixture, root, ...(mode === undefined ? [] : [mode])],
  77. reconnect: { initialDelayMs: 20, maxDelayMs: 40, maxAttempts: 2 },
  78. }
  79. : name === '@deepseek-ai/dsh-attachment-local'
  80. ? { dshHome: root }
  81. : name === '@deepseek-ai/dsh-agent-loop' ? { agents: [] } : {},
  82. }))))
  83. const ctx = new Context()
  84. contexts.push(ctx)
  85. ctx.baseUrl = pathToFileURL(root).href + '/'
  86. await ctx.plugin(Loader)
  87. ctx.loader.builtins.include = Include
  88. ctx.loader.internal = {
  89. version: 'v2',
  90. async import(specifier: string) {
  91. if (!modules.has(specifier)) throw new Error(`Unexpected Loader import: ${specifier}`)
  92. return modules.get(specifier)
  93. },
  94. } as unknown as NonNullable<typeof ctx.loader.internal>
  95. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  96. await ctx.loader.await()
  97. return { ctx, root, model }
  98. }
  99. async function driverEvents(root: string): Promise<{ event: string; pid: number; name?: string; arguments?: unknown }[]> {
  100. return (await readFile(join(root, 'driver.ndjson'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { event: string; pid: number })
  101. }
  102. function expectProcessExited(pid: number): void {
  103. expect(() => process.kill(pid, 0)).toThrow(expect.objectContaining({ code: 'ESRCH' }))
  104. }
  105. async function expectActiveDriver(root: string, connectionCount: number): Promise<void> {
  106. const events = await driverEvents(root)
  107. const starts = events.filter(event => event.event === 'start').map(event => event.pid)
  108. const probes = events.filter(event => event.event === 'discover').map(event => event.pid)
  109. const drivers = events.filter(event => event.event === 'initialize').map(event => event.pid)
  110. expect(probes).toHaveLength(connectionCount)
  111. expect(drivers).toHaveLength(connectionCount)
  112. expect(starts).toHaveLength(connectionCount * 2)
  113. expect(new Set(starts)).toEqual(new Set([...probes, ...drivers]))
  114. for (const pid of [...probes, ...drivers.slice(0, -1)]) expectProcessExited(pid)
  115. expect(() => process.kill(drivers[connectionCount - 1]!, 0)).not.toThrow()
  116. }
  117. describe('installed Cua Driver Loader composition', () => {
  118. it('keeps upstream schemas and stores screenshot history as durable images through a real Agent', async () => {
  119. const { ctx, root, model } = await load()
  120. await expectActiveDriver(root, 1)
  121. const fibers = [...ctx.loader.entries()].flatMap(entry => entry.fiber === undefined ? [] : [entry.fiber])
  122. expect(fibers.every(fiber => fiber.state === FiberState.ACTIVE)).toBe(true)
  123. expect(ctx.computerUse.providerName).toBe('cua-driver-mcp')
  124. const agent = await ctx.agentLoop.create(SessionId('computer-use-composition'), { provider: 'fixture', model: 'vision' })
  125. const idle = new Promise<void>((resolve) => {
  126. const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
  127. if (subject === agent && status === 'idle') { dispose(); resolve() }
  128. })
  129. })
  130. agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Inspect display zero.' }], source: { kind: 'user' } }))
  131. await idle
  132. expect(model.requests).toHaveLength(2)
  133. expect(model.requests[0]?.tools?.find(tool => tool.name === TOOL)).toMatchObject({
  134. description: 'Capture the selected display.',
  135. parameters: { type: 'object', properties: { display: { type: 'integer', minimum: 0 } }, required: ['display'], additionalProperties: false },
  136. })
  137. const result = agent.session.snapshotEvents().find(event => event.type === 'tool/result')
  138. expect(result?.data).toMatchObject({ message: { content: [{ type: 'tool-result', content: [{ type: 'text', text: 'Display 0' }, { type: 'image' }] }] } })
  139. if (result?.type !== 'tool/result') throw new Error('Missing durable screenshot result')
  140. const toolResult = result.data.message.content[0]
  141. if (toolResult?.type !== 'tool-result') throw new Error('Missing tool result block')
  142. const image = toolResult.content.find(block => block.type === 'image')
  143. if (image?.type !== 'image') throw new Error('Missing durable screenshot image')
  144. expect(await ctx.attachments.readImage(image.attachment)).toMatchObject({ ref: { width: 1, height: 1, mediaType: 'image/png' } })
  145. expect(JSON.stringify(agent.session.snapshotEvents())).not.toContain('iVBORw0KGgo')
  146. expect(model.requests[1]?.messages).toEqual(agent.session.deriveMessages().slice(0, -1))
  147. expect(await driverEvents(root)).toContainEqual(expect.objectContaining({ event: 'call', name: 'screenshot', arguments: { display: 0 } }))
  148. const entry = [...ctx.loader.entries()].find(item => item.options.id === 'computer-use-driver')
  149. await entry?.fiber?.dispose()
  150. expect(ctx.tools.get(TOOL)).toBeUndefined()
  151. expect(ctx.computerUse.providerName).toBeUndefined()
  152. const events = await driverEvents(root)
  153. for (const event of events.filter(event => event.event === 'start')) expectProcessExited(event.pid)
  154. })
  155. it('retains exclusive ownership when the external process disconnects and reconnects', async () => {
  156. const { ctx, root } = await load()
  157. await ctx.tools.execute({ name: 'mcp__cua-driver-mcp__disconnect', arguments: {}, callId: ToolCallId('disconnect'), signal: new AbortController().signal })
  158. expect(ctx.computerUse.providerName).toBe('cua-driver-mcp')
  159. expect(() => ctx.computerUse.register(ComputerUseProviderName('replacement'))).toThrow('already registered')
  160. await vi.waitFor(async () => {
  161. expect((await driverEvents(root)).filter(event => event.event === 'initialize')).toHaveLength(2)
  162. }, { timeout: 10_000 })
  163. await vi.waitFor(async () => {
  164. const result = await ctx.tools.execute({ name: TOOL, arguments: { display: 2 }, callId: ToolCallId('reconnected'), signal: new AbortController().signal })
  165. expect(result.isError).not.toBe(true)
  166. expect(result.content[0]).toEqual({ type: 'text', text: 'Display 2' })
  167. })
  168. expect(ctx.computerUse.providerName).toBe('cua-driver-mcp')
  169. await expectActiveDriver(root, 2)
  170. await ctx.fiber.dispose()
  171. for (const event of (await driverEvents(root)).filter(event => event.event === 'start')) expectProcessExited(event.pid)
  172. })
  173. it('fails the Loader entry and releases ownership when installed driver initialization fails', async () => {
  174. const { ctx, root } = await load('fail')
  175. const entry = [...ctx.loader.entries()].find(item => item.options.id === 'computer-use-driver')
  176. expect(entry?.fiber?.state).toBe(FiberState.FAILED)
  177. await expect(entry?.fiber?.await()).rejects.toThrow('initial connection or tool synchronization failed')
  178. expect(ctx.computerUse.providerName).toBeUndefined()
  179. expect(ctx.tools.get(TOOL)).toBeUndefined()
  180. const events = await driverEvents(root)
  181. expect(events.filter(event => event.event === 'discover')).toHaveLength(1)
  182. expect(events.filter(event => event.event === 'initialize')).toHaveLength(1)
  183. expect(events.filter(event => event.event === 'start')).toHaveLength(2)
  184. for (const event of events.filter(event => event.event === 'start')) expectProcessExited(event.pid)
  185. })
  186. })