inventory.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import Loader from '@deepseek-ai/cordis-plugin-loader'
  8. import Include from '@deepseek-ai/cordis-plugin-include'
  9. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  10. import { SessionId } from '@deepseek-ai/dsh-session'
  11. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  12. import { createScope } from '@deepseek-ai/dsh-scope'
  13. import AgentPresets, { mountPreset } from '@deepseek-ai/dsh-agent-presets'
  14. import { PluginPackages } from '@deepseek-ai/dsh-app-boot'
  15. import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
  16. import * as PluginInventory from '../src/index.ts'
  17. const contexts: Context[] = []
  18. const roots: string[] = []
  19. const SIGNAL = new AbortController().signal
  20. afterEach(async () => {
  21. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  22. await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
  23. })
  24. async function packagePlugin(
  25. root: string,
  26. dir: string,
  27. manifest: object,
  28. source = 'export default () => {}\n',
  29. ): Promise<string> {
  30. const packageDir = join(root, dir)
  31. await mkdir(packageDir, { recursive: true })
  32. await writeFile(join(packageDir, 'package.json'), `${JSON.stringify({ type: 'module', ...manifest })}\n`)
  33. await writeFile(join(packageDir, 'plugin.mjs'), source)
  34. return `./${dir}/plugin.mjs`
  35. }
  36. async function harness(
  37. enabled?: boolean, packageService = false,
  38. ): Promise<{ ctx: Context; root: string; disposeInventory: () => Promise<void> }> {
  39. const root = await mkdtemp(join(tmpdir(), 'dsh-plugin-packages-'))
  40. roots.push(root)
  41. const ctx = new Context()
  42. contexts.push(ctx)
  43. ctx.baseUrl = pathToFileURL(join(root, 'cordis.yml')).href
  44. await ctx.plugin(Loader)
  45. if (packageService) await ctx.plugin(PluginPackages)
  46. ctx.loader.builtins.include = Include
  47. await ctx.plugin(AgentRegistry)
  48. await ctx.plugin(SessionProjectionRegistry)
  49. await ctx.plugin(AgentPresets, { default: 'fixture', roots: [], includeShippedRoot: false, includeUserRoot: false })
  50. await ctx.plugin(DeepSeekLlmApiExtensionRegistry)
  51. const inventory = enabled === undefined
  52. ? ctx.plugin(PluginInventory)
  53. : ctx.plugin(PluginInventory, { enabled })
  54. await inventory
  55. return { ctx, root, disposeInventory: () => inventory.dispose() }
  56. }
  57. describe('DeepSeek plugin package inventory', () => {
  58. it('contributes by default and can be explicitly disabled', async () => {
  59. const defaultHarness = await harness()
  60. const defaultFields = await defaultHarness.ctx.deepseekLlmApiExtensions.prepare({
  61. body: { messages: [] }, signal: SIGNAL,
  62. })
  63. expect(defaultFields.fields).toHaveProperty('dsh_plugin_packages')
  64. const disabledHarness = await harness(false)
  65. const disabledFields = await disabledHarness.ctx.deepseekLlmApiExtensions.prepare({
  66. body: { messages: [] }, signal: SIGNAL,
  67. })
  68. expect(disabledFields.fields).not.toHaveProperty('dsh_plugin_packages')
  69. })
  70. it('reports active package versions once, retains parallel versions, and excludes inactive or loose entries', async () => {
  71. const { ctx, root } = await harness()
  72. const oneA = await packagePlugin(root, 'one-a', { name: 'one', version: '1.0.0' })
  73. const oneB = await packagePlugin(root, 'one-b', { name: 'one', version: '2.0.0' })
  74. const disabled = await packagePlugin(root, 'disabled', { name: 'disabled', version: '1.0.0' })
  75. await mkdir(join(root, 'loose'), { recursive: true })
  76. await writeFile(join(root, 'loose/plugin.mjs'), 'export default () => {}\n')
  77. await ctx.loader.create({ name: oneA })
  78. await ctx.loader.create({ name: oneA })
  79. await ctx.loader.create({ name: oneB })
  80. await ctx.loader.create({ name: disabled, disabled: true })
  81. await ctx.loader.create({ name: './loose/plugin.mjs' })
  82. const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
  83. expect(prepared.fields.dsh_plugin_packages).toEqual({
  84. version: 1,
  85. packages: [
  86. { name: 'one', version: '1.0.0' },
  87. { name: 'one', version: '2.0.0' },
  88. ],
  89. })
  90. })
  91. it('fails request preparation for an active package with malformed identity metadata', async () => {
  92. const { ctx, root } = await harness()
  93. const bad = await packagePlugin(root, 'bad', { name: 'bad' })
  94. await ctx.loader.create({ name: bad })
  95. await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }))
  96. .rejects.toThrow(/must declare non-empty name and version/)
  97. })
  98. it('omits a loose ESM module whose nearest manifest only marks the module type', async () => {
  99. const { ctx, root } = await harness()
  100. const marker = await packagePlugin(root, 'marker-only', {})
  101. await ctx.loader.create({ name: marker })
  102. await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }))
  103. .resolves.toMatchObject({ fields: { dsh_plugin_packages: { version: 1, packages: [] } } })
  104. })
  105. it('uses the host inventory when a request has no matching or joined live agent', async () => {
  106. const { ctx, root } = await harness()
  107. const plugin = await packagePlugin(root, 'host-only', { name: 'host-only', version: '3.0.0' })
  108. await ctx.loader.create({ name: plugin })
  109. const missing = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: 'missing' })
  110. expect(missing.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'host-only', version: '3.0.0' }])
  111. const id = SessionId('bare-agent')
  112. const agentScope = createScope(ctx, {})
  113. await ctx.agents.register({ id, ctx: agentScope.ctx, session: { id } } as unknown as Agent)
  114. const bare = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id })
  115. expect(bare.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'host-only', version: '3.0.0' }])
  116. })
  117. it('resolves scoped and unscoped bare subpaths, absolute/file modules, and skips URL or Cordis modules', async () => {
  118. const { ctx, root } = await harness(undefined, true)
  119. await packagePlugin(root, 'node_modules/plain-package', { name: 'plain-package', version: '1.0.0' })
  120. await packagePlugin(root, 'node_modules/@scope/scoped-package', { name: '@scope/scoped-package', version: '2.0.0' })
  121. await packagePlugin(root, 'absolute-package', { name: 'absolute-package', version: '3.0.0' })
  122. const absolute = join(root, 'absolute-package/plugin.mjs')
  123. const internal = ctx.loader.internal
  124. ctx.loader.internal = {
  125. version: 'v2',
  126. import: async (specifier: string, ...args: unknown[]) => {
  127. if (specifier === 'https://plugins.example/test.mjs') return { default: () => {} }
  128. // Node ESM on Windows requires a file URL; retain the raw Loader name for package attribution.
  129. const portableSpecifier = specifier === absolute ? pathToFileURL(specifier).href : specifier
  130. return await (internal as never as { import(specifier: string, ...args: unknown[]): Promise<unknown> })
  131. .import(portableSpecifier, ...args)
  132. },
  133. } as unknown as NonNullable<typeof ctx.loader.internal>
  134. await ctx.loader.create({ name: 'plain-package/plugin.mjs' })
  135. await ctx.loader.create({ name: '@scope/scoped-package/plugin.mjs' })
  136. await ctx.loader.create({ name: absolute })
  137. await ctx.loader.create({ name: pathToFileURL(absolute).href })
  138. ctx.loader.builtins.noop = () => {}
  139. await ctx.loader.create({ name: 'cordis:noop' })
  140. await ctx.loader.create({ name: 'https://plugins.example/test.mjs' })
  141. const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
  142. expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([
  143. { name: '@scope/scoped-package', version: '2.0.0' },
  144. { name: 'absolute-package', version: '3.0.0' },
  145. { name: 'plain-package', version: '1.0.0' },
  146. ])
  147. })
  148. it('fails when a Loader-resolved bare entry has no package manifest', async () => {
  149. const { ctx } = await harness()
  150. ctx.loader.internal = {
  151. version: 'v2',
  152. import: async () => ({ default: () => {} }),
  153. } as unknown as NonNullable<typeof ctx.loader.internal>
  154. await ctx.loader.create({ name: 'missing-package' })
  155. await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }))
  156. .rejects.toThrow(/cannot resolve active package/)
  157. })
  158. it('does not bypass the profile package service for a missing bare package', async () => {
  159. const { ctx } = await harness(undefined, true)
  160. ctx.loader.internal = {
  161. version: 'v2',
  162. import: async () => ({ default: () => {} }),
  163. } as unknown as NonNullable<typeof ctx.loader.internal>
  164. await ctx.loader.create({ name: 'missing-profile-package' })
  165. await expect(ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL }))
  166. .rejects.toThrow(/cannot resolve active package/)
  167. })
  168. it('supports a direct embedding whose context has no base URL', async () => {
  169. const ctx = new Context()
  170. contexts.push(ctx)
  171. await ctx.plugin(Loader)
  172. await ctx.plugin(AgentRegistry)
  173. await ctx.plugin(DeepSeekLlmApiExtensionRegistry)
  174. await ctx.plugin(PluginInventory)
  175. const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
  176. expect(prepared.fields.dsh_plugin_packages).toEqual({ version: 1, packages: [] })
  177. })
  178. it('uses each ordinary Loader tree base for conflicting bare package versions', async () => {
  179. const { ctx, root } = await harness()
  180. await packagePlugin(root, 'node_modules/versioned-plugin', {
  181. name: 'versioned-plugin', version: '1.0.0',
  182. })
  183. const nestedRoot = join(root, 'nested')
  184. await packagePlugin(nestedRoot, 'node_modules/versioned-plugin', {
  185. name: 'versioned-plugin', version: '2.0.0',
  186. })
  187. const composition = join(nestedRoot, 'cordis.yml')
  188. await writeFile(composition, '- id: nested\n name: versioned-plugin/plugin.mjs\n')
  189. await ctx.loader.create({ name: 'versioned-plugin/plugin.mjs' })
  190. await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(composition).href } })
  191. await ctx.loader.await()
  192. const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
  193. expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([
  194. { name: 'versioned-plugin', version: '1.0.0' },
  195. { name: 'versioned-plugin', version: '2.0.0' },
  196. ])
  197. })
  198. it('mirrors the standing preset bare-package override instead of its local node_modules', async () => {
  199. const { ctx, root } = await harness()
  200. await packagePlugin(root, 'node_modules/preset-only', { name: 'preset-only', version: '4.0.0' })
  201. const presetDir = join(root, 'preset')
  202. await mkdir(presetDir, { recursive: true })
  203. await packagePlugin(presetDir, 'node_modules/preset-only', { name: 'preset-only', version: '9.0.0' })
  204. const composition = join(presetDir, 'agent.cordis.yml')
  205. await writeFile(composition, '- id: preset-only\n name: preset-only/plugin.mjs\n')
  206. const standingKey = {}
  207. const standing = createScope(ctx, standingKey)
  208. await mountPreset(standing.ctx, { id: 'fixture', trust: 'user', path: composition })
  209. const agentKey = {}
  210. const agentScope = createScope(ctx, agentKey, { parent: standingKey })
  211. const id = SessionId('preset-agent')
  212. const agent = { id, ctx: agentScope.ctx, session: { id } } as unknown as Agent
  213. await ctx.agents.register(agent)
  214. const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL, sessionId: id })
  215. expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([{ name: 'preset-only', version: '4.0.0' }])
  216. })
  217. it('withdraws the inventory field when the contributing plugin reloads', async () => {
  218. const { ctx, disposeInventory } = await harness()
  219. expect((await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })).fields)
  220. .toHaveProperty('dsh_plugin_packages')
  221. await disposeInventory()
  222. expect((await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })).fields)
  223. .not.toHaveProperty('dsh_plugin_packages')
  224. })
  225. })