model-selection-settings.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. /** Default-off settings and per-session model-selection decisions. */
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  5. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  6. import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
  7. import { SettingsProvider } from '@deepseek-ai/dsh-settings'
  8. import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
  9. import InvariantRegistry from '@deepseek-ai/dsh-invariants'
  10. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  11. import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
  12. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  13. import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
  14. import * as tool from '../src/index.ts'
  15. import * as ToolInvariant from '../src/invariant.ts'
  16. import SubagentModelSelectionConfig, {
  17. SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE,
  18. } from '../src/model-selection-settings.ts'
  19. import { hasSubagentModelSelection } from '../src/model-selection-state.ts'
  20. /** Writable in-memory settings provider for the package integration. */
  21. class MemorySettings extends SettingsProvider {
  22. doc: Record<string, unknown> = {}
  23. get writable(): boolean {
  24. return true
  25. }
  26. protected load(): Promise<Record<string, unknown>> {
  27. return Promise.resolve(structuredClone(this.doc))
  28. }
  29. protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
  30. this.doc = { ...this.doc, [ns]: structuredClone(section) }
  31. return Promise.resolve()
  32. }
  33. }
  34. /** Read whether one Agent's delegation definition contains route fields. */
  35. function selectable(ctx: Context, agent: Awaited<ReturnType<Context['agents']['create']>>['agent']): boolean {
  36. const schema = ctx.tools.schemas(agent).find(candidate => candidate.name === 'subagent')
  37. const properties = (schema?.parameters as { properties?: Record<string, unknown> } | undefined)?.properties
  38. return properties?.['provider'] !== undefined
  39. && properties['model'] !== undefined
  40. && properties['reasoning_effort'] !== undefined
  41. && ctx.tools.schemas(agent).some(candidate => candidate.name === 'list_subagent_models')
  42. }
  43. /** Mount the real settings, Agent, provider, and tool services. */
  44. async function boot(): Promise<Context> {
  45. const ctx = new Context()
  46. await ctx.plugin(MemorySettings)
  47. await ctx.plugin(SubagentModelSelectionConfig)
  48. await mountAgentLoopTestDependencies(ctx)
  49. await ctx.plugin(AgentLoop, { agents: [] })
  50. await ctx.plugin(SubagentRuntime)
  51. await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
  52. return ctx
  53. }
  54. /** Create one Agent whose setup mounts the settings-controlled tool preset row. */
  55. async function createAgent(ctx: Context, id: string, options: {
  56. meta?: { parentSession: SessionId; origin: 'subagent' }
  57. seed?: readonly SessionEvent[]
  58. } = {}) {
  59. const handle = await ctx.agents.create({
  60. sessionId: SessionId(id),
  61. ...options,
  62. setup: async (agentCtx) => {
  63. await agentCtx.plugin(tool, {
  64. provider: 'spawn',
  65. modelSelectionSettings: true,
  66. backgroundMode: 'continuable',
  67. })
  68. },
  69. })
  70. return handle.agent
  71. }
  72. describe('SubagentModelSelectionConfig', () => {
  73. it('uses the composed default without a settings provider', async () => {
  74. const ctx = new Context()
  75. await ctx.plugin(SubagentModelSelectionConfig, { enabled: true })
  76. expect(ctx.subagentModelSelection.currentEnabled()).toBe(true)
  77. await ctx.fiber.dispose()
  78. })
  79. it('defaults off and follows the validated user layer', async () => {
  80. const ctx = new Context()
  81. await ctx.plugin(MemorySettings)
  82. await ctx.plugin(SubagentModelSelectionConfig)
  83. expect(ctx.subagentModelSelection.currentEnabled()).toBe(false)
  84. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
  85. expect(ctx.subagentModelSelection.currentEnabled()).toBe(true)
  86. await ctx.fiber.dispose()
  87. })
  88. it('samples each new root session without changing existing Agents', async () => {
  89. const ctx = await boot()
  90. const disabled = await createAgent(ctx, 'disabled')
  91. expect(selectable(ctx, disabled)).toBe(false)
  92. expect(hasSubagentModelSelection(disabled.session)).toBe(false)
  93. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
  94. const enabled = await createAgent(ctx, 'enabled')
  95. expect(hasSubagentModelSelection(enabled.session)).toBe(true)
  96. expect(selectable(ctx, enabled)).toBe(true)
  97. expect(selectable(ctx, disabled)).toBe(false)
  98. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
  99. const disabledAgain = await createAgent(ctx, 'disabled-again')
  100. expect(selectable(ctx, disabledAgain)).toBe(false)
  101. expect(selectable(ctx, enabled)).toBe(true)
  102. await ctx.fiber.dispose()
  103. })
  104. it('installs per-Agent definitions for a shared preset scope', async () => {
  105. const ctx = await boot()
  106. const preset = createScope(ctx, { preset: 'standard' })
  107. const other = createScope(ctx, { preset: 'minimal' })
  108. await preset.ctx.plugin(tool, {
  109. provider: 'spawn',
  110. modelSelectionSettings: true,
  111. backgroundMode: 'continuable',
  112. })
  113. let enabledBinding: ReturnType<typeof bindScopeParent> | undefined
  114. const createComposed = async (id: string) => ctx.agents.create({
  115. sessionId: SessionId(id),
  116. setup: (agentCtx) => {
  117. const binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
  118. if (id === 'preset-enabled') enabledBinding = binding
  119. },
  120. })
  121. const disabled = await createComposed('preset-disabled')
  122. expect(selectable(ctx, disabled.agent)).toBe(false)
  123. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
  124. const enabled = await createComposed('preset-enabled')
  125. expect(selectable(ctx, enabled.agent)).toBe(true)
  126. expect(selectable(ctx, disabled.agent)).toBe(false)
  127. enabledBinding!.rebind(scopeOf(other.ctx)!)
  128. ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
  129. await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(false) })
  130. enabledBinding!.rebind(scopeOf(preset.ctx)!)
  131. ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
  132. await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(true) })
  133. await enabled.dispose()
  134. ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
  135. await disabled.dispose()
  136. await ctx.fiber.dispose()
  137. })
  138. it('inherits the parent decision and preserves seeded decisions across composition', async () => {
  139. const ctx = await boot()
  140. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
  141. const parent = await createAgent(ctx, 'parent')
  142. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
  143. const child = await createAgent(ctx, 'child', {
  144. meta: { parentSession: parent.id, origin: 'subagent' },
  145. })
  146. expect(selectable(ctx, child)).toBe(true)
  147. expect(hasSubagentModelSelection(child.session)).toBe(true)
  148. const enabledSeed = Session.create(SessionId('enabled-seed'))
  149. enabledSeed.append('subagent/model-selection-enabled', {})
  150. const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events })
  151. expect(selectable(ctx, resumedEnabled)).toBe(true)
  152. const oldSeed = Session.create(SessionId('old-seed'), [])
  153. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
  154. const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events })
  155. expect(selectable(ctx, resumedDisabled)).toBe(false)
  156. expect(hasSubagentModelSelection(resumedDisabled.session)).toBe(false)
  157. await ctx.fiber.dispose()
  158. })
  159. it('rejects ambiguous static and settings-controlled configuration', async () => {
  160. const ctx = new Context()
  161. await mountAgentLoopTestDependencies(ctx)
  162. await ctx.plugin(SubagentRuntime)
  163. expect(() => {
  164. tool.apply(ctx, {
  165. provider: 'missing',
  166. enableModelSelection: true,
  167. modelSelectionSettings: true,
  168. })
  169. }).toThrow('mutually exclusive')
  170. await ctx.fiber.dispose()
  171. })
  172. it('requires both the Host setting owner and a composition scope', async () => {
  173. const withoutSettings = new Context()
  174. await mountAgentLoopTestDependencies(withoutSettings)
  175. await withoutSettings.plugin(SubagentRuntime)
  176. expect(() => {
  177. tool.apply(withoutSettings, {
  178. provider: 'missing',
  179. modelSelectionSettings: true,
  180. maxDepth: 'provider-managed',
  181. })
  182. }).toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings')
  183. await withoutSettings.fiber.dispose()
  184. const withoutAgent = await boot()
  185. expect(() => {
  186. tool.apply(withoutAgent, {
  187. provider: 'spawn',
  188. modelSelectionSettings: true,
  189. backgroundMode: 'continuable',
  190. })
  191. }).toThrow('requires an Agent or preset scope')
  192. await withoutAgent.fiber.dispose()
  193. })
  194. it('checks the durable decision against the published tool definitions', async () => {
  195. const ctx = await boot()
  196. await ctx.plugin(InvariantRegistry, { enabled: true })
  197. await ctx.plugin(ToolInvariant)
  198. const disabled = await createAgent(ctx, 'invariant-disabled')
  199. const next = () => Promise.resolve({ kind: 'enter' as const, messages: [] })
  200. const payload = {
  201. agent: disabled,
  202. messages: [],
  203. turn: 1,
  204. step: 1,
  205. signal: new AbortController().signal,
  206. }
  207. await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)).resolves.toEqual({
  208. kind: 'enter', messages: [],
  209. })
  210. disabled.session.append('subagent/model-selection-enabled', {})
  211. await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next))
  212. .rejects.toThrow('must expose route fields and list_subagent_models')
  213. await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true })
  214. const enabled = await createAgent(ctx, 'invariant-enabled')
  215. await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next))
  216. .resolves.toEqual({ kind: 'enter', messages: [] })
  217. await ctx.fiber.dispose()
  218. })
  219. })