201 lines
9.0 KiB
TypeScript
201 lines
9.0 KiB
TypeScript
import { describe, expect, it, onTestFinished } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import {
|
|
agentEvents,
|
|
installModelSelection,
|
|
type Agent,
|
|
type AgentOptions,
|
|
type ModelSelection,
|
|
} from '@deepseek-ai/dsh-agent'
|
|
import { createUserMessage, ReasoningEffortId, type UserMessage } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore, { Session, SessionId, type EpochHeader } from '@deepseek-ai/dsh-session'
|
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
import { AgentModel, modelSelectionProjectionDefinition } from '../src/model-selection.ts'
|
|
|
|
const INITIAL = { provider: 'alpha', model: 'a0' }
|
|
const SIGNAL = new AbortController().signal
|
|
const INPUT = createUserMessage({
|
|
content: [{ type: 'text', text: 'continue' }],
|
|
source: { kind: 'user' },
|
|
})
|
|
|
|
async function harness(options: AgentOptions = INITIAL) {
|
|
const ctx = new Context()
|
|
onTestFinished(() => ctx.fiber.dispose())
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition)
|
|
const session = ctx.sessions.create(SessionId('model-selection'))
|
|
const model = new AgentModel(() => projection.stateOf(session), session, options)
|
|
const unused = (): never => { throw new Error('model-selection fixture does not drive the Agent') }
|
|
const agent: Agent = {
|
|
id: session.id, session, ctx, options, model, status: 'idle',
|
|
get inbox() { return unused() },
|
|
cancel: unused, whenIdle: unused, runMaintenance: unused,
|
|
send: unused, followup: unused, steer: unused, inject: unused,
|
|
}
|
|
const current = () => agent.model.current()
|
|
const record = (selection: ModelSelection) => { agent.model.select(selection) }
|
|
const header = (config: ModelSelection, adapterDefaults?: EpochHeader['adapterDefaults']) => session.append('request/header', {
|
|
header: { config, ...adapterDefaults === undefined ? {} : { adapterDefaults } }, reason: 'initial',
|
|
})
|
|
return { ctx, agent, session, current, record, header, state: () => projection.stateOf(session) }
|
|
}
|
|
|
|
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: 'model-selection', form: 'notice', summary: `${from} → ${to}` },
|
|
}
|
|
}
|
|
|
|
async function preStep(
|
|
agent: Agent,
|
|
modelSelection: ModelSelection,
|
|
{
|
|
messages = [INPUT], offered = [INPUT], step = 1, signal = SIGNAL,
|
|
}: { messages?: UserMessage[]; offered?: UserMessage[]; step?: number; signal?: AbortSignal } = {},
|
|
) {
|
|
return agentEvents(agent.ctx, agent).waterfall(
|
|
'agent/pre-step',
|
|
{ turn: 1, step, messages: offered, signal, modelSelection },
|
|
() => Promise.resolve({ kind: 'enter' as const, messages, startsRequestSeries: true as const }),
|
|
)
|
|
}
|
|
|
|
describe('Session model selection', () => {
|
|
it('uses initial options only until a selection or request is recorded and returns detached values', async () => {
|
|
const h = await harness({})
|
|
expect(h.current()).toBeUndefined()
|
|
h.agent.options.provider = 'alpha'
|
|
expect(h.current()).toBeUndefined()
|
|
h.agent.options.model = 'a0'
|
|
expect(h.current()).toEqual(INITIAL)
|
|
expect(h.ctx.sessionProjections.snapshot(h.session).values.modelSelection).toEqual({ lastUsed: null, next: null })
|
|
|
|
h.record(INITIAL)
|
|
expect(h.session.snapshotEvents()).toMatchObject([{ type: 'model/selection', data: INITIAL }])
|
|
const selected = { provider: 'beta', model: 'b1', reasoningEffort: ReasoningEffortId('high') }
|
|
h.record(selected)
|
|
selected.model = 'mutated'
|
|
const read: ModelSelection = h.current()!
|
|
read.model = 'also mutated'
|
|
expect(h.current()).toEqual({ provider: 'beta', model: 'b1', reasoningEffort: 'high' })
|
|
h.record(h.current()!)
|
|
expect(h.session.snapshotEvents()).toHaveLength(2)
|
|
h.agent.options.model = 'other initial model'
|
|
expect(h.current()?.model).toBe('b1')
|
|
})
|
|
|
|
it('retires only a matching pending selection and reconstructs it from the log', async () => {
|
|
const h = await harness()
|
|
h.header(INITIAL)
|
|
const selected = { provider: 'beta', model: 'b1', reasoningEffort: ReasoningEffortId('high') }
|
|
h.record(selected)
|
|
for (const config of [
|
|
{ ...selected, provider: 'other' },
|
|
{ ...selected, model: 'other' },
|
|
{ ...selected, reasoningEffort: ReasoningEffortId('low') },
|
|
]) {
|
|
h.header(config)
|
|
expect(h.state()?.pending).toEqual(selected)
|
|
}
|
|
const restored = Session.create(SessionId('restored'), h.session.snapshotEvents())
|
|
const projection = await h.ctx.sessionProjections.register(modelSelectionProjectionDefinition)
|
|
const restoredModel = new AgentModel(() => projection.stateOf(restored), restored, {})
|
|
expect(restoredModel.current()).toEqual(selected)
|
|
h.header(selected)
|
|
expect(h.state()).toEqual({ lastUsed: selected, pending: null })
|
|
expect(h.current()).toEqual(selected)
|
|
const state = h.state()
|
|
h.header(selected)
|
|
expect(h.state()).toBe(state)
|
|
})
|
|
|
|
it('leaves adapter-defaulted reasoning unselected while retaining explicit effort', async () => {
|
|
const h = await harness()
|
|
const selected = { provider: 'beta', model: 'b1' }
|
|
h.record(selected)
|
|
h.header({ ...selected, reasoningEffort: ReasoningEffortId('high') }, { reasoningEffort: true })
|
|
expect(h.state()).toEqual({ lastUsed: selected, pending: null })
|
|
expect(h.current()).toEqual(selected)
|
|
expect(h.ctx.sessionProjections.snapshot(h.session).values.modelSelection).toEqual({ lastUsed: selected, next: selected })
|
|
h.header({ ...selected, reasoningEffort: ReasoningEffortId('low') })
|
|
expect(h.current()).toEqual({ ...selected, reasoningEffort: 'low' })
|
|
})
|
|
|
|
it('reuses fold state for duplicate recorded selections and unrelated events', async () => {
|
|
const h = await harness()
|
|
const selected = { provider: 'beta', model: 'b1' }
|
|
h.record(selected)
|
|
const state = h.state()
|
|
h.session.append('model/selection', selected)
|
|
h.session.append('user/message', INPUT, { surfaceOp: 'append' })
|
|
expect(h.state()).toBe(state)
|
|
})
|
|
|
|
it('replaces the complete selection and clears an omitted explicit effort', async () => {
|
|
const h = await harness()
|
|
h.record({ ...INITIAL, reasoningEffort: ReasoningEffortId('high') })
|
|
h.record(INITIAL)
|
|
expect(h.current()).toEqual(INITIAL)
|
|
expect(h.session.snapshotEvents()).toHaveLength(2)
|
|
h.record(INITIAL)
|
|
expect(h.session.snapshotEvents()).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('installModelSelection()', () => {
|
|
it('announces the fixed step route, preserves request-series declarations, and repeats before a header commits', async () => {
|
|
const h = await harness()
|
|
h.header(INITIAL)
|
|
installModelSelection(h.ctx)
|
|
h.record({ provider: 'alpha', model: 'future' })
|
|
const selected = { provider: 'beta', model: 'b1' }
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
await expect(preStep(h.agent, selected)).resolves.toMatchObject({
|
|
startsRequestSeries: true,
|
|
messages: [INPUT, expectedNotice('alpha/a0', 'beta/b1')],
|
|
})
|
|
}
|
|
h.header(selected)
|
|
await expect(preStep(h.agent, selected)).resolves.toMatchObject({ messages: [INPUT] })
|
|
await expect(preStep(h.agent, { provider: 'beta', model: 'b2' })).resolves.toMatchObject({
|
|
messages: [INPUT, expectedNotice('b1', 'b2')],
|
|
})
|
|
})
|
|
|
|
it('does not announce initial, same-route, effort-only, rejected, aborted, or disposed steps', async () => {
|
|
const h = await harness()
|
|
const dispose = installModelSelection(h.ctx)
|
|
await expect(preStep(h.agent, INITIAL)).resolves.toMatchObject({ messages: [INPUT] })
|
|
h.header(INITIAL)
|
|
await expect(preStep(h.agent, INITIAL)).resolves.toMatchObject({ messages: [INPUT] })
|
|
await expect(preStep(h.agent, { ...INITIAL, reasoningEffort: ReasoningEffortId('high') }))
|
|
.resolves.toMatchObject({ messages: [INPUT] })
|
|
const selected = { provider: 'alpha', model: 'a1' }
|
|
await expect(agentEvents(h.ctx, h.agent).waterfall(
|
|
'agent/pre-step', { turn: 1, step: 1, messages: [], signal: SIGNAL, modelSelection: selected },
|
|
() => Promise.resolve({ kind: 'reject' as const }),
|
|
)).resolves.toEqual({ kind: 'reject' })
|
|
await expect(preStep(h.agent, selected, { signal: AbortSignal.abort() })).resolves.toMatchObject({ messages: [INPUT] })
|
|
dispose()
|
|
await expect(preStep(h.agent, selected)).resolves.toMatchObject({ messages: [INPUT] })
|
|
})
|
|
|
|
it('preserves empty no-call decisions and announces an empty tool continuation', async () => {
|
|
const h = await harness()
|
|
h.header(INITIAL)
|
|
installModelSelection(h.ctx)
|
|
const selected = { provider: 'alpha', model: 'a1' }
|
|
await expect(preStep(h.agent, selected, { messages: [] })).resolves.toMatchObject({ messages: [] })
|
|
await expect(preStep(h.agent, selected, { messages: [], step: 2 })).resolves.toMatchObject({ messages: [] })
|
|
await expect(preStep(h.agent, selected, { messages: [], offered: [], step: 2 })).resolves.toMatchObject({
|
|
messages: [expectedNotice('a0', 'a1')],
|
|
})
|
|
})
|
|
})
|