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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  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 { GoalId } from '@deepseek-ai/dsh-goal'
  16. import { createApiProxy } from '../src/api-proxy.ts'
  17. import { describe, expect, it } from 'vitest'
  18. import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
  19. let nextRpc = 0
  20. function request<P>(payload: P): RpcRequest<P> {
  21. return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload }
  22. }
  23. const sessionHarnesses = new WeakMap<ApiProxy, { ctx: Context; cwd: string }>()
  24. async function createSession(
  25. api: ApiProxy,
  26. request: { readonly sessionId: SessionId; readonly agentPreset?: string },
  27. ): Promise<void> {
  28. const harness = sessionHarnesses.get(api)
  29. if (harness === undefined) throw new Error('Session test harness is not installed')
  30. const presets = harness.ctx.get('agentPresets')
  31. const agentPreset = presets === undefined
  32. ? undefined
  33. : (await presets.resolve(request.agentPreset)).id
  34. await harness.ctx.agents.create({
  35. sessionId: request.sessionId,
  36. meta: {
  37. cwd: harness.cwd,
  38. ...(agentPreset === undefined ? {} : { agentPreset }),
  39. },
  40. ...(agentPreset === undefined || presets === undefined
  41. ? {}
  42. : { setup: async (agentCtx: Context) => { await presets.mount(agentCtx, agentPreset) } }),
  43. })
  44. }
  45. /** Minimal live agent; the gateway only needs identity and its session. */
  46. function stubAgent(session: Session): Agent {
  47. return { id: session.id, session, status: 'idle' } as unknown as Agent
  48. }
  49. /**
  50. * A roster whose `mount` is a no-op: this spec is about the gateway's identity
  51. * rules, and the composition itself is covered by the real-composition test in
  52. * `apps/cli`. Ids listed in `userIds` present as locally authored; the rest
  53. * ship with the deployment.
  54. */
  55. function roster(ids: readonly string[], userIds: readonly string[] = []): unknown {
  56. const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system')
  57. const presetOf = (id: string): object =>
  58. ({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` })
  59. return {
  60. defaultId: ids[0],
  61. list: () => Promise.resolve(ids.map(presetOf)),
  62. resolve: (id?: string) => {
  63. const wanted = id ?? ids[0] ?? ''
  64. if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
  65. return Promise.resolve(presetOf(wanted))
  66. },
  67. mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
  68. // What a real mount leaves behind: a service instance only the agent that
  69. // mounted it can be used to address. The doubles are per agent so a test
  70. // can tell "this session's" from "some session's".
  71. serviceFor: (agent: { id: unknown }, name: string) => {
  72. const perAgent = services.get(String(agent.id))
  73. return perAgent?.[name]
  74. },
  75. authorable: true,
  76. read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
  77. copy: (from: string, id: string) => {
  78. if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids))
  79. if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id))
  80. if (ids.includes(id)) return Promise.reject(new PresetExistsError(id))
  81. return Promise.resolve()
  82. },
  83. remove: (id: string) => {
  84. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  85. return Promise.resolve()
  86. },
  87. recompose: (_ctx: Context, id: string) => {
  88. if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
  89. return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
  90. },
  91. // The standing scope key a cold transcript read resolves presenters in.
  92. standingKeyFor: (id?: string) => {
  93. const wanted = id ?? ids[0] ?? ''
  94. if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
  95. let key = standingKeys.get(wanted)
  96. if (key === undefined) {
  97. key = { agentPreset: wanted }
  98. standingKeys.set(wanted, key)
  99. }
  100. return Promise.resolve(key)
  101. },
  102. }
  103. }
  104. /** Standing keys minted by the roster double. */
  105. const standingKeys = new Map<string, object>()
  106. /** Per-agent service instances a mounted preset would own, keyed by session id. */
  107. const services = new Map<string, Record<string, unknown>>()
  108. async function harness(
  109. presets?: readonly string[],
  110. options: { userIds?: readonly string[]; defaults?: Record<string, unknown> } = {},
  111. ) {
  112. const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
  113. const ctx = new Context()
  114. await ctx.plugin(SessionStore)
  115. await ctx.plugin(AgentRegistry)
  116. if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
  117. ctx.provide('sessionQuery', {
  118. observeSession: (sessionId: SessionId) => {
  119. const session = ctx.sessions.get(sessionId)
  120. if (session === undefined) {
  121. return Promise.reject(new SessionQueryError(
  122. `session "${sessionId}" not found`,
  123. 'SESSION_QUERY_SESSION_NOT_FOUND',
  124. ))
  125. }
  126. let preset = agentPresetProjectionDefinition.init(session.header)
  127. for (const event of session.events) {
  128. preset = agentPresetProjectionDefinition.apply(preset, event)
  129. }
  130. const events = Object.freeze([...session.events])
  131. const lease = (): SessionObservation => ({
  132. source: 'live' as const,
  133. header: session.header,
  134. events,
  135. cursor: events.at(-1)?.seq ?? -1,
  136. projections: {
  137. asOfSeq: events.at(-1)?.seq ?? -1,
  138. values: { agentPreset: preset },
  139. },
  140. retain: lease,
  141. [Symbol.dispose]: () => {},
  142. })
  143. return Promise.resolve(lease())
  144. },
  145. } as never)
  146. const factory: AgentFactory = {
  147. async createAgent(_ownerCtx, options) {
  148. const session = ctx.sessions.create(
  149. options.sessionId,
  150. options.meta === undefined ? {} : { meta: options.meta },
  151. )
  152. const agent = stubAgent(session)
  153. // Setup runs before publication against a context that carries the
  154. // agent, and the agent reaches back through `agent.ctx` — the pair the
  155. // gateway's own `installTarget` relies on.
  156. const agentCtx = ctx.extend({ agent })
  157. ;(agent as { ctx?: Context }).ctx = agentCtx
  158. await options.setup?.(agentCtx)
  159. const unregister = ctx.agents.register(agent)
  160. return { agent, dispose: () => { unregister(); return Promise.resolve() } }
  161. },
  162. async resume() {
  163. throw new Error('test harness has no persisted sessions')
  164. },
  165. }
  166. ctx.agents.setFactory(factory)
  167. ctx.provide('sessionController', {
  168. resolveAgent: (sessionId: SessionId) => {
  169. const agent = ctx.agents.get(sessionId)
  170. return Promise.resolve(agent === undefined
  171. ? {
  172. error: {
  173. code: 'session-not-found',
  174. message: `session "${sessionId}" not found`,
  175. details: { sessionId },
  176. },
  177. }
  178. : { agent })
  179. },
  180. inspect: (sessionId: SessionId) => {
  181. const session = ctx.sessions.get(sessionId)
  182. if (session === undefined) throw new Error(`session "${sessionId}" not found`)
  183. return Promise.resolve({ meta: session.header, events: [...session.events] })
  184. },
  185. } as never)
  186. const defaults = {
  187. defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
  188. cwd,
  189. ...options.defaults,
  190. }
  191. const api = createApiProxy(ctx, defaults)
  192. sessionHarnesses.set(api, { ctx, cwd })
  193. return { api, ctx, cwd }
  194. }
  195. /**
  196. * A capability a preset mounts is reachable from nowhere the host normally
  197. * looks: an `isolate` realm is what makes it per session. The gateway serves
  198. * requests that are ABOUT a session from OUTSIDE it, so it addresses the
  199. * instance through the agent instead of reading a root-realm singleton.
  200. */
  201. describe('a capability the session\'s preset mounts', () => {
  202. it('serves the goal RPC from the session\'s own goal service', async () => {
  203. const { api } = await harness(['standard'])
  204. await createSession(api, { sessionId: SessionId('g1'), agentPreset: 'standard' })
  205. const ref = { id: GoalId('goal-1'), revision: 1 }
  206. const paused: unknown[] = []
  207. services.set('g1', {
  208. goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
  209. })
  210. const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
  211. expect(response.result).toMatchObject({ ok: true, value: { ref } })
  212. // Reached the instance this session mounted, and was handed its own agent.
  213. expect(paused).toEqual([['g1', ref]])
  214. services.delete('g1')
  215. })
  216. it('serves the skill catalog from the session\'s own registry', async () => {
  217. const { api } = await harness(['standard'])
  218. await createSession(api, { sessionId: SessionId('k1'), agentPreset: 'standard' })
  219. services.set('k1', {
  220. skills: {
  221. list: () => Promise.resolve([{
  222. name: 'preset-owned',
  223. description: 'ships inside the preset directory',
  224. invocation: { modelInvocable: true, userInvocable: true },
  225. }]),
  226. },
  227. })
  228. const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
  229. // A preset ships its own skill directory, so the catalog IS the
  230. // session's; reading a host singleton would answer for the wrong one.
  231. expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
  232. services.delete('k1')
  233. })
  234. it('says so when no composition mounts the capability at all', async () => {
  235. const { api } = await harness(['standard'])
  236. await createSession(api, { sessionId: SessionId('n1'), agentPreset: 'standard' })
  237. const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
  238. // Absent means absent — not "this session has none", which is what a
  239. // root-realm read used to report for every presetd session.
  240. expect(response.result.ok).toBe(false)
  241. const failure = response.result as { ok: false; error: { message: string } }
  242. expect(failure.error.message).toContain('neither this session')
  243. })
  244. })
  245. describe('agentPreset.list', () => {
  246. it('marks the default and carries each preset\'s trust', async () => {
  247. const { api } = await harness(['standard', 'minimal'])
  248. const response = await api.agentPresets.list(request({}))
  249. expect(response.result.ok).toBe(true)
  250. if (!response.result.ok) throw new Error('unreachable')
  251. expect(response.result.value.presets).toEqual([
  252. { id: 'standard', trust: 'system', isDefault: true },
  253. { id: 'minimal', trust: 'system', isDefault: false },
  254. ])
  255. expect(response.result.value.authorable).toBe(true)
  256. })
  257. it('answers with an empty roster when the deployment composes no presets', async () => {
  258. const { api } = await harness()
  259. const response = await api.agentPresets.list(request({}))
  260. // Composing no presets is a valid deployment, not an error: every session
  261. // then shares the host composition and the browser offers no choice.
  262. expect(response.result.ok).toBe(true)
  263. if (!response.result.ok) throw new Error('unreachable')
  264. expect(response.result.value.presets).toEqual([])
  265. // Nothing to write to either, so a surface offering "new preset" knows to
  266. // stay hidden rather than offering a button whose save always fails.
  267. expect(response.result.value.authorable).toBe(false)
  268. })
  269. })
  270. describe('agentPreset.select', () => {
  271. it('recomposes a blank session', async () => {
  272. const { api } = await harness(['standard', 'minimal'])
  273. await createSession(api, { sessionId: SessionId('sel-1'), agentPreset: 'standard' })
  274. const response = await api.agentPresets.select(
  275. request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' }))
  276. expect(response.result.ok).toBe(true)
  277. if (!response.result.ok) throw new Error('unreachable')
  278. expect(response.result.value.agentPreset).toBe('minimal')
  279. })
  280. it('records the switch in the log', async () => {
  281. const { api, ctx } = await harness(['standard', 'minimal'])
  282. await createSession(api, { sessionId: SessionId('sel-log'), agentPreset: 'standard' })
  283. await api.agentPresets.select(
  284. request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' }))
  285. // The header is written once at creation, so the switch lives in the log —
  286. // this is what a restart replays and what every projection resolves from.
  287. // Asserting only the RPC's echo would miss a switch that never persisted.
  288. const session = ctx.sessions.get(SessionId('sel-log'))
  289. if (session === undefined) throw new Error('unreachable')
  290. expect(session.header.agentPreset).toBe('standard')
  291. expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
  292. .toEqual({ agentPreset: 'minimal' })
  293. })
  294. it('serializes two concurrent selects on one session', async () => {
  295. const { api, ctx } = await harness(['standard', 'minimal'])
  296. await createSession(api, { sessionId: SessionId('sel-race'), agentPreset: 'standard' })
  297. // Both pass the blank check; unserialized, the second unmount finds no
  298. // record because the first already removed it, and two compositions end up
  299. // in one agent layer. The client's busy flag is not enforcement.
  300. const [first, second] = await Promise.all([
  301. api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })),
  302. api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })),
  303. ])
  304. expect(first.result.ok).toBe(true)
  305. expect(second.result.ok).toBe(true)
  306. const session = ctx.sessions.get(SessionId('sel-race'))
  307. if (session === undefined) throw new Error('unreachable')
  308. // One winner, and the log agrees with it: the last committed switch.
  309. expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
  310. .toEqual({ agentPreset: 'standard' })
  311. })
  312. it('refuses once the conversation has started', async () => {
  313. const { api, ctx } = await harness(['standard', 'minimal'])
  314. await createSession(api, { sessionId: SessionId('sel-2'), agentPreset: 'standard' })
  315. // One turn is enough: the history from here on was produced under
  316. // `standard`'s tools, and a swap would strand those tool calls.
  317. ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
  318. const response = await api.agentPresets.select(
  319. request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' }))
  320. expect(response.result.ok).toBe(false)
  321. if (response.result.ok) throw new Error('unreachable')
  322. expect(response.result.error.code).toBe('agent-preset-locked')
  323. })
  324. it('reports an unknown preset without disturbing the session', async () => {
  325. const { api } = await harness(['standard'])
  326. await createSession(api, { sessionId: SessionId('sel-3') })
  327. const response = await api.agentPresets.select(
  328. request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
  329. expect(response.result.ok).toBe(false)
  330. if (response.result.ok) throw new Error('unreachable')
  331. expect(response.result.error.code).toBe('agent-preset-not-found')
  332. })
  333. it('reports a deployment that composes no presets', async () => {
  334. const { api } = await harness()
  335. await createSession(api, { sessionId: SessionId('sel-4') })
  336. const response = await api.agentPresets.select(
  337. request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
  338. expect(response.result.ok).toBe(false)
  339. if (response.result.ok) throw new Error('unreachable')
  340. expect(response.result.error.code).toBe('agent-preset-not-found')
  341. })
  342. })
  343. describe('authoring over the wire', () => {
  344. it('reads a composition with its trust', async () => {
  345. const { api } = await harness(['standard'])
  346. const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
  347. expect(response.result.ok).toBe(true)
  348. if (!response.result.ok) throw new Error('unreachable')
  349. // The shipped set is readable: it is the known-good composition a copy
  350. // starts from, and trust is what tells a surface to say so.
  351. expect(response.result.value.trust).toBe('system')
  352. expect(response.result.value.content).toContain('- id: x')
  353. })
  354. it('copies a preset under a new id', async () => {
  355. const { api } = await harness(['standard'])
  356. const response = await api.agentPresets.copy(
  357. request({ from: 'standard', agentPreset: 'mine', name: '我的模式' }))
  358. expect(response.result.ok).toBe(true)
  359. if (!response.result.ok) throw new Error('unreachable')
  360. expect(response.result.value.agentPreset).toBe('mine')
  361. })
  362. it('rejects a copy target that could escape the preset root', async () => {
  363. const { api } = await harness(['standard'])
  364. const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: '../escape' }))
  365. expect(response.result.ok).toBe(false)
  366. if (response.result.ok) throw new Error('unreachable')
  367. expect(response.result.error.code).toBe('agent-preset-invalid')
  368. })
  369. it('rejects a copy target the roster already supplies', async () => {
  370. const { api } = await harness(['standard', 'minimal'])
  371. const response = await api.agentPresets.copy(request({ from: 'standard', agentPreset: 'minimal' }))
  372. expect(response.result.ok).toBe(false)
  373. if (response.result.ok) throw new Error('unreachable')
  374. expect(response.result.error.code).toBe('agent-preset-invalid')
  375. expect(response.result.error.message).toMatch(/already exists/)
  376. })
  377. it('rejects a copy whose source is unknown', async () => {
  378. const { api } = await harness(['standard'])
  379. const response = await api.agentPresets.copy(request({ from: 'never-existed', agentPreset: 'mine' }))
  380. expect(response.result.ok).toBe(false)
  381. if (response.result.ok) throw new Error('unreachable')
  382. expect(response.result.error.code).toBe('agent-preset-not-found')
  383. })
  384. it('reports a deployment that composes no presets', async () => {
  385. const { api } = await harness()
  386. const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
  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 an unknown id on delete rather than succeeding silently', async () => {
  392. const { api } = await harness(['standard'])
  393. const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
  394. expect(response.result.ok).toBe(false)
  395. if (response.result.ok) throw new Error('unreachable')
  396. expect(response.result.error.code).toBe('agent-preset-not-found')
  397. })
  398. })
  399. describe('opening a preset directory', () => {
  400. it('hands the resolved directory to the native opener', async () => {
  401. const opened: string[] = []
  402. const { api } = await harness(['standard', 'my-preset'], {
  403. userIds: ['my-preset'],
  404. defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
  405. })
  406. const response = await api.agentPresets.openDocument(
  407. request({ agentPreset: 'my-preset' }), new AbortController().signal)
  408. expect(response.result.ok).toBe(true)
  409. if (!response.result.ok) throw new Error('unreachable')
  410. expect(response.result.value).toEqual({ opened: true })
  411. // The id selected the directory; the browser supplied no path.
  412. expect(opened).toEqual(['/presets/my-preset'])
  413. })
  414. it('answers the path as text where the deployment has no opener', async () => {
  415. const { api } = await harness(['standard', 'my-preset'], {
  416. userIds: ['my-preset'],
  417. defaults: { canOpenPath: () => false },
  418. })
  419. const response = await api.agentPresets.openDocument(
  420. request({ agentPreset: 'my-preset' }), new AbortController().signal)
  421. expect(response.result.ok).toBe(true)
  422. if (!response.result.ok) throw new Error('unreachable')
  423. expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' })
  424. })
  425. it('refuses a preset that ships with the deployment', async () => {
  426. const opened: string[] = []
  427. const { api } = await harness(['standard'], {
  428. defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } },
  429. })
  430. const response = await api.agentPresets.openDocument(
  431. request({ agentPreset: 'standard' }), new AbortController().signal)
  432. // Pointing an editor into the install invites edits an upgrade will
  433. // silently overwrite; the refusal mirrors copy/remove.
  434. expect(response.result.ok).toBe(false)
  435. if (response.result.ok) throw new Error('unreachable')
  436. expect(response.result.error.code).toBe('agent-preset-read-only')
  437. expect(opened).toEqual([])
  438. })
  439. it('reports the roster capability on list', async () => {
  440. const openable = await harness(['standard'], {
  441. defaults: { canOpenPath: () => true },
  442. })
  443. const headless = await harness(['standard'], {
  444. defaults: { canOpenPath: () => false },
  445. })
  446. const yes = await openable.api.agentPresets.list(request({}))
  447. const no = await headless.api.agentPresets.list(request({}))
  448. expect(yes.result.ok && yes.result.value.hasDocument).toBe(true)
  449. expect(no.result.ok && no.result.value.hasDocument).toBe(false)
  450. })
  451. it('counts an injected opener as openable', async () => {
  452. const { api } = await harness(['standard'], {
  453. defaults: { openPath: () => Promise.resolve() },
  454. })
  455. const response = await api.agentPresets.list(request({}))
  456. expect(response.result.ok && response.result.value.hasDocument).toBe(true)
  457. })
  458. })
  459. describe('skills over the layered host registry', () => {
  460. it('passes the live agent as the view scope to the host registry', async () => {
  461. const { api, ctx } = await harness(['standard'])
  462. const seen: unknown[] = []
  463. ctx.provide('skills', {
  464. list: (options: { scope?: unknown }) => {
  465. seen.push(options.scope)
  466. return Promise.resolve([])
  467. },
  468. } as never)
  469. await createSession(api, { sessionId: SessionId('h1'), agentPreset: 'standard' })
  470. const response = await api.skills.list(request({ sessionId: SessionId('h1') }))
  471. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  472. expect(seen).toEqual([ctx.agents.get(SessionId('h1'))])
  473. })
  474. it('resolves a cold session to its recorded preset standing key', async () => {
  475. const { api, ctx } = await harness(['standard', 'minimal'])
  476. const seen: unknown[] = []
  477. ctx.provide('skills', {
  478. list: (options: { scope?: unknown }) => {
  479. seen.push(options.scope)
  480. return Promise.resolve([])
  481. },
  482. } as never)
  483. ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } })
  484. const response = await api.skills.list(request({ sessionId: SessionId('h2') }))
  485. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  486. expect(seen).toEqual([standingKeys.get('minimal')])
  487. })
  488. it('serves the global view when the roster no longer supplies the recorded preset', async () => {
  489. const { api, ctx } = await harness(['standard'])
  490. const seen: unknown[] = []
  491. ctx.provide('skills', {
  492. list: (options: { scope?: unknown }) => {
  493. seen.push(options.scope)
  494. return Promise.resolve([])
  495. },
  496. } as never)
  497. ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } })
  498. const response = await api.skills.list(request({ sessionId: SessionId('h3') }))
  499. expect(response.result).toMatchObject({ ok: true, value: { skills: [] } })
  500. expect(seen).toEqual([undefined])
  501. })
  502. })