| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191 |
- import { describe, expect, it } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import {
- agentEvents,
- installModelSelection,
- type Agent,
- type ModelSelection,
- type ModelSelectionRef,
- } from '../src/index.ts'
- import {
- createUserMessage,
- ReasoningEffortId,
- type LlmCallConfig,
- type UserMessage,
- } from '@deepseek-ai/dsh-llm'
- import { Session, SessionId } from '@deepseek-ai/dsh-session'
- const SIGNAL = new AbortController().signal
- const INPUT = createUserMessage({
- content: [{ type: 'text', text: 'continue' }],
- source: { kind: 'user' },
- })
- function createAgent(): Agent {
- return { session: Session.create(SessionId('model-selection')) } as Agent
- }
- function expectedNotice(from: string, to: string) {
- return {
- content: [{
- type: 'text',
- text: `[model changed: assistant turns above this point were generated by ${from}; the session continues with ${to}]`,
- }],
- source: { kind: 'plugin', plugin: 'model-selection', form: 'notice', summary: `${from} → ${to}` },
- }
- }
- async function switchHarness(current: ModelSelection, previous?: ModelSelection) {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- const selection: ModelSelectionRef = { current, assembled: undefined }
- const dispose = installModelSelection(ctx, selection)
- const agent = createAgent()
- if (previous !== undefined) {
- agent.session.append('request/header', { header: { config: previous }, reason: 'initial' })
- }
- await ctx.systemPrompt.assemble()
- return { agent, ctx, dispose, selection }
- }
- async function preStep(
- ctx: Context,
- agent: Agent,
- {
- messages = [INPUT],
- offered = [INPUT],
- step = 1,
- signal = SIGNAL,
- }: {
- messages?: UserMessage[]
- offered?: UserMessage[]
- step?: number
- signal?: AbortSignal
- } = {},
- ) {
- return agentEvents(ctx, agent).waterfall(
- 'agent/pre-step',
- { turn: 1, step, messages: offered, signal },
- () => Promise.resolve({ kind: 'enter' as const, messages }),
- )
- }
- describe('installModelSelection()', () => {
- it('snapshots prompt variables and request routing together, then disposes its listeners', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- const selection: ModelSelectionRef = { current: undefined, assembled: undefined }
- const dispose = installModelSelection(ctx, selection)
- const agent = createAgent()
- const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
- const signal = new AbortController().signal
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
- await expect(agentEvents(ctx, agent).waterfall(
- 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
- )).resolves.toBe(seed)
- selection.current = {
- provider: 'alpha',
- model: 'a1',
- reasoningEffort: ReasoningEffortId('high'),
- }
- expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
- selection.current = { provider: 'beta', model: 'b1' }
- await expect(agentEvents(ctx, agent).waterfall(
- 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
- )).resolves.toEqual({
- provider: 'alpha',
- model: 'a1',
- reasoningEffort: ReasoningEffortId('high'),
- temperature: 0.2,
- })
- expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
- const inherited: LlmCallConfig = {
- provider: 'alpha',
- model: 'a1',
- reasoningEffort: ReasoningEffortId('max'),
- temperature: 0.2,
- }
- await expect(agentEvents(ctx, agent).waterfall(
- 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited),
- )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
- dispose()
- expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
- await expect(agentEvents(ctx, agent).waterfall(
- 'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed),
- )).resolves.toBe(seed)
- await ctx.fiber.dispose()
- })
- it('announces same-provider and cross-provider route changes from the assembled selection', async () => {
- const { agent, ctx, dispose, selection } = await switchHarness(
- { provider: 'alpha', model: 'a1' },
- { provider: 'alpha', model: 'a0' },
- )
- await expect(preStep(ctx, agent)).resolves.toMatchObject({
- messages: [INPUT, expectedNotice('a0', 'a1')],
- })
- selection.current = { provider: 'beta', model: 'b1' }
- await ctx.systemPrompt.assemble()
- selection.current = { provider: 'alpha', model: 'a2' }
- await expect(preStep(ctx, agent)).resolves.toMatchObject({
- messages: [INPUT, expectedNotice('alpha/a0', 'beta/b1')],
- })
- await ctx.systemPrompt.assemble()
- await expect(preStep(ctx, agent)).resolves.toMatchObject({
- messages: [INPUT, expectedNotice('a0', 'a2')],
- })
- dispose()
- await ctx.fiber.dispose()
- })
- it('does not announce initial, same-route, effort-only, rejected, aborted, or disposed steps', async () => {
- const { agent, ctx, dispose, selection } = await switchHarness({ provider: 'alpha', model: 'a0' })
- await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
- agent.session.append('request/header', {
- header: { config: { provider: 'alpha', model: 'a0' } }, reason: 'initial',
- })
- selection.current = {
- provider: 'alpha',
- model: 'a0',
- reasoningEffort: ReasoningEffortId('high'),
- }
- await ctx.systemPrompt.assemble()
- await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
- selection.current = { provider: 'alpha', model: 'a1' }
- await ctx.systemPrompt.assemble()
- const rejected = await agentEvents(ctx, agent).waterfall(
- 'agent/pre-step',
- { turn: 1, step: 1, messages: [], signal: SIGNAL },
- () => Promise.resolve({ kind: 'reject' as const }),
- )
- expect(rejected).toEqual({ kind: 'reject' })
- const aborted = new AbortController()
- aborted.abort()
- await expect(preStep(ctx, agent, { signal: aborted.signal })).resolves.toMatchObject({ messages: [INPUT] })
- dispose()
- await expect(preStep(ctx, agent)).resolves.toMatchObject({ kind: 'enter', messages: [INPUT] })
- await ctx.fiber.dispose()
- })
- it('preserves empty no-call decisions and announces an empty tool continuation', async () => {
- const { agent, ctx } = await switchHarness(
- { provider: 'alpha', model: 'a1' },
- { provider: 'alpha', model: 'a0' },
- )
- await expect(preStep(ctx, agent, { messages: [] })).resolves.toEqual({ kind: 'enter', messages: [] })
- await expect(preStep(ctx, agent, { messages: [], step: 2 })).resolves.toEqual({ kind: 'enter', messages: [] })
- await expect(preStep(ctx, agent, { messages: [], offered: [], step: 2 })).resolves.toMatchObject({
- messages: [{ source: { summary: 'a0 → a1' } }],
- })
- await ctx.fiber.dispose()
- })
- })
|