diff --git a/FEATURES.md b/FEATURES.md
index a670bf32..3932ee71 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -21,7 +21,7 @@ otherwise.
packages target compatible x64 and arm64 environments, including UOS, Kylin,
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
LoongArch has a separate experimental loong64 preview outside standard
- releases and automatic updates; no preview is planned for 0.12.10. See the
+ releases and automatic updates; no preview is planned for 0.12.11. See the
[preview boundaries](./docs/development/loongarch-preview-build.md).
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
accelerator under Platform Features / General. The default remains
@@ -187,7 +187,7 @@ otherwise.
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
Prompt, provider, or tool replay observed. The current Agent source lock is
- `0.11.22`, while the current Desktop release candidate is `0.12.10`; formal
+ `0.11.22`, while the current Desktop release candidate is `0.12.11`; formal
publication status follows the separate Agent and Desktop
release channels. Current macOS source has passed native package installation,
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md
index 5314f2a2..4fe74e04 100644
--- a/FEATURES.zh-CN.md
+++ b/FEATURES.zh-CN.md
@@ -14,7 +14,7 @@
### 桌面基础、工作空间与上下文
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
-- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.12.10` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
+- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.12.11` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
@@ -107,7 +107,7 @@
### 开源、构建与发布
-- 当前源码候选为 Desktop `0.12.10`、Agent `0.11.22`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
+- 当前源码候选为 Desktop `0.12.11`、Agent `0.11.22`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。Windows 提供 NSIS 与 portable ZIP,macOS 仅 DMG,Linux 提供 AppImage、DEB 和 RPM,共 12 个安装包、20 个发布资产。
diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md
index 29eab517..3b548791 100644
--- a/docs/features/remote-host/technical-design.md
+++ b/docs/features/remote-host/technical-design.md
@@ -2,7 +2,7 @@
## 状态
-本文记录截至 2026-09-09 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
+本文记录截至 2026-09-10 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
详细事务与验收边界见
[SSH Host 远程环境准备与直连下载设计](./environment-provisioning-technical-design.md);
@@ -12,7 +12,7 @@ Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前源码
-候选为 Agent `0.11.22`、Desktop `0.12.10`,包含托管 OpenCode 自动快照策略修复。
+候选为 Agent `0.11.22`、Desktop `0.12.11`,包含托管 OpenCode 自动快照策略修复。
正式发布状态以 Agent 与 Desktop 独立发布渠道为准。
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
diff --git a/package-lock.json b/package-lock.json
index 4ba30df9..d2dc4477 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
- "version": "0.12.10",
+ "version": "0.12.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
- "version": "0.12.10",
+ "version": "0.12.11",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
diff --git a/package.json b/package.json
index 4dcf7eb0..26f8bc10 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
- "version": "0.12.10",
+ "version": "0.12.11",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
diff --git a/resources/release-notes.json b/resources/release-notes.json
index ad8d4e9d..ad3880fd 100644
--- a/resources/release-notes.json
+++ b/resources/release-notes.json
@@ -1,6 +1,50 @@
{
"formatVersion": 1,
"releases": [
+ {
+ "version": "0.12.11",
+ "releasedAt": "2026-09-10",
+ "notes": {
+ "zh-CN": {
+ "highlights": [],
+ "features": [
+ "**工作栏直接管理项目文件与 Git**:可在右侧工作区创建、重命名、移动和删除文件或目录,并查看属性。Git 工具新增分支搜索、新建与切换、手动 Fetch、提交历史和单文件差异,返回文件预览后仍保留目录展开、滚动位置和焦点。",
+ "**多个独立浏览器页签**:可在工作栏中同时打开多个浏览器,每个页签分别保留页面、导航和操作状态,并支持直接交互和全屏显示。同一对话的页签共享登录状态,模型浏览器工具固定使用请求开始时绑定的页签,不会因用户切换页签而改投其他页面。",
+ "**直连模型获得完整开发工具**:安装包随附 ripgrep,直连模型可搜索项目文件和内容、分页读取大型 UTF-8 文本,并通过多文件补丁修改代码。Ask 可搜索和读取,Execute 还可修改文件和运行命令。",
+ "**长任务保留更多上下文**:移除历史消息、长回复和文档解析文本的固定截断限制,并允许模型分页续读较长的命令与 Subagent 输出。浏览器快照和知识库单次可选结果范围也已扩大,实际可用上下文仍受所选模型窗口限制。",
+ "**任务中心可切换范围**:任务中心默认跟随当前项目,也可切换到全局任务或全部项目。所选范围会随工作栏布局保存,切换项目时不必重复调整。"
+ ],
+ "fixes": [
+ "**计划任务状态及时同步**:排队和运行中的计划任务会及时刷新状态。当前 occurrence 完成前“立即运行”保持禁用,避免同一计划重复入队。",
+ "**Runtime 与模型选择保持原意**:未固定连接的项目、通道、对话和 Agent Runtime 会继续跟随当前兼容的全局推荐连接,显式选择的连接保持固定;远程项目保存后也会保留用户选择的 Runtime。切换全局默认连接后,新请求使用新配置,已经开始的请求不受影响。",
+ "**远程 Runtime 元数据兼容**:有效的 Runtime 锁定 JSON 不再仅因空白或缩进格式不同而被误判为无效。签名、Schema、版本和完整性校验保持不变。"
+ ],
+ "notices": [
+ "删除文件或目录在确认后永久执行;移动范围限制在当前工作区内,Git 分支切换不会自动暂存或丢弃冲突改动。",
+ "本次仅发布 Desktop `0.12.11`,Agent 保持 `0.11.22`;新版远端限额相关源码不作为当前 Agent 已交付能力。标准发布使用 Windows、macOS、Linux 的 x64/arm64 六平台矩阵,不构建 LoongArch 实验预览。"
+ ]
+ },
+ "en-US": {
+ "highlights": [],
+ "features": [
+ "**Manage project files and Git from the workbar**: Create, rename, move, and delete files or directories in the right workspace panel, and inspect their properties. Git tools now support branch search, creation and switching, explicit Fetch, commit history, and per-file diffs, while returning from a preview preserves expansion, scroll position, and focus.",
+ "**Multiple independent browser tabs**: Open several browsers in the workbar, each with its own page, navigation, and interaction state, plus direct interaction and fullscreen display. Tabs in one conversation share login state; model browser tools stay bound to the tab selected when the request starts, and later UI switches do not change that target.",
+ "**Complete development tools for direct models**: Packaged ripgrep lets direct models search project files and content, read large UTF-8 files in pages, and modify code with multi-file patches. Ask can search and read; Execute can additionally edit files and run commands.",
+ "**More context for long tasks**: Fixed truncation limits on message history, long replies, and parsed document text have been removed, and models can page through longer command and Subagent output. Browser snapshots and selectable knowledge-search result ranges are also larger, while the selected model's context window still applies.",
+ "**Switchable Task Center scopes**: Task Center follows the current project by default and can switch to Global Tasks or All Projects. The selected scope is saved with the workbar layout, so project switches do not require repeated adjustment."
+ ],
+ "fixes": [
+ "**Prompt scheduled-task status**: Queued and running schedules now refresh their status promptly. Run Now remains disabled until the active occurrence finishes, preventing duplicate queue entries for the same schedule.",
+ "**Runtime and model selections keep their intent**: Projects, channels, conversations, and Agent Runtimes without a pinned connection continue following the currently compatible global recommendation, while explicit selections remain fixed; remote projects also retain the Runtime chosen during save. New requests use a changed global default, while requests already in progress remain unchanged.",
+ "**Compatible remote Runtime metadata**: Valid Runtime lock JSON is no longer rejected solely because whitespace or indentation differs. Signature, schema, version, and integrity validation remain unchanged."
+ ],
+ "notices": [
+ "File and directory deletion is permanent after confirmation; moves remain inside the current workspace, and Git branch switching does not automatically stash or discard conflicting changes.",
+ "This release publishes Desktop `0.12.11` only; Agent remains at `0.11.22`, and source changes to newer remote limits are not claimed as delivered Agent behavior. The standard Windows, macOS, and Linux x64/arm64 matrix is used without a LoongArch experimental preview."
+ ]
+ }
+ }
+ },
{
"version": "0.12.10",
"releasedAt": "2026-09-09",
diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts
index c0fb6fd3..4ef2960b 100644
--- a/src/main/agent/deepseek-harness-acp-e2e.test.ts
+++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts
@@ -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',
diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts
index 3962a2db..7767e0a2 100644
--- a/src/main/agent/deepseek-harness-runtime.ts
+++ b/src/main/agent/deepseek-harness-runtime.ts
@@ -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)
diff --git a/src/main/agent/runtime-discovery.test.ts b/src/main/agent/runtime-discovery.test.ts
index ba0bc7fa..745bae73 100644
--- a/src/main/agent/runtime-discovery.test.ts
+++ b/src/main/agent/runtime-discovery.test.ts
@@ -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 () => {
diff --git a/src/main/agent/runtime-e2e.manual.test.ts b/src/main/agent/runtime-e2e.manual.test.ts
index e0342642..f6ed6fe0 100644
--- a/src/main/agent/runtime-e2e.manual.test.ts
+++ b/src/main/agent/runtime-e2e.manual.test.ts
@@ -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: {
diff --git a/src/main/agent/runtime-selection.test.ts b/src/main/agent/runtime-selection.test.ts
index a4c47131..053ced6c 100644
--- a/src/main/agent/runtime-selection.test.ts
+++ b/src/main/agent/runtime-selection.test.ts
@@ -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, {
diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts
index ef098886..faa6eeda 100644
--- a/src/main/assistant/assistant-database.test.ts
+++ b/src/main/assistant/assistant-database.test.ts
@@ -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()
})
diff --git a/src/main/remote-agent/remote-project-save-service.test.ts b/src/main/remote-agent/remote-project-save-service.test.ts
index 9994b15f..39319fd0 100644
--- a/src/main/remote-agent/remote-project-save-service.test.ts
+++ b/src/main/remote-agent/remote-project-save-service.test.ts
@@ -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 () => {
diff --git a/src/main/remote-agent/remote-project-save-service.ts b/src/main/remote-agent/remote-project-save-service.ts
index d763fb83..b8923e1c 100644
--- a/src/main/remote-agent/remote-project-save-service.ts
+++ b/src/main/remote-agent/remote-project-save-service.ts
@@ -320,7 +320,7 @@ export class RemoteProjectSaveService {
signal.throwIfAborted()
runtimeLease.assertCurrent()
return {
- runtimeSelection,
+ runtimeSelection: draft.runtimeSelection,
target,
installation,
connection,
diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts
index 8d7df440..5748eefa 100644
--- a/src/main/runtime-settings-store.test.ts
+++ b/src/main/runtime-settings-store.test.ts
@@ -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({
diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts
index 5cda1e9d..33ae038d 100644
--- a/src/main/runtime-settings-store.ts
+++ b/src/main/runtime-settings-store.ts
@@ -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 引用的模型连接不存在')
}
diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx
index c6816cd2..b995878b 100644
--- a/src/renderer/src/App.test.tsx
+++ b/src/renderer/src/App.test.tsx
@@ -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,
},
}),
),
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 8a8f9e84..551b3456 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -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",
diff --git a/src/renderer/src/ChannelSettingsSection.test.tsx b/src/renderer/src/ChannelSettingsSection.test.tsx
index b6a64bd5..d53dffdc 100644
--- a/src/renderer/src/ChannelSettingsSection.test.tsx
+++ b/src/renderer/src/ChannelSettingsSection.test.tsx
@@ -497,8 +497,7 @@ describe('ChannelSettingsSection', () => {
)
expect(backend).toHaveValue(
agentRuntimeSelectionKey({
- provider: 'model',
- profileId: directProfileId
+ provider: 'model'
})
)
expect(
diff --git a/src/renderer/src/ProjectRuntimeSelector.tsx b/src/renderer/src/ProjectRuntimeSelector.tsx
index 3b471071..af0c9766 100644
--- a/src/renderer/src/ProjectRuntimeSelector.tsx
+++ b/src/renderer/src/ProjectRuntimeSelector.tsx
@@ -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) => (
+
+ ))}
+ {fixedRuntimeSelection && (
+
+ )}