openpencil/tests/engine/app/ai/model-roles.test.ts
2026-08-13 21:18:10 +03:00

250 lines
8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { buildReasoningProviderOptions } from '@/app/ai/chat/reasoning'
import {
aiModelSettings,
createAIModelRuntime,
createModelProfileDraft,
designModelProfiles,
modelSettingsSnapshot,
removeModelProfile,
replaceAIModelSettings,
resolveAIModelRole,
saveModelProfileDraft,
setModelConnectionAPIKey,
setModelRoleAssignment,
type AIModelSettings
} from '@/app/ai/models'
import { appCredentialRefs } from '@/app/settings/credentials/persistence'
import { credentialKey } from '@/app/settings/credentials/reference'
let original: AIModelSettings
function settingsFixture(): AIModelSettings {
return {
version: 1,
connections: [
{
id: 'connection-anthropic',
providerID: 'anthropic',
customBaseURL: '',
customAPIType: 'completions',
credentialProfileId: 'anthropic-main'
},
{
id: 'connection-google',
providerID: 'google',
customBaseURL: '',
customAPIType: 'completions',
credentialProfileId: 'google-main'
}
],
models: [
{
id: 'model-design',
name: 'Design model',
connectionId: 'connection-anthropic',
modelID: 'claude-sonnet-4-6-20260301',
customModelID: '',
maxOutputTokens: 16_384,
capabilities: ['tools', 'vision']
},
{
id: 'model-fast',
name: 'Fast model',
connectionId: 'connection-google',
modelID: 'gemini-3-flash-preview',
customModelID: '',
maxOutputTokens: 8192,
capabilities: ['tools']
}
],
assignments: {
design: 'model-design',
review: 'design',
fast: 'model-fast',
vision: 'design'
}
}
}
beforeEach(() => {
original = modelSettingsSnapshot()
replaceAIModelSettings(settingsFixture())
})
afterEach(() => {
replaceAIModelSettings(original)
})
describe('AI model profiles and role assignments', () => {
test('resolves inherited and independent assignments', () => {
expect(resolveAIModelRole('review')).toMatchObject({
requestedRole: 'review',
profile: { id: 'model-design' },
connection: { id: 'connection-anthropic' }
})
expect(resolveAIModelRole('fast')).toMatchObject({
requestedRole: 'fast',
profile: { id: 'model-fast' },
connection: { id: 'connection-google' }
})
})
test('enforces role capability requirements', () => {
setModelRoleAssignment('design', 'model-fast')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-fast')
setModelRoleAssignment('vision', 'model-fast')
expect(resolveAIModelRole('vision')).toBeNull()
setModelRoleAssignment('vision', null)
expect(resolveAIModelRole('vision')).toBeNull()
})
test('switches the design agent between saved profiles', () => {
const switchable = designModelProfiles()
expect(switchable.map((profile) => profile.id)).toEqual(['model-design', 'model-fast'])
setModelRoleAssignment('design', 'model-fast')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-fast')
expect(resolveAIModelRole('design')?.connection.id).toBe('connection-google')
setModelRoleAssignment('design', 'model-design')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-design')
})
test('refuses to assign a profile that cannot use tools as the design agent', () => {
aiModelSettings.value.models.push({
id: 'model-textonly',
name: 'Text only',
connectionId: 'connection-google',
modelID: 'text-only',
customModelID: '',
maxOutputTokens: 4096,
capabilities: []
})
setModelRoleAssignment('design', 'model-textonly')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-design')
})
test('reuses matching provider connections when adding models', () => {
const draft = createModelProfileDraft()
draft.name = 'Review model'
draft.providerID = 'anthropic'
draft.modelID = 'claude-opus-4-6-20260301'
draft.sourceConnectionId = null
const profile = saveModelProfileDraft(draft)
expect(profile.connectionId).toBe('connection-anthropic')
expect(modelSettingsSnapshot().connections).toHaveLength(2)
})
test('keeps ACP agents exclusive to the Design role', () => {
const settings = modelSettingsSnapshot()
settings.connections.push({
id: 'connection-acp',
providerID: 'acp:claude-code',
customBaseURL: '',
customAPIType: 'completions',
credentialProfileId: 'connection-acp'
})
settings.models.push({
id: 'model-acp',
name: 'Claude Code',
connectionId: 'connection-acp',
modelID: '',
customModelID: '',
maxOutputTokens: 16_384,
capabilities: ['tools']
})
replaceAIModelSettings(settings)
setModelRoleAssignment('review', null)
setModelRoleAssignment('review', 'model-acp')
expect(resolveAIModelRole('review')).toBeNull()
setModelRoleAssignment('design', 'model-acp')
expect(resolveAIModelRole('design')?.profile.id).toBe('model-acp')
setModelRoleAssignment('fast', null)
setModelRoleAssignment('fast', 'design')
expect(resolveAIModelRole('fast')).toBeNull()
})
test('normalizes invalid output limits before persistence', () => {
const draft = createModelProfileDraft('model-fast')
draft.maxOutputTokens = Number.NaN
expect(saveModelProfileDraft(draft).maxOutputTokens).toBe(16_384)
})
test('persists provider-specific reasoning effort', () => {
const draft = createModelProfileDraft('model-fast')
draft.reasoningEffort = 'none'
expect(saveModelProfileDraft(draft).reasoningEffort).toBe('none')
expect(createModelProfileDraft('model-fast').reasoningEffort).toBe('none')
})
test('maps reasoning effort to supported provider options', () => {
expect(buildReasoningProviderOptions('openai-compatible', 'none')).toEqual({
openai: { reasoningEffort: 'none' }
})
expect(buildReasoningProviderOptions('openrouter', 'high')).toEqual({
openrouter: { reasoning: { effort: 'high' } }
})
expect(buildReasoningProviderOptions('google', 'high')).toBeUndefined()
expect(buildReasoningProviderOptions('openai', '')).toBeUndefined()
})
test('repairs assignments when removing a model', () => {
removeModelProfile('model-design')
const settings = modelSettingsSnapshot()
expect(settings.assignments.design).toBe('model-fast')
expect(settings.assignments.vision).toBeNull()
expect(settings.connections.map((connection) => connection.id)).toEqual(['connection-google'])
})
test('keeps the assigned design profile when no capable fallback exists', () => {
const settings = settingsFixture()
settings.models = [
settings.models[0],
{
id: 'model-text-only',
name: 'Text-only model',
connectionId: 'connection-google',
modelID: 'text-only',
customModelID: '',
maxOutputTokens: 4096,
capabilities: []
}
]
replaceAIModelSettings(settings)
removeModelProfile('model-design')
expect(modelSettingsSnapshot().models.map((profile) => profile.id)).toEqual([
'model-design',
'model-text-only'
])
expect(resolveAIModelRole('design')?.profile.id).toBe('model-design')
})
test('includes configured connection credentials in persistence changes', () => {
const keys = appCredentialRefs().map(credentialKey)
expect(keys).toContain('v1:anthropic:anthropic-main:api-key')
expect(keys).toContain('v1:google:google-main:api-key')
})
test('creates a role runtime without exposing its resolved credential', async () => {
await setModelConnectionAPIKey('connection-anthropic', 'review-secret')
try {
const runtime = await createAIModelRuntime('review')
expect(runtime?.kind).toBe('direct')
expect(runtime?.role.profile.id).toBe('model-design')
expect(runtime).not.toHaveProperty('apiKey')
} finally {
await setModelConnectionAPIKey('connection-anthropic', '')
}
})
})