feat: layer runtime and model selection across conversation, project and global

- Store runtime selection as { provider?, model? } layers and resolve them per
  field (conversation -> project -> global); drop the legacy auto provider.
- Migrate stored selections (database v49) and clear deleted profile ids.
- Re-resolve selections in Main per request and at queue dispatch.
- Add ProjectRuntimeSelector and a two-column RuntimeModelPicker; manage
  link opens model connections; restore per-runtime default model tabs.
- Stop duplicating trusted instructions and history across OpenCode,
  Continue and DeepSeek Harness runtimes.
- Document the layered design, global provider removal plan and planned
  purpose-specific models.
This commit is contained in:
mesalogo
2026-09-29 21:33:06 +08:00
parent d1b92deff3
commit 9c88ca20f3
50 changed files with 2879 additions and 1836 deletions
@@ -1,109 +1,168 @@
import { describe, expect, it } from 'vitest'
import { applyRuntimeSelection } from './runtime-selection'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import { defaultRuntimeCustomizationSettings } from '../../shared/contracts'
import {
repairChannelRuntimeSelection,
type AgentRuntimeSelection,
type RuntimeSelectionRepairSettings
compactRuntimeSelectionLayer,
optionalAgentRuntimeSelectionSchema,
repairRuntimeSelectionLayer,
resolveRuntimeChoice,
runtimeSelectionLayerSchema,
withoutLegacyAutoSelection,
type RuntimeResolutionSettings
} from '../../shared/runtime-selection-contracts'
const defaultId = '00000000-0000-4000-8000-000000000001'
const fallbackId = '00000000-0000-4000-8000-000000000002'
const missingId = '00000000-0000-4000-8000-000000000003'
const secondId = '00000000-0000-4000-8000-000000000002'
const imageId = '00000000-0000-4000-8000-000000000003'
const keylessId = '00000000-0000-4000-8000-000000000004'
const missingId = '00000000-0000-4000-8000-000000000009'
function settings(
defaultProfile: Partial<RuntimeSelectionRepairSettings['modelProfiles'][number]>
): RuntimeSelectionRepairSettings {
overrides: Partial<RuntimeResolutionSettings> = {}
): RuntimeResolutionSettings {
return {
provider: 'opencode',
defaultModelProfileId: defaultId,
modelProfiles: [
{
id: defaultId, protocol: 'openai-chat-completions',
authentication: 'api-key', apiKeyConfigured: true, ...defaultProfile
},
{
id: fallbackId, protocol: 'openai-chat-completions',
authentication: 'none', apiKeyConfigured: false
}
{ id: defaultId, name: 'Default', protocol: 'openai-chat-completions', baseUrl: 'https://a.example/v1', authentication: 'api-key', apiKeyConfigured: true },
{ id: secondId, name: 'Second', protocol: 'anthropic-messages', baseUrl: 'https://b.example/v1', authentication: 'none' },
{ id: imageId, name: 'Image', protocol: 'openai-images-generations', baseUrl: 'https://c.example/v1', authentication: 'none' },
{ id: keylessId, name: 'Keyless', protocol: 'openai-chat-completions', baseUrl: 'https://d.example/v1', authentication: 'api-key', apiKeyConfigured: false }
],
opencodeModelSource: { kind: 'platform' },
continueModelSource: { kind: 'platform' }
opencodeModelSource: { kind: 'profile', profileId: secondId },
continueModelSource: { kind: 'platform' },
deepseekHarnessModelSource: { kind: 'default' },
...overrides
}
}
function resolvedSettings(input: RuntimeSelectionRepairSettings): ResolvedRuntimeSettings {
return {
provider: 'model', modelBaseUrl: 'https://model.example/v1', modelName: 'default',
modelProtocol: 'openai-chat-completions', modelAuthentication: 'api-key',
imageGenerationQuality: 'auto',
modelProfiles: input.modelProfiles.map((profile) => ({
id: profile.id, name: profile.id, baseUrl: 'https://model.example/v1',
modelName: profile.id,
protocol: profile.protocol as ResolvedRuntimeSettings['modelProtocol'],
authentication: profile.authentication!,
apiKey: profile.apiKeyConfigured ? 'fixture-key' : undefined
})),
defaultModelProfileId: input.defaultModelProfileId,
opencodeBaseUrl: '', opencodeEmbedded: false, opencodeBinaryPath: '',
opencodeConfigPath: '', continueBinaryPath: '', continueConfigPath: '',
continueMode: 'chat', subagentSmartRoutingEnabled: false,
knowledgeEmbeddingEnabled: false, knowledgeEmbeddingBaseUrl: '',
knowledgeEmbeddingModel: '', knowledgeRerankEnabled: false,
knowledgeRerankEndpoint: '', knowledgeRerankModel: '',
runtimeCustomization: defaultRuntimeCustomizationSettings,
workspacePath: '.', toolApproval: 'always'
}
}
describe('layered runtime resolution', () => {
it('uses global defaults when project and conversation inherit', () => {
const resolved = resolveRuntimeChoice(settings())
expect(resolved.selection).toEqual({ provider: 'opencode', profileId: secondId })
expect(resolved).toMatchObject({ providerSource: 'global', modelSource: 'global' })
})
describe('channel direct model repair', () => {
const selections: AgentRuntimeSelection[] = [
{ provider: 'auto' },
{ provider: 'model' },
{ provider: 'model', profileId: defaultId },
{ provider: 'model', profileId: missingId }
]
it.each([
{ protocol: 'openai-images-generations' },
{ apiKeyConfigured: false },
{ id: missingId }
])('pins a verified fallback when the default is unusable: %j', (defaultProfile) => {
const input = settings(defaultProfile)
if (defaultProfile.id) input.modelProfiles = input.modelProfiles.slice(1)
for (const selection of selections) {
const repaired = repairChannelRuntimeSelection(selection, input)
expect(repaired).toEqual({ provider: 'model', profileId: fallbackId })
const resolved = applyRuntimeSelection(resolvedSettings(input), repaired)
expect(resolved.settings.defaultModelProfileId).toBe(fallbackId)
expect(resolved.settings.modelProtocol).toBe('openai-chat-completions')
expect(resolved.settings.modelAuthentication).toBe('none')
it('inherits each field independently across layers', () => {
const resolved = resolveRuntimeChoice(settings(), {
project: { provider: 'continue' },
conversation: { model: { kind: 'profile', profileId: defaultId } }
})
expect(resolved.selection).toEqual({ provider: 'continue', profileId: defaultId })
expect(resolved).toMatchObject({ providerSource: 'project', modelSource: 'conversation' })
})
it('switching only the execution mode takes that mode’s own default model', () => {
const resolved = resolveRuntimeChoice(settings(), {
project: { provider: 'opencode', model: { kind: 'profile', profileId: secondId } },
conversation: { provider: 'continue' }
})
// The project's OpenCode model still applies because it is valid for Continue too;
// a Continue-incompatible model would fall back to Continue's global default.
expect(resolved.selection).toEqual({ provider: 'continue', profileId: secondId })
const harness = resolveRuntimeChoice(settings(), {
project: { model: { kind: 'profile', profileId: secondId } },
conversation: { provider: 'deepseek-harness' }
})
expect(harness.selection).toEqual({ provider: 'deepseek-harness', profileId: defaultId })
expect(harness.ignored).toMatchObject({ layer: 'project', kind: 'model', reason: 'incompatible' })
})
it('keeps a keyless model so the run can explain the missing key', () => {
expect(resolveRuntimeChoice(settings(), {
conversation: { model: { kind: 'profile', profileId: keylessId } }
}).selection).toEqual({ provider: 'opencode', profileId: keylessId })
})
it('falls back and reports deleted or incompatible models', () => {
for (const [profileId, reason] of [
[missingId, 'missing'],
[imageId, 'incompatible']
] as const) {
const resolved = resolveRuntimeChoice(settings(), {
conversation: { model: { kind: 'profile', profileId } }
})
expect(resolved.selection).toEqual({ provider: 'opencode', profileId: secondId })
expect(resolved.ignored).toEqual({ layer: 'conversation', kind: 'model', reason })
}
})
it('keeps a healthy default dynamic and preserves valid explicit pins', () => {
const input = settings({})
for (const selection of [selections[0]!, selections[1]!, selections[3]!]) {
const repaired = repairChannelRuntimeSelection(selection, input)
expect(repaired).toEqual({ provider: 'model' })
const changed = { ...resolvedSettings(input), defaultModelProfileId: fallbackId }
expect(applyRuntimeSelection(changed, repaired).settings.defaultModelProfileId).toBe(fallbackId)
}
for (const profileId of [defaultId, fallbackId]) {
const selection = { provider: 'model', profileId } as const
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
}
it('treats own configuration as an explicit choice', () => {
expect(resolveRuntimeChoice(settings(), { project: { provider: 'continue' } }).selection)
.toEqual({ provider: 'continue', runtimeConfig: true })
const direct = resolveRuntimeChoice(settings(), {
project: { provider: 'model' },
conversation: { model: { kind: 'runtime-config' } }
})
expect(direct.selection).toEqual({ provider: 'model', profileId: defaultId })
expect(direct.ignored?.reason).toBe('incompatible')
})
it('preserves a valid pin even when the global default is an image model', () => {
const input = settings({ protocol: 'openai-images-generations' })
const selection = { provider: 'model', profileId: fallbackId } as const
expect(repairChannelRuntimeSelection(selection, input)).toEqual(selection)
it('limits managed SSH projects to OpenCode and Continue with GoodBuddy connections', () => {
const resolved = resolveRuntimeChoice(
settings({ provider: 'model', continueModelSource: { kind: 'platform' } }),
{ project: { provider: 'deepseek-harness' }, conversation: { provider: 'continue' } },
{ remote: true }
)
expect(resolved.selection).toEqual({ provider: 'continue', profileId: defaultId })
expect(resolved.ignored).toMatchObject({ layer: 'project', kind: 'provider' })
})
it('does not invent a profile when no usable fallback exists', () => {
const input = settings({ apiKeyConfigured: false })
input.modelProfiles = input.modelProfiles.slice(0, 1)
expect(repairChannelRuntimeSelection({ provider: 'auto' }, input))
it('treats legacy automatic global settings as the direct model', () => {
expect(resolveRuntimeChoice(settings({ provider: 'auto' })).selection)
.toEqual({ provider: 'model', profileId: defaultId })
})
it('skips an unusable global direct default instead of failing', () => {
const resolved = resolveRuntimeChoice(settings({ provider: 'model', defaultModelProfileId: imageId }))
expect(resolved.selection).toEqual({ provider: 'model', profileId: defaultId })
})
it('never invents a profile id when no connections exist', () => {
expect(resolveRuntimeChoice(settings({ provider: 'model', defaultModelProfileId: '', modelProfiles: [] })).selection)
.toEqual({ provider: 'model' })
})
it('refuses GoodBuddy connections for an external OpenCode server', () => {
const resolved = resolveRuntimeChoice(settings({ opencodeBaseUrl: 'http://127.0.0.1:4096' }), {
conversation: { model: { kind: 'profile', profileId: defaultId } }
})
expect(resolved.selection).toEqual({ provider: 'opencode', runtimeConfig: true })
})
})
describe('runtime selection layer storage', () => {
it('upgrades legacy stored selections', () => {
expect(runtimeSelectionLayerSchema.parse({ provider: 'auto' })).toEqual({})
expect(runtimeSelectionLayerSchema.parse({ provider: 'opencode' })).toEqual({ provider: 'opencode' })
expect(runtimeSelectionLayerSchema.parse({ provider: 'model', profileId: defaultId }))
.toEqual({ provider: 'model', model: { kind: 'profile', profileId: defaultId } })
expect(() => runtimeSelectionLayerSchema.parse({ provider: 'model', model: { kind: 'runtime-config' } }))
.toThrow()
})
it('stores inheritance as absence', () => {
expect(compactRuntimeSelectionLayer({})).toBeUndefined()
expect(compactRuntimeSelectionLayer(undefined)).toBeUndefined()
})
it('repairs only references to deleted connections', () => {
const input = settings()
expect(repairRuntimeSelectionLayer(
{ provider: 'continue', model: { kind: 'profile', profileId: missingId } }, input
)).toEqual({ provider: 'continue' })
expect(repairRuntimeSelectionLayer(
{ model: { kind: 'profile', profileId: missingId } }, input
)).toBeUndefined()
const valid = { provider: 'continue' as const, model: { kind: 'profile' as const, profileId: imageId } }
// Incompatible but existing: kept so the UI can explain the fallback.
expect(repairRuntimeSelectionLayer(valid, input)).toEqual(valid)
})
it('maps legacy automatic request selections to unspecified', () => {
expect(withoutLegacyAutoSelection({ provider: 'auto' })).toBeUndefined()
expect(withoutLegacyAutoSelection({ prompt: 'x', runtimeSelection: { provider: 'auto' } }))
.toEqual({ prompt: 'x' })
expect(() => optionalAgentRuntimeSelectionSchema.parse({ provider: 'auto' })).toThrow()
expect(optionalAgentRuntimeSelectionSchema.parse(withoutLegacyAutoSelection({ provider: 'auto' })))
.toBeUndefined()
})
})