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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. /**
  2. * A session's agent preset is fixed at creation. The gateway records the
  3. * resolved id on the header and refuses to adopt the identity under a different
  4. * one, because the session's history was produced under that preset's tools:
  5. * rebuilding it differently would replay tool calls the new agent cannot make.
  6. */
  7. import { mkdtempSync, realpathSync } from 'node:fs'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { Context } from 'cordis'
  11. import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
  12. import type { Agent } from '@deepseek-ai/dsh-agent'
  13. import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
  14. import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  15. import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
  16. import {
  17. InvalidCompositionError, InvalidPresetIdError, UnknownPresetError,
  18. } from '@deepseek-ai/dsh-agent-presets'
  19. import { GoalId } from '@deepseek-ai/dsh-goal'
  20. import { createApiProxy } from '../src/api-proxy.ts'
  21. import { describe, expect, it } from 'vitest'
  22. let nextRpc = 0
  23. function request<P>(payload: P): RpcRequest<P> {
  24. return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload }
  25. }
  26. /** Minimal live agent; the gateway only needs identity and its session. */
  27. function stubAgent(session: Session): Agent {
  28. return { id: session.id, session, status: 'idle' } as unknown as Agent
  29. }
  30. /**
  31. * A roster whose `mount` is a no-op: this spec is about the gateway's identity
  32. * rules, and the composition itself is covered by the real-composition test in
  33. * `apps/cli`.
  34. */
  35. function roster(ids: readonly string[]): unknown {
  36. return {
  37. defaultId: ids[0],
  38. list: () => Promise.resolve(ids.map(id => ({ id, trust: 'system', path: `/presets/${id}.yml` }))),
  39. resolve: (id?: string) => {
  40. const wanted = id ?? ids[0] ?? ''
  41. if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
  42. return Promise.resolve({ id: wanted, trust: 'system', path: `/presets/${wanted}.yml` })
  43. },
  44. mount: (_ctx: Context, id?: string) =>
  45. Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }),
  46. // What a real mount leaves behind: a service instance only the agent that
  47. // mounted it can be used to address. The doubles are per agent so a test
  48. // can tell "this session's" from "some session's".
  49. serviceFor: (agent: { id: unknown }, name: string) => {
  50. const perAgent = services.get(String(agent.id))
  51. return perAgent?.[name]
  52. },
  53. authorable: true,
  54. read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
  55. write: (id: string, content: string) => {
  56. if (!ids.includes(id) && !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
  57. return Promise.reject(new InvalidPresetIdError(id))
  58. }
  59. if (!content.trimStart().startsWith('-')) return Promise.reject(new InvalidCompositionError('not a list'))
  60. return Promise.resolve()
  61. },
  62. remove: (id: string) => {
  63. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  64. return Promise.resolve()
  65. },
  66. recompose: (_ctx: Context, id: string) => {
  67. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  68. return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
  69. },
  70. }
  71. }
  72. /** Per-agent service instances a mounted preset would own, keyed by session id. */
  73. const services = new Map<string, Record<string, unknown>>()
  74. async function harness(presets?: readonly string[]) {
  75. const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
  76. const ctx = new Context()
  77. await ctx.plugin(SessionStore)
  78. await ctx.plugin(AgentRegistry)
  79. await ctx.plugin(UserInteractionService)
  80. ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
  81. if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
  82. const factory: AgentFactory = {
  83. async createAgent(_ownerCtx, options) {
  84. const session = ctx.sessions.create(
  85. options.sessionId,
  86. options.meta === undefined ? {} : { meta: options.meta },
  87. )
  88. const agent = stubAgent(session)
  89. // Setup runs before publication against a context that carries the
  90. // agent, and the agent reaches back through `agent.ctx` — the pair the
  91. // gateway's own `installTarget` relies on.
  92. const agentCtx = ctx.extend({ agent })
  93. ;(agent as { ctx?: Context }).ctx = agentCtx
  94. await options.setup?.(agentCtx)
  95. const unregister = ctx.agents.register(agent)
  96. return { agent, dispose: () => { unregister(); return Promise.resolve() } }
  97. },
  98. async resume() {
  99. throw new Error('test harness has no persisted sessions')
  100. },
  101. }
  102. ctx.agents.setFactory(factory)
  103. const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', cwd, workspaceRoot: cwd })
  104. return { api, ctx, cwd }
  105. }
  106. describe('session.create with an agent preset', () => {
  107. it('records the resolved preset on the session header', async () => {
  108. const { api, ctx } = await harness(['standard', 'minimal'])
  109. const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' }))
  110. expect(created.result.ok).toBe(true)
  111. expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
  112. })
  113. it('records the default when the caller names none', async () => {
  114. const { api, ctx } = await harness(['standard', 'minimal'])
  115. await api.sessions.create(request({ sessionId: SessionId('s2') }))
  116. expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
  117. })
  118. it('rejects an unknown preset and names the ones that exist', async () => {
  119. const { api } = await harness(['standard'])
  120. const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' }))
  121. expect(response.result.ok).toBe(false)
  122. if (response.result.ok) throw new Error('unreachable')
  123. expect(response.result.error.code).toBe('agent-preset-not-found')
  124. })
  125. it('refuses to adopt a live session under a different preset', async () => {
  126. const { api } = await harness(['standard', 'minimal'])
  127. await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' }))
  128. const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' }))
  129. expect(response.result.ok).toBe(false)
  130. if (response.result.ok) throw new Error('unreachable')
  131. expect(response.result.error.code).toBe('agent-preset-conflict')
  132. expect(response.result.error.details).toEqual({
  133. sessionId: 's4',
  134. requestedPreset: 'standard',
  135. existingPreset: 'minimal',
  136. })
  137. })
  138. it('adopts a live session unchanged when the caller names no preset', async () => {
  139. const { api } = await harness(['standard', 'minimal'])
  140. await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
  141. // Reconnecting and retrying a create must stay ordinary operations.
  142. const response = await api.sessions.create(request({ sessionId: SessionId('s5') }))
  143. expect(response.result.ok).toBe(true)
  144. })
  145. it('leaves the header preset-less when no roster is composed', async () => {
  146. const { api, ctx } = await harness()
  147. await api.sessions.create(request({ sessionId: SessionId('s6') }))
  148. expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
  149. })
  150. it('says why a preset-less session cannot be adopted under one', async () => {
  151. // Two callers reach this: a deployment that composes no roster, and a
  152. // session created before one existed. Both record no preset, so naming
  153. // any is a conflict rather than an adoption — the history was produced
  154. // under a composition this roster cannot name. The message has to say
  155. // that, because "already runs agent preset undefined" reads as a bug.
  156. const { api } = await harness()
  157. await api.sessions.create(request({ sessionId: SessionId('s7') }))
  158. const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' }))
  159. expect(response.result.ok).toBe(false)
  160. if (response.result.ok) throw new Error('unreachable')
  161. expect(response.result.error.code).toBe('agent-preset-conflict')
  162. expect(response.result.error.message).toContain('records no agent preset')
  163. expect(response.result.error.details).toEqual({
  164. sessionId: 's7',
  165. requestedPreset: 'standard',
  166. existingPreset: undefined,
  167. })
  168. })
  169. })
  170. /**
  171. * A capability a preset mounts is reachable from nowhere the host normally
  172. * looks: an `isolate` realm is what makes it per session. The gateway serves
  173. * requests that are ABOUT a session from OUTSIDE it, so it addresses the
  174. * instance through the agent instead of reading a root-realm singleton.
  175. */
  176. describe('a capability the session\'s preset mounts', () => {
  177. it('serves the goal RPC from the session\'s own goal service', async () => {
  178. const { api } = await harness(['standard'])
  179. await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' }))
  180. const ref = { id: GoalId('goal-1'), revision: 1 }
  181. const paused: unknown[] = []
  182. services.set('g1', {
  183. goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
  184. })
  185. const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
  186. expect(response.result).toMatchObject({ ok: true, value: { ref } })
  187. // Reached the instance this session mounted, and was handed its own agent.
  188. expect(paused).toEqual([['g1', ref]])
  189. services.delete('g1')
  190. })
  191. it('serves the skill catalog from the session\'s own registry', async () => {
  192. const { api } = await harness(['standard'])
  193. await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' }))
  194. services.set('k1', {
  195. skills: {
  196. list: () => Promise.resolve([{
  197. name: 'preset-owned',
  198. description: 'ships inside the preset directory',
  199. invocation: { modelInvocable: true, userInvocable: true },
  200. }]),
  201. },
  202. })
  203. const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
  204. // A preset ships its own skill directory, so the catalog IS the
  205. // session's; reading a host singleton would answer for the wrong one.
  206. expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
  207. services.delete('k1')
  208. })
  209. it('says so when no composition mounts the capability at all', async () => {
  210. const { api } = await harness(['standard'])
  211. await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' }))
  212. const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
  213. // Absent means absent — not "this session has none", which is what a
  214. // root-realm read used to report for every presetd session.
  215. expect(response.result.ok).toBe(false)
  216. const failure = response.result as { ok: false; error: { message: string } }
  217. expect(failure.error.message).toContain('neither this session')
  218. })
  219. })
  220. describe('agentPreset.list', () => {
  221. it('marks the default and carries each preset\'s trust', async () => {
  222. const { api } = await harness(['standard', 'minimal'])
  223. const response = await api.agentPresets.list(request({}))
  224. expect(response.result.ok).toBe(true)
  225. if (!response.result.ok) throw new Error('unreachable')
  226. expect(response.result.value.presets).toEqual([
  227. { id: 'standard', trust: 'system', isDefault: true },
  228. { id: 'minimal', trust: 'system', isDefault: false },
  229. ])
  230. expect(response.result.value.authorable).toBe(true)
  231. })
  232. it('answers with an empty roster when the deployment composes no presets', async () => {
  233. const { api } = await harness()
  234. const response = await api.agentPresets.list(request({}))
  235. // Composing no presets is a valid deployment, not an error: every session
  236. // then shares the host composition and the browser offers no choice.
  237. expect(response.result.ok).toBe(true)
  238. if (!response.result.ok) throw new Error('unreachable')
  239. expect(response.result.value.presets).toEqual([])
  240. // Nothing to write to either, so a surface offering "new preset" knows to
  241. // stay hidden rather than offering a button whose save always fails.
  242. expect(response.result.value.authorable).toBe(false)
  243. })
  244. })
  245. describe('agentPreset.select', () => {
  246. it('recomposes a blank session', async () => {
  247. const { api } = await harness(['standard', 'minimal'])
  248. await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' }))
  249. const response = await api.agentPresets.select(
  250. request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' }))
  251. expect(response.result.ok).toBe(true)
  252. if (!response.result.ok) throw new Error('unreachable')
  253. expect(response.result.value.agentPreset).toBe('minimal')
  254. })
  255. it('refuses once the conversation has started', async () => {
  256. const { api, ctx } = await harness(['standard', 'minimal'])
  257. await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
  258. // One turn is enough: the history from here on was produced under
  259. // `standard`'s tools, and a swap would strand those tool calls.
  260. ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
  261. const response = await api.agentPresets.select(
  262. request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' }))
  263. expect(response.result.ok).toBe(false)
  264. if (response.result.ok) throw new Error('unreachable')
  265. expect(response.result.error.code).toBe('agent-preset-locked')
  266. })
  267. it('reports an unknown preset without disturbing the session', async () => {
  268. const { api } = await harness(['standard'])
  269. await api.sessions.create(request({ sessionId: SessionId('sel-3') }))
  270. const response = await api.agentPresets.select(
  271. request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
  272. expect(response.result.ok).toBe(false)
  273. if (response.result.ok) throw new Error('unreachable')
  274. expect(response.result.error.code).toBe('agent-preset-not-found')
  275. })
  276. it('reports a deployment that composes no presets', async () => {
  277. const { api } = await harness()
  278. await api.sessions.create(request({ sessionId: SessionId('sel-4') }))
  279. const response = await api.agentPresets.select(
  280. request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
  281. expect(response.result.ok).toBe(false)
  282. if (response.result.ok) throw new Error('unreachable')
  283. expect(response.result.error.code).toBe('agent-preset-not-found')
  284. })
  285. })
  286. describe('authoring over the wire', () => {
  287. it('reads a composition and reports whether it may be edited', async () => {
  288. const { api } = await harness(['standard'])
  289. const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
  290. expect(response.result.ok).toBe(true)
  291. if (!response.result.ok) throw new Error('unreachable')
  292. // The shipped set is readable but not writable: it belongs to the
  293. // deployment, and it is what a broken local preset is compared against.
  294. expect(response.result.value.trust).toBe('system')
  295. expect(response.result.value.writable).toBe(false)
  296. expect(response.result.value.content).toContain('- id: x')
  297. })
  298. it('rejects an id that could escape the preset root', async () => {
  299. const { api } = await harness(['standard'])
  300. const response = await api.agentPresets.write(request({ agentPreset: '../escape', content: '- id: x\n' }))
  301. expect(response.result.ok).toBe(false)
  302. if (response.result.ok) throw new Error('unreachable')
  303. expect(response.result.error.code).toBe('agent-preset-invalid')
  304. })
  305. it('rejects content that is not an entry list', async () => {
  306. const { api } = await harness(['standard'])
  307. const response = await api.agentPresets.write(request({ agentPreset: 'mine', content: 'tools: []\n' }))
  308. expect(response.result.ok).toBe(false)
  309. if (response.result.ok) throw new Error('unreachable')
  310. expect(response.result.error.code).toBe('agent-preset-invalid')
  311. })
  312. it('reports a deployment that composes no presets', async () => {
  313. const { api } = await harness()
  314. const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
  315. expect(response.result.ok).toBe(false)
  316. if (response.result.ok) throw new Error('unreachable')
  317. expect(response.result.error.code).toBe('agent-preset-not-found')
  318. })
  319. it('reports an unknown id on delete rather than succeeding silently', async () => {
  320. const { api } = await harness(['standard'])
  321. const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
  322. expect(response.result.ok).toBe(false)
  323. if (response.result.ok) throw new Error('unreachable')
  324. expect(response.result.error.code).toBe('agent-preset-not-found')
  325. })
  326. })