| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- /**
- * A session's agent preset is fixed at creation. The gateway records the
- * resolved id on the header and refuses to adopt the identity under a different
- * one, because the session's history was produced under that preset's tools:
- * rebuilding it differently would replay tool calls the new agent cannot make.
- */
- import { mkdtempSync, realpathSync } from 'node:fs'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { Context } from 'cordis'
- import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
- import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
- import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
- import {
- InvalidCompositionError, InvalidPresetIdError, UnknownPresetError,
- } from '@deepseek-ai/dsh-agent-presets'
- import { GoalId } from '@deepseek-ai/dsh-goal'
- import { createApiProxy } from '../src/api-proxy.ts'
- import { describe, expect, it } from 'vitest'
- let nextRpc = 0
- function request<P>(payload: P): RpcRequest<P> {
- return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload }
- }
- /** Minimal live agent; the gateway only needs identity and its session. */
- function stubAgent(session: Session): Agent {
- return { id: session.id, session, status: 'idle' } as unknown as Agent
- }
- /**
- * A roster whose `mount` is a no-op: this spec is about the gateway's identity
- * rules, and the composition itself is covered by the real-composition test in
- * `apps/cli`.
- */
- function roster(ids: readonly string[]): unknown {
- return {
- defaultId: ids[0],
- list: () => Promise.resolve(ids.map(id => ({ id, trust: 'system', path: `/presets/${id}.yml` }))),
- resolve: (id?: string) => {
- const wanted = id ?? ids[0] ?? ''
- if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids))
- return Promise.resolve({ id: wanted, trust: 'system', path: `/presets/${wanted}.yml` })
- },
- mount: (_ctx: Context, id?: string) =>
- Promise.resolve({ id: id ?? ids[0], trust: 'system', path: '/presets/x.yml' }),
- // What a real mount leaves behind: a service instance only the agent that
- // mounted it can be used to address. The doubles are per agent so a test
- // can tell "this session's" from "some session's".
- serviceFor: (agent: { id: unknown }, name: string) => {
- const perAgent = services.get(String(agent.id))
- return perAgent?.[name]
- },
- authorable: true,
- read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`),
- write: (id: string, content: string) => {
- if (!ids.includes(id) && !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
- return Promise.reject(new InvalidPresetIdError(id))
- }
- if (!content.trimStart().startsWith('-')) return Promise.reject(new InvalidCompositionError('not a list'))
- return Promise.resolve()
- },
- remove: (id: string) => {
- if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
- return Promise.resolve()
- },
- recompose: (_ctx: Context, id: string) => {
- if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids))
- return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` })
- },
- }
- }
- /** Per-agent service instances a mounted preset would own, keyed by session id. */
- const services = new Map<string, Record<string, unknown>>()
- async function harness(presets?: readonly string[]) {
- const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-')))
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(UserInteractionService)
- ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
- if (presets !== undefined) ctx.provide('agentPresets', roster(presets) as never)
- const factory: AgentFactory = {
- async createAgent(_ownerCtx, options) {
- const session = ctx.sessions.create(
- options.sessionId,
- options.meta === undefined ? {} : { meta: options.meta },
- )
- const agent = stubAgent(session)
- // Setup runs before publication against a context that carries the
- // agent, and the agent reaches back through `agent.ctx` — the pair the
- // gateway's own `installTarget` relies on.
- const agentCtx = ctx.extend({ agent })
- ;(agent as { ctx?: Context }).ctx = agentCtx
- await options.setup?.(agentCtx)
- const unregister = ctx.agents.register(agent)
- return { agent, dispose: () => { unregister(); return Promise.resolve() } }
- },
- async resume() {
- throw new Error('test harness has no persisted sessions')
- },
- }
- ctx.agents.setFactory(factory)
- const api = createApiProxy(ctx, { provider: 'test', model: 'test-model', cwd, workspaceRoot: cwd })
- return { api, ctx, cwd }
- }
- describe('session.create with an agent preset', () => {
- it('records the resolved preset on the session header', async () => {
- const { api, ctx } = await harness(['standard', 'minimal'])
- const created = await api.sessions.create(request({ sessionId: SessionId('s1'), agentPreset: 'minimal' }))
- expect(created.result.ok).toBe(true)
- expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
- })
- it('records the default when the caller names none', async () => {
- const { api, ctx } = await harness(['standard', 'minimal'])
- await api.sessions.create(request({ sessionId: SessionId('s2') }))
- expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
- })
- it('rejects an unknown preset and names the ones that exist', async () => {
- const { api } = await harness(['standard'])
- const response = await api.sessions.create(request({ sessionId: SessionId('s3'), agentPreset: 'nope' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-not-found')
- })
- it('refuses to adopt a live session under a different preset', async () => {
- const { api } = await harness(['standard', 'minimal'])
- await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'minimal' }))
- const response = await api.sessions.create(request({ sessionId: SessionId('s4'), agentPreset: 'standard' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-conflict')
- expect(response.result.error.details).toEqual({
- sessionId: 's4',
- requestedPreset: 'standard',
- existingPreset: 'minimal',
- })
- })
- it('adopts a live session unchanged when the caller names no preset', async () => {
- const { api } = await harness(['standard', 'minimal'])
- await api.sessions.create(request({ sessionId: SessionId('s5'), agentPreset: 'minimal' }))
- // Reconnecting and retrying a create must stay ordinary operations.
- const response = await api.sessions.create(request({ sessionId: SessionId('s5') }))
- expect(response.result.ok).toBe(true)
- })
- it('leaves the header preset-less when no roster is composed', async () => {
- const { api, ctx } = await harness()
- await api.sessions.create(request({ sessionId: SessionId('s6') }))
- expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
- })
- it('says why a preset-less session cannot be adopted under one', async () => {
- // Two callers reach this: a deployment that composes no roster, and a
- // session created before one existed. Both record no preset, so naming
- // any is a conflict rather than an adoption — the history was produced
- // under a composition this roster cannot name. The message has to say
- // that, because "already runs agent preset undefined" reads as a bug.
- const { api } = await harness()
- await api.sessions.create(request({ sessionId: SessionId('s7') }))
- const response = await api.sessions.create(request({ sessionId: SessionId('s7'), agentPreset: 'standard' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-conflict')
- expect(response.result.error.message).toContain('records no agent preset')
- expect(response.result.error.details).toEqual({
- sessionId: 's7',
- requestedPreset: 'standard',
- existingPreset: undefined,
- })
- })
- })
- /**
- * A capability a preset mounts is reachable from nowhere the host normally
- * looks: an `isolate` realm is what makes it per session. The gateway serves
- * requests that are ABOUT a session from OUTSIDE it, so it addresses the
- * instance through the agent instead of reading a root-realm singleton.
- */
- describe('a capability the session\'s preset mounts', () => {
- it('serves the goal RPC from the session\'s own goal service', async () => {
- const { api } = await harness(['standard'])
- await api.sessions.create(request({ sessionId: SessionId('g1'), agentPreset: 'standard' }))
- const ref = { id: GoalId('goal-1'), revision: 1 }
- const paused: unknown[] = []
- services.set('g1', {
- goals: { pause: (agent: { id: unknown }, r: unknown) => { paused.push([String(agent.id), r]); return ref } },
- })
- const response = await api.goals.pause(request({ sessionId: SessionId('g1'), ref }))
- expect(response.result).toMatchObject({ ok: true, value: { ref } })
- // Reached the instance this session mounted, and was handed its own agent.
- expect(paused).toEqual([['g1', ref]])
- services.delete('g1')
- })
- it('serves the skill catalog from the session\'s own registry', async () => {
- const { api } = await harness(['standard'])
- await api.sessions.create(request({ sessionId: SessionId('k1'), agentPreset: 'standard' }))
- services.set('k1', {
- skills: {
- list: () => Promise.resolve([{
- name: 'preset-owned',
- description: 'ships inside the preset directory',
- invocation: { modelInvocable: true, userInvocable: true },
- }]),
- },
- })
- const response = await api.skills.list(request({ sessionId: SessionId('k1') }))
- // A preset ships its own skill directory, so the catalog IS the
- // session's; reading a host singleton would answer for the wrong one.
- expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } })
- services.delete('k1')
- })
- it('says so when no composition mounts the capability at all', async () => {
- const { api } = await harness(['standard'])
- await api.sessions.create(request({ sessionId: SessionId('n1'), agentPreset: 'standard' }))
- const response = await api.skills.list(request({ sessionId: SessionId('n1') }))
- // Absent means absent — not "this session has none", which is what a
- // root-realm read used to report for every presetd session.
- expect(response.result.ok).toBe(false)
- const failure = response.result as { ok: false; error: { message: string } }
- expect(failure.error.message).toContain('neither this session')
- })
- })
- describe('agentPreset.list', () => {
- it('marks the default and carries each preset\'s trust', async () => {
- const { api } = await harness(['standard', 'minimal'])
- const response = await api.agentPresets.list(request({}))
- expect(response.result.ok).toBe(true)
- if (!response.result.ok) throw new Error('unreachable')
- expect(response.result.value.presets).toEqual([
- { id: 'standard', trust: 'system', isDefault: true },
- { id: 'minimal', trust: 'system', isDefault: false },
- ])
- expect(response.result.value.authorable).toBe(true)
- })
- it('answers with an empty roster when the deployment composes no presets', async () => {
- const { api } = await harness()
- const response = await api.agentPresets.list(request({}))
- // Composing no presets is a valid deployment, not an error: every session
- // then shares the host composition and the browser offers no choice.
- expect(response.result.ok).toBe(true)
- if (!response.result.ok) throw new Error('unreachable')
- expect(response.result.value.presets).toEqual([])
- // Nothing to write to either, so a surface offering "new preset" knows to
- // stay hidden rather than offering a button whose save always fails.
- expect(response.result.value.authorable).toBe(false)
- })
- })
- describe('agentPreset.select', () => {
- it('recomposes a blank session', async () => {
- const { api } = await harness(['standard', 'minimal'])
- await api.sessions.create(request({ sessionId: SessionId('sel-1'), agentPreset: 'standard' }))
- const response = await api.agentPresets.select(
- request({ sessionId: SessionId('sel-1'), agentPreset: 'minimal' }))
- expect(response.result.ok).toBe(true)
- if (!response.result.ok) throw new Error('unreachable')
- expect(response.result.value.agentPreset).toBe('minimal')
- })
- it('refuses once the conversation has started', async () => {
- const { api, ctx } = await harness(['standard', 'minimal'])
- await api.sessions.create(request({ sessionId: SessionId('sel-2'), agentPreset: 'standard' }))
- // One turn is enough: the history from here on was produced under
- // `standard`'s tools, and a swap would strand those tool calls.
- ctx.sessions.get(SessionId('sel-2'))?.append('turn/start', { turn: 0 })
- const response = await api.agentPresets.select(
- request({ sessionId: SessionId('sel-2'), agentPreset: 'minimal' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-locked')
- })
- it('reports an unknown preset without disturbing the session', async () => {
- const { api } = await harness(['standard'])
- await api.sessions.create(request({ sessionId: SessionId('sel-3') }))
- const response = await api.agentPresets.select(
- request({ sessionId: SessionId('sel-3'), agentPreset: 'nope' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-not-found')
- })
- it('reports a deployment that composes no presets', async () => {
- const { api } = await harness()
- await api.sessions.create(request({ sessionId: SessionId('sel-4') }))
- const response = await api.agentPresets.select(
- request({ sessionId: SessionId('sel-4'), agentPreset: 'anything' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-not-found')
- })
- })
- describe('authoring over the wire', () => {
- it('reads a composition and reports whether it may be edited', async () => {
- const { api } = await harness(['standard'])
- const response = await api.agentPresets.read(request({ agentPreset: 'standard' }))
- expect(response.result.ok).toBe(true)
- if (!response.result.ok) throw new Error('unreachable')
- // The shipped set is readable but not writable: it belongs to the
- // deployment, and it is what a broken local preset is compared against.
- expect(response.result.value.trust).toBe('system')
- expect(response.result.value.writable).toBe(false)
- expect(response.result.value.content).toContain('- id: x')
- })
- it('rejects an id that could escape the preset root', async () => {
- const { api } = await harness(['standard'])
- const response = await api.agentPresets.write(request({ agentPreset: '../escape', content: '- id: x\n' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-invalid')
- })
- it('rejects content that is not an entry list', async () => {
- const { api } = await harness(['standard'])
- const response = await api.agentPresets.write(request({ agentPreset: 'mine', content: 'tools: []\n' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-invalid')
- })
- it('reports a deployment that composes no presets', async () => {
- const { api } = await harness()
- const response = await api.agentPresets.read(request({ agentPreset: 'anything' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-not-found')
- })
- it('reports an unknown id on delete rather than succeeding silently', async () => {
- const { api } = await harness(['standard'])
- const response = await api.agentPresets.remove(request({ agentPreset: 'never-existed' }))
- expect(response.result.ok).toBe(false)
- if (response.result.ok) throw new Error('unreachable')
- expect(response.result.error.code).toBe('agent-preset-not-found')
- })
- })
|