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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730
  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 '@deepseek-ai/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 type { HostFrame } from '../src/api/events.ts'
  17. import {
  18. InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
  19. } from '@deepseek-ai/dsh-agent-presets'
  20. import type {} from '@deepseek-ai/dsh-agent-presets/types'
  21. import { GoalId } from '@deepseek-ai/dsh-goal'
  22. import { createApiProxy } from '../src/api-proxy.ts'
  23. import { describe, expect, it } from 'vitest'
  24. let nextRpc = 0
  25. function request<P>(payload: P): RpcRequest<P> {
  26. return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload }
  27. }
  28. /** Minimal live agent; the gateway only needs identity and its session. */
  29. function stubAgent(session: Session): Agent {
  30. return { id: session.id, session, status: 'idle' } as unknown as Agent
  31. }
  32. /**
  33. * A roster whose `mount` is a no-op: this spec is about the gateway's identity
  34. * rules, and the composition itself is covered by the real-composition test in
  35. * `apps/cli`. Ids listed in `userIds` present as locally authored; the rest
  36. * ship with the deployment.
  37. */
  38. function roster(ids: readonly string[], userIds: readonly string[] = []): unknown {
  39. const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system')
  40. const presetOf = (id: string): object =>
  41. ({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` })
  42. return {
  43. defaultId: ids[0],
  44. list: () => Promise.resolve(ids.map(presetOf)),
  45. resolve: (id?: string) => {
  46. const wanted = id ?? ids[0] ?? ''
  47. if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
  48. return Promise.resolve(presetOf(wanted))
  49. },
  50. mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
  51. // What a real mount leaves behind: a service instance only the agent that
  52. // mounted it can be used to address. The doubles are per agent so a test
  53. // can tell "this session's" from "some session's".
  54. serviceFor: (agent: { id: unknown }, name: string) => {
  55. const perAgent = services.get(String(agent.id))
  56. return perAgent?.[name]
  57. },
  58. authorable: true,
  59. read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
  60. copy: (from: string, id: string) => {
  61. if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids))
  62. if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id))
  63. if (ids.includes(id)) return Promise.reject(new PresetExistsError(id))
  64. return Promise.resolve()
  65. },
  66. remove: (id: string) => {
  67. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  68. return Promise.resolve()
  69. },
  70. recompose: (_ctx: Context, id: string) => {
  71. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  72. return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
  73. },
  74. // The standing scope key a cold transcript read resolves presenters in.
  75. standingKeyFor: (id?: string) => {
  76. const wanted = id ?? ids[0] ?? ''
  77. standingKeyRequests.push(wanted)
  78. if (!ids.includes(wanted) || failingStandingKeys.has(wanted)) {
  79. return Promise.reject(new UnknownPresetError(wanted, ids))
  80. }
  81. let key = standingKeys.get(wanted)
  82. if (key === undefined) {
  83. key = { agentPreset: wanted }
  84. standingKeys.set(wanted, key)
  85. }
  86. return Promise.resolve(key)
  87. },
  88. }
  89. }
  90. /** Standing keys the roster double minted, and the ids readers asked for. */
  91. const standingKeys = new Map<string, object>()
  92. const standingKeyRequests: string[] = []
  93. /** Preset ids whose standing mount the double reports as unusable. */
  94. const failingStandingKeys = new Set<string>()
  95. /** Per-agent service instances a mounted preset would own, keyed by session id. */
  96. const services = new Map<string, Record<string, unknown>>()
  97. async function harness(
  98. presets?: readonly string[],
  99. persistence?: unknown,
  100. options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {},
  101. ) {
  102. const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
  103. const ctx = new Context()
  104. await ctx.plugin(SessionStore)
  105. await ctx.plugin(AgentRegistry)
  106. await ctx.plugin(UserInteractionService)
  107. ctx.provide('sessionPersistence', (persistence ?? { list: () => Promise.resolve([]) }) as never)
  108. if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
  109. const factory: AgentFactory = {
  110. async createAgent(_ownerCtx, options) {
  111. const session = ctx.sessions.create(
  112. options.sessionId,
  113. options.meta === undefined ? {} : { meta: options.meta },
  114. )
  115. const agent = stubAgent(session)
  116. // Setup runs before publication against a context that carries the
  117. // agent, and the agent reaches back through `agent.ctx` — the pair the
  118. // gateway's own `installTarget` relies on.
  119. const agentCtx = ctx.extend({ agent })
  120. ;(agent as { ctx?: Context }).ctx = agentCtx
  121. await options.setup?.(agentCtx)
  122. const unregister = ctx.agents.register(agent)
  123. return { agent, dispose: () => { unregister(); return Promise.resolve() } }
  124. },
  125. async resume() {
  126. throw new Error('test harness has no persisted sessions')
  127. },
  128. }
  129. ctx.agents.setFactory(factory)
  130. const api = createApiProxy(ctx, {
  131. defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
  132. cwd,
  133. ...options.defaults,
  134. })
  135. return { api, ctx, cwd }
  136. }
  137. describe('session.create with an agent preset', () => {
  138. it('records the resolved preset on the session header', async () => {
  139. const { api, ctx } = await harness(['standard', 'minimal'])
  140. const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' }))
  141. expect(created.result.ok).toBe(true)
  142. expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
  143. })
  144. it('records the default when the caller names none', async () => {
  145. const { api, ctx } = await harness(['standard', 'minimal'])
  146. await api.sessions.create(request({ sessionId: SessionId('s2') }))
  147. expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
  148. })
  149. it('rejects an unknown preset and names the ones that exist', async () => {
  150. const { api } = await harness(['standard'])
  151. const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' }))
  152. expect(response.result.ok).toBe(false)
  153. if (response.result.ok) throw new Error('unreachable')
  154. expect(response.result.error.code).toBe('agent-preset-not-found')
  155. })
  156. it('refuses to adopt a live session under a different preset', async () => {
  157. const { api } = await harness(['standard', 'minimal'])
  158. await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' }))
  159. const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' }))
  160. expect(response.result.ok).toBe(false)
  161. if (response.result.ok) throw new Error('unreachable')
  162. expect(response.result.error.code).toBe('agent-preset-conflict')
  163. expect(response.result.error.details).toEqual({
  164. sessionId: 's4',
  165. requestedPreset: 'standard',
  166. existingPreset: 'minimal',
  167. })
  168. })
  169. it('adopts a live session under the preset it SWITCHED to', async () => {
  170. const { api, ctx } = await harness(['standard', 'minimal'])
  171. await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
  172. // Exactly what `agentPreset.select` leaves behind on a blank session: the
  173. // header keeps the creation fact, the log states what the agent runs.
  174. ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
  175. const adopted = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }))
  176. const stale = await api.sessions.create(request({ sessionId: SessionId('s4b'), agentPreset: 'standard' }))
  177. // Comparing against the header would invert both answers: the preset the
  178. // session actually runs would be refused, and the one it left would pass.
  179. expect(adopted.result.ok).toBe(true)
  180. // The echo has to name the same preset the adoption just accepted, or the
  181. // client labels the session with one it has already left — and disagrees
  182. // with the row `session.list` serves for it.
  183. if (!adopted.result.ok) throw new Error('unreachable')
  184. expect(adopted.result.value).toMatchObject({ agentPreset: 'minimal' })
  185. expect(stale.result.ok).toBe(false)
  186. if (stale.result.ok) throw new Error('unreachable')
  187. expect(stale.result.error.details).toMatchObject({ existingPreset: 'minimal' })
  188. })
  189. it('adopts a live session unchanged when the caller names no preset', async () => {
  190. const { api } = await harness(['standard', 'minimal'])
  191. await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
  192. // Reconnecting and retrying a create must stay ordinary operations.
  193. const response = await api.sessions.create(request({ sessionId: SessionId('s5') }))
  194. expect(response.result.ok).toBe(true)
  195. })
  196. it('leaves the header preset-less when no roster is composed', async () => {
  197. const { api, ctx } = await harness()
  198. await api.sessions.create(request({ sessionId: SessionId('s6') }))
  199. expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
  200. })
  201. it('says why a preset-less session cannot be adopted under one', async () => {
  202. // Two callers reach this: a deployment that composes no roster, and a
  203. // session created before one existed. Both record no preset, so naming
  204. // any is a conflict rather than an adoption — the history was produced
  205. // under a composition this roster cannot name. The message has to say
  206. // that, because "already runs agent preset undefined" reads as a bug.
  207. const { api } = await harness()
  208. await api.sessions.create(request({ sessionId: SessionId('s7') }))
  209. const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' }))
  210. expect(response.result.ok).toBe(false)
  211. if (response.result.ok) throw new Error('unreachable')
  212. expect(response.result.error.code).toBe('agent-preset-conflict')
  213. expect(response.result.error.message).toContain('records no agent preset')
  214. expect(response.result.error.details).toEqual({
  215. sessionId: 's7',
  216. requestedPreset: 'standard',
  217. existingPreset: undefined,
  218. })
  219. })
  220. })
  221. /**
  222. * A capability a preset mounts is reachable from nowhere the host normally
  223. * looks: an `isolate` realm is what makes it per session. The gateway serves
  224. * requests that are ABOUT a session from OUTSIDE it, so it addresses the
  225. * instance through the agent instead of reading a root-realm singleton.
  226. */
  227. describe('a capability the session\'s preset mounts', () => {
  228. it('serves the goal RPC from the session\'s own goal service', async () => {
  229. const { api } = await harness(['standard'])
  230. await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' }))
  231. const ref = { id: GoalId('goal-1'), revision: 1 }
  232. const paused: unknown[] = []
  233. services.set('g1', {
  234. goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
  235. })
  236. const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
  237. expect(response.result).toMatchObject({ ok: true, value: { ref } })
  238. // Reached the instance this session mounted, and was handed its own agent.
  239. expect(paused).toEqual([['g1', ref]])
  240. services.delete('g1')
  241. })
  242. it('serves the skill catalog from the session\'s own registry', async () => {
  243. const { api } = await harness(['standard'])
  244. await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' }))
  245. services.set('k1', {
  246. skills: {
  247. list: () => Promise.resolve([{
  248. name: 'preset-owned',
  249. description: 'ships inside the preset directory',
  250. invocation: { modelInvocable: true, userInvocable: true },
  251. }]),
  252. },
  253. })
  254. const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
  255. // A preset ships its own skill directory, so the catalog IS the
  256. // session's; reading a host singleton would answer for the wrong one.
  257. expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
  258. services.delete('k1')
  259. })
  260. it('says so when no composition mounts the capability at all', async () => {
  261. const { api } = await harness(['standard'])
  262. await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' }))
  263. const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
  264. // Absent means absent — not "this session has none", which is what a
  265. // root-realm read used to report for every presetd session.
  266. expect(response.result.ok).toBe(false)
  267. const failure = response.result as { ok: false; error: { message: string } }
  268. expect(failure.error.message).toContain('neither this session')
  269. })
  270. })
  271. describe('agentPreset.list', () => {
  272. it('marks the default and carries each preset\'s trust', async () => {
  273. const { api } = await harness(['standard', 'minimal'])
  274. const response = await api.agentPresets.list(request({}))
  275. expect(response.result.ok).toBe(true)
  276. if (!response.result.ok) throw new Error('unreachable')
  277. expect(response.result.value.presets).toEqual([
  278. { id: 'standard', trust: 'system', isDefault: true },
  279. { id: 'minimal', trust: 'system', isDefault: false },
  280. ])
  281. expect(response.result.value.authorable).toBe(true)
  282. })
  283. it('answers with an empty roster when the deployment composes no presets', async () => {
  284. const { api } = await harness()
  285. const response = await api.agentPresets.list(request({}))
  286. // Composing no presets is a valid deployment, not an error: every session
  287. // then shares the host composition and the browser offers no choice.
  288. expect(response.result.ok).toBe(true)
  289. if (!response.result.ok) throw new Error('unreachable')
  290. expect(response.result.value.presets).toEqual([])
  291. // Nothing to write to either, so a surface offering "new preset" knows to
  292. // stay hidden rather than offering a button whose save always fails.
  293. expect(response.result.value.authorable).toBe(false)
  294. })
  295. })
  296. describe('agentPreset.select', () => {
  297. it('recomposes a blank session', async () => {
  298. const { api } = await harness(['standard', 'minimal'])
  299. await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' }))
  300. const response = await api.agentPresets.select(
  301. request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' }))
  302. expect(response.result.ok).toBe(true)
  303. if (!response.result.ok) throw new Error('unreachable')
  304. expect(response.result.value.agentPreset).toBe('minimal')
  305. })
  306. it('records the switch in the log, and the list reads it back', async () => {
  307. const { api, ctx } = await harness(['standard', 'minimal'])
  308. await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' }))
  309. await api.agentPresets.select(
  310. request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' }))
  311. // The header is written once at creation, so the switch lives in the log —
  312. // this is what a restart replays and what every projection resolves from.
  313. // Asserting only the RPC's echo would miss a switch that never persisted.
  314. const session = ctx.sessions.get(SessionId('sel-log'))
  315. if (session === undefined) throw new Error('unreachable')
  316. expect(session.header.agentPreset).toBe('standard')
  317. expect(resolveSessionPreset(session)).toBe('minimal')
  318. const listed = await api.sessions.list(request({}))
  319. if (!listed.result.ok) throw new Error('unreachable')
  320. expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset)
  321. .toBe('minimal')
  322. })
  323. it('forwards the owner event so clients can drop that session\'s catalogs', async () => {
  324. const { api, ctx } = await harness(['standard', 'minimal'])
  325. await api.sessions.create(request({ sessionId: SessionId('sel-frame'), agentPreset: 'standard' }))
  326. // The host-stream opener reads the committed-workspace baseline; this
  327. // spec owns preset identity, so the stub suffices (api-proxy-commands
  328. // precedent).
  329. ctx.provide('workspace', { list: () => [] } as never)
  330. const abort = new AbortController()
  331. const frames: HostFrame[] = []
  332. const stream = api.events.host(request({}), abort.signal)
  333. const consume = (async () => {
  334. for await (const frame of stream) {
  335. if (frame.payload.type === 'host/remote-event'
  336. && frame.payload.event === 'agent-preset/selected') frames.push(frame.payload)
  337. }
  338. })()
  339. // AgentPresets owns the committed-log-to-event mapping; this spec owns the
  340. // forwarding of that event without recreating the owner's implementation.
  341. ctx.emit('agent-preset/selected', SessionId('sel-frame'), 'minimal')
  342. // The queue push is synchronous; one turn lets the async iterator consume
  343. // it before the stream closes.
  344. await new Promise(resolve => setTimeout(resolve, 0))
  345. abort.abort()
  346. await consume
  347. // Recomposing registers nothing, so the owner event — not the
  348. // registry-wide commands one — tells clients their cached catalogs are stale.
  349. expect(frames).toEqual([
  350. { type: 'host/remote-event', event: 'agent-preset/selected', args: ['sel-frame', 'minimal'] },
  351. ])
  352. })
  353. it('serializes two concurrent selects on one session', async () => {
  354. const { api, ctx } = await harness(['standard', 'minimal'])
  355. await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' }))
  356. // Both pass the blank check; unserialized, the second unmount finds no
  357. // record because the first already removed it, and two compositions end up
  358. // in one agent layer. The client's busy flag is not enforcement.
  359. const [first, second] = await Promise.all([
  360. api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })),
  361. api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
  362. ])
  363. expect(first.result.ok).toBe(true)
  364. expect(second.result.ok).toBe(true)
  365. const session = ctx.sessions.get(SessionId('sel-race'))
  366. if (session === undefined) throw new Error('unreachable')
  367. // One winner, and the log agrees with it: the last committed switch.
  368. expect(resolveSessionPreset(session)).toBe('standard')
  369. })
  370. it('refuses once the conversation has started', async () => {
  371. const { api, ctx } = await harness(['standard', 'minimal'])
  372. await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
  373. // One turn is enough: the history from here on was produced under
  374. // `standard`'s tools, and a swap would strand those tool calls.
  375. ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
  376. const response = await api.agentPresets.select(
  377. request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' }))
  378. expect(response.result.ok).toBe(false)
  379. if (response.result.ok) throw new Error('unreachable')
  380. expect(response.result.error.code).toBe('agent-preset-locked')
  381. })
  382. it('reports an unknown preset without disturbing the session', async () => {
  383. const { api } = await harness(['standard'])
  384. await api.sessions.create(request({ sessionId: SessionId('sel-3') }))
  385. const response = await api.agentPresets.select(
  386. request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
  387. expect(response.result.ok).toBe(false)
  388. if (response.result.ok) throw new Error('unreachable')
  389. expect(response.result.error.code).toBe('agent-preset-not-found')
  390. })
  391. it('reports a deployment that composes no presets', async () => {
  392. const { api } = await harness()
  393. await api.sessions.create(request({ sessionId: SessionId('sel-4') }))
  394. const response = await api.agentPresets.select(
  395. request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
  396. expect(response.result.ok).toBe(false)
  397. if (response.result.ok) throw new Error('unreachable')
  398. expect(response.result.error.code).toBe('agent-preset-not-found')
  399. })
  400. })
  401. describe('authoring over the wire', () => {
  402. it('reads a composition with its trust', async () => {
  403. const { api } = await harness(['standard'])
  404. const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
  405. expect(response.result.ok).toBe(true)
  406. if (!response.result.ok) throw new Error('unreachable')
  407. // The shipped set is readable: it is the known-good composition a copy
  408. // starts from, and trust is what tells a surface to say so.
  409. expect(response.result.value.trust).toBe('system')
  410. expect(response.result.value.content).toContain('- id: x')
  411. })
  412. it('copies a preset under a new id', async () => {
  413. const { api } = await harness(['standard'])
  414. const response = await api.agentPresets.copy(
  415. request({ from: 'standard', agentPreset: 'mine', name: '我的模式' }))
  416. expect(response.result.ok).toBe(true)
  417. if (!response.result.ok) throw new Error('unreachable')
  418. expect(response.result.value.agentPreset).toBe('mine')
  419. })
  420. it('rejects a copy target that could escape the preset root', async () => {
  421. const { api } = await harness(['standard'])
  422. const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' }))
  423. expect(response.result.ok).toBe(false)
  424. if (response.result.ok) throw new Error('unreachable')
  425. expect(response.result.error.code).toBe('agent-preset-invalid')
  426. })
  427. it('rejects a copy target the roster already supplies', async () => {
  428. const { api } = await harness(['standard', 'minimal'])
  429. const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' }))
  430. expect(response.result.ok).toBe(false)
  431. if (response.result.ok) throw new Error('unreachable')
  432. expect(response.result.error.code).toBe('agent-preset-invalid')
  433. expect(response.result.error.message).toMatch(/already exists/)
  434. })
  435. it('rejects a copy whose source is unknown', async () => {
  436. const { api } = await harness(['standard'])
  437. const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' }))
  438. expect(response.result.ok).toBe(false)
  439. if (response.result.ok) throw new Error('unreachable')
  440. expect(response.result.error.code).toBe('agent-preset-not-found')
  441. })
  442. it('reports a deployment that composes no presets', async () => {
  443. const { api } = await harness()
  444. const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
  445. expect(response.result.ok).toBe(false)
  446. if (response.result.ok) throw new Error('unreachable')
  447. expect(response.result.error.code).toBe('agent-preset-not-found')
  448. })
  449. it('reports an unknown id on delete rather than succeeding silently', async () => {
  450. const { api } = await harness(['standard'])
  451. const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
  452. expect(response.result.ok).toBe(false)
  453. if (response.result.ok) throw new Error('unreachable')
  454. expect(response.result.error.code).toBe('agent-preset-not-found')
  455. })
  456. })
  457. describe('opening a preset directory', () => {
  458. it('hands the resolved directory to the native opener', async () => {
  459. const opened: string[] = []
  460. const { api } = await harness(['standard', 'my-preset'], undefined, {
  461. userIds: ['my-preset'],
  462. defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
  463. })
  464. const response = await api.agentPresets.openDocument(
  465. request({ agentPreset: 'my-preset' }), new AbortController().signal)
  466. expect(response.result.ok).toBe(true)
  467. if (!response.result.ok) throw new Error('unreachable')
  468. expect(response.result.value).toEqual({ opened: true })
  469. // The id selected the directory; the browser supplied no path.
  470. expect(opened).toEqual(['/presets/my-preset'])
  471. })
  472. it('answers the path as text where the deployment has no opener', async () => {
  473. const { api } = await harness(['standard', 'my-preset'], undefined, {
  474. userIds: ['my-preset'],
  475. defaults: { canOpenPath: () => false },
  476. })
  477. const response = await api.agentPresets.openDocument(
  478. request({ agentPreset: 'my-preset' }), new AbortController().signal)
  479. expect(response.result.ok).toBe(true)
  480. if (!response.result.ok) throw new Error('unreachable')
  481. expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' })
  482. })
  483. it('refuses a preset that ships with the deployment', async () => {
  484. const opened: string[] = []
  485. const { api } = await harness(['standard'], undefined, {
  486. defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
  487. })
  488. const response = await api.agentPresets.openDocument(
  489. request({ agentPreset: 'standard' }), new AbortController().signal)
  490. // Pointing an editor into the install invites edits an upgrade will
  491. // silently overwrite; the refusal mirrors copy/remove.
  492. expect(response.result.ok).toBe(false)
  493. if (response.result.ok) throw new Error('unreachable')
  494. expect(response.result.error.code).toBe('agent-preset-read-only')
  495. expect(opened).toEqual([])
  496. })
  497. it('reports the roster capability on list', async () => {
  498. const openable = await harness(['standard'], undefined, {
  499. defaults: { canOpenPath: () => true },
  500. })
  501. const headless = await harness(['standard'], undefined, {
  502. defaults: { canOpenPath: () => false },
  503. })
  504. const yes = await openable.api.agentPresets.list(request({}))
  505. const no = await headless.api.agentPresets.list(request({}))
  506. expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
  507. expect(no.result.ok && no.result.value.hasDocument).toBe(false)
  508. })
  509. it('counts an injected opener as openable', async () => {
  510. const { api } = await harness(['standard'], undefined, {
  511. defaults: { openPath: () => Promise.resolve() },
  512. })
  513. const response = await api.agentPresets.list(request({}))
  514. expect(response.result.ok && response.result.value.hasDocument).toBe(true)
  515. })
  516. })
  517. describe('skills over the layered host registry', () => {
  518. it('passes the live agent as the view scope to the host registry', async () => {
  519. const { api, ctx } = await harness(['standard'])
  520. const seen: unknown[] = []
  521. ctx.provide('skills', {
  522. list: (options: { scope?: unknown }) => {
  523. seen.push(options.scope)
  524. return Promise.resolve([])
  525. },
  526. } as never)
  527. await api.sessions.create(request({ sessionId: SessionId('h1'), agentPreset: 'standard' }))
  528. const response = await api.skills.list(request({ sessionId: SessionId('h1') }))
  529. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  530. expect(seen).toEqual([ctx.agents.get(SessionId('h1'))])
  531. })
  532. it('resolves a cold session to its recorded preset standing key', async () => {
  533. const { api, ctx } = await harness(['standard', 'minimal'])
  534. const seen: unknown[] = []
  535. ctx.provide('skills', {
  536. list: (options: { scope?: unknown }) => {
  537. seen.push(options.scope)
  538. return Promise.resolve([])
  539. },
  540. } as never)
  541. ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } })
  542. const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
  543. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  544. expect(seen).toEqual([standingKeys.get('minimal')])
  545. })
  546. it('serves the global view when the roster no longer supplies the recorded preset', async () => {
  547. const { api, ctx } = await harness(['standard'])
  548. const seen: unknown[] = []
  549. ctx.provide('skills', {
  550. list: (options: { scope?: unknown }) => {
  551. seen.push(options.scope)
  552. return Promise.resolve([])
  553. },
  554. } as never)
  555. ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } })
  556. const response = await api.skills.list(request({ sessionId: SessionId('h3') }))
  557. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  558. expect(seen).toEqual([undefined])
  559. })
  560. })
  561. describe('session.history presenter scope', () => {
  562. it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => {
  563. const { api } = await harness(['standard', 'minimal'])
  564. await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' }))
  565. // Cold: creation registered a live agent in this harness, so simulate the
  566. // cold path by asking for a session only persistence knows... the harness
  567. // has no persistence, so read the live one and assert no roster query.
  568. standingKeyRequests.length = 0
  569. const live = await api.sessions.history(request({ sessionId: SessionId('p1') }))
  570. expect(live.result.ok).toBe(true)
  571. // A live agent IS the presenter scope; the roster is not consulted.
  572. expect(standingKeyRequests).toEqual([])
  573. })
  574. it('resolves a switched session from the LOG, not its creation header', async () => {
  575. // The header is a creation fact; a switch while blank is a logged event,
  576. // and every turn after it ran under the newer composition. Reading the
  577. // header would render that history through the older preset's layer,
  578. // where the tools it is made of have no presenter at all.
  579. const meta = { id: SessionId('p4'), createdAt: 1, cwd: '/tmp/p4', agentPreset: 'standard' }
  580. const { api } = await harness(['standard', 'minimal'], {
  581. list: () => Promise.resolve([meta]),
  582. inspect: () => Promise.resolve({
  583. meta,
  584. events: [{ type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } }],
  585. }),
  586. })
  587. standingKeyRequests.length = 0
  588. const response = await api.sessions.history(request({ sessionId: SessionId('p4') }))
  589. expect(response.result.ok).toBe(true)
  590. expect(standingKeyRequests).toEqual(['minimal'])
  591. })
  592. it('serves a COLD transcript whose standing mount is no longer usable', async () => {
  593. // A genuinely cold session: persistence knows it, no live agent exists.
  594. const meta = { id: SessionId('p3'), createdAt: 1, cwd: '/tmp/p3', agentPreset: 'standard' }
  595. const { api } = await harness(['standard'], {
  596. list: () => Promise.resolve([meta]),
  597. inspect: () => Promise.resolve({ meta, events: [] }),
  598. })
  599. // The preset broke after the session ran: the roster rejects the mount.
  600. failingStandingKeys.add('standard')
  601. try {
  602. standingKeyRequests.length = 0
  603. const response = await api.sessions.history(request({ sessionId: SessionId('p3') }))
  604. // Degraded, never failed: the roster WAS asked, and the transcript
  605. // still serves — with the generic cards a viewless entry renders.
  606. expect(standingKeyRequests).toEqual(['standard'])
  607. expect(response.result.ok).toBe(true)
  608. } finally {
  609. failingStandingKeys.delete('standard')
  610. }
  611. })
  612. })