fix: preserve runtime configuration references
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Projects, channels, conversations, and Agent Runtimes now retain dynamic default references while explicit model selections remain pinned. Compatible fallback resolution stays aligned across settings validation, runtime creation, labels, context metrics, DeepSeek Harness browser routing, and remote project saves. Prepare the bilingual Desktop 0.12.11 release metadata and keep Agent 0.11.22 as the published remote baseline. Release note: 修复默认模型与 Runtime 配置变化后项目、通道和对话选择失去跟随关系的问题;显式选择仍保持固定,新请求使用当前兼容配置。
This commit is contained in:
@@ -677,7 +677,7 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
assignments: ['deepseek-harness'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [
|
||||
resolve(
|
||||
'tests',
|
||||
|
||||
@@ -832,6 +832,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
conversationId:
|
||||
run?.request.conversationId ??
|
||||
'deepseek-harness-tool-catalog',
|
||||
browserTabId: run?.request.browserTabId,
|
||||
workMode:
|
||||
run?.request.workMode === 'ask'
|
||||
? ('ask' as const)
|
||||
@@ -873,6 +874,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
}
|
||||
const context = {
|
||||
conversationId: run.request.conversationId,
|
||||
browserTabId: run.request.browserTabId,
|
||||
workMode:
|
||||
run.request.workMode === 'execute'
|
||||
? ('execute' as const)
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('runtime discovery', () => {
|
||||
await expect(validation).resolves.toEqual({ valid: false })
|
||||
})
|
||||
|
||||
it('canonicalizes and validates a configured ordinary file first', async () => {
|
||||
it('canonicalizes a configured ordinary file without launching it', async () => {
|
||||
process.env.PATH = ''
|
||||
process.env.Path = ''
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('runtime discovery', () => {
|
||||
path: await realpath(process.execPath),
|
||||
source: 'configured'
|
||||
})
|
||||
expect(detection.version).toMatch(/^\d+\.\d+\.\d+/u)
|
||||
expect(detection.version).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trusts a configured execution path without reading or launching it', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
browserTabIdSchema,
|
||||
defaultContextCompressionSettings,
|
||||
modelProtocolSchema
|
||||
} from '../../shared/contracts'
|
||||
@@ -1612,6 +1613,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
url,
|
||||
origin: new URL(url).origin
|
||||
}))
|
||||
const browserTabId = browserTabIdSchema.parse(crypto.randomUUID())
|
||||
const browserService = {
|
||||
getOrigin: vi.fn(() => undefined),
|
||||
navigate,
|
||||
@@ -1621,7 +1623,19 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
select: vi.fn(),
|
||||
back: vi.fn(),
|
||||
screenshot: vi.fn(),
|
||||
releaseConversation: vi.fn(async () => undefined)
|
||||
releaseConversation: vi.fn(async () => undefined),
|
||||
listTabs: vi.fn(() => [
|
||||
{
|
||||
conversationId,
|
||||
tabId: browserTabId,
|
||||
primary: true,
|
||||
status: 'ready' as const,
|
||||
isLoading: false,
|
||||
canGoBack: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
])
|
||||
}
|
||||
const gateway = new KnowledgeMcpGateway({} as never, {
|
||||
browserService: browserService as never
|
||||
@@ -1635,7 +1649,8 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
controller.signal,
|
||||
'none',
|
||||
undefined,
|
||||
conversationId
|
||||
conversationId,
|
||||
browserTabId
|
||||
)!
|
||||
const common = {
|
||||
modelProfile: {
|
||||
|
||||
@@ -100,6 +100,30 @@ function settings(
|
||||
}
|
||||
|
||||
describe('runtime selection', () => {
|
||||
it('resolves direct defaults per request while fixed selections and started requests stay unchanged', () => {
|
||||
const before = settings()
|
||||
const started = applyRuntimeSelection(before, { provider: 'model' })
|
||||
const after = settings({ defaultModelProfileId: secondProfileId })
|
||||
expect(applyRuntimeSelection(after, { provider: 'model' }).settings.defaultModelProfileId).toBe(secondProfileId)
|
||||
expect(applyRuntimeSelection(after, { provider: 'model', profileId: defaultProfileId }).settings.defaultModelProfileId).toBe(defaultProfileId)
|
||||
expect(started.settings.defaultModelProfileId).toBe(defaultProfileId)
|
||||
})
|
||||
|
||||
it.each(['opencode', 'continue', 'deepseek-harness'] as const)(
|
||||
'resolves %s references from its current configuration and preserves explicit overrides', (provider) => {
|
||||
const base = settings()
|
||||
const first = base.modelProfiles[4]!
|
||||
const second = { ...first, id: responsesProfileId }
|
||||
const key = provider === 'opencode' ? 'opencodeModelProfile' : provider === 'continue' ? 'continueModelProfile' : 'deepseekHarnessModelProfile'
|
||||
const configured = settings({ [key]: first })
|
||||
const started = applyRuntimeSelection(configured, { provider })
|
||||
const changed = settings({ [key]: second })
|
||||
expect(applyRuntimeSelection(changed, { provider }).settings[key]?.id).toBe(second.id)
|
||||
expect(applyRuntimeSelection(changed, { provider, profileId: first.id }).settings[key]?.id).toBe(first.id)
|
||||
expect(started.settings[key]?.id).toBe(first.id)
|
||||
}
|
||||
)
|
||||
|
||||
it('selects an independent direct model profile without changing defaults', () => {
|
||||
const original = settings()
|
||||
const selected = applyRuntimeSelection(original, {
|
||||
|
||||
@@ -5240,22 +5240,19 @@ describe('AssistantDatabase', () => {
|
||||
expect(
|
||||
database.getProject(imageChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
provider: 'model'
|
||||
})
|
||||
expect(
|
||||
database.getProject(automaticChannelProject.id).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
provider: 'model'
|
||||
})
|
||||
expect(
|
||||
database.getConversation(
|
||||
automaticRemoteConversation.id
|
||||
).runtimeSelection
|
||||
).toEqual({
|
||||
provider: 'model',
|
||||
profileId: defaultProfileId
|
||||
provider: 'model'
|
||||
})
|
||||
database.close()
|
||||
})
|
||||
|
||||
@@ -327,7 +327,7 @@ describe('RemoteProjectSaveService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('persists the resolved Runtime selection', async () => {
|
||||
it('validates a resolved Runtime but persists the requested reference', async () => {
|
||||
const selection = {
|
||||
provider: 'opencode' as const,
|
||||
profileId: '00000000-0000-4000-8000-000000000099'
|
||||
@@ -341,7 +341,7 @@ describe('RemoteProjectSaveService', () => {
|
||||
draft
|
||||
})
|
||||
|
||||
expect(value.writes[0]?.project.runtimeSelection).toEqual(selection)
|
||||
expect(value.writes[0]?.project.runtimeSelection).toEqual(draft.runtimeSelection)
|
||||
})
|
||||
|
||||
it('uses the preflight project revision for updates', async () => {
|
||||
|
||||
@@ -320,7 +320,7 @@ export class RemoteProjectSaveService {
|
||||
signal.throwIfAborted()
|
||||
runtimeLease.assertCurrent()
|
||||
return {
|
||||
runtimeSelection,
|
||||
runtimeSelection: draft.runtimeSelection,
|
||||
target,
|
||||
installation,
|
||||
connection,
|
||||
|
||||
@@ -80,6 +80,89 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('RuntimeSettingsStore', () => {
|
||||
it('persists default references separately from fixed profiles and resolves each settings generation', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
const firstId = '00000000-0000-4000-8000-000000000041'
|
||||
const secondId = '00000000-0000-4000-8000-000000000042'
|
||||
const modelProfiles = [firstId, secondId].map((id) => ({
|
||||
id, name: id, baseUrl: 'https://gateway.example/v1', modelName: id,
|
||||
protocol: 'openai-chat-completions' as const,
|
||||
authentication: 'api-key' as const, imageGenerationQuality: 'auto' as const,
|
||||
apiKey: { action: 'keep' as const }
|
||||
}))
|
||||
await store.update(settings({ modelProfiles, defaultModelProfileId: firstId,
|
||||
opencodeModelSource: { kind: 'default' },
|
||||
continueModelSource: { kind: 'profile', profileId: firstId },
|
||||
deepseekHarnessModelSource: { kind: 'default' }
|
||||
}))
|
||||
const started = await store.getResolvedSettings()
|
||||
await store.update(settings({ defaultModelProfileId: secondId, deepseekHarnessModelSource: { kind: 'default' } }))
|
||||
const reloaded = new RuntimeSettingsStore(filePath, cipher, {})
|
||||
await expect(reloaded.getPublicSettings()).resolves.toMatchObject({
|
||||
opencodeModelSource: { kind: 'default' },
|
||||
continueModelSource: { kind: 'profile', profileId: firstId },
|
||||
deepseekHarnessModelSource: { kind: 'default' }
|
||||
})
|
||||
await expect(reloaded.getResolvedSettings()).resolves.toMatchObject({
|
||||
opencodeModelProfile: { id: secondId },
|
||||
continueModelProfile: { id: firstId },
|
||||
deepseekHarnessModelProfile: { id: secondId }
|
||||
})
|
||||
expect(started.opencodeModelProfile?.id).toBe(firstId)
|
||||
})
|
||||
|
||||
it('falls back to compatible profiles when the global default is incompatible with a Runtime', async () => {
|
||||
const { store } = await createStore()
|
||||
const textId = '00000000-0000-4000-8000-000000000043'
|
||||
const imageId = '00000000-0000-4000-8000-000000000044'
|
||||
const anthropicId = '00000000-0000-4000-8000-000000000045'
|
||||
const modelProfiles = [
|
||||
{
|
||||
id: textId, name: 'OpenAI text', baseUrl: 'https://gateway.example/v1', modelName: 'text',
|
||||
protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const,
|
||||
imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const }
|
||||
},
|
||||
{
|
||||
id: imageId, name: 'Image', baseUrl: 'https://gateway.example/v1', modelName: 'image',
|
||||
protocol: 'openai-images-generations' as const, authentication: 'api-key' as const,
|
||||
imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const }
|
||||
},
|
||||
{
|
||||
id: anthropicId, name: 'Anthropic text', baseUrl: 'https://gateway.example/v1', modelName: 'text',
|
||||
protocol: 'anthropic-messages' as const, authentication: 'api-key' as const,
|
||||
imageGenerationQuality: 'auto' as const, apiKey: { action: 'keep' as const }
|
||||
}
|
||||
]
|
||||
const runtimeDefaults = settings({
|
||||
modelProfiles,
|
||||
defaultModelProfileId: textId,
|
||||
opencodeModelSource: { kind: 'default' },
|
||||
continueModelSource: { kind: 'default' },
|
||||
deepseekHarnessModelSource: { kind: 'default' }
|
||||
})
|
||||
await store.update(runtimeDefaults)
|
||||
|
||||
await store.update({
|
||||
...runtimeDefaults,
|
||||
defaultModelProfileId: imageId
|
||||
})
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
opencodeModelProfile: { id: textId },
|
||||
continueModelProfile: { id: textId },
|
||||
deepseekHarnessModelProfile: { id: textId }
|
||||
})
|
||||
|
||||
await store.update({
|
||||
...runtimeDefaults,
|
||||
defaultModelProfileId: anthropicId
|
||||
})
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
opencodeModelProfile: { id: anthropicId },
|
||||
continueModelProfile: { id: anthropicId },
|
||||
deepseekHarnessModelProfile: { id: textId }
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds a local provider-neutral model connection', async () => {
|
||||
const { store } = await createStore()
|
||||
|
||||
@@ -491,12 +574,10 @@ describe('RuntimeSettingsStore', () => {
|
||||
await expect(store.getPublicSettings()).resolves.toMatchObject({
|
||||
opencodeEmbedded: true,
|
||||
opencodeModelSource: {
|
||||
kind: 'profile',
|
||||
profileId: '00000000-0000-4000-8000-000000000001'
|
||||
kind: 'default'
|
||||
},
|
||||
continueModelSource: {
|
||||
kind: 'profile',
|
||||
profileId: '00000000-0000-4000-8000-000000000001'
|
||||
kind: 'default'
|
||||
}
|
||||
})
|
||||
await expect(store.getResolvedSettings()).resolves.toMatchObject({
|
||||
|
||||
@@ -1431,7 +1431,13 @@ export class RuntimeSettingsStore {
|
||||
): ResolvedModelProfile | undefined {
|
||||
const source = settings.deepseekHarnessModelSource
|
||||
if (source.kind === 'default') {
|
||||
return modelProfiles.find((profile) => profile.id === settings.defaultModelProfileId)
|
||||
return (
|
||||
modelProfiles.find(
|
||||
(profile) =>
|
||||
profile.id === settings.defaultModelProfileId &&
|
||||
isDeepSeekHarnessModelProfile(profile)
|
||||
) ?? modelProfiles.find(isDeepSeekHarnessModelProfile)
|
||||
)
|
||||
}
|
||||
if (source.kind === 'profile') {
|
||||
return modelProfiles.find(
|
||||
@@ -1939,13 +1945,19 @@ export class RuntimeSettingsStore {
|
||||
const opencodeModelProfile =
|
||||
!agent.opencodeBaseUrl &&
|
||||
settings.opencodeModelSource.kind !== 'platform'
|
||||
? profilesById.get(settings.opencodeModelSource.kind === 'default'
|
||||
? settings.defaultModelProfileId : settings.opencodeModelSource.profileId)
|
||||
? profilesById.get(
|
||||
settings.opencodeModelSource.kind === 'default'
|
||||
? compatibleTextProfileId(settings) ?? ''
|
||||
: settings.opencodeModelSource.profileId
|
||||
)
|
||||
: undefined
|
||||
const continueModelProfile =
|
||||
settings.continueModelSource.kind !== 'platform'
|
||||
? profilesById.get(settings.continueModelSource.kind === 'default'
|
||||
? settings.defaultModelProfileId : settings.continueModelSource.profileId)
|
||||
? profilesById.get(
|
||||
settings.continueModelSource.kind === 'default'
|
||||
? compatibleTextProfileId(settings) ?? ''
|
||||
: settings.continueModelSource.profileId
|
||||
)
|
||||
: undefined
|
||||
const deepseekHarnessModelProfile =
|
||||
this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles)
|
||||
@@ -2366,12 +2378,22 @@ export class RuntimeSettingsStore {
|
||||
source: RuntimeSettings['opencodeModelSource'],
|
||||
runtimeLabel: 'OpenCode' | 'Continue'
|
||||
): void => {
|
||||
if (source.kind !== 'profile') {
|
||||
if (source.kind === 'platform') {
|
||||
return
|
||||
}
|
||||
const profile = modelProfiles.find(
|
||||
(candidate) => candidate.id === source.profileId
|
||||
)
|
||||
const profile =
|
||||
source.kind === 'default'
|
||||
? (modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id === defaultModelProfileId &&
|
||||
isAgentRuntimeModelProtocol(candidate.protocol)
|
||||
) ??
|
||||
modelProfiles.find((candidate) =>
|
||||
isAgentRuntimeModelProtocol(candidate.protocol)
|
||||
))
|
||||
: modelProfiles.find(
|
||||
(candidate) => candidate.id === source.profileId
|
||||
)
|
||||
if (!profile) {
|
||||
throw new Error(`${runtimeLabel} 引用的模型连接不存在`)
|
||||
}
|
||||
@@ -2417,11 +2439,18 @@ export class RuntimeSettingsStore {
|
||||
const requestedDeepSeekHarnessSource =
|
||||
input.deepseekHarnessModelSource ??
|
||||
current.deepseekHarnessModelSource
|
||||
if (requestedDeepSeekHarnessSource.kind === 'profile') {
|
||||
const profile = modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id === requestedDeepSeekHarnessSource.profileId
|
||||
)
|
||||
if (requestedDeepSeekHarnessSource.kind !== 'platform') {
|
||||
const profile =
|
||||
requestedDeepSeekHarnessSource.kind === 'default'
|
||||
? (modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id === defaultModelProfileId &&
|
||||
isDeepSeekHarnessModelProfile(candidate)
|
||||
) ?? modelProfiles.find(isDeepSeekHarnessModelProfile))
|
||||
: modelProfiles.find(
|
||||
(candidate) =>
|
||||
candidate.id === requestedDeepSeekHarnessSource.profileId
|
||||
)
|
||||
if (!profile) {
|
||||
throw new Error('DeepSeek Harness 引用的模型连接不存在')
|
||||
}
|
||||
|
||||
@@ -3507,7 +3507,6 @@ describe("App", () => {
|
||||
expect(request?.prompt).toBe("帮我分析项目");
|
||||
expect(request?.runtimeSelection).toEqual({
|
||||
provider: "model",
|
||||
profileId: modelProfileId,
|
||||
});
|
||||
expect(
|
||||
screen
|
||||
@@ -6267,7 +6266,7 @@ describe("App", () => {
|
||||
name: /^OpenCode · 默认模型.*sonnet-5$/u,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(within(runtimeMenu).getAllByRole("menuitemradio")).toHaveLength(1);
|
||||
expect(within(runtimeMenu).getAllByRole("menuitemradio")).toHaveLength(2);
|
||||
expect(within(runtimeMenu).queryByText("直连模型")).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(runtimeMenu).queryByText("Continue Runtime"),
|
||||
@@ -8411,7 +8410,7 @@ describe("App", () => {
|
||||
expect(openCodeModel).toBeEnabled();
|
||||
expect(continueModel).toBeEnabled();
|
||||
expect(deepseekHarness).toBeEnabled();
|
||||
expect(screen.getAllByRole("menuitemradio")).toHaveLength(5);
|
||||
expect(screen.getAllByRole("menuitemradio")).toHaveLength(10);
|
||||
expect(within(runtimeMenu).getAllByRole("separator")).toHaveLength(4);
|
||||
expect(within(runtimeMenu).queryByRole("menu")).not.toBeInTheDocument();
|
||||
expect(
|
||||
@@ -8428,7 +8427,9 @@ describe("App", () => {
|
||||
screen.queryByRole("menuitem", { name: /Agent Runtime/u }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(directModel).toHaveFocus());
|
||||
const defaultDirect = screen.getByRole("menuitemradio", { name: /^直连 · 跟随全局默认模型/u });
|
||||
await waitFor(() => expect(defaultDirect).toHaveFocus());
|
||||
fireEvent.keyDown(defaultDirect, { key: "ArrowDown" });
|
||||
expect(directModel).toHaveAttribute("tabindex", "0");
|
||||
expect(secondDirectModel).toHaveAttribute("tabindex", "-1");
|
||||
fireEvent.keyDown(directModel, { key: "ArrowDown" });
|
||||
@@ -8453,7 +8454,6 @@ describe("App", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
|
||||
provider: "opencode",
|
||||
profileId: modelProfileId,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -8475,7 +8475,6 @@ describe("App", () => {
|
||||
await waitFor(() =>
|
||||
expect(api.agent.getStatus).toHaveBeenLastCalledWith({
|
||||
provider: "continue",
|
||||
profileId: modelProfileId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -8499,7 +8498,7 @@ describe("App", () => {
|
||||
|
||||
fireEvent.click(runtimeButton);
|
||||
const selectedModel = screen.getByRole("menuitemradio", {
|
||||
name: /^默认模型.*sonnet-5$/u,
|
||||
name: /^直连 · 跟随全局默认模型/u,
|
||||
});
|
||||
await waitFor(() => expect(selectedModel).toHaveFocus());
|
||||
fireEvent.keyDown(selectedModel, { key: "Tab" });
|
||||
@@ -9313,7 +9312,6 @@ describe("App", () => {
|
||||
rootPath: project.rootPath,
|
||||
runtimeSelection: {
|
||||
provider: "continue",
|
||||
profileId: modelProfileId,
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1491,7 +1491,8 @@ function getProjectDefaultRuntimeSelection(
|
||||
settings: RuntimeSettings,
|
||||
): AgentRuntimeSelection {
|
||||
if (isManagedSshProject(project)) {
|
||||
return getRuntimeSelectionForProvider("opencode", settings);
|
||||
return project.runtimeSelection?.provider === "opencode"
|
||||
? project.runtimeSelection : getRuntimeSelectionForProvider("opencode", settings);
|
||||
}
|
||||
const selection = project?.runtimeSelection;
|
||||
return !selection || selection.provider === "auto"
|
||||
@@ -3634,7 +3635,7 @@ function App(): React.JSX.Element {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
return true;
|
||||
},
|
||||
[notify, projects, runtimeSettings, setActiveId, setView],
|
||||
[notify, projects, setActiveId, setView],
|
||||
);
|
||||
const activeProjectDisplayName = activeProject
|
||||
? getProjectDisplayText(activeProject, tWorkspace).name
|
||||
@@ -7209,7 +7210,9 @@ function App(): React.JSX.Element {
|
||||
...conversation,
|
||||
contextCompressionState: state,
|
||||
contextMetrics: {
|
||||
runtimeSelectionKey: activeRuntimeSelectionKey,
|
||||
runtimeSelectionKey: agentRuntimeSelectionKey(runtimeSettings
|
||||
? resolveContextMetricsRuntimeSelection(activeRuntimeSelection, runtimeSettings)
|
||||
: activeRuntimeSelection),
|
||||
contextTokens: estimatedAfterTokens,
|
||||
source: "estimated",
|
||||
basis: "conversation",
|
||||
|
||||
@@ -497,8 +497,7 @@ describe('ChannelSettingsSection', () => {
|
||||
)
|
||||
expect(backend).toHaveValue(
|
||||
agentRuntimeSelectionKey({
|
||||
provider: 'model',
|
||||
profileId: directProfileId
|
||||
provider: 'model'
|
||||
})
|
||||
)
|
||||
expect(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import {
|
||||
agentRuntimeSelectionKey,
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionForProvider,
|
||||
isChannelModelProfileUsable,
|
||||
repairChannelRuntimeSelection,
|
||||
@@ -27,7 +26,7 @@ function runtimeSelectionDescription(
|
||||
): string {
|
||||
if (selection.provider === 'model') {
|
||||
const profile = settings.modelProfiles.find(
|
||||
(candidate) => candidate.id === selection.profileId
|
||||
(candidate) => candidate.id === (selection.profileId ?? settings.defaultModelProfileId)
|
||||
)
|
||||
if (!profile) {
|
||||
return t('channels.project.missingSelection')
|
||||
@@ -71,13 +70,11 @@ export function ProjectRuntimeSelector({
|
||||
}: ProjectRuntimeSelectorProps): React.JSX.Element {
|
||||
const { t } = useTranslation('integrations')
|
||||
const initialSelection =
|
||||
selection ?? getDefaultRuntimeSelection(runtimeSettings)
|
||||
selection ?? { provider: 'auto' as const }
|
||||
const runtimeSelection =
|
||||
selectionMode === 'channel'
|
||||
? repairChannelRuntimeSelection(initialSelection, runtimeSettings)
|
||||
: initialSelection.provider === 'auto'
|
||||
? getDefaultRuntimeSelection(runtimeSettings)
|
||||
: initialSelection
|
||||
: initialSelection
|
||||
const directProfiles = runtimeSettings.modelProfiles.filter(
|
||||
isChannelModelProfileUsable
|
||||
)
|
||||
@@ -99,9 +96,6 @@ export function ProjectRuntimeSelector({
|
||||
'deepseek-harness'
|
||||
] as const
|
||||
const runtimeSelections = runtimeProviders.map((provider) => {
|
||||
if (runtimeSelection.provider === provider) {
|
||||
return runtimeSelection
|
||||
}
|
||||
return selectionMode === 'channel'
|
||||
? ({ provider } as AgentRuntimeSelection)
|
||||
: getRuntimeSelectionForProvider(provider, runtimeSettings)
|
||||
@@ -110,7 +104,14 @@ export function ProjectRuntimeSelector({
|
||||
provider: 'model' as const,
|
||||
profileId: profile.id
|
||||
}))
|
||||
const selections = [...directSelections, ...runtimeSelections]
|
||||
const inheritedSelections: AgentRuntimeSelection[] = [
|
||||
...(selectionMode === 'configured' ? [{ provider: 'auto' as const }] : []),
|
||||
{ provider: 'model' }
|
||||
]
|
||||
const fixedRuntimeSelection = runtimeSelection.provider !== 'model' &&
|
||||
'profileId' in runtimeSelection && runtimeSelection.profileId ? runtimeSelection : undefined
|
||||
const selections = [...inheritedSelections, ...directSelections, ...runtimeSelections,
|
||||
...(fixedRuntimeSelection ? [fixedRuntimeSelection] : [])]
|
||||
const selectionByKey = new Map(
|
||||
selections.map((candidate) => [
|
||||
agentRuntimeSelectionKey(candidate),
|
||||
@@ -132,6 +133,16 @@ export function ProjectRuntimeSelector({
|
||||
}}
|
||||
value={agentRuntimeSelectionKey(runtimeSelection)}
|
||||
>
|
||||
{inheritedSelections.map((candidate) => (
|
||||
<option key={candidate.provider} value={agentRuntimeSelectionKey(candidate)}>
|
||||
{t(candidate.provider === 'auto' ? 'channels.project.automaticDescription' : 'channels.project.defaultDirect')}
|
||||
</option>
|
||||
))}
|
||||
{fixedRuntimeSelection && (
|
||||
<option value={agentRuntimeSelectionKey(fixedRuntimeSelection)}>
|
||||
{fixedRuntimeSelection.provider} · {runtimeSettings.modelProfiles.find((profile) => profile.id === fixedRuntimeSelection.profileId)?.name ?? t('channels.project.missingProfile')}
|
||||
</option>
|
||||
)}
|
||||
<optgroup label={t('channels.project.directModels')}>
|
||||
{selectedDirectUnavailable && (
|
||||
<option
|
||||
|
||||
@@ -3146,7 +3146,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
const source = screen.getByLabelText(
|
||||
'DeepSeek Harness OpenAI 兼容模型连接'
|
||||
)
|
||||
expect(source).toHaveValue('')
|
||||
expect(source).toHaveValue('platform')
|
||||
fireEvent.change(source, {
|
||||
target: { value: runtimeSettings.modelProfiles[0]!.id }
|
||||
})
|
||||
@@ -3155,7 +3155,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
await waitFor(() =>
|
||||
expect(updateRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deepseekHarnessModelSource: { kind: 'platform' }
|
||||
deepseekHarnessModelSource: { kind: 'profile', profileId: runtimeSettings.defaultModelProfileId }
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -4215,11 +4215,11 @@ describe('SettingsPanel runtime files', () => {
|
||||
defaultModelProfileId: nextDefault?.id,
|
||||
opencodeModelSource: {
|
||||
kind: 'profile',
|
||||
profileId: nextDefault?.id
|
||||
profileId: runtimeSettings.defaultModelProfileId
|
||||
},
|
||||
continueModelSource: {
|
||||
kind: 'profile',
|
||||
profileId: nextDefault?.id
|
||||
profileId: runtimeSettings.defaultModelProfileId
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1440,12 +1440,6 @@ export function SettingsPanel({
|
||||
profileInputs.find(
|
||||
(profile) => profile.id === defaultProfile.id
|
||||
) ?? profileInputs[0]!
|
||||
const normalizedDeepseekHarnessModelSource =
|
||||
deepseekHarnessModelSource.kind === 'profile' &&
|
||||
deepseekHarnessModelSource.profileId === defaultProfile.id &&
|
||||
defaultProfile.credentialSource === 'environment'
|
||||
? { kind: 'platform' as const }
|
||||
: deepseekHarnessModelSource
|
||||
const value = await window.goodbuddy.settings.updateRuntime({
|
||||
provider,
|
||||
modelBaseUrl: defaultProfileInput.baseUrl,
|
||||
@@ -1514,8 +1508,7 @@ export function SettingsPanel({
|
||||
defaultModelProfileId: defaultProfile.id,
|
||||
opencodeModelSource,
|
||||
continueModelSource,
|
||||
deepseekHarnessModelSource:
|
||||
normalizedDeepseekHarnessModelSource,
|
||||
deepseekHarnessModelSource,
|
||||
contextCompression: contextCompressionInput,
|
||||
toolApproval,
|
||||
subagentSmartRoutingEnabled
|
||||
@@ -1897,44 +1890,12 @@ export function SettingsPanel({
|
||||
const selectDefaultModelProfile = (
|
||||
profile: ModelProfileDraft
|
||||
): void => {
|
||||
const previousDefaultProfileId = defaultModelProfileId
|
||||
const compatibleProfile = findPreferredCompatibleModelProfile(
|
||||
modelProfiles,
|
||||
profile.id,
|
||||
(candidate) =>
|
||||
isAgentRuntimeModelProtocol(candidate.protocol)
|
||||
)
|
||||
const nextRuntimeSource: RuntimeModelSource = compatibleProfile
|
||||
? { kind: 'profile', profileId: compatibleProfile.id }
|
||||
: { kind: 'platform' }
|
||||
setDefaultModelProfileId(profile.id)
|
||||
if (
|
||||
opencodeModelSource.kind === 'profile' &&
|
||||
opencodeModelSource.profileId === previousDefaultProfileId
|
||||
) {
|
||||
setOpencodeModelSource(nextRuntimeSource)
|
||||
}
|
||||
if (
|
||||
continueModelSource.kind === 'profile' &&
|
||||
continueModelSource.profileId === previousDefaultProfileId
|
||||
) {
|
||||
setContinueModelSource(nextRuntimeSource)
|
||||
}
|
||||
if (
|
||||
deepseekHarnessModelSource.kind === 'profile' &&
|
||||
deepseekHarnessModelSource.profileId === previousDefaultProfileId &&
|
||||
profile.protocol === 'openai-chat-completions'
|
||||
) {
|
||||
setDeepseekHarnessModelSource({
|
||||
kind: 'profile',
|
||||
profileId: profile.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const parseModelSource = (value: string): RuntimeModelSource =>
|
||||
value === 'platform'
|
||||
? { kind: 'platform' }
|
||||
value === 'platform' || value === 'default'
|
||||
? { kind: value }
|
||||
: { kind: 'profile', profileId: value }
|
||||
|
||||
const isOpenCodeCompatible = (
|
||||
@@ -1981,7 +1942,11 @@ export function SettingsPanel({
|
||||
? isDeepseekHarnessCompatible(profile)
|
||||
: isAgentRuntimeModelProtocol(profile.protocol))
|
||||
)
|
||||
: undefined
|
||||
: activeRuntimeModelSource.kind === 'default'
|
||||
? agentRuntimeType === 'deepseek-harness'
|
||||
? defaultDeepseekHarnessModelProfile
|
||||
: defaultTextModelProfile
|
||||
: undefined
|
||||
const savedRoleModelProfiles = (settings?.modelProfiles ?? [])
|
||||
.filter((profile) =>
|
||||
isAgentRuntimeModelProtocol(profile.protocol)
|
||||
@@ -2386,14 +2351,13 @@ export function SettingsPanel({
|
||||
<legend>{t('runtime.sourceLegend')}</legend>
|
||||
<label>
|
||||
<input
|
||||
checked={opencodeModelSource.kind === 'profile'}
|
||||
checked={opencodeModelSource.kind !== 'platform'}
|
||||
disabled={!defaultTextModelProfile}
|
||||
name="opencode-model-source"
|
||||
onChange={() => {
|
||||
if (defaultTextModelProfile) {
|
||||
setOpencodeModelSource({
|
||||
kind: 'profile',
|
||||
profileId: defaultTextModelProfile.id
|
||||
kind: 'default'
|
||||
})
|
||||
setOpencodeBaseUrl('')
|
||||
}
|
||||
@@ -2401,7 +2365,7 @@ export function SettingsPanel({
|
||||
type="radio"
|
||||
/>
|
||||
<span>
|
||||
<strong>{t('runtime.followRecommended')}</strong>
|
||||
<strong>{t('runtime.goodBuddyModelSource')}</strong>
|
||||
<small>
|
||||
{t('runtime.opencode.followDescription')}
|
||||
</small>
|
||||
@@ -2428,14 +2392,14 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{opencodeModelSource.kind === 'profile' && (
|
||||
{opencodeModelSource.kind !== 'platform' && (
|
||||
<label className="field">
|
||||
<span>{t('runtime.goodBuddyConnection')}</span>
|
||||
<select
|
||||
aria-label={`OpenCode ${t(
|
||||
'runtime.goodBuddyConnection'
|
||||
)}`}
|
||||
value={opencodeModelSource.profileId}
|
||||
value={opencodeModelSource.kind === 'default' ? 'default' : opencodeModelSource.profileId}
|
||||
onChange={(event) => {
|
||||
setOpencodeModelSource(
|
||||
parseModelSource(event.target.value)
|
||||
@@ -2443,6 +2407,7 @@ export function SettingsPanel({
|
||||
setOpencodeBaseUrl('')
|
||||
}}
|
||||
>
|
||||
<option value="default">{t('runtime.followRecommended')}</option>
|
||||
{modelProfiles.map((profile) => (
|
||||
<option
|
||||
disabled={!isOpenCodeCompatible(profile)}
|
||||
@@ -2617,21 +2582,20 @@ export function SettingsPanel({
|
||||
<legend>{t('runtime.sourceLegend')}</legend>
|
||||
<label>
|
||||
<input
|
||||
checked={continueModelSource.kind === 'profile'}
|
||||
checked={continueModelSource.kind !== 'platform'}
|
||||
disabled={!defaultTextModelProfile}
|
||||
name="continue-model-source"
|
||||
onChange={() => {
|
||||
if (defaultTextModelProfile) {
|
||||
setContinueModelSource({
|
||||
kind: 'profile',
|
||||
profileId: defaultTextModelProfile.id
|
||||
kind: 'default'
|
||||
})
|
||||
}
|
||||
}}
|
||||
type="radio"
|
||||
/>
|
||||
<span>
|
||||
<strong>{t('runtime.followRecommended')}</strong>
|
||||
<strong>{t('runtime.goodBuddyModelSource')}</strong>
|
||||
<small>
|
||||
{t('runtime.continue.followDescription')}
|
||||
</small>
|
||||
@@ -2658,20 +2622,21 @@ export function SettingsPanel({
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
{continueModelSource.kind === 'profile' && (
|
||||
{continueModelSource.kind !== 'platform' && (
|
||||
<label className="field">
|
||||
<span>{t('runtime.goodBuddyConnection')}</span>
|
||||
<select
|
||||
aria-label={`Continue ${t(
|
||||
'runtime.goodBuddyConnection'
|
||||
)}`}
|
||||
value={continueModelSource.profileId}
|
||||
value={continueModelSource.kind === 'default' ? 'default' : continueModelSource.profileId}
|
||||
onChange={(event) =>
|
||||
setContinueModelSource(
|
||||
parseModelSource(event.target.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="default">{t('runtime.followRecommended')}</option>
|
||||
{modelProfiles.map((profile) => (
|
||||
<option
|
||||
disabled={!isContinueCompatible(profile)}
|
||||
@@ -2822,14 +2787,11 @@ export function SettingsPanel({
|
||||
value={
|
||||
deepseekHarnessModelSource.kind === 'profile'
|
||||
? deepseekHarnessModelSource.profileId
|
||||
: ''
|
||||
: deepseekHarnessModelSource.kind
|
||||
}
|
||||
>
|
||||
<option disabled value="">
|
||||
{t(
|
||||
'runtime.deepseekHarness.connectionPlaceholder'
|
||||
)}
|
||||
</option>
|
||||
<option value="default">{t('runtime.followRecommended')}</option>
|
||||
<option value="platform">{t('runtime.ownConfiguration', { runtime: 'DeepSeek Harness' })}</option>
|
||||
{modelProfiles.map((profile) => (
|
||||
<option
|
||||
disabled={!isDeepseekHarnessCompatible(profile)}
|
||||
|
||||
@@ -45,8 +45,9 @@ export const integrations = {
|
||||
'The selected direct model has no credential. Configure it under Model connections first.',
|
||||
directDescription:
|
||||
'Process messages directly with {{name}} ({{modelName}}).',
|
||||
defaultDirect: 'Direct · Follow the global default model',
|
||||
automaticDescription:
|
||||
'Process messages with the default direct model from Model settings.',
|
||||
'Follow the global default Runtime setting.',
|
||||
runtimeDescription:
|
||||
'Run through the {{runtime}} Agent Runtime using the global {{runtime}} configuration under Agent Runtime settings.',
|
||||
defaultMode: 'Default mode',
|
||||
|
||||
@@ -347,7 +347,8 @@ export const settings = {
|
||||
},
|
||||
advanced: 'Advanced settings',
|
||||
sourceLegend: 'Model configuration source',
|
||||
followRecommended: 'Follow the GoodBuddy model (recommended)',
|
||||
followRecommended: 'Follow the global default model',
|
||||
goodBuddyModelSource: 'Use a GoodBuddy model connection',
|
||||
goodBuddyConnection: 'GoodBuddy model connection',
|
||||
pinConnectionDescription:
|
||||
'You can pin this Runtime to another GoodBuddy text model connection.',
|
||||
|
||||
@@ -38,7 +38,8 @@ export const integrations = {
|
||||
'所选直连模型尚未配置密钥,请先到模型连接中完成配置。',
|
||||
directDescription:
|
||||
'直接使用 {{name}}({{modelName}})处理消息。',
|
||||
automaticDescription: '使用模型设置中的默认直连模型处理消息。',
|
||||
automaticDescription: '跟随全局 Runtime 默认设置',
|
||||
defaultDirect: '直连 · 跟随全局默认模型',
|
||||
runtimeDescription:
|
||||
'通过 {{runtime}} Agent Runtime 运行,并跟随“Agent Runtime”设置中的全局 {{runtime}} 配置。',
|
||||
defaultMode: '默认模式',
|
||||
|
||||
@@ -318,9 +318,10 @@ export const settings = {
|
||||
},
|
||||
advanced: '高级设置',
|
||||
sourceLegend: '模型配置来源',
|
||||
followRecommended: '跟随 GoodBuddy 模型(推荐)',
|
||||
followRecommended: '跟随全局默认模型',
|
||||
goodBuddyModelSource: '使用 GoodBuddy 模型连接',
|
||||
goodBuddyConnection: 'GoodBuddy 模型连接',
|
||||
pinConnectionDescription: '可固定到其他 GoodBuddy 文本模型连接。',
|
||||
pinConnectionDescription: '选择默认项会跟随全局默认模型;选择具体连接会固定使用该连接。',
|
||||
incompatibleSuffix: '(不兼容)',
|
||||
serverAddress: 'Server 地址',
|
||||
bundledProgramPlaceholder: '留空使用 GoodBuddy 内置程序',
|
||||
|
||||
@@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeSettings } from '../../shared/contracts'
|
||||
import {
|
||||
getDefaultRuntimeSelection,
|
||||
getRuntimeSelectionProfileId,
|
||||
getRuntimeSelectionForProvider
|
||||
} from './runtime-selection'
|
||||
} from '../../shared/runtime-selection-contracts'
|
||||
|
||||
const harnessProfileId = '00000000-0000-4000-8000-000000000071'
|
||||
|
||||
@@ -38,4 +39,34 @@ describe('DeepSeek Harness runtime selection', () => {
|
||||
provider: 'deepseek-harness'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves default references to the first Runtime-compatible profile', () => {
|
||||
const imageId = '00000000-0000-4000-8000-000000000072'
|
||||
const anthropicId = '00000000-0000-4000-8000-000000000073'
|
||||
const openAiId = '00000000-0000-4000-8000-000000000074'
|
||||
const settings = {
|
||||
provider: 'model' as const,
|
||||
defaultModelProfileId: imageId,
|
||||
opencodeBaseUrl: '',
|
||||
opencodeEmbedded: true,
|
||||
opencodeModelSource: { kind: 'default' as const },
|
||||
continueModelSource: { kind: 'default' as const },
|
||||
deepseekHarnessModelSource: { kind: 'default' as const },
|
||||
modelProfiles: [
|
||||
{ id: imageId, protocol: 'openai-images-generations' },
|
||||
{ id: anthropicId, protocol: 'anthropic-messages' },
|
||||
{
|
||||
id: openAiId,
|
||||
baseUrl: 'https://gateway.example/v1',
|
||||
protocol: 'openai-chat-completions',
|
||||
authentication: 'api-key' as const,
|
||||
apiKeyConfigured: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(getRuntimeSelectionProfileId({ provider: 'opencode' }, settings)).toBe(anthropicId)
|
||||
expect(getRuntimeSelectionProfileId({ provider: 'continue' }, settings)).toBe(anthropicId)
|
||||
expect(getRuntimeSelectionProfileId({ provider: 'deepseek-harness' }, settings)).toBe(openAiId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,6 +69,7 @@ type RuntimeModelSource =
|
||||
export type RuntimeSelectionDefaultSettings = {
|
||||
provider: AgentRuntimeSelection['provider']
|
||||
defaultModelProfileId: string
|
||||
modelProfiles?: RuntimeSelectionRepairSettings['modelProfiles']
|
||||
opencodeBaseUrl: string
|
||||
opencodeEmbedded: boolean
|
||||
opencodeModelSource?: RuntimeModelSource
|
||||
@@ -85,6 +86,7 @@ export function getRuntimeSelectionForProvider(
|
||||
provider: Exclude<AgentRuntimeSelection['provider'], 'auto'>,
|
||||
_settings: RuntimeSelectionDefaultSettings
|
||||
): AgentRuntimeSelection {
|
||||
void _settings
|
||||
return { provider }
|
||||
}
|
||||
|
||||
@@ -110,8 +112,25 @@ export function getRuntimeSelectionProfileId(
|
||||
if (selection.provider === 'model') return settings.defaultModelProfileId
|
||||
const source = selection.provider === 'opencode' ? settings.opencodeModelSource
|
||||
: selection.provider === 'continue' ? settings.continueModelSource : settings.deepseekHarnessModelSource
|
||||
return source?.kind === 'default' ? settings.defaultModelProfileId
|
||||
: source?.kind === 'profile' ? source.profileId : undefined
|
||||
if (source?.kind === 'profile') return source.profileId
|
||||
if (source?.kind !== 'default') return undefined
|
||||
const profiles = settings.modelProfiles ?? []
|
||||
const isCompatible = (profile: ChannelModelProfile): boolean =>
|
||||
selection.provider === 'deepseek-harness'
|
||||
? profile.protocol === 'openai-chat-completions' &&
|
||||
profile.authentication === 'api-key' &&
|
||||
Boolean(profile.baseUrl) &&
|
||||
isDeepSeekHarnessModelProfile({
|
||||
baseUrl: profile.baseUrl!,
|
||||
protocol: profile.protocol,
|
||||
authentication: profile.authentication
|
||||
})
|
||||
: Boolean(profile.protocol) &&
|
||||
profile.protocol !== 'openai-images-generations'
|
||||
return profiles.find(
|
||||
(profile) =>
|
||||
profile.id === settings.defaultModelProfileId && isCompatible(profile)
|
||||
)?.id ?? profiles.find(isCompatible)?.id
|
||||
}
|
||||
|
||||
export function isChannelModelProfileUsable(
|
||||
|
||||
Reference in New Issue
Block a user