session-presets.host.spec.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. /** Session creation and adoption rules for Agent preset identity. */
  2. import { mkdtempSync, realpathSync, rmSync } 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 from '@deepseek-ai/dsh-agent'
  7. import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
  8. import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
  9. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  10. import type { Session } from '@deepseek-ai/dsh-session'
  11. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  12. import { afterEach, describe, expect, it } from 'vitest'
  13. import { createSessionTestRemote } from './test-remote.ts'
  14. /** Booted contexts and their temp roots, torn down after each test. */
  15. const contexts: Context[] = []
  16. const tempDirs: string[] = []
  17. afterEach(async () => {
  18. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  19. for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
  20. })
  21. function stubAgent(session: Session): Agent {
  22. return { id: session.id, session, status: 'idle' } as unknown as Agent
  23. }
  24. function roster(ids: readonly string[]): unknown {
  25. const presetOf = (id: string): object => ({
  26. id,
  27. trust: 'system',
  28. path: `/presets/${id}/agent.cordis.yml`,
  29. })
  30. return {
  31. defaultId: ids[0],
  32. resolve: (id?: string) => {
  33. const wanted = id ?? ids[0] ?? ''
  34. if (!ids.includes(wanted)) {
  35. return Promise.reject(new RemoteError(
  36. 'agent-preset/not-found',
  37. `agent-presets: preset "${wanted}" not found (available: ${ids.join(', ') || 'none'})`,
  38. { agentPreset: wanted, available: ids },
  39. ))
  40. }
  41. return Promise.resolve(presetOf(wanted))
  42. },
  43. mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')),
  44. }
  45. }
  46. async function harness(presets?: readonly string[]) {
  47. const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-')))
  48. tempDirs.push(cwd)
  49. const ctx = new Context()
  50. contexts.push(ctx)
  51. await ctx.plugin(SessionStore)
  52. await ctx.plugin(AgentRegistry)
  53. if (presets !== undefined) {
  54. ctx.provide('agentPresets', roster(presets) as never)
  55. }
  56. const factory: AgentFactory = {
  57. async createAgent(_ownerCtx, options) {
  58. const session = ctx.sessions.create(
  59. options.sessionId,
  60. options.meta === undefined ? {} : { meta: options.meta },
  61. )
  62. const agent = stubAgent(session)
  63. const agentCtx = ctx.extend({ agent })
  64. ;(agent as { ctx?: Context }).ctx = agentCtx
  65. await options.setup?.(agentCtx)
  66. const unregister = ctx.agents.register(agent)
  67. return { agent, dispose: () => { unregister(); return Promise.resolve() } }
  68. },
  69. async resume() {
  70. throw new Error('test harness has no persisted sessions')
  71. },
  72. }
  73. ctx.agents.setFactory(factory)
  74. const remote = createSessionTestRemote(ctx, {
  75. defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
  76. cwd,
  77. })
  78. if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
  79. return { ctx, remote }
  80. }
  81. describe('session.create Agent preset identity', () => {
  82. it('records the requested preset on the Session header', async () => {
  83. const { ctx, remote } = await harness(['standard', 'minimal'])
  84. const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' })
  85. expect(created.ok).toBe(true)
  86. expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal')
  87. })
  88. it('records the roster default when the caller names no preset', async () => {
  89. const { ctx, remote } = await harness(['standard', 'minimal'])
  90. await remote.create({ sessionId: SessionId('s2') })
  91. expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard')
  92. })
  93. it('rejects an unknown preset', async () => {
  94. const { remote } = await harness(['standard'])
  95. const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' })
  96. expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset/not-found' } })
  97. })
  98. it('refuses to adopt a live Session under a different preset', async () => {
  99. const { remote } = await harness(['standard', 'minimal'])
  100. await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' })
  101. const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' })
  102. expect(response).toMatchObject({
  103. ok: false,
  104. error: {
  105. code: 'agent-preset/conflict',
  106. details: {
  107. sessionId: 's4',
  108. requestedPreset: 'standard',
  109. existingPreset: 'minimal',
  110. },
  111. },
  112. })
  113. })
  114. it('adopts a live Session under the preset selected in its log', async () => {
  115. const { ctx, remote } = await harness(['standard', 'minimal'])
  116. await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' })
  117. ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' })
  118. const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' })
  119. const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' })
  120. expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } })
  121. expect(stale).toMatchObject({
  122. ok: false,
  123. error: { details: { existingPreset: 'minimal' } },
  124. })
  125. })
  126. it('adopts a live Session unchanged when the caller names no preset', async () => {
  127. const { remote } = await harness(['standard', 'minimal'])
  128. await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' })
  129. await expect(remote.create({ sessionId: SessionId('s5') }))
  130. .resolves.toMatchObject({ ok: true })
  131. })
  132. it('leaves the header preset-less when no roster is composed', async () => {
  133. const { ctx, remote } = await harness()
  134. await remote.create({ sessionId: SessionId('s6') })
  135. expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined()
  136. })
  137. it('explains why a preset-less Session cannot be adopted under one', async () => {
  138. const { remote } = await harness()
  139. await remote.create({ sessionId: SessionId('s7') })
  140. const response = await remote.create({ sessionId: SessionId('s7'), agentPreset: 'standard' })
  141. expect(response).toMatchObject({
  142. ok: false,
  143. error: {
  144. code: 'agent-preset/conflict',
  145. details: {
  146. sessionId: 's7',
  147. requestedPreset: 'standard',
  148. },
  149. },
  150. })
  151. if (response.ok) throw new Error('unreachable')
  152. expect('existingPreset' in response.error.details).toBe(false)
  153. expect(response.error.message).toContain('records no agent preset')
  154. })
  155. })