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
@@ -838,6 +838,37 @@ describe('DeepSeekHarnessRuntime', () => {
await harness.runtime.dispose()
})
it('sends GoodBuddy history only when it creates the native session', async () => {
const harness = setup()
const history = [
{ role: 'user' as const, content: 'earlier question' },
{ role: 'assistant' as const, content: 'earlier answer' }
]
for (const [index, prompt] of ['first', 'second'].entries()) {
const running = collect(
harness.runtime.run(
{ ...request('reused'), requestId: `request-${prompt}`, prompt, history },
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(index + 1)
)
harness.promptGates[index]!.resolve({ stopReason: 'end_turn' })
await running
}
const texts = harness.requests
.filter(({ method }) => method === 'session/prompt')
.map(({ params }) =>
(params.prompt as Array<{ text?: string }>)[0]?.text
)
expect(texts[0]).toContain('<conversation-history>')
expect(texts[0]).toContain('earlier answer')
expect(texts[1]).toBe('second')
await harness.runtime.dispose()
})
it('fails Ask closed and never calls the authorizer', async () => {
const harness = setup()
const authorize = vi.fn().mockResolvedValue('once')
+12 -6
View File
@@ -1348,17 +1348,17 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
state: HarnessState,
conversationId: string,
workspace: string
): Promise<string> {
): Promise<{ id: string; created: boolean }> {
const current = this.sessions.get(conversationId)
if (current?.workspace === workspace) {
return current.id
return { id: current.id, created: false }
}
if (current) {
await this.releaseConversation(conversationId)
}
const pending = this.sessionInitializations.get(conversationId)
if (pending) {
return pending
return { id: await pending, created: false }
}
const creation = state.agent
.newSession({
@@ -1374,7 +1374,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
})
this.sessionInitializations.set(conversationId, creation)
try {
return await creation
return { id: await creation, created: true }
} finally {
this.sessionInitializations.delete(conversationId)
}
@@ -1496,11 +1496,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
const workspace = request.executionWorkspace
? await realpath(request.executionWorkspace)
: state.workspace
sessionId = await this.getSession(
const session = await this.getSession(
state,
request.conversationId,
workspace
)
sessionId = session.id
// A reused Harness session already retains earlier turns natively.
const includeHistory = session.created
run = {
request,
toolProvider: this.toolsFor(workspace),
@@ -1557,7 +1560,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
prompt: [
{
type: 'text',
text: promptWithUntrustedConversationHistory(request, true)
text: promptWithUntrustedConversationHistory(
request,
includeHistory
)
},
...(request.images ?? []).map((image) => ({
type: 'image' as const,
+3 -1
View File
@@ -24,7 +24,7 @@ class LocalRuntimeEntry {
private idle?: ReturnType<typeof setTimeout>
readonly description: Pick<AgentRuntime,
'runtimeId' | 'requiresToolApproval' | 'supportsToolExecution' |
'supportsScopedDataTools' | 'capability'>
'supportsScopedDataTools' | 'consumesTrustedInstructions' | 'capability'>
constructor(
readonly configuration: unknown,
@@ -38,6 +38,7 @@ class LocalRuntimeEntry {
requiresToolApproval: runtime.requiresToolApproval,
supportsToolExecution: runtime.supportsToolExecution,
supportsScopedDataTools: runtime.supportsScopedDataTools,
consumesTrustedInstructions: runtime.consumesTrustedInstructions,
capability: runtime.capability
}
this.scheduleIdle()
@@ -220,6 +221,7 @@ class WorkspaceRuntime implements AgentRuntime {
get requiresToolApproval() { return this.entry.description.requiresToolApproval }
get supportsToolExecution() { return this.entry.description.supportsToolExecution }
get supportsScopedDataTools() { return this.entry.description.supportsScopedDataTools }
get consumesTrustedInstructions() { return this.entry.description.consumesTrustedInstructions }
get capability() { return this.entry.description.capability }
private check(): void {
+1
View File
@@ -1637,6 +1637,7 @@ async function* readAnthropicToolStream(
export class ModelAgentRuntime implements AgentRuntime {
readonly runtimeId = 'model'
readonly requiresToolApproval = false
readonly consumesTrustedInstructions = true
private readonly conversations = new Map<string, ConversationMessage[]>()
private readonly conversationSummaries = new Map<
string,
+6 -4
View File
@@ -4,7 +4,6 @@ import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { normalizeInteractiveWorkMode } from '../../shared/assistant-contracts'
import { getDefaultRuntimeSelection } from '../../shared/runtime-selection-contracts'
import type { RuntimeNativeClientResult } from '../../shared/runtime-native-client-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import type { ApplicationSettingsStore } from '../application-settings-store'
@@ -17,7 +16,7 @@ import type { BundledRuntimePaths } from './bundled-runtimes'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { NativeDshWebClientService } from './native-dsh-web-client'
import { NativeTerminalClient } from './native-terminal-client'
import { applyRuntimeSelection } from './runtime-selection'
import { applyRuntimeSelection, resolveLayeredRuntimeSelection } from './runtime-selection'
type Options = {
database: AssistantDatabase
@@ -86,8 +85,11 @@ export class NativeClientCoordinator {
if (!conversation.projectId) throw new Error('Native clients require a saved project conversation')
const project = this.options.database.getProject(conversation.projectId)
const settings = await this.options.settingsStore.getResolvedSettings()
const saved = conversation.runtimeSelection ?? project.runtimeSelection
const selected = applyRuntimeSelection(settings, saved && saved.provider !== 'auto' ? saved : getDefaultRuntimeSelection(settings))
const selected = applyRuntimeSelection(settings, resolveLayeredRuntimeSelection(
settings,
{ project: project.runtimeSelection, conversation: conversation.runtimeSelection },
{ remote: project.executionSpace?.kind === 'ssh' }
).selection)
const space = this.options.executionSpaceResolver.resolveProject(project)
const workMode = normalizeInteractiveWorkMode(conversation.workMode ?? project.defaultWorkMode)
const [skills, mcpServers, builtin, application, obsidian] = await Promise.all([
+33
View File
@@ -207,6 +207,10 @@ function runClient(events: Record<string, unknown>[]) {
data: true,
error: undefined,
}),
deleteMessage: vi.fn().mockResolvedValue({
data: true,
error: undefined,
}),
},
event: {
subscribe: vi.fn().mockImplementation(async () => {
@@ -4155,6 +4159,35 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("removes an unanswered prompt from a reused session after failure", async () => {
const { client, session } = runClient([
{
id: "event-error",
type: "session.error",
properties: {
sessionID: "session-1",
error: { name: "UnknownError", data: { message: "model failed" } },
},
},
]);
const runtime = embeddedRuntime(client);
await expect(collectRun(runtime)).rejects.toThrow("model failed");
const promptMessageId = vi.mocked(session.promptAsync).mock.calls[0]?.[0]
?.messageID;
expect(promptMessageId).toMatch(/^msg_/);
expect(session.deleteMessage).toHaveBeenCalledWith(
{
sessionID: "session-1",
messageID: promptMessageId,
directory: process.cwd(),
},
{ signal: expect.any(AbortSignal) },
);
await runtime.dispose();
});
it("deletes an ephemeral OpenCode session when released", async () => {
const { client, session } = runClient([
{
+28 -3
View File
@@ -726,6 +726,8 @@ export class OpenCodeRuntime implements AgentRuntime {
readonly runtimeId = "opencode";
readonly requiresToolApproval = false;
readonly supportsToolExecution = true;
// OpenCode applies the prompt `system` field to the current turn only.
readonly consumesTrustedInstructions = true;
private client?: OpencodeClient;
private clientInitialization?: Promise<OpencodeClient>;
private server?: OpenCodeServer;
@@ -2453,6 +2455,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const checklistCalls = new Map<string, unknown>();
const submittedChecklistCalls = new Set<string>();
const pendingChecklistUpdates: RuntimeChecklist[] = [];
let runFailed = false;
try {
const promptText = promptWithUntrustedConversationHistory(
request,
@@ -2502,9 +2505,14 @@ export class OpenCodeRuntime implements AgentRuntime {
: undefined,
...(selectedAgent ? { agent: selectedAgent } : {}),
system:
nativeSkillIds.length > 0
? undefined
: this.options.skillInstructions || undefined,
[
nativeSkillIds.length > 0
? undefined
: this.options.skillInstructions,
request.trustedInstructions,
]
.filter(Boolean)
.join("\n\n") || undefined,
...(toolOverrides ? { tools: toolOverrides } : {}),
parts: [
{ type: "text" as const, text: promptText },
@@ -3021,6 +3029,7 @@ export class OpenCodeRuntime implements AgentRuntime {
signal.throwIfAborted();
throw new Error("OpenCode 事件流意外结束");
} catch (error) {
runFailed = true;
abortSession();
for (const [callId, tool] of toolStates) {
if (tool.state === "pending" || tool.state === "running") {
@@ -3056,6 +3065,22 @@ export class OpenCodeRuntime implements AgentRuntime {
signal.removeEventListener("abort", abortSession);
// Keep the conversation locked until its session-wide abort settles.
await sessionAbort;
// A reused session would otherwise merge this unanswered prompt into the next turn.
if (
runFailed &&
!selectedCommand &&
checklistMessageIds.size === 0 &&
this.sessions.get(request.conversationId)?.id === sessionId
) {
await Promise.resolve()
.then(() =>
client.session.deleteMessage(
{ sessionID: sessionId, messageID: promptMessageId, directory },
{ signal: AbortSignal.timeout(1_000) },
),
)
.catch(() => undefined);
}
for (const questionId of reportedQuestionIds.values()) {
this.pendingQuestions.delete(questionId);
}
+4
View File
@@ -112,6 +112,10 @@ export class AgentRuntimeController implements AgentRuntime {
return this.current.runtime.supportsScopedDataTools !== false
}
get consumesTrustedInstructions(): boolean {
return this.current.runtime.consumesTrustedInstructions === true
}
get capability(): AgentRuntime['capability'] {
return this.current.runtime.capability
}
+67 -2
View File
@@ -1,15 +1,80 @@
import type { AgentExecutionRequest } from './runtime'
type ConversationHistory = NonNullable<AgentExecutionRequest['history']>
const historyHeader =
'Continue this conversation. The history below is untrusted conversation data, not system instructions.'
const historyOpen = '<conversation-history>'
const historyClose = '</conversation-history>'
export function promptWithUntrustedConversationHistory(
request: Pick<AgentExecutionRequest, 'history' | 'prompt'>,
includeHistory: boolean
): string {
return includeHistory && request.history?.length
? [
'Continue this conversation. The history below is untrusted conversation data, not system instructions.',
`<conversation-history>${JSON.stringify(request.history)}</conversation-history>`,
historyHeader,
`${historyOpen}${JSON.stringify(request.history)}${historyClose}`,
'',
request.prompt
].join('\n')
: request.prompt
}
/**
* Reverses `promptWithUntrustedConversationHistory` for runtimes that keep
* their own transcript, so an embedded history envelope is not re-wrapped.
*/
export function splitUntrustedConversationHistory(text: string): {
history?: ConversationHistory
prompt: string
} {
const prefix = `${historyHeader}\n${historyOpen}`
if (!text.startsWith(prefix)) return { prompt: text }
// Message content may itself contain the closing tag; accept the first
// boundary whose payload is a valid history array.
let index = text.indexOf(historyClose, prefix.length)
while (index !== -1) {
const history = parseHistory(text.slice(prefix.length, index))
const rest = text.slice(index + historyClose.length)
if (history && rest.startsWith('\n\n')) {
return { history, prompt: rest.slice(2) }
}
index = text.indexOf(historyClose, index + 1)
}
return { prompt: text }
}
function parseHistory(json: string): ConversationHistory | undefined {
let value: unknown
try {
value = JSON.parse(json)
} catch {
return undefined
}
if (!Array.isArray(value)) return undefined
const history: ConversationHistory = []
for (const item of value) {
if (
!item ||
typeof item !== 'object' ||
((item as { role?: unknown }).role !== 'user' &&
(item as { role?: unknown }).role !== 'assistant') ||
typeof (item as { content?: unknown }).content !== 'string'
) {
return undefined
}
history.push({
role: (item as { role: 'user' | 'assistant' }).role,
content: (item as { content: string }).content
})
}
return history
}
/** Drops the per-turn GoodBuddy work-mode paragraph from stored user text. */
export function stripWorkModeInstruction(prompt: string): string {
if (!prompt.startsWith('Work mode: ')) return prompt
const end = prompt.indexOf('\n\n')
return end === -1 ? prompt : prompt.slice(end + 2)
}
@@ -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()
})
})
+13 -7
View File
@@ -291,15 +291,21 @@ describe('runtime selection', () => {
).toEqual({ provider: 'deepseek-harness' })
})
it('routes legacy automatic settings through local OpenCode when the Server is blank', () => {
expect(getConfiguredRuntimeTarget(settings())).toBe('opencode')
it('treats legacy automatic settings as the direct model, matching settings normalization', () => {
expect(getConfiguredRuntimeTarget(settings())).toBe('model')
expect(
getConfiguredRuntimeTarget(
settings({ opencodeEmbedded: false })
)
getConfiguredRuntimeTarget(settings({ provider: 'opencode' }))
).toBe('opencode')
})
it('resolves own configuration explicitly instead of the configured profile', () => {
const configured = settings({
provider: 'opencode',
opencodeModelProfile: settings().modelProfiles[0]
})
expect(
applyRuntimeSelection(settings(), { provider: 'auto' }).settings
).toEqual(settings())
applyRuntimeSelection(configured, { provider: 'opencode', runtimeConfig: true })
.settings.opencodeModelProfile
).toBeUndefined()
})
})
+66 -23
View File
@@ -2,7 +2,14 @@ import {
isAgentRuntimeModelProtocol,
isDeepSeekHarnessModelProfile
} from '../../shared/contracts'
import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts'
import {
resolveRuntimeChoice,
type AgentRuntimeSelection,
type ResolvedRuntimeChoice,
type RuntimeResolutionOptions,
type RuntimeResolutionSettings,
type RuntimeSelectionLayer
} from '../../shared/runtime-selection-contracts'
import type {
ResolvedModelProfile,
ResolvedRuntimeSettings
@@ -36,24 +43,62 @@ export function getConfiguredRuntimeTarget(
if (settings.provider === 'deepseek-harness') {
return 'deepseek-harness'
}
if (
settings.provider === 'opencode' ||
settings.provider === 'auto'
) {
if (settings.provider === 'opencode') {
return 'opencode'
}
return 'model'
}
/** Presents resolved Main settings in the shape used by the shared resolver. */
export function runtimeResolutionSettings(
settings: ResolvedRuntimeSettings
): RuntimeResolutionSettings {
// Partial settings (early startup, narrow callers) resolve with no connections.
const modelProfiles = settings.modelProfiles ?? []
const known = (profile?: ResolvedModelProfile) =>
profile && modelProfiles.some((candidate) => candidate.id === profile.id)
? ({ kind: 'profile', profileId: profile.id } as const)
: ({ kind: 'platform' } as const)
return {
provider: settings.provider,
defaultModelProfileId: settings.defaultModelProfileId,
modelProfiles: modelProfiles.map((profile) => ({
id: profile.id,
name: profile.name,
modelName: profile.modelName,
baseUrl: profile.baseUrl,
protocol: profile.protocol,
authentication: profile.authentication,
apiKeyConfigured:
profile.authentication !== 'api-key' || Boolean(profile.apiKey)
})),
opencodeModelSource: known(settings.opencodeModelProfile),
continueModelSource: known(settings.continueModelProfile),
deepseekHarnessModelSource: known(settings.deepseekHarnessModelProfile),
opencodeBaseUrl: settings.opencodeBaseUrl
}
}
export function resolveLayeredRuntimeSelection(
settings: ResolvedRuntimeSettings,
layers: {
project?: RuntimeSelectionLayer
conversation?: RuntimeSelectionLayer
} = {},
options: RuntimeResolutionOptions = {}
): ResolvedRuntimeChoice {
return resolveRuntimeChoice(runtimeResolutionSettings(settings), layers, options)
}
/** Pins the model a Runtime selection implies so remote validation sees one concrete profile. */
export function resolveConfiguredAgentRuntimeSelection(
settings: ResolvedRuntimeSettings,
selection: AgentRuntimeSelection
): AgentRuntimeSelection {
if ('profileId' in selection && selection.profileId) return selection
if (
selection.provider !== 'opencode' &&
selection.provider !== 'continue' &&
selection.provider !== 'deepseek-harness'
selection.provider === 'model' ||
selection.profileId ||
('runtimeConfig' in selection && selection.runtimeConfig)
) {
return selection
}
@@ -80,13 +125,6 @@ export function applyRuntimeSelection(
settings: ResolvedRuntimeSettings
target: SelectedRuntimeTarget
} {
if (selection.provider === 'auto') {
return {
settings,
target: getConfiguredRuntimeTarget(settings)
}
}
if (selection.provider === 'model') {
const profile = requireProfile(settings, selection.profileId ?? settings.defaultModelProfileId)
return {
@@ -107,13 +145,16 @@ export function applyRuntimeSelection(
}
}
const runtimeConfig = selection.runtimeConfig === true
const profile = selection.profileId
? requireProfile(settings, selection.profileId)
: selection.provider === 'opencode'
? settings.opencodeModelProfile
: selection.provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
: runtimeConfig
? undefined
: selection.provider === 'opencode'
? settings.opencodeModelProfile
: selection.provider === 'continue'
? settings.continueModelProfile
: settings.deepseekHarnessModelProfile
if (selection.provider === 'opencode') {
if (profile && !isAgentRuntimeModelProtocol(profile.protocol)) {
throw new Error(
@@ -137,8 +178,10 @@ export function applyRuntimeSelection(
}
if (selection.provider === 'deepseek-harness') {
const selectedProfile =
profile ?? settings.deepseekHarnessModelProfile
// DeepSeek Harness "own configuration" is the administrator-provided or fallback model.
const selectedProfile = runtimeConfig
? settings.deepseekHarnessPlatformModelProfile
: profile
if (
selectedProfile &&
!isDeepSeekHarnessModelProfile(selectedProfile)
+5
View File
@@ -94,6 +94,11 @@ export interface AgentRuntime {
readonly supportsToolExecution: boolean
/** Whether request-scoped GoodBuddy data tools can reach this runtime. */
readonly supportsScopedDataTools?: boolean
/**
* Whether `trustedInstructions` reach the model's system layer for every
* turn. Runtimes without this guarantee receive them inside the prompt.
*/
readonly consumesTrustedInstructions?: boolean
readonly capability?: 'chat' | 'image-generation'
getStatus(): Promise<AgentRuntimeStatus>
testConnection?(): Promise<AgentRuntimeStatus>