api-proxy-agent-preset.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. /** API Proxy behavior for Agent preset management and preset-scoped catalogs. */
  2. import { mkdtempSync, realpathSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { Context } from '@deepseek-ai/cordis'
  6. import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
  9. import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
  10. import type { ApiProxy } from '../src/api/index.ts'
  11. import {
  12. agentPresetProjectionDefinition, InvalidPresetIdError, PresetExistsError, UnknownPresetError,
  13. } from '@deepseek-ai/dsh-agent-presets'
  14. import type {} from '@deepseek-ai/dsh-agent-presets/types'
  15. import { createApiProxy } from '../src/api-proxy.ts'
  16. import { describe, expect, it } from 'vitest'
  17. import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
  18. let nextRpc = 0
  19. function request<P>(payload: P): RpcRequest<P> {
  20. return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload }
  21. }
  22. const sessionHarnesses = new WeakMap<ApiProxy, { ctx: Context; cwd: string }>()
  23. async function createSession(
  24. api: ApiProxy,
  25. request: { readonly sessionId: SessionId; readonly agentPreset?: string },
  26. ): Promise<void> {
  27. const harness = sessionHarnesses.get(api)
  28. if (harness === undefined) throw new Error('Session test harness is not installed')
  29. const presets = harness.ctx.get('agentPresets')
  30. const agentPreset = presets === undefined
  31. ? undefined
  32. : (await presets.resolve(request.agentPreset)).id
  33. await harness.ctx.agents.create({
  34. sessionId: request.sessionId,
  35. meta: {
  36. cwd: harness.cwd,
  37. ...(agentPreset === undefined ? {} : { agentPreset }),
  38. },
  39. ...(agentPreset === undefined || presets === undefined
  40. ? {}
  41. : { setup: async (agentCtx: Context) => { await presets.mount(agentCtx, agentPreset) } }),
  42. })
  43. }
  44. /** Minimal live agent; the gateway only needs identity and its session. */
  45. function stubAgent(session: Session): Agent {
  46. return { id: session.id, session, status: 'idle' } as unknown as Agent
  47. }
  48. /**
  49. * A roster whose `mount` is a no-op: this spec is about the gateway's identity
  50. * rules, and the composition itself is covered by the real-composition test in
  51. * `apps/cli`. Ids listed in `userIds` present as locally authored; the rest
  52. * ship with the deployment.
  53. */
  54. function roster(ids: readonly string[], userIds: readonly string[] = []): unknown {
  55. const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system')
  56. const presetOf = (id: string): object =>
  57. ({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` })
  58. return {
  59. defaultId: ids[0],
  60. list: () => Promise.resolve(ids.map(presetOf)),
  61. resolve: (id?: string) => {
  62. const wanted = id ?? ids[0] ?? ''
  63. if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
  64. return Promise.resolve(presetOf(wanted))
  65. },
  66. mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
  67. // What a real mount leaves behind: a service instance only the agent that
  68. // mounted it can be used to address. The doubles are per agent so a test
  69. // can tell "this session's" from "some session's".
  70. serviceFor: (agent: { id: unknown }, name: string) => {
  71. const perAgent = services.get(String(agent.id))
  72. return perAgent?.[name]
  73. },
  74. authorable: true,
  75. read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
  76. copy: (from: string, id: string) => {
  77. if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids))
  78. if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id))
  79. if (ids.includes(id)) return Promise.reject(new PresetExistsError(id))
  80. return Promise.resolve()
  81. },
  82. remove: (id: string) => {
  83. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  84. return Promise.resolve()
  85. },
  86. recompose: (_ctx: Context, id: string) => {
  87. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  88. return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
  89. },
  90. // The standing scope key a cold transcript read resolves presenters in.
  91. standingKeyFor: (id?: string) => {
  92. const wanted = id ?? ids[0] ?? ''
  93. if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
  94. let key = standingKeys.get(wanted)
  95. if (key === undefined) {
  96. key = { agentPreset: wanted }
  97. standingKeys.set(wanted, key)
  98. }
  99. return Promise.resolve(key)
  100. },
  101. }
  102. }
  103. /** Standing keys minted by the roster double. */
  104. const standingKeys = new Map<string, object>()
  105. /** Per-agent service instances a mounted preset would own, keyed by session id. */
  106. const services = new Map<string, Record<string, unknown>>()
  107. async function harness(
  108. presets?: readonly string[],
  109. options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {},
  110. ) {
  111. const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
  112. const ctx = new Context()
  113. await ctx.plugin(SessionStore)
  114. await ctx.plugin(AgentRegistry)
  115. if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
  116. ctx.provide('sessionQuery', {
  117. observeSession: (sessionId: SessionId) => {
  118. const session = ctx.sessions.get(sessionId)
  119. if (session === undefined) {
  120. return Promise.reject(new SessionQueryError(
  121. `session "${sessionId}" not found`,
  122. 'SESSION_QUERY_SESSION_NOT_FOUND',
  123. ))
  124. }
  125. let preset = agentPresetProjectionDefinition.init(session.header)
  126. for (const event of session.events) {
  127. preset = agentPresetProjectionDefinition.apply(preset, event)
  128. }
  129. const events = Object.freeze([...session.events])
  130. const lease = (): SessionObservation => ({
  131. source: 'live' as const,
  132. header: session.header,
  133. events,
  134. cursor: events.at(-1)?.seq ?? -1,
  135. projections: {
  136. asOfSeq: events.at(-1)?.seq ?? -1,
  137. values: { agentPreset: preset },
  138. },
  139. retain: lease,
  140. [Symbol.dispose]: () => {},
  141. })
  142. return Promise.resolve(lease())
  143. },
  144. } as never)
  145. const factory: AgentFactory = {
  146. async createAgent(_ownerCtx, options) {
  147. const session = ctx.sessions.create(
  148. options.sessionId,
  149. options.meta === undefined ? {} : { meta: options.meta },
  150. )
  151. const agent = stubAgent(session)
  152. // Setup runs before publication against a context that carries the
  153. // agent, and the agent reaches back through `agent.ctx` — the pair the
  154. // gateway's own `installTarget` relies on.
  155. const agentCtx = ctx.extend({ agent })
  156. ;(agent as { ctx?: Context }).ctx = agentCtx
  157. await options.setup?.(agentCtx)
  158. const unregister = ctx.agents.register(agent)
  159. return { agent, dispose: () => { unregister(); return Promise.resolve() } }
  160. },
  161. async resume() {
  162. throw new Error('test harness has no persisted sessions')
  163. },
  164. }
  165. ctx.agents.setFactory(factory)
  166. ctx.provide('sessionController', {
  167. resolveAgent: (sessionId: SessionId) => {
  168. const agent = ctx.agents.get(sessionId)
  169. return Promise.resolve(agent === undefined
  170. ? {
  171. error: {
  172. code: 'session-not-found',
  173. message: `session "${sessionId}" not found`,
  174. details: { sessionId },
  175. },
  176. }
  177. : { agent })
  178. },
  179. inspect: (sessionId: SessionId) => {
  180. const session = ctx.sessions.get(sessionId)
  181. if (session === undefined) throw new Error(`session "${sessionId}" not found`)
  182. return Promise.resolve({ meta: session.header, events: [...session.events] })
  183. },
  184. } as never)
  185. const defaults = {
  186. defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
  187. cwd,
  188. ...options.defaults,
  189. }
  190. const api = createApiProxy(ctx, defaults)
  191. sessionHarnesses.set(api, { ctx, cwd })
  192. return { api, ctx, cwd }
  193. }
  194. /**
  195. * A capability a preset mounts is reachable from nowhere the host normally
  196. * looks: an `isolate` realm is what makes it per session. The gateway serves
  197. * requests that are ABOUT a session from OUTSIDE it, so it addresses the
  198. * instance through the agent instead of reading a root-realm singleton.
  199. */
  200. describe('a capability the session\'s preset mounts', () => {
  201. it('serves the skill catalog from the session\'s own registry', async () => {
  202. const { api } = await harness(['standard'])
  203. await createSession(api, { sessionId: SessionId('k1'), agentPreset: 'standard' })
  204. services.set('k1', {
  205. skills: {
  206. list: () => Promise.resolve([{
  207. name: 'preset-owned',
  208. description: 'ships inside the preset directory',
  209. invocation: { modelInvocable: true, userInvocable: true },
  210. }]),
  211. },
  212. })
  213. const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
  214. // A preset ships its own skill directory, so the catalog IS the
  215. // session's; reading a host singleton would answer for the wrong one.
  216. expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
  217. services.delete('k1')
  218. })
  219. it('says so when no composition mounts the capability at all', async () => {
  220. const { api } = await harness(['standard'])
  221. await createSession(api, { sessionId: SessionId('n1'), agentPreset: 'standard' })
  222. const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
  223. // Absent means absent — not "this session has none", which is what a
  224. // root-realm read used to report for every presetd session.
  225. expect(response.result.ok).toBe(false)
  226. const failure = response.result as { ok: false; error: { message: string } }
  227. expect(failure.error.message).toContain('neither this session')
  228. })
  229. })
  230. describe('opening a preset directory', () => {
  231. it('hands the resolved directory to the native opener', async () => {
  232. const opened: string[] = []
  233. const { api } = await harness(['standard', 'my-preset'], {
  234. userIds: ['my-preset'],
  235. defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
  236. })
  237. const response = await api.agentPresets.openDocument(
  238. request({ agentPreset: 'my-preset' }), new AbortController().signal)
  239. expect(response.result.ok).toBe(true)
  240. if (!response.result.ok) throw new Error('unreachable')
  241. expect(response.result.value).toEqual({ opened: true })
  242. // The id selected the directory; the browser supplied no path.
  243. expect(opened).toEqual(['/presets/my-preset'])
  244. })
  245. it('answers the path as text where the deployment has no opener', async () => {
  246. const { api } = await harness(['standard', 'my-preset'], {
  247. userIds: ['my-preset'],
  248. defaults: { canOpenPath: () => false },
  249. })
  250. const response = await api.agentPresets.openDocument(
  251. request({ agentPreset: 'my-preset' }), new AbortController().signal)
  252. expect(response.result.ok).toBe(true)
  253. if (!response.result.ok) throw new Error('unreachable')
  254. expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' })
  255. })
  256. it('refuses a preset that ships with the deployment', async () => {
  257. const opened: string[] = []
  258. const { api } = await harness(['standard'], {
  259. defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
  260. })
  261. const response = await api.agentPresets.openDocument(
  262. request({ agentPreset: 'standard' }), new AbortController().signal)
  263. // Pointing an editor into the install invites edits an upgrade will
  264. // silently overwrite; the refusal mirrors copy/remove.
  265. expect(response.result.ok).toBe(false)
  266. if (response.result.ok) throw new Error('unreachable')
  267. expect(response.result.error.code).toBe('agent-preset-read-only')
  268. expect(opened).toEqual([])
  269. })
  270. it('reports the opener capability on host.describe', async () => {
  271. const openable = await harness(['standard'], {
  272. defaults: { canOpenPath: () => true },
  273. })
  274. const headless = await harness(['standard'], {
  275. defaults: { canOpenPath: () => false },
  276. })
  277. // The capability a surface joins onto the roster to decide between opening
  278. // a preset directory and showing its path as text.
  279. const yes = await openable.api.host.describe(request({}))
  280. const no = await headless.api.host.describe(request({}))
  281. expect(yes.result.ok && yes.result.value.canOpenPath).toBe(true)
  282. expect(no.result.ok && no.result.value.canOpenPath).toBe(false)
  283. })
  284. it('counts an injected opener as openable', async () => {
  285. const { api } = await harness(['standard'], {
  286. defaults: { openPath: () => Promise.resolve() },
  287. })
  288. const response = await api.host.describe(request({}))
  289. expect(response.result.ok && response.result.value.canOpenPath).toBe(true)
  290. })
  291. })
  292. describe('skills over the layered host registry', () => {
  293. it('passes the live agent as the view scope to the host registry', async () => {
  294. const { api, ctx } = await harness(['standard'])
  295. const seen: unknown[] = []
  296. ctx.provide('skills', {
  297. list: (options: { scope?: unknown }) => {
  298. seen.push(options.scope)
  299. return Promise.resolve([])
  300. },
  301. } as never)
  302. await createSession(api, { sessionId: SessionId('h1'), agentPreset: 'standard' })
  303. const response = await api.skills.list(request({ sessionId: SessionId('h1') }))
  304. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  305. expect(seen).toEqual([ctx.agents.get(SessionId('h1'))])
  306. })
  307. it('resolves a cold session to its recorded preset standing key', async () => {
  308. const { api, ctx } = await harness(['standard', 'minimal'])
  309. const seen: unknown[] = []
  310. ctx.provide('skills', {
  311. list: (options: { scope?: unknown }) => {
  312. seen.push(options.scope)
  313. return Promise.resolve([])
  314. },
  315. } as never)
  316. ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } })
  317. const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
  318. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  319. expect(seen).toEqual([standingKeys.get('minimal')])
  320. })
  321. it('serves the global view when the roster no longer supplies the recorded preset', async () => {
  322. const { api, ctx } = await harness(['standard'])
  323. const seen: unknown[] = []
  324. ctx.provide('skills', {
  325. list: (options: { scope?: unknown }) => {
  326. seen.push(options.scope)
  327. return Promise.resolve([])
  328. },
  329. } as never)
  330. ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } })
  331. const response = await api.skills.list(request({ sessionId: SessionId('h3') }))
  332. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  333. expect(seen).toEqual([undefined])
  334. })
  335. })