model-selection.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  4. import {
  5. agentEvents,
  6. installModelSelection,
  7. type Agent,
  8. type ModelSelection,
  9. type ModelSelectionRef,
  10. } from '../src/index.ts'
  11. import {
  12. createUserMessage,
  13. ReasoningEffortId,
  14. type LlmCallConfig,
  15. type UserMessage,
  16. } from '@deepseek-ai/dsh-llm'
  17. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  18. const SIGNAL = new AbortController().signal
  19. const INPUT = createUserMessage({
  20. content: [{ type: 'text', text: 'continue' }],
  21. source: { kind: 'user' },
  22. })
  23. function createAgent(): Agent {
  24. return { session: Session.create(SessionId('model-selection')) } as Agent
  25. }
  26. function expectedNotice(from: string, to: string) {
  27. return {
  28. content: [{
  29. type: 'text',
  30. text: `[model changed: assistant turns above this point were generated by ${from}; the session continues with ${to}]`,
  31. }],
  32. source: { kind: 'plugin', plugin: 'model-selection', form: 'notice', summary: `${from} → ${to}` },
  33. }
  34. }
  35. async function switchHarness(current: ModelSelection, previous?: ModelSelection) {
  36. const ctx = new Context()
  37. await ctx.plugin(SystemPrompt)
  38. const selection: ModelSelectionRef = { current, assembled: undefined }
  39. const dispose = installModelSelection(ctx, selection)
  40. const agent = createAgent()
  41. if (previous !== undefined) {
  42. agent.session.append('request/header', { header: { config: previous }, reason: 'initial' })
  43. }
  44. await ctx.systemPrompt.assemble()
  45. return { agent, ctx, dispose, selection }
  46. }
  47. async function preStep(
  48. ctx: Context,
  49. agent: Agent,
  50. {
  51. messages = [INPUT],
  52. offered = [INPUT],
  53. step = 1,
  54. signal = SIGNAL,
  55. }: {
  56. messages?: UserMessage[]
  57. offered?: UserMessage[]
  58. step?: number
  59. signal?: AbortSignal
  60. } = {},
  61. ) {
  62. return agentEvents(ctx, agent).waterfall(
  63. 'agent/pre-step',
  64. { turn: 1, step, messages: offered, signal },
  65. () => Promise.resolve({ kind: 'enter' as const, messages }),
  66. )
  67. }
  68. describe('installModelSelection()', () => {
  69. it('snapshots prompt variables and request routing together, then disposes its listeners', async () => {
  70. const ctx = new Context()
  71. await ctx.plugin(SystemPrompt)
  72. const selection: ModelSelectionRef = { current: undefined, assembled: undefined }
  73. const dispose = installModelSelection(ctx, selection)
  74. const agent = createAgent()
  75. const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
  76. const signal = new AbortController().signal
  77. expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
  78. await expect(agentEvents(ctx, agent).waterfall(
  79. 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
  80. )).resolves.toBe(seed)
  81. selection.current = {
  82. provider: 'alpha',
  83. model: 'a1',
  84. reasoningEffort: ReasoningEffortId('high'),
  85. }
  86. expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
  87. selection.current = { provider: 'beta', model: 'b1' }
  88. await expect(agentEvents(ctx, agent).waterfall(
  89. 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
  90. )).resolves.toEqual({
  91. provider: 'alpha',
  92. model: 'a1',
  93. reasoningEffort: ReasoningEffortId('high'),
  94. temperature: 0.2,
  95. })
  96. expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
  97. const inherited: LlmCallConfig = {
  98. provider: 'alpha',
  99. model: 'a1',
  100. reasoningEffort: ReasoningEffortId('max'),
  101. temperature: 0.2,
  102. }
  103. await expect(agentEvents(ctx, agent).waterfall(
  104. 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited),
  105. )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
  106. dispose()
  107. expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
  108. await expect(agentEvents(ctx, agent).waterfall(
  109. 'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed),
  110. )).resolves.toBe(seed)
  111. await ctx.fiber.dispose()
  112. })
  113. it('announces same-provider and cross-provider route changes from the assembled selection', async () => {
  114. const { agent, ctx, dispose, selection } = await switchHarness(
  115. { provider: 'alpha', model: 'a1' },
  116. { provider: 'alpha', model: 'a0' },
  117. )
  118. await expect(preStep(ctx, agent)).resolves.toMatchObject({
  119. messages: [INPUT, expectedNotice('a0', 'a1')],
  120. })
  121. selection.current = { provider: 'beta', model: 'b1' }
  122. await ctx.systemPrompt.assemble()
  123. selection.current = { provider: 'alpha', model: 'a2' }
  124. await expect(preStep(ctx, agent)).resolves.toMatchObject({
  125. messages: [INPUT, expectedNotice('alpha/a0', 'beta/b1')],
  126. })
  127. await ctx.systemPrompt.assemble()
  128. await expect(preStep(ctx, agent)).resolves.toMatchObject({
  129. messages: [INPUT, expectedNotice('a0', 'a2')],
  130. })
  131. dispose()
  132. await ctx.fiber.dispose()
  133. })
  134. it('does not announce initial, same-route, effort-only, rejected, aborted, or disposed steps', async () => {
  135. const { agent, ctx, dispose, selection } = await switchHarness({ provider: 'alpha', model: 'a0' })
  136. await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
  137. agent.session.append('request/header', {
  138. header: { config: { provider: 'alpha', model: 'a0' } }, reason: 'initial',
  139. })
  140. selection.current = {
  141. provider: 'alpha',
  142. model: 'a0',
  143. reasoningEffort: ReasoningEffortId('high'),
  144. }
  145. await ctx.systemPrompt.assemble()
  146. await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
  147. selection.current = { provider: 'alpha', model: 'a1' }
  148. await ctx.systemPrompt.assemble()
  149. const rejected = await agentEvents(ctx, agent).waterfall(
  150. 'agent/pre-step',
  151. { turn: 1, step: 1, messages: [], signal: SIGNAL },
  152. () => Promise.resolve({ kind: 'reject' as const }),
  153. )
  154. expect(rejected).toEqual({ kind: 'reject' })
  155. const aborted = new AbortController()
  156. aborted.abort()
  157. await expect(preStep(ctx, agent, { signal: aborted.signal })).resolves.toMatchObject({ messages: [INPUT] })
  158. dispose()
  159. await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
  160. await ctx.fiber.dispose()
  161. })
  162. it('preserves empty no-call decisions and announces an empty tool continuation', async () => {
  163. const { agent, ctx } = await switchHarness(
  164. { provider: 'alpha', model: 'a1' },
  165. { provider: 'alpha', model: 'a0' },
  166. )
  167. await expect(preStep(ctx, agent, { messages: [] })).resolves.toEqual({ kind: 'enter', messages: [] })
  168. await expect(preStep(ctx, agent, { messages: [], step: 2 })).resolves.toEqual({ kind: 'enter', messages: [] })
  169. await expect(preStep(ctx, agent, { messages: [], offered: [], step: 2 })).resolves.toMatchObject({
  170. messages: [{ source: { summary: 'a0 → a1' } }],
  171. })
  172. await ctx.fiber.dispose()
  173. })
  174. })