loader-composition.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. /** REAL-composition proof for Host authority and the preset model contribution. */
  2. import { mkdtemp, 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 } from '@deepseek-ai/cordis'
  8. import Include from '@deepseek-ai/cordis-plugin-include'
  9. import Loader from '@deepseek-ai/cordis-plugin-loader'
  10. import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
  11. import AgentRegistry from '@deepseek-ai/dsh-agent'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import { createScope } from '@deepseek-ai/dsh-scope'
  14. import { SessionId } from '@deepseek-ai/dsh-session'
  15. import { Session } from '@deepseek-ai/dsh-session'
  16. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  17. import ToolRuntime from '@deepseek-ai/dsh-tools'
  18. import VisualizerService from '../src/index.ts'
  19. import * as VisualizerModel from '../src/model.ts'
  20. const REPOSITORY_ROOT = fileURLToPath(new URL('../../../../', import.meta.url))
  21. const bundlePatch = (name: 'base' | 'headless') => loadOverlayPatches(
  22. 'visualizer composition test',
  23. join(REPOSITORY_ROOT, 'packages', 'bundle', name, 'cordis.patch.yml'),
  24. )
  25. const contexts: Context[] = []
  26. const roots: string[] = []
  27. afterEach(async () => {
  28. await Promise.all(contexts.splice(0).map(async (ctx) => { await ctx.fiber.dispose() }))
  29. await Promise.all(roots.splice(0).map(async (root) => { await rm(root, { recursive: true, force: true }) }))
  30. })
  31. async function boot(rows: string[]): Promise<{ ctx: Context; imports: string[] }> {
  32. const root = await mkdtemp(join(tmpdir(), 'dsh-visualizer-composition-'))
  33. roots.push(root)
  34. const configPath = join(root, 'cordis.yml')
  35. await writeFile(configPath, `${rows.join('\n')}\n`)
  36. const ctx = new Context()
  37. contexts.push(ctx)
  38. ctx.baseUrl = pathToFileURL(root).href + '/'
  39. await ctx.plugin(Loader)
  40. ctx.loader.builtins.include = Include
  41. const imports: string[] = []
  42. const modules = new Map<string, unknown>([
  43. ['@deepseek-ai/dsh-agent', AgentRegistry],
  44. ['@deepseek-ai/dsh-system-prompt', SystemPrompt],
  45. ['@deepseek-ai/dsh-tools', ToolRuntime],
  46. ['@deepseek-ai/dsh-tool-visualizer', VisualizerService],
  47. ['@deepseek-ai/dsh-tool-visualizer/model', VisualizerModel],
  48. ])
  49. ctx.loader.internal = {
  50. version: 'v2',
  51. async import(specifier: string) {
  52. imports.push(specifier)
  53. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  54. return modules.get(specifier)
  55. },
  56. } as unknown as NonNullable<typeof ctx.loader.internal>
  57. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
  58. await ctx.loader.await()
  59. return { ctx, imports }
  60. }
  61. async function scopedBase(ctx: Context): Promise<Context> {
  62. let base: Context | undefined
  63. await ctx.plugin(Object.assign((inner: Context) => { base = inner }, { inject: ['systemPrompt', 'tools'] }))
  64. if (base === undefined) throw new Error('missing scoped test base')
  65. return base
  66. }
  67. async function mountPresetModel(ctx: Context) {
  68. const base = await scopedBase(ctx)
  69. const presetKey = { agentPreset: 'standard' }
  70. const presetScope = createScope(base, presetKey)
  71. const presetRoot = await mkdtemp(join(tmpdir(), 'dsh-visualizer-preset-row-'))
  72. roots.push(presetRoot)
  73. const composition = join(presetRoot, 'agent.cordis.yml')
  74. await writeFile(composition, [
  75. '- id: tool-visualizer',
  76. " name: '@deepseek-ai/dsh-tool-visualizer/model'",
  77. '',
  78. ].join('\n'))
  79. const modelTree = presetScope.ctx.plugin(Include, { path: pathToFileURL(composition).href })
  80. await modelTree.await()
  81. return { base, presetKey }
  82. }
  83. function registerPresetAgent(
  84. ctx: Context,
  85. preset: Awaited<ReturnType<typeof mountPresetModel>>,
  86. id: string,
  87. ) {
  88. const session = Session.create(SessionId(id))
  89. const agent = { id: session.id, session, ctx } as unknown as Agent
  90. const agentScope = createScope(preset.base, agent, { parent: preset.presetKey })
  91. Object.assign(agent, { ctx: agentScope.ctx.extend({ agent }) })
  92. ctx.agents.register(agent)
  93. return agent
  94. }
  95. async function expectVisualizerSurface(ctx: Context, agent: Agent, visible: boolean): Promise<void> {
  96. await vi.waitFor(async () => {
  97. expect(ctx.tools.schemas(agent).map(schema => schema.name).sort())
  98. .toEqual(visible ? ['show_widget', 'widget_guidelines'] : [])
  99. const prompt = await ctx.systemPrompt.assemble({ agent, scope: agent })
  100. expect(prompt.sections.some(section => section.name === 'tool:visualizer')).toBe(visible)
  101. })
  102. }
  103. describe('Visualizer Host composition', () => {
  104. it('keeps headless dormant and mounts the model wrapper only in full presets', () => {
  105. const headlessEntries = composeEntries([bundlePatch('base'), bundlePatch('headless')])
  106. expect(headlessEntries.some(entry => entry.name === '@deepseek-ai/dsh-tool-visualizer')).toBe(false)
  107. const presetModelRows = (id: 'standard' | 'minimal' | 'ptc' | 'cordis') => loadOverlayPatches(
  108. 'visualizer preset composition test',
  109. join(REPOSITORY_ROOT, 'packages/preset/agent-presets/presets', id, 'agent.cordis.yml'),
  110. ).filter(entry => entry.name === '@deepseek-ai/dsh-tool-visualizer/model')
  111. for (const id of ['standard', 'cordis'] as const) {
  112. const rows = presetModelRows(id)
  113. expect(rows).toHaveLength(1)
  114. expect(rows[0]?.disabled).toBeUndefined()
  115. }
  116. expect(presetModelRows('ptc')).toEqual([])
  117. expect(presetModelRows('minimal')).toEqual([])
  118. })
  119. it('boots the test-only Host and model through Loader without global model state', async () => {
  120. const { ctx, imports } = await boot([
  121. "- id: agents\n name: '@deepseek-ai/dsh-agent'",
  122. "- id: system-prompt\n name: '@deepseek-ai/dsh-system-prompt'",
  123. "- id: tools\n name: '@deepseek-ai/dsh-tools'",
  124. "- id: tool-visualizer\n name: '@deepseek-ai/dsh-tool-visualizer'",
  125. ])
  126. expect(ctx.tools.schemas()).toEqual([])
  127. expect((await ctx.systemPrompt.assemble()).sections.some(section => section.name === 'tool:visualizer'))
  128. .toBe(false)
  129. const preset = await mountPresetModel(ctx)
  130. const agent = registerPresetAgent(ctx, preset, 'test-standard')
  131. expect(imports).toContain('@deepseek-ai/dsh-tool-visualizer')
  132. expect(imports).toContain('@deepseek-ai/dsh-tool-visualizer/model')
  133. expect(ctx.tools.schemas(agent).map(schema => schema.name).sort())
  134. .toEqual(['show_widget', 'widget_guidelines'])
  135. const prompt = await ctx.systemPrompt.assemble({ agent, scope: agent })
  136. expect(prompt.sections.find(section => section.name === 'tool:visualizer')?.text)
  137. .toContain('Use show_widget for a temporary inline visual')
  138. })
  139. it('loads a dormant model wrapper without Host authority', async () => {
  140. const { ctx, imports } = await boot([
  141. "- id: agents\n name: '@deepseek-ai/dsh-agent'",
  142. "- id: system-prompt\n name: '@deepseek-ai/dsh-system-prompt'",
  143. "- id: tools\n name: '@deepseek-ai/dsh-tools'",
  144. ])
  145. const preset = await mountPresetModel(ctx)
  146. const agent = registerPresetAgent(ctx, preset, 'dormant-standard')
  147. expect(imports).toContain('@deepseek-ai/dsh-tool-visualizer/model')
  148. expect(ctx.get('visualizer')).toBeUndefined()
  149. expect(ctx.tools.schemas()).toEqual([])
  150. await expectVisualizerSurface(ctx, agent, false)
  151. })
  152. it('activates, withdraws, and reactivates the wrapper for existing and new Agents', async () => {
  153. const { ctx } = await boot([
  154. "- id: agents\n name: '@deepseek-ai/dsh-agent'",
  155. "- id: system-prompt\n name: '@deepseek-ai/dsh-system-prompt'",
  156. "- id: tools\n name: '@deepseek-ai/dsh-tools'",
  157. ])
  158. const preset = await mountPresetModel(ctx)
  159. const beforeAuthority = registerPresetAgent(ctx, preset, 'before-authority')
  160. await expectVisualizerSurface(ctx, beforeAuthority, false)
  161. const firstAuthorityId = await ctx.loader.create({ name: '@deepseek-ai/dsh-tool-visualizer' })
  162. await ctx.loader.await()
  163. await expectVisualizerSurface(ctx, beforeAuthority, true)
  164. const duringAuthority = registerPresetAgent(ctx, preset, 'during-authority')
  165. await expectVisualizerSurface(ctx, duringAuthority, true)
  166. await ctx.loader.remove(firstAuthorityId)
  167. await ctx.loader.await()
  168. await expectVisualizerSurface(ctx, beforeAuthority, false)
  169. await expectVisualizerSurface(ctx, duringAuthority, false)
  170. const betweenAuthorities = registerPresetAgent(ctx, preset, 'between-authorities')
  171. await expectVisualizerSurface(ctx, betweenAuthorities, false)
  172. await ctx.loader.create({ name: '@deepseek-ai/dsh-tool-visualizer' })
  173. await ctx.loader.await()
  174. await expectVisualizerSurface(ctx, beforeAuthority, true)
  175. await expectVisualizerSurface(ctx, duringAuthority, true)
  176. await expectVisualizerSurface(ctx, betweenAuthorities, true)
  177. const afterReactivation = registerPresetAgent(ctx, preset, 'after-reactivation')
  178. await expectVisualizerSurface(ctx, afterReactivation, true)
  179. })
  180. })