From cbbe30896ebe12795f8ab78c765d7fa59b96c2a7 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 17 Aug 2026 12:57:12 +0800 Subject: [PATCH] feat: expand multi-runtime workflows for 0.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoodBuddy previously exposed Runtime capabilities, MCP assignments, plugin controls, context compaction, usage reporting, and task-completion behavior through incomplete or inconsistent paths. This release unifies managed OpenCode, Continue, and DeepSeek Harness controls; adds DSH plugin and image workflows; strengthens MCP and Runtime lifecycle bounds; fixes Windows notification activation; validates cross-architecture packages; and presents the approved bilingual four-section release notes. The DSH plugin marketplace remains a default-off preview whose trusted third-party code runs with current-user permissions. Ask remains read-only, Execute keeps approval controls, and context compaction may use the selected model without deleting GoodBuddy chat history. Release note: GoodBuddy 0.10.0 重点完善多 Runtime 工作流,统一 OpenCode、Continue 与 DeepSeek Harness 的能力、MCP、插件和上下文管理,并提升长对话、多会话与任务通知的连贯性。 --- BUILD.md | 8 +- build/build-release.cjs | 97 +++- build/release-notes.cjs | 77 ++- docs/deepseek-harness-runtime-design.md | 6 +- package-lock.json | 4 +- package.json | 2 +- resources/release-notes.json | 62 +++ src/main/agent/continue-runtime.test.ts | 150 +++++- src/main/agent/continue-runtime.ts | 81 ++- .../agent/deepseek-harness-acp-e2e.test.ts | 333 ++++++------ .../deepseek-harness-control-protocol.ts | 36 ++ .../deepseek-harness-extension-loader.test.ts | 94 ++++ .../deepseek-harness-extension-loader.ts | 74 ++- .../agent/deepseek-harness-runtime.test.ts | 134 ++++- src/main/agent/deepseek-harness-runtime.ts | 33 +- .../deepseek-harness-utility-launcher.ts | 13 +- .../dsh-extension-marketplace.e2e.test.ts | 27 +- .../agent/dsh-extension-marketplace.test.ts | 99 +++- src/main/agent/dsh-extension-marketplace.ts | 233 +++++++-- src/main/agent/knowledge-mcp-gateway.test.ts | 463 ++++++++++++++++- src/main/agent/knowledge-mcp-gateway.ts | 482 ++++++++++++++---- src/main/agent/mcp-tool-utils.ts | 71 +++ src/main/agent/model-tool-provider.test.ts | 128 +++++ src/main/agent/model-tool-provider.ts | 47 +- src/main/agent/opencode-runtime.test.ts | 138 ++++- src/main/agent/opencode-runtime.ts | 132 ++++- src/main/agent/runtime-e2e.manual.test.ts | 97 ++++ src/main/desktop-notification.test.ts | 235 ++++++++- src/main/desktop-notification.ts | 93 +++- src/main/index.ts | 74 ++- src/main/ipc.test.ts | 48 +- src/main/ipc.ts | 9 +- src/main/release-notes-source.test.ts | 51 +- .../windows-notification-identity.test.ts | 194 +++++++ src/main/windows-notification-identity.ts | 133 +++++ src/renderer/src/App.test.tsx | 20 +- src/renderer/src/MarkdownRenderer.test.tsx | 20 +- src/renderer/src/MarkdownRenderer.tsx | 19 + src/renderer/src/ReleaseNotesDialog.test.tsx | 51 +- src/renderer/src/ReleaseNotesDialog.tsx | 103 +++- .../src/RuntimeCustomizationSection.tsx | 2 +- src/renderer/src/SettingsPanel.test.tsx | 38 +- src/renderer/src/WorkspacePrimitives.test.tsx | 6 + src/renderer/src/i18n/locales/en-US/app.ts | 5 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 4 +- src/renderer/src/runtime-selection.ts | 45 +- src/renderer/src/styles.css | 17 +- src/shared/release-notes-contracts.ts | 72 ++- src/shared/runtime-selection-contracts.ts | 61 +++ tests/build-release.test.ts | 261 +++++++++- 50 files changed, 4077 insertions(+), 605 deletions(-) create mode 100644 src/main/windows-notification-identity.test.ts create mode 100644 src/main/windows-notification-identity.ts diff --git a/BUILD.md b/BUILD.md index 043808f..302e57c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -138,7 +138,7 @@ npm run dist:linux:arm64 - OpenCode 平台二进制来自 `.runtime-resources/`。 - Continue Runtime 来自锁定版本的 `@continuedev/cli`。 - DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。 -- DSH 图片输入使用精确锁定的 `@napi-rs/canvas` 完整解码 JPEG/PNG。通用包与当前平台的 Skia 原生包必须从 `app.asar` 解包;发布校验会检查版本、目标架构和 MIT 许可证。 +- DSH 图片输入使用精确锁定的 `@napi-rs/canvas` 完整解码 JPEG/PNG。通用包与目标平台、目标架构的 Skia 原生包必须从 `app.asar` 解包;当打包 Runner 的架构与目标架构不同时,发布脚本会根据 lockfile 的精确版本、下载地址和 integrity 临时暂存目标原生包,完成后清理。发布校验会检查版本、目标架构和 MIT 许可证。 - 打包钩子位于 `build/runtime-hooks.cjs`。 跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。 @@ -172,6 +172,12 @@ manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流创建 更新 draft GitHub Release,上传全部资产成功后才发布。重跑会保留人工 编辑的 Release notes 和未知附件。 +中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次 +亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前 +请留意 / Before You Start”四段组织,应用首次启动弹窗与 GitHub Release +正文共用该来源。旧版两段式记录会兼容读取,无需改写。提交发布候选前运行 +`npm run release:notes:verify` 校验版本、双语条目数量并生成 Markdown。 + 发布标签必须与 `package.json` 版本完全一致。实际推送标签和触发发布前仍 需人工确认,例如当前版本应使用: diff --git a/build/build-release.cjs b/build/build-release.cjs index 5bbdccc..18febb5 100644 --- a/build/build-release.cjs +++ b/build/build-release.cjs @@ -527,14 +527,39 @@ function targetHarnessPaths(options) { function targetRuntimePackageNames(options) { const target = targetHarnessPaths(options) - return [target.koffiPackage] + return [target.koffiPackage, target.canvasPackage] } -function lockedTargetRuntimePackage(packageName) { - const expectedVersion = - packageJson.optionalDependencies?.[packageName] +function lockedTargetRuntimePackage( + packageName, + packageMetadata = packageJson, + lockMetadata = packageLock +) { + let expectedVersion = + packageMetadata.optionalDependencies?.[packageName] + if ( + typeof expectedVersion !== 'string' && + packageName.startsWith('@napi-rs/canvas-') + ) { + const canvasPackageName = '@napi-rs/canvas' + const canvasVersion = + packageMetadata.dependencies?.[canvasPackageName] + const canvasLockEntry = + lockMetadata.packages?.[`node_modules/${canvasPackageName}`] + if ( + typeof canvasVersion !== 'string' || + canvasLockEntry?.version !== canvasVersion || + canvasLockEntry.optionalDependencies?.[packageName] !== + canvasVersion + ) { + throw new Error( + `目标 Runtime 依赖未完整锁定:${packageName}` + ) + } + expectedVersion = canvasVersion + } const lockEntry = - packageLock.packages?.[`node_modules/${packageName}`] + lockMetadata.packages?.[`node_modules/${packageName}`] if ( typeof expectedVersion !== 'string' || lockEntry?.version !== expectedVersion || @@ -548,7 +573,6 @@ function lockedTargetRuntimePackage(packageName) { return { name: packageName, version: expectedVersion, - resolved: lockEntry.resolved, integrity: lockEntry.integrity } } @@ -596,9 +620,13 @@ function verifyArchiveIntegrity(filePath, expectedIntegrity) { } } -function installedPackageMatches(packageName, expectedVersion) { +function installedPackageMatches( + packageName, + expectedVersion, + runtimeRoot = root +) { const manifestPath = join( - root, + runtimeRoot, 'node_modules', ...packageName.split('/'), 'package.json' @@ -618,14 +646,29 @@ function installedPackageMatches(packageName, expectedVersion) { return true } -async function stageTargetRuntimeDependencies(options) { +async function stageTargetRuntimeDependencies( + options, + dependencies = {} +) { + const runtimeRoot = dependencies.root ?? root + const runtimePackageJson = + dependencies.packageJson ?? packageJson + const runtimePackageLock = + dependencies.packageLock ?? packageLock const missing = targetRuntimePackageNames(options) - .map(lockedTargetRuntimePackage) + .map((packageName) => + lockedTargetRuntimePackage( + packageName, + runtimePackageJson, + runtimePackageLock + ) + ) .filter( (dependency) => !installedPackageMatches( dependency.name, - dependency.version + dependency.version, + runtimeRoot ) ) if (missing.length === 0) { @@ -644,14 +687,27 @@ async function stageTargetRuntimeDependencies(options) { } try { - const npm = npmInvocation() + const npm = dependencies.npmInvocation?.() ?? npmInvocation() + const captureCommand = + dependencies.runCapture ?? runCapture + const extractArchive = + dependencies.extractArchive ?? + ((archivePath, destination) => + run('tar', [ + '-xzf', + archivePath, + '-C', + destination, + '--strip-components', + '1' + ])) for (const [index, dependency] of missing.entries()) { const archiveDirectory = join( stagingRoot, `package-${index}` ) mkdirSync(archiveDirectory, { recursive: true }) - const output = await runCapture(npm.command, [ + const output = await captureCommand(npm.command, [ ...npm.prefixArgs, 'pack', `${dependency.name}@${dependency.version}`, @@ -671,7 +727,7 @@ async function stageTargetRuntimeDependencies(options) { verifyArchiveIntegrity(archivePath, dependency.integrity) const destination = join( - root, + runtimeRoot, 'node_modules', ...dependency.name.split('/') ) @@ -682,18 +738,12 @@ async function stageTargetRuntimeDependencies(options) { } mkdirSync(destination, { recursive: true }) stagedDirectories.push(destination) - await run('tar', [ - '-xzf', - archivePath, - '-C', - destination, - '--strip-components', - '1' - ]) + await extractArchive(archivePath, destination) if ( !installedPackageMatches( dependency.name, - dependency.version + dependency.version, + runtimeRoot ) ) { throw new Error( @@ -1619,6 +1669,7 @@ module.exports = { parseArguments, parsePackedPackageMetadata, platformDefinitions, + lockedTargetRuntimePackage, replaceOutput, stageTargetRuntimeDependencies, targetRuntimePackageNames, diff --git a/build/release-notes.cjs b/build/release-notes.cjs index a9076e0..effaa50 100644 --- a/build/release-notes.cjs +++ b/build/release-notes.cjs @@ -32,13 +32,21 @@ function validateItems(value, label) { fail(`${label} contains a non-string item`) } const normalized = item.trim() - if (!normalized || normalized.length > 240) { + if (!normalized || normalized.length > 500) { fail(`${label} contains an empty or oversized item`) } return normalized }) } +const releaseNoteSections = [ + 'highlights', + 'features', + 'fixes', + 'notices' +] +const legacyReleaseNoteSections = ['features', 'fixes'] + function validateRelease(value, index) { const label = `releases[${index}]` if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) { @@ -63,28 +71,50 @@ function validateRelease(value, index) { const notes = Object.fromEntries( ['zh-CN', 'en-US'].map((locale) => { const localized = value.notes[locale] - if (!hasExactKeys(localized, ['features', 'fixes'])) { + const isCurrentFormat = hasExactKeys( + localized, + releaseNoteSections + ) + const isLegacyFormat = hasExactKeys( + localized, + legacyReleaseNoteSections + ) + if (!isCurrentFormat && !isLegacyFormat) { fail(`${label}.notes.${locale} has invalid fields`) } - const features = validateItems( - localized.features, - `${label}.notes.${locale}.features` + const normalized = Object.fromEntries( + releaseNoteSections.map((section) => [ + section, + section in localized + ? validateItems( + localized[section], + `${label}.notes.${locale}.${section}` + ) + : [] + ]) ) - const fixes = validateItems( - localized.fixes, - `${label}.notes.${locale}.fixes` - ) - if (features.length + fixes.length === 0) { + if (normalized.highlights.length > 3) { + fail( + `${label}.notes.${locale}.highlights must contain no more than 3 items` + ) + } + if ( + releaseNoteSections.every( + (section) => normalized[section].length === 0 + ) + ) { fail(`${label}.notes.${locale} must not be empty`) } - return [locale, { features, fixes }] + return [locale, normalized] }) ) - if ( - notes['zh-CN'].features.length !== notes['en-US'].features.length || - notes['zh-CN'].fixes.length !== notes['en-US'].fixes.length - ) { - fail(`${label} localized section counts do not match`) + for (const section of releaseNoteSections) { + if ( + notes['zh-CN'][section].length !== + notes['en-US'][section].length + ) { + fail(`${label} localized ${section} counts do not match`) + } } return { version: value.version, @@ -126,14 +156,18 @@ const localizedDefinitions = [ { locale: 'zh-CN', title: `GoodBuddy ${release.version} 更新内容`, + highlights: '本次亮点', features: '功能更新', - fixes: '问题修复' + fixes: '问题修复', + notices: '使用前请留意' }, { locale: 'en-US', title: `What's New in GoodBuddy ${release.version}`, + highlights: 'Highlights', features: 'Features', - fixes: 'Bug Fixes' + fixes: 'Bug Fixes', + notices: 'Before You Start' } ] @@ -151,8 +185,13 @@ const markdown = localizedDefinitions ...(index === 0 ? [] : ['---', '']), `# ${definition.title}`, '', + ...markdownSection( + definition.highlights, + notes.highlights + ), ...markdownSection(definition.features, notes.features), - ...markdownSection(definition.fixes, notes.fixes) + ...markdownSection(definition.fixes, notes.fixes), + ...markdownSection(definition.notices, notes.notices) ] }) .join('\n') diff --git a/docs/deepseek-harness-runtime-design.md b/docs/deepseek-harness-runtime-design.md index 7f70726..b8d568d 100644 --- a/docs/deepseek-harness-runtime-design.md +++ b/docs/deepseek-harness-runtime-design.md @@ -212,7 +212,9 @@ GoodBuddy 控制面自身不导出 `apply(ctx, config)`,也不提供默认 std - Main 只传递受管 Store 中已启用插件的稳定 ID、规范化入口文件和 JSON 配置。 - Launcher 与 Host 对消息结构和绝对入口路径执行严格校验;Host 解析真实路径并要求入口是普通文件。 - Host 动态加载 Cordis 插件并等待激活,每个插件有独立的 5 秒激活超时,完整插件序列最多占用 90 秒。 +- Main 的 Host 启动预算使用 10 秒基础预算,加上每个已启用插件 5 秒激活与最多 1 秒失败清理、且整个插件序列最多占用 91 秒,再预留 2 秒保存失败插件状态;显式测试超时仍作为调用方指定的硬上限。 - 插件按清单依次加载;导入、导出形态或激活失败只记录该插件,不阻止其他插件和 Host 启动。失败 Fiber 的清理同样有界。 +- 有限但超过预算的同步导入或同步 `apply` 在返回后按超时失败并继续加载后续插件;JavaScript 不能在同一事件循环内抢占永不返回的同步第三方代码,此时由 Main 的独立启动截止时间终止整个 Utility。 - 失败 ID 在 ready 握手中返回 Main;Main 原子写入停用状态和启动错误。 - 插件成功激活后可注册工具或后台生命周期逻辑。Ask 只能拦截模型工具调用,不能撤销初始化阶段已经发生的副作用。 @@ -335,6 +337,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation - Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。 - Main 在宽限期内等待正常退出。 - 超时后终止 utilityProcess,并在平台允许时清理完整进程树。 +- 应用退出时中止正在运行的 npm 插件安装并终止其完整进程树,不能让 lifecycle script 在 GoodBuddy 退出后继续运行。 - 应用退出不得因 Harness 清理无限阻塞。 ## 10. 权限与主机执行 @@ -460,7 +463,8 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接: | 工具输出摘要 | 4,000 字符 | | 待处理事件数 | 1,000 | | stderr 累计 | 64 KiB | -| 初始化 | 10 秒 | +| Host 启动 | 10 秒基础预算 + 每插件 5 秒激活与最多 1 秒失败清理,插件序列最多 91 秒;Main 另预留 2 秒持久化失败状态 | +| ACP 初始化与内部握手 | 每阶段 10 秒 | | 单次 Prompt | 10 分钟 | | 有序关闭宽限期 | 2 秒 | diff --git a/package-lock.json b/package-lock.json index a85aa0a..f8717e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.9.3", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.9.3", + "version": "0.10.0", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index 3520ae7..97bea61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.9.3", + "version": "0.10.0", "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 1e52b9c..a875596 100644 --- a/resources/release-notes.json +++ b/resources/release-notes.json @@ -1,6 +1,68 @@ { "formatVersion": 1, "releases": [ + { + "version": "0.10.0", + "releasedAt": "2026-08-17", + "notes": { + "zh-CN": { + "highlights": [ + "0.10.0 重点完善多 Runtime 工作流。OpenCode、Continue 和 DeepSeek Harness 的能力、MCP、插件与上下文管理现在可以在 GoodBuddy 内统一查看和配置,长对话、多会话和任务通知也更加连贯。" + ], + "features": [ + "**Runtime 能力概览。** 切换 Runtime 或排查不可用工具,设置页会展示内置 OpenCode、Continue 和 DeepSeek Harness 实际提供的 Agents、Tools、Commands、Rules、Prompts、Skills、MCP 等能力,并提供 Runtime 默认项与上下文压缩配置。", + "**DSH 插件管理。** DeepSeek Harness 的工具能力可以通过默认关闭的插件市场扩展,支持搜索、安装、更新和启停 npm 插件,并填写插件所需的 JSON 配置。", + "**按 Runtime 分配 MCP。** 知识库、魔法笔记等内置 MCP 可以分别分配给直连模型、OpenCode 和 Continue;自定义 MCP 也能分配给 OpenCode、Continue 和 DeepSeek Harness,同一套工具服务不必重复配置。", + "**上下文压缩。** 内置 OpenCode 默认自动整理上下文,也可手动触发;Continue 可手动生成并复用 GoodBuddy 摘要;直连模型是否自动压缩由用户决定。界面会分别显示本次调用用量和压缩后的对话估算。", + "**DSH 图片输入。** 支持图片的模型连接可以接收经过校验的 JPEG/PNG 截图与图片;文本模型会在调用前明确提示不支持图片,避免提交后才发现无法处理。", + "**多会话并行处理。** 多个会话可以在后台运行任务,另一个会话仍可继续聊天。侧栏会标记活动和未读状态,聊天内容与页面位置也会按会话保留。", + "**Runtime 用量统计。** 运行记录会按 Runtime 和模型归类每次调用,连续工具调用与上下文摘要不再合并计数,并会显示归一化的缓存命中率。" + ], + "fixes": [ + "**Windows 通知激活。** 任务完成后,点击系统通知会回到 GoodBuddy,不再打开 Electron 默认页;开发版、解包版和安装版也使用相互隔离的通知身份。", + "**直连模型连续对话。** 较长的连续对话和消息重编辑不再因本地消息 ID 被发送给模型服务而失败,工具调用与上下文摘要也不会覆盖前序用量记录。", + "**DSH 插件与 MCP 稳定性。** 多个 DSH 插件共同运行、MCP 返回大量分页工具,启动、取消和退出依然可靠;单个插件失败会被隔离,循环游标和未释放会话也会得到处理。", + "**设置界面一致性。** DSH 搜索文字不再被图标遮挡,正常状态不会重复显示说明,导航高亮与页面边距也保持一致。", + "**跨架构发布包。** Windows、macOS 和 Linux 的 x64、arm64 安装包会校验 Canvas、Koffi 等原生依赖,避免构建成功后加载错误架构的二进制文件。", + "**魔法笔记编辑体验。** 更高且可纵向拖动的编辑区域为长笔记留出空间,数字字号选项、工具栏和分栏间距也更加清楚。" + ], + "notices": [ + "**DSH 插件市场。** 该功能仍处于预览阶段并默认关闭。第三方插件的安装脚本、初始化代码和 Execute 工具会以当前用户权限运行,请只安装可信的包。关闭市场不会自动停用或卸载已有插件。", + "**工作模式权限。** Ask 模式仍保持只读;Execute 模式下,经过批准的工具会以当前用户权限操作文件、运行命令或访问外部服务。", + "**上下文压缩默认行为。** 内置 OpenCode 默认启用原生自动压缩;直连模型的自动压缩默认关闭;Continue 当前不会自动生成摘要,需要手动执行压缩。压缩可能调用所选模型并产生额外 Token 用量,GoodBuddy 中的原始聊天记录不会删除。", + "**Runtime 兼容性。** 外部 OpenCode Server 当前只提供连接状态,Continue 暂不支持静态发现原生 Tools;DeepSeek Harness 暂不支持内置 MCP,但可在 Execute 模式使用已分配的自定义 MCP。" + ] + }, + "en-US": { + "highlights": [ + "GoodBuddy 0.10.0 strengthens multi-Runtime workflows. OpenCode, Continue, and DeepSeek Harness capabilities, MCP, plugins, and context controls can now be viewed and configured in one place, with smoother long conversations, parallel work, and task notifications." + ], + "features": [ + "**Runtime capability overview.** Switching Runtimes or diagnosing unavailable tools now reveals the Agents, Tools, Commands, Rules, Prompts, Skills, MCP, and other capabilities actually provided by managed OpenCode, Continue, and DeepSeek Harness, along with Runtime defaults and context controls.", + "**DSH plugin management.** DeepSeek Harness can be extended through the default-off plugin marketplace, with npm plugin search, installation, updates, enablement, and JSON configuration.", + "**MCP assignment by Runtime.** Built-in MCP servers such as Knowledge and Magic Notes can be assigned individually to direct models, OpenCode, and Continue. Custom MCP can also be shared with OpenCode, Continue, and DeepSeek Harness without duplicating service configuration.", + "**Context compaction.** Managed OpenCode compacts context automatically by default and also provides a manual action. Continue can manually create and reuse GoodBuddy summaries, while users decide whether to enable automatic compaction for direct models. Latest-call usage and compressed-conversation estimates are shown separately.", + "**DSH image input.** Image-capable model connections accept validated JPEG/PNG screenshots and images. GoodBuddy rejects image input with a clear message before invoking a text-only model.", + "**Parallel conversations.** Multiple conversations can keep running tasks in the background while you continue chatting in another. The sidebar marks active and unread conversations, while chat content and page position remain preserved per conversation.", + "**Runtime usage reporting.** Run History groups every call by Runtime and model. Consecutive tool calls and context summaries remain separate usage records, with normalized prompt-cache hit rates for easier comparison." + ], + "fixes": [ + "**Windows notification activation.** Clicking a task-completion notification now opens GoodBuddy instead of Electron’s default page. Development, unpacked, and installed builds also use isolated notification identities.", + "**Direct-model follow-ups.** Long conversations and edited messages no longer fail because local message IDs reached model providers. Tool and context-summary calls also preserve earlier usage records.", + "**DSH plugin and MCP reliability.** Startup, cancellation, and shutdown remain reliable with several DSH plugins or large paginated MCP tool sets. Plugin failures are isolated, cursor loops are bounded, and sessions are released.", + "**Settings consistency.** DSH plugin search text no longer overlaps its icon, healthy status descriptions are no longer duplicated, and navigation highlights and page gutters remain aligned.", + "**Cross-architecture packages.** Windows, macOS, and Linux packages validate Canvas, Koffi, and related native dependencies for x64 and arm64, preventing successful builds from loading binaries for the wrong architecture.", + "**Magic Notes editing.** The editor is taller and vertically resizable, with numeric font-size choices and clearer toolbar and pane spacing." + ], + "notices": [ + "**DSH plugin marketplace.** This preview feature is disabled by default. Third-party install scripts, initialization code, and Execute tools run with current-user permissions, so install only trusted packages. Disabling the marketplace does not disable or remove installed plugins.", + "**Work mode permissions.** Ask remains read-only. In Execute, approved tools can modify files, run commands, or access external services with current-user permissions.", + "**Context compaction defaults.** Managed OpenCode enables native automatic compaction by default. Automatic compaction for direct models is disabled by default, while Continue requires manual compaction to create a summary. Compaction may call the selected model and incur additional token usage; original chat history in GoodBuddy is not deleted.", + "**Runtime compatibility.** External OpenCode Servers currently expose connection status only, and Continue cannot statically discover native Tools. DeepSeek Harness does not yet support built-in MCP, but assigned custom MCP is available in Execute mode." + ] + } + } + }, { "version": "0.9.3", "releasedAt": "2026-08-15", diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index f113ff6..6551cd1 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -27,7 +27,10 @@ vi.mock('./runtime-discovery', () => ({ detectRuntimeBinary: mocks.detectRuntimeBinary })) -import { ContinueAgentRuntime } from './continue-runtime' +import { + buildContinuePrompt, + ContinueAgentRuntime +} from './continue-runtime' function createRuntime(): ContinueAgentRuntime { return new ContinueAgentRuntime({ @@ -618,6 +621,151 @@ describe('ContinueAgentRuntime', () => { expect(prompt).not.toContain('old secret turn') }) + it('keeps a persisted summary when its covered prefix rolls out of the bounded history window', () => { + const history = Array.from({ length: 500 }, (_, index) => ({ + role: + index % 2 === 0 + ? ('user' as const) + : ('assistant' as const), + content: `recent message ${index}` + })) + const prompt = buildContinuePrompt({ + requestId: randomUUID(), + conversationId: 'evicted-summary-conversation', + prompt: 'continue', + history, + historyMessageIds: history.map(() => randomUUID()), + contextCompressionState: { + coveredHistoryDigest: createHash('sha256') + .update( + JSON.stringify([ + { role: 'user', content: 'evicted question' }, + { role: 'assistant', content: 'evicted answer' } + ]) + ) + .digest('hex'), + coveredMessageCount: 2, + coveredFromMessageId: randomUUID(), + coveredThroughMessageId: randomUUID(), + summary: 'persisted evicted facts' + } + }) + + expect(prompt).toContain('persisted evicted facts') + expect(prompt).toContain('recent message 499') + expect(prompt).not.toContain('evicted question') + }) + + it('keeps a persisted summary when filtered messages shorten the bounded history window', () => { + const history = Array.from({ length: 499 }, (_, index) => ({ + role: + index % 2 === 0 + ? ('user' as const) + : ('assistant' as const), + content: `filtered recent message ${index}` + })) + const prompt = buildContinuePrompt({ + requestId: randomUUID(), + conversationId: 'filtered-evicted-summary-conversation', + prompt: 'continue', + history, + historyMessageIds: history.map(() => randomUUID()), + contextCompressionState: { + coveredHistoryDigest: createHash('sha256') + .update( + JSON.stringify([ + { role: 'user', content: 'evicted question' }, + { role: 'assistant', content: 'evicted answer' } + ]) + ) + .digest('hex'), + coveredMessageCount: 2, + coveredFromMessageId: randomUUID(), + coveredThroughMessageId: randomUUID(), + summary: 'persisted facts after filtering' + } + }) + + expect(prompt).toContain('persisted facts after filtering') + expect(prompt).toContain('filtered recent message 498') + expect(prompt).not.toContain('evicted question') + }) + + it('keeps a persisted summary when only its covered start rolls out of the history window', () => { + const coveredThroughMessageId = randomUUID() + const history = [ + { + role: 'assistant' as const, + content: 'covered answer still at window start' + }, + ...Array.from({ length: 499 }, (_, index) => ({ + role: + index % 2 === 0 + ? ('user' as const) + : ('assistant' as const), + content: `later message ${index}` + })) + ] + const prompt = buildContinuePrompt({ + requestId: randomUUID(), + conversationId: 'partially-evicted-summary-conversation', + prompt: 'continue', + history, + historyMessageIds: [ + coveredThroughMessageId, + ...history.slice(1).map(() => randomUUID()) + ], + contextCompressionState: { + coveredHistoryDigest: createHash('sha256') + .update( + JSON.stringify([ + { role: 'user', content: 'evicted covered question' }, + history[0] + ]) + ) + .digest('hex'), + coveredMessageCount: 2, + coveredFromMessageId: randomUUID(), + coveredThroughMessageId, + summary: 'persisted partially evicted facts' + } + }) + + expect(prompt).toContain('persisted partially evicted facts') + expect(prompt).toContain('later message 498') + expect(prompt).not.toContain('covered answer still at window start') + }) + + it('rejects a persisted summary that contradicts the current bounded history window', () => { + const coveredThroughMessageId = randomUUID() + const history = Array.from({ length: 500 }, (_, index) => ({ + role: + index % 2 === 0 + ? ('user' as const) + : ('assistant' as const), + content: `conflicting message ${index}` + })) + const historyMessageIds = history.map(() => randomUUID()) + historyMessageIds[10] = coveredThroughMessageId + const prompt = buildContinuePrompt({ + requestId: randomUUID(), + conversationId: 'conflicting-summary-conversation', + prompt: 'continue', + history, + historyMessageIds, + contextCompressionState: { + coveredHistoryDigest: '0'.repeat(64), + coveredMessageCount: 2, + coveredFromMessageId: randomUUID(), + coveredThroughMessageId, + summary: 'contradictory summary must not appear' + } + }) + + expect(prompt).toContain('conflicting message 499') + expect(prompt).not.toContain('contradictory summary must not appear') + }) + it('falls back to bounded raw history when a persisted summary is stale', async () => { const runtime = createRuntime() for await (const _event of runtime.run( diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index 3989fdb..6fd7530 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -110,41 +110,74 @@ function flattenContinueSegment(value: string): string { .trim() } -function hasValidCompressionPrefix( +function getCurrentCompressionPrefixLength( request: AgentExecutionRequest -): boolean { +): number | undefined { const state = request.contextCompressionState const history = request.history if ( !state || !history || - state.coveredMessageCount <= 0 || - state.coveredMessageCount > history.length + state.coveredMessageCount <= 0 ) { - return false - } - const coveredHistory = history.slice(0, state.coveredMessageCount) - if ( - createHash('sha256') - .update(JSON.stringify(coveredHistory)) - .digest('hex') !== state.coveredHistoryDigest - ) { - return false + return undefined } const ids = request.historyMessageIds if ( (state.coveredFromMessageId || state.coveredThroughMessageId) && (!ids || ids.length !== history.length) ) { - return false + return undefined } - return ( - (!state.coveredFromMessageId || - ids?.[0] === state.coveredFromMessageId) && - (!state.coveredThroughMessageId || - ids?.[state.coveredMessageCount - 1] === - state.coveredThroughMessageId) + + if (state.coveredMessageCount <= history.length) { + const coveredHistory = history.slice( + 0, + state.coveredMessageCount + ) + const digestMatches = + createHash('sha256') + .update(JSON.stringify(coveredHistory)) + .digest('hex') === state.coveredHistoryDigest + const boundariesMatch = + (!state.coveredFromMessageId || + ids?.[0] === state.coveredFromMessageId) && + (!state.coveredThroughMessageId || + ids?.[state.coveredMessageCount - 1] === + state.coveredThroughMessageId) + if (digestMatches && boundariesMatch) { + return state.coveredMessageCount + } + } + + if ( + !ids || + !state.coveredFromMessageId || + !state.coveredThroughMessageId + ) { + return undefined + } + + const coveredFromIndex = ids.indexOf( + state.coveredFromMessageId ) + const coveredThroughIndex = ids.indexOf( + state.coveredThroughMessageId + ) + if ( + coveredFromIndex === -1 && + coveredThroughIndex >= 0 && + coveredThroughIndex < state.coveredMessageCount - 1 + ) { + return coveredThroughIndex + 1 + } + if ( + coveredFromIndex === -1 && + coveredThroughIndex === -1 + ) { + return 0 + } + return undefined } export function buildContinuePrompt( @@ -176,7 +209,9 @@ export function buildContinuePrompt( 'Answer the CURRENT USER REQUEST now.' ].join(' | ') - if (hasValidCompressionPrefix(request)) { + const compressionPrefixLength = + getCurrentCompressionPrefixLength(request) + if (compressionPrefixLength !== undefined) { const state = request.contextCompressionState! const summaryEnvelope = { role: 'user' as const, @@ -207,9 +242,7 @@ export function buildContinuePrompt( } if (compose(summaryPair).length <= MAX_CONTINUE_PROMPT_CHARACTERS) { const retained = [...summaryPair] - const recent = request.history!.slice( - state.coveredMessageCount - ) + const recent = request.history!.slice(compressionPrefixLength) for (const message of recent.slice(-18).reverse()) { const candidate = [ ...summaryPair, diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts index 28ef2b0..9920752 100644 --- a/src/main/agent/deepseek-harness-acp-e2e.test.ts +++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts @@ -1005,174 +1005,181 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => { mkdir(dshHome), mkdir(installation) ]) - const entry = { - id: 'dsh-plugin-greet-live', - package: { - name: 'dsh-plugin-greet', - version: '0.1.0' - }, - displayName: 'dsh-plugin-greet', - description: 'Reviewed minimal live DSH plugin fixture.' - } - const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI - ? resolve(process.env.GOODBUDDY_DSH_NPM_CLI) - : resolve( - 'node_modules', - 'npm', - 'bin', - 'npm-cli.js' - ) - const nodeExecutablePath = - process.env.GOODBUDDY_DSH_NODE_EXECUTABLE - ? resolve(process.env.GOODBUDDY_DSH_NODE_EXECUTABLE) - : undefined - const installer = new DshNpmExtensionInstaller({ - dshHome, - npmCliPath, - ...(nodeExecutablePath ? { nodeExecutablePath } : {}) - }) - const installed = await installer.install({ - entry, - destinationDirectory: installation - }) - const observedRequests: GenerateOptions[] = [] - const inProcess = createInProcessLaunch( - dshHome, - undefined, - (options) => observedRequests.push(options) - ) - const runtime = new DeepSeekHarnessRuntime({ - defaultWorkspace: workspace, - baseUrl: liveBaseUrl, - model: liveModel, - launch: (options) => inProcess.launch(options), - credentialRefs: { - [CREDENTIAL_REF]: liveApiKey - }, - extensionPackages: [ - { - id: entry.id, - entrypoint: join( - installation, - ...installed.entrypoint.split('/') - ), - configuration: {} - } - ], - initializationTimeoutMs: 20_000, - promptTimeoutMs: 120_000, - shutdownTimeoutMs: 5_000 - }) - + let installer: DshNpmExtensionInstaller | undefined try { - const askEvents = await collect( - runtime.run( - { - requestId: 'request-live-plugin-ask', - conversationId: 'live-plugin-ask', - prompt: - 'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.', - workMode: 'ask' - }, - new AbortController().signal - ) - ) - const askRequests = observedRequests.filter((options) => - latestUserText(options).includes( - 'DSH_ASK_PLUGIN_PROBE' - ) - ) - expect(askRequests.length).toBeGreaterThan(0) - expect( - askRequests.flatMap( - (options) => - options.tools?.map((tool) => tool.name) ?? [] - ) - ).toContain('greet') - expect(askEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'tool', - name: 'greet', - state: 'pending' - }), - expect.objectContaining({ - type: 'tool', - state: 'failed', - output: expect.stringContaining( - 'Ask 模式不允许执行非只读工具' - ) - }), - expect.objectContaining({ type: 'done' }) - ]) - ) - expect( - askEvents.some( - (event) => - event.type === 'tool' && - event.state === 'completed' - ) - ).toBe(false) - expect( - askEvents - .flatMap((event) => - event.type === 'text' ? [event.delta] : [] + const entry = { + id: 'dsh-plugin-greet-live', + package: { + name: 'dsh-plugin-greet', + version: '0.2.0' + }, + displayName: 'dsh-plugin-greet', + description: 'Reviewed minimal live DSH plugin fixture.' + } + const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI + ? resolve(process.env.GOODBUDDY_DSH_NPM_CLI) + : resolve( + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js' ) - .join('') - ).toContain('DSH_ASK_PLUGIN_BLOCKED') + const nodeExecutablePath = + process.env.GOODBUDDY_DSH_NODE_EXECUTABLE + ? resolve( + process.env.GOODBUDDY_DSH_NODE_EXECUTABLE + ) + : undefined + installer = new DshNpmExtensionInstaller({ + dshHome, + npmCliPath, + ...(nodeExecutablePath ? { nodeExecutablePath } : {}) + }) + const installed = await installer.install({ + entry, + destinationDirectory: installation + }) + const observedRequests: GenerateOptions[] = [] + const inProcess = createInProcessLaunch( + dshHome, + undefined, + (options) => observedRequests.push(options) + ) + const runtime = new DeepSeekHarnessRuntime({ + defaultWorkspace: workspace, + baseUrl: liveBaseUrl, + model: liveModel, + launch: (options) => inProcess.launch(options), + credentialRefs: { + [CREDENTIAL_REF]: liveApiKey + }, + extensionPackages: [ + { + id: entry.id, + entrypoint: join( + installation, + ...installed.entrypoint.split('/') + ), + configuration: {} + } + ], + initializationTimeoutMs: 20_000, + promptTimeoutMs: 120_000, + shutdownTimeoutMs: 5_000 + }) - const executeEvents = await collect( - runtime.run( - { - requestId: 'request-live-plugin-execute', - conversationId: 'live-plugin-execute', - prompt: - 'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.', - workMode: 'execute' - }, - new AbortController().signal - ) - ) - const executeRequests = observedRequests.filter((options) => - latestUserText(options).includes( - 'DSH_EXECUTE_PLUGIN_PROBE' - ) - ) - expect(executeRequests.length).toBeGreaterThan(0) - expect( - executeRequests.some((options) => - options.tools?.some((tool) => tool.name === 'greet') - ) - ).toBe(true) - expect(executeEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'tool', - name: 'greet', - state: 'pending' - }), - expect.objectContaining({ - type: 'tool', - state: 'completed', - output: expect.stringContaining( - 'Hello, GoodBuddyLive!' - ) - }), - expect.objectContaining({ type: 'done' }) - ]) - ) - expect( - executeEvents - .flatMap((event) => - event.type === 'text' ? [event.delta] : [] + try { + const askEvents = await collect( + runtime.run( + { + requestId: 'request-live-plugin-ask', + conversationId: 'live-plugin-ask', + prompt: + 'DSH_ASK_PLUGIN_PROBE: attempt to call greet exactly once with name GoodBuddyAsk. The runtime must reject it. After the tool result, reply with DSH_ASK_PLUGIN_BLOCKED.', + workMode: 'ask' + }, + new AbortController().signal ) - .join('') - ).toContain('DSH_EXECUTE_PLUGIN_OK') + ) + const askRequests = observedRequests.filter((options) => + latestUserText(options).includes( + 'DSH_ASK_PLUGIN_PROBE' + ) + ) + expect(askRequests.length).toBeGreaterThan(0) + expect( + askRequests.flatMap( + (options) => + options.tools?.map((tool) => tool.name) ?? [] + ) + ).toContain('greet') + expect(askEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'tool', + name: 'greet', + state: 'pending' + }), + expect.objectContaining({ + type: 'tool', + state: 'failed', + output: expect.stringContaining( + 'Ask 模式不允许执行非只读工具' + ) + }), + expect.objectContaining({ type: 'done' }) + ]) + ) + expect( + askEvents.some( + (event) => + event.type === 'tool' && + event.state === 'completed' + ) + ).toBe(false) + expect( + askEvents + .flatMap((event) => + event.type === 'text' ? [event.delta] : [] + ) + .join('') + ).toContain('DSH_ASK_PLUGIN_BLOCKED') + + const executeEvents = await collect( + runtime.run( + { + requestId: 'request-live-plugin-execute', + conversationId: 'live-plugin-execute', + prompt: + 'DSH_EXECUTE_PLUGIN_PROBE: call greet exactly once with name GoodBuddyLive. After its result, reply with DSH_EXECUTE_PLUGIN_OK and the exact greeting.', + workMode: 'execute' + }, + new AbortController().signal + ) + ) + const executeRequests = observedRequests.filter((options) => + latestUserText(options).includes( + 'DSH_EXECUTE_PLUGIN_PROBE' + ) + ) + expect(executeRequests.length).toBeGreaterThan(0) + expect( + executeRequests.some((options) => + options.tools?.some((tool) => tool.name === 'greet') + ) + ).toBe(true) + expect(executeEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'tool', + name: 'greet', + state: 'pending' + }), + expect.objectContaining({ + type: 'tool', + state: 'completed', + output: expect.stringContaining( + 'Hello, GoodBuddyLive!' + ) + }), + expect.objectContaining({ type: 'done' }) + ]) + ) + expect( + executeEvents + .flatMap((event) => + event.type === 'text' ? [event.delta] : [] + ) + .join('') + ).toContain('DSH_EXECUTE_PLUGIN_OK') + } finally { + await Promise.allSettled([ + runtime.dispose(), + ...inProcess.hosts.map((host) => host.dispose()) + ]) + } } finally { - await runtime.dispose() - await Promise.allSettled( - inProcess.hosts.map((host) => host.dispose()) - ) + await installer?.dispose().catch(() => undefined) await rm(root, { recursive: true, force: true }) } }, diff --git a/src/main/agent/deepseek-harness-control-protocol.ts b/src/main/agent/deepseek-harness-control-protocol.ts index d71c37c..cd028f7 100644 --- a/src/main/agent/deepseek-harness-control-protocol.ts +++ b/src/main/agent/deepseek-harness-control-protocol.ts @@ -14,6 +14,42 @@ export const DEEPSEEK_HARNESS_CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY' export const DEEPSEEK_HARNESS_MAX_FRAME_BYTES = 8 * 1024 * 1024 +export const DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS = + 5_000 +export const DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS = + 1_000 +export const DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS = + 90_000 +const DEEPSEEK_HARNESS_HOST_STARTUP_OVERHEAD_MS = 10_000 +const DEEPSEEK_HARNESS_STARTUP_FAILURE_CLEANUP_MS = 2_000 + +export function deepSeekHarnessStartupBudget( + extensionCount: number +): { + hostTimeoutMs: number + mainTimeoutMs: number +} { + const boundedExtensionCount = Math.max( + 0, + Math.floor(extensionCount) + ) + const extensionSequenceMs = Math.min( + DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS + + DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS, + boundedExtensionCount * + (DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS + + DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS) + ) + const hostTimeoutMs = + DEEPSEEK_HARNESS_HOST_STARTUP_OVERHEAD_MS + + extensionSequenceMs + return { + hostTimeoutMs, + mainTimeoutMs: + hostTimeoutMs + + DEEPSEEK_HARNESS_STARTUP_FAILURE_CLEANUP_MS + } +} const skillPackageSchema = z .object({ diff --git a/src/main/agent/deepseek-harness-extension-loader.test.ts b/src/main/agent/deepseek-harness-extension-loader.test.ts index 53f057d..3f010e3 100644 --- a/src/main/agent/deepseek-harness-extension-loader.test.ts +++ b/src/main/agent/deepseek-harness-extension-loader.test.ts @@ -15,6 +15,13 @@ function extension( } } +function blockEventLoop(durationMs: number): void { + const deadline = Date.now() + durationMs + while (Date.now() <= deadline) { + // Deliberately model finite synchronous CommonJS/plugin startup work. + } +} + describe('DeepSeek Harness extension loader', () => { it('loads named Cordis plugin exports and keeps working extensions active', async () => { const ctx = new Context() @@ -157,4 +164,91 @@ describe('DeepSeek Harness extension loader', () => { expect(apply).not.toHaveBeenCalled() await ctx.fiber.dispose() }) + + it('rejects a synchronous import that returns after its budget', async () => { + const ctx = new Context() + const apply = vi.fn() + + const result = await loadControlledHarnessExtensions( + ctx, + [extension('slow-import')], + { + activationTimeoutMs: 10, + importModule: async () => { + blockEventLoop(25) + return { apply } + } + } + ) + + expect(result).toEqual({ + loadedIds: [], + failedIds: ['slow-import'], + failures: [ + { + id: 'slow-import', + message: + 'DeepSeek Harness extension activation timed out' + } + ] + }) + expect(apply).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('rejects over-budget synchronous apply and loads the next extension', async () => { + const ctx = new Context() + const laterApply = vi.fn() + + const result = await loadControlledHarnessExtensions( + ctx, + [extension('slow-apply'), extension('later')], + { + activationTimeoutMs: 10, + totalActivationTimeoutMs: 100, + importModule: async (url) => + url.includes('slow-apply') + ? { + apply() { + blockEventLoop(25) + } + } + : { apply: laterApply } + } + ) + + expect(result.loadedIds).toEqual(['later']) + expect(result.failedIds).toEqual(['slow-apply']) + expect(result.failures[0]?.message).toBe( + 'DeepSeek Harness extension activation timed out' + ) + expect(laterApply).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + + it('times out asynchronous activation and disposes its effects', async () => { + const ctx = new Context() + const cleanup = vi.fn() + + const result = await loadControlledHarnessExtensions( + ctx, + [extension('async-slow')], + { + activationTimeoutMs: 10, + importModule: async () => ({ + apply(pluginContext: Context) { + pluginContext.effect(() => cleanup) + return new Promise((resolve) => + setTimeout(resolve, 30) + ) + } + }) + } + ) + + expect(result.failedIds).toEqual(['async-slow']) + expect(result.failures[0]?.message).toContain('timed out') + expect(cleanup).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) }) diff --git a/src/main/agent/deepseek-harness-extension-loader.ts b/src/main/agent/deepseek-harness-extension-loader.ts index 85a8796..6b34549 100644 --- a/src/main/agent/deepseek-harness-extension-loader.ts +++ b/src/main/agent/deepseek-harness-extension-loader.ts @@ -1,10 +1,14 @@ import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis' import { createRequire } from 'node:module' import { fileURLToPath, pathToFileURL } from 'node:url' +import { + DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS, + DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS, + DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS +} from './deepseek-harness-control-protocol' -const DEFAULT_ACTIVATION_TIMEOUT_MS = 5_000 -const DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS = 90_000 -const DISPOSAL_TIMEOUT_MS = 1_000 +const ACTIVATION_TIMEOUT_MESSAGE = + 'DeepSeek Harness extension activation timed out' export type ControlledHarnessExtensionPackage = { id: string @@ -117,16 +121,18 @@ export async function loadControlledHarnessExtensions( const failedIds: string[] = [] const failures: Array<{ id: string; message: string }> = [] const activationTimeoutMs = - options.activationTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS + options.activationTimeoutMs ?? + DEEPSEEK_HARNESS_EXTENSION_ACTIVATION_TIMEOUT_MS const deadline = Date.now() + (options.totalActivationTimeoutMs ?? - DEFAULT_TOTAL_ACTIVATION_TIMEOUT_MS) + DEEPSEEK_HARNESS_TOTAL_EXTENSION_ACTIVATION_TIMEOUT_MS) const importModule = options.importModule ?? defaultImportModule for (const extension of extensions) { let fiber: (Fiber & PromiseLike) | undefined let acceptActivation = true + let activationDeadline: number | undefined try { const remainingMs = deadline - Date.now() if (remainingMs <= 0) { @@ -134,32 +140,54 @@ export async function loadControlledHarnessExtensions( 'DeepSeek Harness extension startup deadline exceeded' ) } + const extensionBudgetMs = Math.max( + 1, + Math.min(activationTimeoutMs, remainingMs) + ) + activationDeadline = Date.now() + extensionBudgetMs + const rejectLateSynchronousWork = (): void => { + if (Date.now() > activationDeadline!) { + acceptActivation = false + throw new Error(ACTIVATION_TIMEOUT_MESSAGE) + } + } + const activation = (async () => { + const module = await importModule( + pathToFileURL(extension.entrypoint).href + ) + rejectLateSynchronousWork() + if (!acceptActivation) { + throw new Error(ACTIVATION_TIMEOUT_MESSAGE) + } + const plugin = resolvePlugin(module) + fiber = ctx.plugin(plugin, extension.configuration) + // A timer cannot run while CommonJS evaluation or a plugin's + // synchronous apply body owns this event loop. Re-check elapsed + // wall time immediately after those calls return so finite + // over-budget work is never reported as successfully activated. + rejectLateSynchronousWork() + await Promise.resolve(fiber) + rejectLateSynchronousWork() + })() await withTimeout( - (async () => { - const module = await importModule( - pathToFileURL(extension.entrypoint).href - ) - if (!acceptActivation) { - throw new Error( - 'DeepSeek Harness extension activation timed out' - ) - } - const plugin = resolvePlugin(module) - fiber = ctx.plugin(plugin, extension.configuration) - await Promise.resolve(fiber) - })(), - Math.max(1, Math.min(activationTimeoutMs, remainingMs)), - 'DeepSeek Harness extension activation timed out', + activation, + Math.max(1, activationDeadline - Date.now()), + ACTIVATION_TIMEOUT_MESSAGE, () => { acceptActivation = false } ) loadedIds.push(extension.id) } catch (error) { + const failure = + activationDeadline !== undefined && + Date.now() > activationDeadline + ? new Error(ACTIVATION_TIMEOUT_MESSAGE) + : error if (fiber) { await withTimeout( fiber.dispose(), - DISPOSAL_TIMEOUT_MS, + DEEPSEEK_HARNESS_EXTENSION_DISPOSAL_TIMEOUT_MS, 'DeepSeek Harness extension disposal timed out' ).catch(() => undefined) } @@ -167,8 +195,8 @@ export async function loadControlledHarnessExtensions( failures.push({ id: extension.id, message: - error instanceof Error && error.message.trim() - ? error.message.slice(0, 1_000) + failure instanceof Error && failure.message.trim() + ? failure.message.slice(0, 1_000) : 'DeepSeek Harness extension failed to start' }) } diff --git a/src/main/agent/deepseek-harness-runtime.test.ts b/src/main/agent/deepseek-harness-runtime.test.ts index 1c50331..d3ce9df 100644 --- a/src/main/agent/deepseek-harness-runtime.test.ts +++ b/src/main/agent/deepseek-harness-runtime.test.ts @@ -6,6 +6,7 @@ import { type ModelToolDefinition, type ModelToolProviderLike } from './model-tool-provider' +import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol' import type { ResolvedMcpServer } from '../capabilities/capability-service' @@ -47,6 +48,19 @@ function setup( maxRequestOutputCharacters?: number supportsImageInput?: boolean advertisedImageInput?: boolean + initializationTimeoutMs?: number + useDefaultInitializationTimeout?: boolean + launchDelayMs?: number + extensionPackages?: Array<{ + id: string + entrypoint: string + configuration: Record + }> + launch?: ( + options: Parameters< + ConstructorParameters[0]['launch'] + >[0] + ) => Promise } = {} ) { const exit = deferred<{ @@ -215,7 +229,17 @@ function setup( ClientSideConnection, ndJsonStream: vi.fn(() => ({ stream: true })) } as unknown as DeepSeekHarnessAcpSdk - const launch = vi.fn(async () => child) + const launch = vi.fn( + options.launch ?? + (async () => { + if (options.launchDelayMs !== undefined) { + await new Promise((resolve) => + setTimeout(resolve, options.launchDelayMs) + ) + } + return child + }) + ) const runtime = new DeepSeekHarnessRuntime({ defaultWorkspace: 'C:\\workspace', baseUrl: 'https://api.deepseek.com', @@ -223,7 +247,12 @@ function setup( supportsImageInput: options.supportsImageInput, launch, loadAcpSdk: async () => sdk, - initializationTimeoutMs: 100, + ...(options.useDefaultInitializationTimeout + ? {} + : { + initializationTimeoutMs: + options.initializationTimeoutMs ?? 100 + }), promptTimeoutMs: options.promptTimeoutMs ?? 100, shutdownTimeoutMs: 10, maxStderrBytes: 16, @@ -231,7 +260,8 @@ function setup( maxRequestOutputCharacters: options.maxRequestOutputCharacters, toolProvider: options.toolProvider, - skillPackages: options.skillPackages + skillPackages: options.skillPackages, + extensionPackages: options.extensionPackages }) const emit = async ( sessionId: string, @@ -409,6 +439,104 @@ function toolProvider( } describe('DeepSeekHarnessRuntime', () => { + it('includes bounded failed-extension cleanup in the startup budget', () => { + expect(deepSeekHarnessStartupBudget(11)).toEqual({ + hostTimeoutMs: 76_000, + mainTimeoutMs: 78_000 + }) + expect(deepSeekHarnessStartupBudget(64)).toEqual({ + hostTimeoutMs: 101_000, + mainTimeoutMs: 103_000 + }) + }) + + it('expands the default launcher deadline for enabled extensions', async () => { + vi.useFakeTimers() + try { + const harness = setup({ + useDefaultInitializationTimeout: true, + launchDelayMs: 10_001, + extensionPackages: [ + { + id: 'slow-one', + entrypoint: 'C:\\extensions\\slow-one.js', + configuration: {} + }, + { + id: 'slow-two', + entrypoint: 'C:\\extensions\\slow-two.js', + configuration: {} + } + ] + }) + + const status = harness.runtime.getStatus() + await vi.advanceTimersByTimeAsync(10_001) + + await expect(status).resolves.toMatchObject({ + available: true + }) + expect(harness.child.terminate).not.toHaveBeenCalled() + const disposal = harness.runtime.dispose() + await vi.advanceTimersByTimeAsync(10) + await disposal + } finally { + vi.useRealTimers() + } + }) + + it('keeps the default no-extension launcher deadline bounded', async () => { + vi.useFakeTimers() + try { + let launchSignal: AbortSignal | undefined + const harness = setup({ + useDefaultInitializationTimeout: true, + launch: (options) => { + launchSignal = options.signal + return new Promise( + () => undefined + ) + } + }) + + const status = harness.runtime.getStatus() + await vi.advanceTimersByTimeAsync(12_000) + + await expect(status).resolves.toMatchObject({ + available: false, + detail: 'DeepSeek Harness 启动超时' + }) + expect(launchSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts a pending launch and terminates a child returned after disposal', async () => { + const launch = deferred() + let launchSignal: AbortSignal | undefined + const harness = setup({ + launch: (options) => { + launchSignal = options.signal + return launch.promise + } + }) + + const status = harness.runtime.getStatus() + await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) + + await harness.runtime.dispose() + expect(launchSignal?.aborted).toBe(true) + + launch.resolve(harness.child) + + await expect(status).resolves.toMatchObject({ + available: false, + detail: 'DeepSeek Harness Runtime 已关闭' + }) + expect(harness.child.terminate).toHaveBeenCalledOnce() + }) + it('surfaces bounded internal Harness details from ACP errors', () => { expect( harnessPromptError( diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index afad1e9..bc2ec84 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -34,6 +34,7 @@ import { GOODBUDDY_TOOLS_CALL, GOODBUDDY_TOOLS_LIST } from './deepseek-harness-protocol' +import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol' const ACP_PACKAGE_NAME = '@agentclientprotocol/sdk' const DEFAULT_INITIALIZATION_TIMEOUT_MS = 10_000 @@ -185,6 +186,11 @@ export type DeepSeekHarnessRuntimeOptions = { launch: ( options: DeepSeekHarnessLaunchOptions ) => Promise + /** + * Explicit hard timeout for each initialization operation, including the + * complete launcher call. When omitted, launcher startup is expanded from + * the enabled extension count while later ACP operations retain 10 seconds. + */ initializationTimeoutMs?: number promptTimeoutMs?: number shutdownTimeoutMs?: number @@ -450,6 +456,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { readonly supportsScopedDataTools = false private state?: HarnessState private initialization?: Promise + private launchController?: AbortController private disposed = false private fatalError?: Error private stderrBytes = 0 @@ -470,6 +477,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { ) } + private get launchTimeoutMs(): number { + return ( + this.options.initializationTimeoutMs ?? + deepSeekHarnessStartupBudget( + this.options.extensionPackages?.length ?? 0 + ).mainTimeoutMs + ) + } + private get promptTimeoutMs(): number { return this.options.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS } @@ -806,6 +822,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { throw new Error('DeepSeek Harness Runtime 已关闭') } const launchController = new AbortController() + this.launchController = launchController let child: DeepSeekHarnessChild | undefined try { child = await withTimeout( @@ -822,9 +839,12 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { skillPackages: this.options.skillPackages ?? [], extensionPackages: this.options.extensionPackages ?? [] }), - this.initializationTimeoutMs, + this.launchTimeoutMs, '启动' ) + if (this.disposed) { + throw new Error('DeepSeek Harness Runtime 已关闭') + } const sdk = await (this.options.loadAcpSdk ?? defaultLoadAcpSdk)() let agent: AcpAgent | undefined const connection = new sdk.ClientSideConnection( @@ -1076,6 +1096,9 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { ...stateWithoutCapabilities, capabilities } + if (this.disposed) { + throw new Error('DeepSeek Harness Runtime 已关闭') + } this.state = state return state } catch (error) { @@ -1084,6 +1107,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { await this.terminate(child) } throw error + } finally { + if (this.launchController === launchController) { + this.launchController = undefined + } } } @@ -1611,6 +1638,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { return } this.disposed = true + this.launchController?.abort( + new Error('DeepSeek Harness Runtime 已关闭') + ) + this.launchController = undefined const state = this.state this.state = undefined this.initialization = undefined diff --git a/src/main/agent/deepseek-harness-utility-launcher.ts b/src/main/agent/deepseek-harness-utility-launcher.ts index f4df056..9ba1273 100644 --- a/src/main/agent/deepseek-harness-utility-launcher.ts +++ b/src/main/agent/deepseek-harness-utility-launcher.ts @@ -15,6 +15,7 @@ import { DEEPSEEK_HARNESS_CREDENTIAL_REF, DEEPSEEK_HARNESS_HOST_VERSION, DEEPSEEK_HARNESS_MAX_FRAME_BYTES, + deepSeekHarnessStartupBudget, parseHarnessControlMessage, type DeepSeekHarnessControlMessage as HarnessControlMessage } from './deepseek-harness-control-protocol' @@ -53,6 +54,11 @@ export type DeepSeekHarnessUtilityLauncherOptions = { onExtensionStartupFailures?: ( extensionIds: readonly string[] ) => Promise + /** + * Explicit hard Host-handshake deadline. Callers that also set the Runtime + * initialization timeout must leave enough additional time for startup + * failure persistence. + */ startupTimeoutMs?: number } @@ -174,10 +180,9 @@ export function createDeepSeekHarnessUtilityLauncher( } const startupTimeoutMs = launcherOptions.startupTimeoutMs ?? - Math.min( - 120_000, - 10_000 + canonicalExtensionPackages.length * 5_000 - ) + deepSeekHarnessStartupBudget( + canonicalExtensionPackages.length + ).hostTimeoutMs let timer: ReturnType | undefined let onAbort: (() => void) | undefined try { diff --git a/src/main/agent/dsh-extension-marketplace.e2e.test.ts b/src/main/agent/dsh-extension-marketplace.e2e.test.ts index 3ce80ec..faf85ed 100644 --- a/src/main/agent/dsh-extension-marketplace.e2e.test.ts +++ b/src/main/agent/dsh-extension-marketplace.e2e.test.ts @@ -19,12 +19,22 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => { const userDataPath = await mkdtemp( join(tmpdir(), 'goodbuddy-dsh-marketplace-live-') ) + let installer: DshNpmExtensionInstaller | undefined + let host: + | Awaited< + ReturnType + > + | undefined try { const market = new DshNpmMarketplaceCatalog() const greet = (await market.list()).find( (entry) => entry.package.name === 'dsh-plugin-greet' ) expect(greet).toBeDefined() + expect(greet?.package).toEqual({ + name: 'dsh-plugin-greet', + version: '0.2.0' + }) const npmCliPath = process.env.GOODBUDDY_DSH_NPM_CLI ? resolve(process.env.GOODBUDDY_DSH_NPM_CLI) : resolve( @@ -39,16 +49,17 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => { process.env.GOODBUDDY_DSH_NODE_EXECUTABLE ) : undefined - const installer = new DshNpmExtensionInstaller({ + const activeInstaller = new DshNpmExtensionInstaller({ dshHome: userDataPath, npmCliPath, ...(nodeExecutablePath ? { nodeExecutablePath } : {}) }) + installer = activeInstaller const store = new RuntimeExtensionStore(userDataPath, { catalog: { list: async () => [greet!] }, - install: (input) => installer.install(input) + install: (input) => activeInstaller.install(input) }) await store.apply({ type: 'set-marketplace-enabled', @@ -77,7 +88,7 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => { Record, Record >() - const host = await startControlledDeepSeekHarnessHost({ + host = await startControlledDeepSeekHarnessHost({ workspace: userDataPath, dshHome: userDataPath, baseUrl: 'https://api.deepseek.com', @@ -103,10 +114,16 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => { } as never) ).resolves.toMatchObject({ isError: false, - value: 'Hello, GoodBuddy!' + value: { + message: 'Hello, GoodBuddy!', + name: 'GoodBuddy', + language: 'en', + style: 'friendly' + } }) - await host.dispose() } finally { + await host?.dispose().catch(() => undefined) + await installer?.dispose().catch(() => undefined) await rm(userDataPath, { recursive: true, force: true, diff --git a/src/main/agent/dsh-extension-marketplace.test.ts b/src/main/agent/dsh-extension-marketplace.test.ts index c9495b9..914a0fc 100644 --- a/src/main/agent/dsh-extension-marketplace.test.ts +++ b/src/main/agent/dsh-extension-marketplace.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { DshNpmExtensionInstaller, DshNpmMarketplaceCatalog, + runPackageManager, type PackageManagerRunner } from './dsh-extension-marketplace' @@ -139,7 +140,7 @@ describe('DSH npm marketplace', () => { expect(fetcher).toHaveBeenCalledOnce() }) - it('uses bundled npm to install the exact package and verifies its entrypoint', async () => { + it('installs the exact package when an older manifest lacks DSH bundle metadata', async () => { const destinationDirectory = await mkdtemp( join(tmpdir(), 'goodbuddy-dsh-npm-installer-') ) @@ -161,6 +162,11 @@ describe('DSH npm marketplace', () => { const fetcher = vi.fn(async () => response({ versions: { + '0.0.1': { + name: packageName, + version: '0.0.1', + dist: { integrity } + }, [version]: manifest } }) @@ -294,4 +300,95 @@ describe('DSH npm marketplace', () => { }) ).rejects.toThrow() }) + + it('aborts an active package-manager process and settles the run', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-dsh-npm-abort-') + ) + temporaryDirectories.push(directory) + const controller = new AbortController() + const operation = runPackageManager( + process.execPath, + ['-e', 'setInterval(() => {}, 1_000)'], + { + cwd: directory, + env: process.env, + timeoutMs: 60_000, + signal: controller.signal + } + ) + + await new Promise((resolve) => setTimeout(resolve, 50)) + controller.abort(new Error('installer cancellation fixture')) + + await expect(operation).rejects.toThrow( + 'installer cancellation fixture' + ) + }) + + it('disposes active installs, propagates cancellation, and rejects new work', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-dsh-installer-dispose-') + ) + temporaryDirectories.push(directory) + const integrity = `sha512-${Buffer.from('verified').toString( + 'base64' + )}` + const packageName = 'dsh-plugin-cancellable' + const version = '1.0.0' + const runner: PackageManagerRunner = vi.fn( + (_command, _args, options) => + new Promise<{ + exitCode: number + stdout: string + stderr: string + }>((_resolve, reject) => { + const rejectCancellation = (): void => { + reject(options.signal?.reason) + } + options.signal?.addEventListener( + 'abort', + rejectCancellation, + { once: true } + ) + }) + ) + const installer = new DshNpmExtensionInstaller({ + dshHome: directory, + fetcher: vi.fn(async () => + response({ + versions: { + [version]: { + name: packageName, + version, + main: 'index.js', + dist: { integrity }, + dsh: { bundle: { patch: './cordis.patch.yml' } } + } + } + }) + ), + runner + }) + const input = { + entry: { + id: 'cancellable', + package: { name: packageName, version }, + displayName: packageName, + description: 'Cancellation fixture.' + }, + destinationDirectory: directory + } + const installation = installer.install(input) + await vi.waitFor(() => expect(runner).toHaveBeenCalledOnce()) + + await installer.dispose() + + await expect(installation).rejects.toThrow( + '应用退出,DSH 插件安装已取消' + ) + await expect(installer.install(input)).rejects.toThrow( + 'DSH 插件安装器正在关闭' + ) + }) }) diff --git a/src/main/agent/dsh-extension-marketplace.ts b/src/main/agent/dsh-extension-marketplace.ts index 774f6d2..a1157af 100644 --- a/src/main/agent/dsh-extension-marketplace.ts +++ b/src/main/agent/dsh-extension-marketplace.ts @@ -33,6 +33,7 @@ const MAXIMUM_CATALOG_ENTRIES = 1_000 const DEFAULT_REQUEST_TIMEOUT_MS = 15_000 const DEFAULT_INSTALL_TIMEOUT_MS = 5 * 60_000 const MAXIMUM_PROCESS_OUTPUT_CHARACTERS = 64 * 1024 +const MAXIMUM_PACKUMENT_VERSIONS = 20_000 const npmSearchPackageSchema = z .object({ @@ -93,11 +94,59 @@ const npmVersionManifestSchema = npmInstalledManifestSchema.extend({ dist: npmDistributionSchema }) -const npmPackumentSchema = z - .object({ - versions: z.record(z.string(), npmVersionManifestSchema) +function isPlainObject( + value: unknown +): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +const npmPackumentVersionsSchema = z + .custom>(isPlainObject, { + message: 'npm packument versions must be a plain object' }) - .passthrough() + .superRefine((versions, context) => { + const keys = Object.keys(versions) + if (keys.length > MAXIMUM_PACKUMENT_VERSIONS) { + context.addIssue({ + code: 'too_big', + origin: 'object', + maximum: MAXIMUM_PACKUMENT_VERSIONS, + inclusive: true, + path: [], + message: 'npm packument contains too many versions' + }) + } + if ( + keys.some( + (key) => + key === '__proto__' || + key === 'prototype' || + key === 'constructor' + ) + ) { + context.addIssue({ + code: 'custom', + path: [], + message: 'npm packument contains an unsafe version key' + }) + } + }) + +const npmPackumentSchema = z + .custom>(isPlainObject, { + message: 'npm packument must be a plain object' + }) + .pipe( + z + .object({ + versions: npmPackumentVersionsSchema + }) + .passthrough() + ) type NpmVersionManifest = z.infer @@ -114,6 +163,7 @@ export type PackageManagerRunner = ( cwd: string env: NodeJS.ProcessEnv timeoutMs: number + signal?: AbortSignal } ) => Promise @@ -124,11 +174,13 @@ function waitForProcessClose( return Promise.resolve() } return new Promise((resolve) => { - const timer = setTimeout(resolve, 5_000) - child.once('close', () => { + const finish = (): void => { clearTimeout(timer) + child.removeListener('close', finish) resolve() - }) + } + const timer = setTimeout(finish, 5_000) + child.once('close', finish) }) } @@ -147,11 +199,13 @@ async function terminatePackageManager( } ) await new Promise((resolve) => { - const timer = setTimeout(resolve, 5_000) const finish = (): void => { clearTimeout(timer) + killer.removeListener('close', finish) + killer.removeListener('error', finish) resolve() } + const timer = setTimeout(finish, 5_000) killer.once('close', finish) killer.once('error', finish) }) @@ -183,6 +237,14 @@ export const runPackageManager: PackageManagerRunner = ( options ) => new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject( + options.signal.reason instanceof Error + ? options.signal.reason + : new Error('DSH 插件安装已取消') + ) + return + } const child = spawn(command, [...args], { cwd: options.cwd, env: options.env, @@ -194,42 +256,79 @@ export const runPackageManager: PackageManagerRunner = ( let stdout = '' let stderr = '' let settled = false - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - void terminatePackageManager(child).then( - () => reject(new Error('DSH 插件安装超时')), - () => reject(new Error('DSH 插件安装超时')) - ) - }, options.timeoutMs) - child.stdout?.on('data', (chunk) => { + let terminating = false + const onStdout = (chunk: unknown): void => { stdout = boundedAppend(stdout, chunk) - }) - child.stderr?.on('data', (chunk) => { + } + const onStderr = (chunk: unknown): void => { stderr = boundedAppend(stderr, chunk) - }) - child.once('error', (error) => { + } + const cleanup = (): void => { + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + child.stdout?.removeListener('data', onStdout) + child.stderr?.removeListener('data', onStderr) + child.removeListener('error', onError) + child.removeListener('close', onClose) + } + const settleRejected = (error: Error): void => { if (settled) { return } settled = true - clearTimeout(timer) + cleanup() reject(error) - }) - child.once('close', (code) => { - if (settled) { + } + const terminateAndReject = (error: Error): void => { + if (settled || terminating) { + return + } + terminating = true + void terminatePackageManager(child).then( + () => settleRejected(error), + () => settleRejected(error) + ) + } + const onAbort = (): void => { + terminateAndReject( + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error('DSH 插件安装已取消') + ) + } + const onError = (error: Error): void => { + if (terminating) { + return + } + settleRejected(error) + } + const onClose = (code: number | null): void => { + if (settled || terminating) { return } settled = true - clearTimeout(timer) + cleanup() resolve({ exitCode: code ?? 1, stdout, stderr }) + } + const timer = setTimeout( + () => + terminateAndReject(new Error('DSH 插件安装超时')), + options.timeoutMs + ) + child.stdout?.on('data', onStdout) + child.stderr?.on('data', onStderr) + child.once('error', onError) + child.once('close', onClose) + options.signal?.addEventListener('abort', onAbort, { + once: true }) + if (options.signal?.aborted) { + onAbort() + } }) function publicHttpUrl(value: string | undefined): string | undefined { @@ -292,14 +391,18 @@ function catalogEntry( async function fetchJson( fetcher: typeof fetch, url: URL, - timeoutMs: number + timeoutMs: number, + signal?: AbortSignal ): Promise { + const timeoutSignal = AbortSignal.timeout(timeoutMs) const response = await fetcher(url, { headers: { accept: 'application/json', 'user-agent': 'GoodBuddy-DSH-Marketplace/1' }, - signal: AbortSignal.timeout(timeoutMs) + signal: signal + ? AbortSignal.any([signal, timeoutSignal]) + : timeoutSignal }) if (!response.ok) { throw new Error(`DSH 插件市场请求失败(HTTP ${response.status})`) @@ -520,6 +623,15 @@ async function prepareNodeCommand( } export class DshNpmExtensionInstaller { + private disposed = false + private readonly activeInstalls = new Map< + Promise<{ + entrypoint: string + integrity?: string + }>, + AbortController + >() + constructor( private readonly options: { dshHome: string @@ -534,7 +646,7 @@ export class DshNpmExtensionInstaller { } ) {} - async install( + install( input: Parameters< RuntimeExtensionStoreDependencies['install'] >[0] @@ -542,7 +654,45 @@ export class DshNpmExtensionInstaller { entrypoint: string integrity?: string }> { - const manifest = await this.resolveManifest(input.entry) + if (this.disposed) { + return Promise.reject(new Error('DSH 插件安装器正在关闭')) + } + const controller = new AbortController() + const operation = this.performInstall(input, controller.signal) + this.activeInstalls.set(operation, controller) + void operation.then( + () => { + this.activeInstalls.delete(operation) + }, + () => { + this.activeInstalls.delete(operation) + } + ) + return operation + } + + async dispose(): Promise { + this.disposed = true + const active = [...this.activeInstalls.entries()] + for (const [, controller] of active) { + controller.abort(new Error('应用退出,DSH 插件安装已取消')) + } + await Promise.allSettled( + active.map(([operation]) => operation) + ) + } + + private async performInstall( + input: Parameters< + RuntimeExtensionStoreDependencies['install'] + >[0], + signal: AbortSignal + ): Promise<{ + entrypoint: string + integrity?: string + }> { + const manifest = await this.resolveManifest(input.entry, signal) + signal.throwIfAborted() await writeFile( join(input.destinationDirectory, 'package.json'), `${JSON.stringify( @@ -608,6 +758,7 @@ export class DshNpmExtensionInstaller { ], { cwd: input.destinationDirectory, + signal, env: { ...packageManagerEnvironment, npm_config_audit: 'false', @@ -625,6 +776,7 @@ export class DshNpmExtensionInstaller { } catch (error) { throw packageManagerError(error) } + signal.throwIfAborted() if (result.exitCode !== 0) { const detail = result.stderr.trim() || @@ -686,7 +838,8 @@ export class DshNpmExtensionInstaller { } private async resolveManifest( - entry: RuntimeExtensionCatalogEntry + entry: RuntimeExtensionCatalogEntry, + signal: AbortSignal ): Promise { const registryUrl = ( this.options.registryUrl ?? NPM_REGISTRY_URL @@ -699,13 +852,21 @@ export class DshNpmExtensionInstaller { this.options.fetcher ?? fetch, url, this.options.requestTimeoutMs ?? - DEFAULT_REQUEST_TIMEOUT_MS + DEFAULT_REQUEST_TIMEOUT_MS, + signal ) ) - const manifest = packument.versions[entry.package.version] - if (!manifest) { + if ( + !Object.prototype.hasOwnProperty.call( + packument.versions, + entry.package.version + ) + ) { throw new Error('DSH 插件精确版本未发布') } + const manifest = npmVersionManifestSchema.parse( + packument.versions[entry.package.version] + ) if ( manifest.name !== entry.package.name || manifest.version !== entry.package.version diff --git a/src/main/agent/knowledge-mcp-gateway.test.ts b/src/main/agent/knowledge-mcp-gateway.test.ts index 3143d85..888d257 100644 --- a/src/main/agent/knowledge-mcp-gateway.test.ts +++ b/src/main/agent/knowledge-mcp-gateway.test.ts @@ -7,7 +7,14 @@ import { z } from 'zod' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { + LATEST_PROTOCOL_VERSION, + ListToolsRequestSchema, + isInitializeRequest, + type Tool +} from '@modelcontextprotocol/sdk/types.js' import type { KnowledgeService } from '../knowledge/knowledge-service' import { AssistantDatabase } from '../assistant/assistant-database' import { @@ -67,6 +74,115 @@ function createService() { return { service, searchHybridMany } } +function customMcpServer( + url: string, + id = '00000000-0000-4000-8000-000000000092' +) { + return { + id, + name: 'Paged MCP', + description: '', + enabled: true, + allowDynamicTools: true, + assignments: ['opencode' as const], + secretConfigured: false, + transport: 'http' as const, + url + } +} + +async function startToolUpstream( + listTools: ( + cursor: string | undefined + ) => + | { tools: Tool[]; nextCursor?: string } + | Promise<{ tools: Tool[]; nextCursor?: string }> +): Promise<{ + url: string + notifyToolsChanged: () => Promise +}> { + const sessions = new Map< + string, + { + protocol: McpProtocolServer + transport: StreamableHTTPServerTransport + } + >() + const server = createServer(async (request, response) => { + let body: unknown + if (request.method === 'POST') { + const chunks: Buffer[] = [] + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)) + } + body = JSON.parse(Buffer.concat(chunks).toString('utf8')) + } + const sessionId = request.headers['mcp-session-id'] + let session = + typeof sessionId === 'string' + ? sessions.get(sessionId) + : undefined + if (!session) { + if ( + request.method !== 'POST' || + !isInitializeRequest(body) + ) { + response.writeHead(404) + response.end() + return + } + const protocol = new McpProtocolServer( + { name: 'tool-upstream', version: '1.0.0' }, + { capabilities: { tools: { listChanged: true } } } + ) + protocol.setRequestHandler( + ListToolsRequestSchema, + async (requestValue) => + listTools(requestValue.params?.cursor) + ) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + onsessioninitialized: (idValue) => { + sessions.set(idValue, { protocol, transport }) + }, + onsessionclosed: (idValue) => { + sessions.delete(idValue) + } + }) + session = { protocol, transport } + await protocol.connect(transport) + } + await session.transport.handleRequest(request, response, body) + }) + httpServers.push(server) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('tool upstream did not bind') + } + return { + url: `http://127.0.0.1:${address.port}/mcp`, + notifyToolsChanged: async () => { + await Promise.all( + [...sessions.values()].map(({ protocol }) => + protocol.sendToolListChanged() + ) + ) + } + } +} + +function testTool(name: string): Tool { + return { + name, + description: name, + inputSchema: { type: 'object' } + } +} + const gateways: KnowledgeMcpGateway[] = [] const databases: AssistantDatabase[] = [] const temporaryDirectories: string[] = [] @@ -437,7 +553,7 @@ describe('KnowledgeMcpGateway', () => { ).toThrow('笔记不存在') }) - it('binds a POST-only authenticated endpoint and rejects oversized bodies', async () => { + it('binds an authenticated MCP endpoint and rejects oversized bodies', async () => { const { service } = createService() const gateway = new KnowledgeMcpGateway(service, { maximumBodyBytes: 32 @@ -452,7 +568,7 @@ describe('KnowledgeMcpGateway', () => { )! const getResponse = await fetch(endpoint) - expect(getResponse.status).toBe(405) + expect(getResponse.status).toBe(401) expect(getResponse.headers.get('access-control-allow-origin')).toBeNull() const unauthorized = await fetch(endpoint, { @@ -587,4 +703,347 @@ describe('KnowledgeMcpGateway', () => { await client.close() } }) + + it('loads every tools/list page before exposing custom MCP tools', async () => { + const listTools = vi.fn((cursor: string | undefined) => + cursor !== undefined + ? { tools: [testTool('second')] } + : { + tools: [testTool('first')], + nextCursor: 'page-2' + } + ) + const upstream = await startToolUpstream(listTools) + const { service } = createService() + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + const token = gateway.grantCustomMcp( + 'paged-tools', + [customMcpServer(upstream.url)], + new AbortController().signal + )! + + const tools = await gateway.prepareCustomMcpTools(token) + + expect(tools.map((tool) => tool.name)).toEqual([ + expect.stringMatching(/_first$/u), + expect.stringMatching(/_second$/u) + ]) + expect(listTools).toHaveBeenNthCalledWith(1, undefined) + expect(listTools).toHaveBeenNthCalledWith(2, 'page-2') + }) + + it('continues tools/list pagination with an empty cursor', async () => { + const listTools = vi.fn((cursor: string | undefined) => + cursor === undefined + ? { + tools: [testTool('first')], + nextCursor: '' + } + : { tools: [testTool('second')] } + ) + const upstream = await startToolUpstream(listTools) + const { service } = createService() + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + const token = gateway.grantCustomMcp( + 'empty-cursor-tools', + [customMcpServer(upstream.url)], + new AbortController().signal + )! + + const tools = await gateway.prepareCustomMcpTools(token) + + expect(tools.map((tool) => tool.name)).toEqual([ + expect.stringMatching(/_first$/u), + expect.stringMatching(/_second$/u) + ]) + expect(listTools).toHaveBeenNthCalledWith(2, '') + }) + + it('explicitly requests task execution for required tools from earlier pages', async () => { + const { service } = createService() + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + const server = customMcpServer('http://127.0.0.1:1/mcp') + const token = gateway.grantCustomMcp( + 'required-task-tool', + [server], + new AbortController().signal + )! + const callToolStream = vi.fn( + async function* () { + yield { + type: 'result' as const, + result: { + content: [{ type: 'text' as const, text: 'done' }], + structuredContent: { value: 'done' } + } + } + } + ) + const outputValidator = vi.fn(() => ({ + valid: true as const, + data: { value: 'done' }, + errorMessage: undefined + })) + const callRequiredTool = ( + gateway as unknown as { + callCustomMcpTool( + capabilityToken: string, + binding: unknown, + input: Record, + signal: AbortSignal + ): Promise + } + ).callCustomMcpTool.bind(gateway) + + await expect( + callRequiredTool( + token, + { + client: { + experimental: { + tasks: { + callToolStream, + cancelTask: vi.fn() + } + } + }, + server, + originalName: 'required-first-page', + taskSupport: 'required', + outputValidator, + exposedTool: testTool('required-first-page') + }, + {}, + new AbortController().signal + ) + ).resolves.toMatchObject({ + content: [{ type: 'text', text: 'done' }] + }) + expect(callToolStream).toHaveBeenCalledWith( + { + name: 'required-first-page', + arguments: {} + }, + undefined, + expect.objectContaining({ task: {} }) + ) + expect(outputValidator).toHaveBeenCalledWith({ value: 'done' }) + }) + + it('rejects cyclic cursors and custom MCP tool counts over 100', async () => { + const cyclicUpstream = await startToolUpstream( + (cursor: string | undefined) => ({ + tools: [testTool(cursor ? 'second' : 'first')], + nextCursor: 'cycle' + }) + ) + const excessiveUpstream = await startToolUpstream(() => ({ + tools: Array.from({ length: 101 }, (_, index) => + testTool(`tool-${index}`) + ) + })) + const { service } = createService() + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + const cycleToken = gateway.grantCustomMcp( + 'cursor-cycle', + [customMcpServer(cyclicUpstream.url)], + new AbortController().signal + )! + const excessiveToken = gateway.grantCustomMcp( + 'excessive-tools', + [ + customMcpServer( + excessiveUpstream.url, + '00000000-0000-4000-8000-000000000093' + ) + ], + new AbortController().signal + )! + + await expect( + gateway.prepareCustomMcpTools(cycleToken) + ).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining('分页游标发生循环') + }) + }) + await expect( + gateway.prepareCustomMcpTools(excessiveToken) + ).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining('工具数量超过安全限制') + }) + }) + }) + + it('releases rejected initialize attempts before enforcing the session limit', async () => { + const { service } = createService() + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + await gateway.start() + const endpoint = gateway.getEndpoint()! + const token = gateway.grant( + 'initialize-retry', + [firstLibraryId], + new AbortController().signal + )! + const initialize = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { + name: 'initialize-retry-fixture', + version: '1.0.0' + } + } + } + + for (let attempt = 0; attempt < 8; attempt += 1) { + const rejected = await fetch(endpoint, { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + body: JSON.stringify(initialize) + }) + expect(rejected.status).toBe(406) + } + + const accepted = await fetch(endpoint, { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + body: JSON.stringify(initialize) + }) + expect(accepted.status).toBe(200) + expect(accepted.headers.get('mcp-session-id')).toEqual( + expect.any(String) + ) + }) + + it('publishes upstream tool changes downstream after a successful refresh', async () => { + let tools = [testTool('before')] + const listTools = vi.fn(async () => ({ tools })) + const upstream = await startToolUpstream(listTools) + const { service } = createService() + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + await gateway.start() + const token = gateway.grantCustomMcp( + 'dynamic-tools', + [customMcpServer(upstream.url)], + new AbortController().signal + )! + const listChanged = vi.fn() + const client = new Client( + { name: 'dynamic-client', version: '1.0.0' }, + { + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 0, + onChanged: listChanged + } + } + } + ) + await client.connect( + new StreamableHTTPClientTransport( + new URL(gateway.getEndpoint()!), + { + requestInit: { + headers: { Authorization: `Bearer ${token}` } + } + } + ) + ) + try { + const initial = await client.listTools() + expect(initial.tools[0]?.name).toMatch(/_before$/u) + await new Promise((resolve) => setTimeout(resolve, 25)) + tools = [testTool('after')] + + await upstream.notifyToolsChanged() + + await vi.waitFor(() => { + expect(listChanged).toHaveBeenCalledWith(null, null) + }) + const updated = await client.listTools() + expect(updated.tools.map((tool) => tool.name)).toEqual([ + expect.stringMatching(/_after$/u) + ]) + } finally { + await client.close() + } + }) + + it('does not publish a downstream change when dynamic refresh fails', async () => { + let failRefresh = false + const listTools = vi.fn(async () => { + if (failRefresh) { + throw new Error('refresh failed') + } + return { tools: [testTool('stable')] } + }) + const upstream = await startToolUpstream(listTools) + const { service } = createService() + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + await gateway.start() + const token = gateway.grantCustomMcp( + 'failed-refresh', + [customMcpServer(upstream.url)], + new AbortController().signal + )! + const listChanged = vi.fn() + const client = new Client( + { name: 'failed-refresh-client', version: '1.0.0' }, + { + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 0, + onChanged: listChanged + } + } + } + ) + await client.connect( + new StreamableHTTPClientTransport( + new URL(gateway.getEndpoint()!), + { + requestInit: { + headers: { Authorization: `Bearer ${token}` } + } + } + ) + ) + try { + await client.listTools() + await new Promise((resolve) => setTimeout(resolve, 25)) + failRefresh = true + + await upstream.notifyToolsChanged() + + await vi.waitFor(() => { + expect(listTools).toHaveBeenCalledTimes(2) + }) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(listChanged).not.toHaveBeenCalled() + } finally { + await client.close() + } + }) }) diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index 31cb53a..751db4c 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -8,10 +8,13 @@ import { import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { Server as McpProtocolServer } from '@modelcontextprotocol/sdk/server/index.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import type { JsonSchemaValidator } from '@modelcontextprotocol/sdk/validation' +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv' import { CallToolRequestSchema, CallToolResultSchema, ListToolsRequestSchema, + isInitializeRequest, type CallToolResult, type Tool } from '@modelcontextprotocol/sdk/types.js' @@ -57,6 +60,7 @@ import type { import { createMcpToolName, isValidMcpToolName, + listAllMcpTools, normalizeMcpToolSchema } from './mcp-tool-utils' @@ -65,11 +69,13 @@ const MAX_RESULT_BYTES = 128 * 1024 const MAX_CUSTOM_MCP_RESULT_BYTES = 256 * 1024 const MAX_CUSTOM_MCP_SERVERS = 16 const MAX_CUSTOM_MCP_TOOLS = 100 +const MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY = 8 const CUSTOM_MCP_TIMEOUT_MS = 30_000 const CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000 const CUSTOM_MCP_TASK_CANCEL_TIMEOUT_MS = 5_000 const DEFAULT_CAPABILITY_TTL_MS = 10 * 60_000 const MAX_CAPABILITY_TTL_MS = 15 * 60_000 +const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator() export { knowledgeToolNames, @@ -179,6 +185,7 @@ type CustomMcpBinding = { originalName: string exposedTool: Tool taskSupport?: 'forbidden' | 'optional' | 'required' + outputValidator?: JsonSchemaValidator> } type CustomMcpConnection = { @@ -187,6 +194,19 @@ type CustomMcpConnection = { bindings: CustomMcpBinding[] dynamicToolsSupported: boolean dynamicToolsChanged: boolean + dynamicToolsChangeVersion: number + dynamicToolsRefresh?: Promise +} + +type DownstreamMcpSession = { + id?: string + registryKey: string + token: string + mcp: McpProtocolServer + transport: StreamableHTTPServerTransport + initialized: boolean + listedTools: boolean + closing?: Promise } export type KnowledgeMcpGatewayOptions = { @@ -308,6 +328,11 @@ async function readBoundedJson( export class KnowledgeMcpGateway { private readonly capabilities = new Map() + private readonly downstreamMcpSessions = new Map< + string, + DownstreamMcpSession + >() + private readonly downstreamMcpCleanups = new Set>() private readonly customMcpCleanups = new Set>() private readonly now: () => number private readonly capabilityTtlMs: number @@ -490,6 +515,11 @@ export class KnowledgeMcpGateway { capability.brokerController.abort( new Error('MCP capability was revoked') ) + for (const session of this.downstreamMcpSessions.values()) { + if (session.token === token) { + void this.closeDownstreamMcpSession(session) + } + } if (capability.configAccess !== 'none') { this.configService?.revokeRequest(capability.requestId) } @@ -500,6 +530,24 @@ export class KnowledgeMcpGateway { }) } + private closeDownstreamMcpSession( + session: DownstreamMcpSession + ): Promise { + if (session.closing) { + return session.closing + } + this.downstreamMcpSessions.delete(session.registryKey) + const cleanup = session.mcp + .close() + .catch(() => undefined) + .finally(() => { + this.downstreamMcpCleanups.delete(cleanup) + }) + session.closing = cleanup + this.downstreamMcpCleanups.add(cleanup) + return cleanup + } + drainReferences( token: string | undefined ): KnowledgeSearchReference[] { @@ -673,6 +721,11 @@ export class KnowledgeMcpGateway { server, originalName: tool.name, taskSupport: tool.execution?.taskSupport, + outputValidator: tool.outputSchema + ? customMcpJsonSchemaValidator.getValidator< + Record + >(normalizeMcpToolSchema(tool.outputSchema)) + : undefined, exposedTool: { name: createMcpToolName(server.id, tool.name), title: `${server.name} / ${tool.name}`.slice(0, 200), @@ -690,11 +743,116 @@ export class KnowledgeMcpGateway { }) } + private async listAllCustomMcpTools( + client: Client, + server: ResolvedMcpServer, + signal: AbortSignal + ): Promise>['tools']> { + return listAllMcpTools(client, server.name, signal, { + maximumTools: MAX_CUSTOM_MCP_TOOLS, + pageTimeoutMs: CUSTOM_MCP_TIMEOUT_MS, + totalTimeoutMs: CUSTOM_MCP_MAX_TOTAL_TIMEOUT_MS + }) + } + + private async publishCustomMcpToolListChanged( + capability: Capability + ): Promise { + const token = [...this.capabilities.entries()].find( + ([, value]) => value === capability + )?.[0] + if (!token) { + return + } + const sessions = [...this.downstreamMcpSessions.values()].filter( + (session) => + session.token === token && + session.initialized && + session.listedTools + ) + await Promise.allSettled( + sessions.map((session) => session.mcp.sendToolListChanged()) + ) + } + + private scheduleDynamicToolsRefresh( + capability: Capability, + connection: CustomMcpConnection + ): void { + void this.refreshDynamicTools(capability, connection).catch( + () => undefined + ) + } + + private refreshDynamicTools( + capability: Capability, + connection: CustomMcpConnection, + signal?: AbortSignal + ): Promise { + if (connection.dynamicToolsRefresh) { + return connection.dynamicToolsRefresh + } + const effectiveSignal = signal + ? AbortSignal.any([ + signal, + capability.signal, + capability.brokerController.signal + ]) + : AbortSignal.any([ + capability.signal, + capability.brokerController.signal + ]) + const changeVersion = connection.dynamicToolsChangeVersion + let refreshSucceeded = false + const refresh = (async () => { + try { + const tools = await this.listAllCustomMcpTools( + connection.client, + connection.server, + effectiveSignal + ) + const bindings = this.createCustomMcpBindings( + connection.client, + connection.server, + tools + ) + connection.bindings = bindings + connection.dynamicToolsChanged = + connection.dynamicToolsChangeVersion !== changeVersion + refreshSucceeded = true + await this.publishCustomMcpToolListChanged(capability) + } catch (error) { + connection.dynamicToolsChanged = true + if (effectiveSignal.aborted) { + throw effectiveSignal.reason + } + throw new Error( + `无法刷新 MCP Server「${connection.server.name}」的工具`, + { cause: error } + ) + } + })() + connection.dynamicToolsRefresh = refresh + void refresh.finally(() => { + connection.dynamicToolsRefresh = undefined + if ( + refreshSucceeded && + connection.dynamicToolsChanged && + !capability.signal.aborted && + !capability.brokerController.signal.aborted + ) { + this.scheduleDynamicToolsRefresh(capability, connection) + } + }).catch(() => undefined) + return refresh + } + private async connectCustomMcpServer( capability: Capability, server: ResolvedMcpServer ): Promise { let connection: CustomMcpConnection | undefined + let dynamicToolsChangeVersion = 0 const client = new Client( { name: 'goodbuddy-main-mcp-broker', @@ -707,8 +865,17 @@ export class KnowledgeMcpGateway { autoRefresh: false, debounceMs: 0, onChanged: (error) => { - if (!error && connection) { - connection.dynamicToolsChanged = true + if (!error) { + dynamicToolsChangeVersion += 1 + if (connection) { + connection.dynamicToolsChangeVersion = + dynamicToolsChangeVersion + connection.dynamicToolsChanged = true + this.scheduleDynamicToolsRefresh( + capability, + connection + ) + } } } } @@ -725,22 +892,29 @@ export class KnowledgeMcpGateway { timeout: CUSTOM_MCP_TIMEOUT_MS, signal }) - const result = await client.listTools(undefined, { - timeout: CUSTOM_MCP_TIMEOUT_MS, + const listedAtChangeVersion = dynamicToolsChangeVersion + const tools = await this.listAllCustomMcpTools( + client, + server, signal - }) + ) connection = { client, server, bindings: this.createCustomMcpBindings( client, server, - result.tools + tools ), dynamicToolsSupported: server.allowDynamicTools && client.getServerCapabilities()?.tools?.listChanged === true, - dynamicToolsChanged: false + dynamicToolsChanged: + dynamicToolsChangeVersion !== listedAtChangeVersion, + dynamicToolsChangeVersion + } + if (connection.dynamicToolsChanged) { + this.scheduleDynamicToolsRefresh(capability, connection) } return connection } catch (error) { @@ -791,41 +965,19 @@ export class KnowledgeMcpGateway { throw error } if (refreshDynamic) { - const effectiveSignal = signal - ? AbortSignal.any([ - signal, - capability.signal, - capability.brokerController.signal - ]) - : AbortSignal.any([ - capability.signal, - capability.brokerController.signal - ]) for (const connection of connections) { if ( !connection.dynamicToolsSupported || - !connection.dynamicToolsChanged + (!connection.dynamicToolsChanged && + !connection.dynamicToolsRefresh) ) { continue } - connection.dynamicToolsChanged = false - try { - const result = await connection.client.listTools(undefined, { - timeout: CUSTOM_MCP_TIMEOUT_MS, - signal: effectiveSignal - }) - connection.bindings = this.createCustomMcpBindings( - connection.client, - connection.server, - result.tools - ) - } catch (error) { - connection.dynamicToolsChanged = true - throw new Error( - `无法刷新 MCP Server「${connection.server.name}」的工具`, - { cause: error } - ) - } + await this.refreshDynamicTools( + capability, + connection, + signal + ) } } const bindings = new Map() @@ -877,7 +1029,8 @@ export class KnowledgeMcpGateway { } try { if (binding.taskSupport !== 'required') { - return ensureBoundedCustomMcpResult( + return this.validateCustomMcpResult( + binding, await binding.client.callTool(params, undefined, options) ) } @@ -886,7 +1039,10 @@ export class KnowledgeMcpGateway { for await (const message of binding.client.experimental.tasks.callToolStream( params, undefined, - options + { + ...options, + task: {} + } )) { if ( (message.type === 'taskCreated' || @@ -895,7 +1051,10 @@ export class KnowledgeMcpGateway { ) { taskId = message.task.taskId } else if (message.type === 'result') { - return ensureBoundedCustomMcpResult(message.result) + return this.validateCustomMcpResult( + binding, + message.result + ) } else if (message.type === 'error') { throw message.error } @@ -923,6 +1082,33 @@ export class KnowledgeMcpGateway { } } + private validateCustomMcpResult( + binding: CustomMcpBinding, + result: unknown + ): CallToolResult { + const bounded = ensureBoundedCustomMcpResult(result) + if (!binding.outputValidator) { + return bounded + } + if (!bounded.structuredContent) { + if (!bounded.isError) { + throw new Error( + `MCP 工具「${binding.originalName}」未返回结构化结果` + ) + } + return bounded + } + const validation = binding.outputValidator( + bounded.structuredContent + ) + if (!validation.valid) { + throw new Error( + `MCP 工具「${binding.originalName}」返回结果不符合声明结构:${validation.errorMessage.slice(0, 500)}` + ) + } + return bounded + } + private async closeCustomMcpConnections( capability: Capability ): Promise { @@ -1218,55 +1404,10 @@ export class KnowledgeMcpGateway { } } - private async handleRequest( - request: IncomingMessage, - response: ServerResponse - ): Promise { - if (request.url !== '/mcp') { - sendJson(response, 404, { error: 'Not found' }) - return - } - if (request.method !== 'POST') { - response.setHeader('allow', 'POST') - sendJson(response, 405, { - jsonrpc: '2.0', - error: { code: -32000, message: 'Method not allowed' }, - id: null - }) - return - } - const authorization = request.headers.authorization - if ( - typeof authorization !== 'string' || - !authorization.startsWith('Bearer ') - ) { - sendJson(response, 401, { error: 'Unauthorized' }) - return - } - const token = authorization.slice('Bearer '.length) - try { - this.getCapability(token) - } catch { - sendJson(response, 401, { error: 'Unauthorized' }) - return - } - - let body: unknown - try { - body = await readBoundedJson(request, this.maximumBodyBytes) - } catch (error) { - sendJson(response, error instanceof RangeError ? 413 : 400, { - error: - error instanceof RangeError - ? 'Request body too large' - : 'Invalid JSON' - }) - return - } - - const availableScopedTools = new Set( - this.getAvailableToolNames(token) - ) + private createDownstreamMcpSession( + token: string, + availableScopedTools: ReadonlySet + ): DownstreamMcpSession { const mcp = new McpProtocolServer( { name: 'goodbuddy-request-scoped-capabilities', @@ -1274,10 +1415,38 @@ export class KnowledgeMcpGateway { }, { capabilities: { - tools: {} + tools: { + listChanged: true + } } } ) + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomBytes(32).toString('base64url'), + onsessioninitialized: (sessionId) => { + this.downstreamMcpSessions.delete(session.registryKey) + session.id = sessionId + session.registryKey = sessionId + this.downstreamMcpSessions.set(sessionId, session) + }, + onsessionclosed: (sessionId) => { + this.downstreamMcpSessions.delete(sessionId) + } + }) + const session: DownstreamMcpSession = { + registryKey: randomBytes(32).toString('base64url'), + token, + mcp, + transport, + initialized: false, + listedTools: false + } + transport.onclose = () => { + this.downstreamMcpSessions.delete(session.registryKey) + } + mcp.oninitialized = () => { + session.initialized = true + } mcp.setRequestHandler( ListToolsRequestSchema, async (_request, extra) => { @@ -1287,9 +1456,7 @@ export class KnowledgeMcpGateway { ) const scopedTools = [...availableScopedTools].flatMap( (name): Tool[] => { - const definition = scopedDataToolByName.get( - name as ScopedDataToolName - ) + const definition = scopedDataToolByName.get(name) if (!definition) { return [] } @@ -1315,6 +1482,7 @@ export class KnowledgeMcpGateway { ] } ) + session.listedTools = true return { tools: [ ...scopedTools, @@ -1330,9 +1498,7 @@ export class KnowledgeMcpGateway { async (call, extra) => { const name = call.params.name const input = call.params.arguments ?? {} - if ( - availableScopedTools.has(name as ScopedDataToolName) - ) { + if (availableScopedTools.has(name as ScopedDataToolName)) { const definition = scopedDataToolByName.get( name as ScopedDataToolName ) @@ -1369,20 +1535,123 @@ export class KnowledgeMcpGateway { ) } ) - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined - }) - const close = (): void => { - void Promise.allSettled([transport.close(), mcp.close()]) + return session + } + + private async handleRequest( + request: IncomingMessage, + response: ServerResponse + ): Promise { + if (request.url !== '/mcp') { + sendJson(response, 404, { error: 'Not found' }) + return } - response.once('close', close) + if ( + request.method !== 'POST' && + request.method !== 'GET' && + request.method !== 'DELETE' + ) { + response.setHeader('allow', 'POST, GET, DELETE') + sendJson(response, 405, { + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed' }, + id: null + }) + return + } + const authorization = request.headers.authorization + if ( + typeof authorization !== 'string' || + !authorization.startsWith('Bearer ') + ) { + sendJson(response, 401, { error: 'Unauthorized' }) + return + } + const token = authorization.slice('Bearer '.length) try { - await mcp.connect(transport) - await transport.handleRequest(request, response, body) + this.getCapability(token) + } catch { + sendJson(response, 401, { error: 'Unauthorized' }) + return + } + + let body: unknown + if (request.method === 'POST') { + try { + body = await readBoundedJson(request, this.maximumBodyBytes) + } catch (error) { + sendJson(response, error instanceof RangeError ? 413 : 400, { + error: + error instanceof RangeError + ? 'Request body too large' + : 'Invalid JSON' + }) + return + } + } + + const sessionId = request.headers['mcp-session-id'] + let createdSession = false + let session = + typeof sessionId === 'string' + ? this.downstreamMcpSessions.get(sessionId) + : undefined + if (session && session.token !== token) { + session = undefined + } + if (!session) { + if ( + request.method !== 'POST' || + !isInitializeRequest(body) || + typeof sessionId === 'string' + ) { + sendJson(response, typeof sessionId === 'string' ? 404 : 400, { + jsonrpc: '2.0', + error: { + code: + typeof sessionId === 'string' ? -32001 : -32000, + message: + typeof sessionId === 'string' + ? 'Session not found' + : 'Bad Request: No valid session ID provided' + }, + id: null + }) + return + } + const sessionCount = [ + ...this.downstreamMcpSessions.values() + ].filter((candidate) => candidate.token === token).length + if ( + sessionCount >= + MAX_DOWNSTREAM_MCP_SESSIONS_PER_CAPABILITY + ) { + sendJson(response, 429, { + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Too many MCP sessions' + }, + id: null + }) + return + } + const availableScopedTools = new Set( + this.getAvailableToolNames(token) + ) + session = this.createDownstreamMcpSession( + token, + availableScopedTools + ) + createdSession = true + this.downstreamMcpSessions.set(session.registryKey, session) + await session.mcp.connect(session.transport) + } + try { + await session.transport.handleRequest(request, response, body) } finally { - if (response.writableFinished) { - response.off('close', close) - close() + if (createdSession && session.id === undefined) { + await this.closeDownstreamMcpSession(session) } } } @@ -1391,6 +1660,7 @@ export class KnowledgeMcpGateway { for (const token of [...this.capabilities.keys()]) { this.revoke(token) } + await Promise.allSettled([...this.downstreamMcpCleanups]) await Promise.allSettled([...this.customMcpCleanups]) const server = this.server this.server = undefined diff --git a/src/main/agent/mcp-tool-utils.ts b/src/main/agent/mcp-tool-utils.ts index da8de96..4f27ea4 100644 --- a/src/main/agent/mcp-tool-utils.ts +++ b/src/main/agent/mcp-tool-utils.ts @@ -1,6 +1,77 @@ import { createHash } from 'node:crypto' +import type { Client } from '@modelcontextprotocol/sdk/client/index.js' const MAXIMUM_MCP_TOOL_SCHEMA_BYTES = 32 * 1024 +const DEFAULT_MAXIMUM_MCP_TOOL_PAGES = 100 + +type ListedMcpTool = + Awaited>['tools'][number] + +export async function listAllMcpTools( + client: Pick, + serverName: string, + signal: AbortSignal, + options: { + maximumTools: number + pageTimeoutMs: number + totalTimeoutMs: number + maximumPages?: number + } +): Promise { + const tools: ListedMcpTool[] = [] + const toolNames = new Set() + const cursors = new Set() + const deadline = Date.now() + options.totalTimeoutMs + const maximumPages = + options.maximumPages ?? DEFAULT_MAXIMUM_MCP_TOOL_PAGES + let cursor: string | undefined + for (let page = 0; page < maximumPages; page += 1) { + signal.throwIfAborted() + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + throw new Error( + `MCP Server「${serverName}」的工具分页超过总超时` + ) + } + const result = await client.listTools( + cursor !== undefined ? { cursor } : undefined, + { + timeout: Math.max( + 1, + Math.min(options.pageTimeoutMs, remainingMs) + ), + signal + } + ) + for (const tool of result.tools) { + if (toolNames.has(tool.name)) { + throw new Error( + `MCP Server「${serverName}」返回了重复工具「${tool.name}」` + ) + } + toolNames.add(tool.name) + tools.push(tool) + if (tools.length > options.maximumTools) { + throw new Error( + `MCP Server「${serverName}」提供的工具数量超过安全限制` + ) + } + } + if (result.nextCursor === undefined) { + return tools + } + if (cursors.has(result.nextCursor)) { + throw new Error( + `MCP Server「${serverName}」的工具分页游标发生循环` + ) + } + cursors.add(result.nextCursor) + cursor = result.nextCursor + } + throw new Error( + `MCP Server「${serverName}」的工具分页超过安全限制` + ) +} export function isValidMcpToolName(value: unknown): value is string { return ( diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index fd8a1ca..bd26796 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -490,6 +490,134 @@ describe('ModelToolProvider', () => { await overflowingProvider.dispose() }) + it('loads every paginated MCP tool before exposing the catalog', async () => { + const workspace = await createWorkspace() + mocks.client.listTools.mockImplementation( + async (params?: { cursor?: string }) => + params?.cursor === '' + ? { + tools: [ + { + name: 'second', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + } + : { + tools: [ + { + name: 'first', + inputSchema: { + type: 'object', + properties: {} + } + } + ], + nextCursor: '' + } + ) + const provider = new ModelToolProvider(workspace, [ + createMcpServer() + ]) + + const tools = await provider.listTools( + toolContext, + new AbortController().signal + ) + + expect(tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayName: 'Search MCP / first' }), + expect.objectContaining({ displayName: 'Search MCP / second' }) + ]) + ) + expect(mocks.client.listTools).toHaveBeenNthCalledWith( + 2, + { cursor: '' }, + expect.objectContaining({ timeout: expect.any(Number) }) + ) + await provider.dispose() + }) + + it('refreshes a dynamic MCP change announced during initial listing', async () => { + const workspace = await createWorkspace() + let resolveInitialList: + | ((value: { + tools: Array<{ + name: string + inputSchema: { + type: 'object' + properties: Record + } + }> + }) => void) + | undefined + mocks.client.getServerCapabilities.mockReturnValue({ + tools: { listChanged: true } + }) + mocks.client.listTools + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInitialList = resolve + }) + ) + .mockResolvedValueOnce({ + tools: [ + { + name: 'current', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + }) + const provider = new ModelToolProvider(workspace, [ + createMcpServer(true) + ]) + + const listing = provider.listTools( + toolContext, + new AbortController().signal + ) + await vi.waitFor(() => { + expect(mocks.client.listTools).toHaveBeenCalledOnce() + }) + const options = mocks.Client.mock.calls[0]?.[1] as + | { + listChanged?: { + tools?: { + onChanged?: (error?: Error) => void + } + } + } + | undefined + options?.listChanged?.tools?.onChanged?.() + resolveInitialList?.({ + tools: [ + { + name: 'stale', + inputSchema: { + type: 'object', + properties: {} + } + } + ] + }) + + await expect(listing).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ displayName: 'Search MCP / current' }) + ]) + ) + expect(mocks.client.listTools).toHaveBeenCalledTimes(2) + await provider.dispose() + }) + it('rejects workspace traversal before accessing the filesystem', async () => { const workspace = await createWorkspace() const provider = new ModelToolProvider(workspace) diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index bee9e10..8875f2c 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -41,6 +41,7 @@ import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway' import { createMcpToolName, isValidMcpToolName, + listAllMcpTools, normalizeMcpToolSchema } from './mcp-tool-utils' @@ -736,6 +737,7 @@ export class ModelToolProvider implements ModelToolProviderLike { clientScope: Set = this.customMcpClients ): Promise { let connection: ConnectedMcp | undefined + let dynamicToolsChangeVersion = 0 const client = new Client( { name: 'goodbuddy-direct-model', @@ -748,8 +750,11 @@ export class ModelToolProvider implements ModelToolProviderLike { autoRefresh: false, debounceMs: 0, onChanged: (error) => { - if (!error && connection) { - connection.dynamicToolsChanged = true + if (!error) { + dynamicToolsChangeVersion += 1 + if (connection) { + connection.dynamicToolsChanged = true + } } } } @@ -764,18 +769,27 @@ export class ModelToolProvider implements ModelToolProviderLike { timeout: MCP_TIMEOUT_MS, signal }) - const result = await client.listTools(undefined, { - timeout: MCP_TIMEOUT_MS, - signal - }) + const listedAtChangeVersion = dynamicToolsChangeVersion + const tools = await listAllMcpTools( + client, + server.name, + signal, + { + maximumTools: + MAX_MODEL_TOOLS - this.getReservedToolCount(), + pageTimeoutMs: MCP_TIMEOUT_MS, + totalTimeoutMs: MCP_CALL_MAX_TOTAL_TIMEOUT_MS + } + ) connection = { client, server, - tools: this.createMcpBindings(client, server, result.tools), + tools: this.createMcpBindings(client, server, tools), dynamicToolsSupported: server.allowDynamicTools && client.getServerCapabilities()?.tools?.listChanged === true, - dynamicToolsChanged: false + dynamicToolsChanged: + dynamicToolsChangeVersion !== listedAtChangeVersion } return connection } catch (error) { @@ -863,14 +877,21 @@ export class ModelToolProvider implements ModelToolProviderLike { } connection.dynamicToolsChanged = false try { - const result = await connection.client.listTools(undefined, { - timeout: MCP_TIMEOUT_MS, - signal - }) + const tools = await listAllMcpTools( + connection.client, + connection.server.name, + signal, + { + maximumTools: + MAX_MODEL_TOOLS - this.getReservedToolCount(), + pageTimeoutMs: MCP_TIMEOUT_MS, + totalTimeoutMs: MCP_CALL_MAX_TOTAL_TIMEOUT_MS + } + ) connection.tools = this.createMcpBindings( connection.client, connection.server, - result.tools + tools ) } catch (error) { connection.dynamicToolsChanged = true diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 28e0d81..5d0ed1f 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -2984,7 +2984,7 @@ describe('OpenCodeRuntime native customization', () => { await runtime.dispose() }) - it('compacts an existing managed session through the v2 API', async () => { + it('compacts a managed session through the supported native API', async () => { const setup = runClient([ { id: 'idle', @@ -2993,19 +2993,74 @@ describe('OpenCodeRuntime native customization', () => { } ]) const context = vi.fn().mockResolvedValue({ - data: { data: [] } + data: { + data: [ + { + type: 'assistant', + model: { + providerID: 'anthropic', + id: 'claude-sonnet' + } + } + ] + } }) - const compact = vi.fn().mockResolvedValue({ - data: undefined, + const summarize = vi.fn().mockResolvedValue({ + data: true, error: undefined }) Object.assign(setup.client, { v2: { - session: { context, compact } - } + session: { context } + }, + session: { ...setup.client.session, summarize } }) const runtime = embeddedRuntime(setup.client) await collectRun(runtime) + vi.mocked(setup.event.subscribe).mockResolvedValueOnce({ + stream: (async function* () { + yield { + type: 'message.updated', + properties: { + sessionID: 'session-1', + info: { + id: 'compaction-message', + sessionID: 'session-1', + role: 'assistant', + time: { + created: 1, + completed: 2 + }, + parentID: 'compaction-parent', + modelID: 'claude-sonnet', + providerID: 'anthropic', + mode: 'compaction', + agent: 'build', + path: { + cwd: process.cwd(), + root: process.cwd() + }, + cost: 0, + tokens: { + input: 100, + output: 20, + reasoning: 0, + cache: { + read: 30, + write: 4 + }, + total: 124 + }, + finish: 'stop' + } + } + } + yield { + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + })() + } as never) const signal = new AbortController().signal await expect( @@ -3025,19 +3080,84 @@ describe('OpenCodeRuntime native customization', () => { strategy: 'native', compacted: true, detail: 'OpenCode 已完成原生上下文压缩' - } + }, + usageEvents: [ + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + type: 'model-usage', + callId: 'compaction-message', + runtime: 'opencode', + provider: 'anthropic', + model: 'claude-sonnet', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 4, + reportedTotalTokens: 124 + } + ] }) expect(context).toHaveBeenCalledWith( { sessionID: 'session-1' }, { signal } ) - expect(compact).toHaveBeenCalledWith( - { sessionID: 'session-1' }, + expect(summarize).toHaveBeenCalledWith( + { + sessionID: 'session-1', + directory: process.cwd(), + providerID: 'anthropic', + modelID: 'claude-sonnet', + auto: false + }, { signal } ) await runtime.dispose() }) + it('reports when a managed session has no model to compact with', async () => { + const setup = runClient([ + { + id: 'idle', + type: 'session.idle', + properties: { sessionID: 'session-1' } + } + ]) + const context = vi.fn().mockResolvedValue({ + data: { data: [] } + }) + const summarize = vi.fn() + Object.assign(setup.client, { + v2: { + session: { context } + }, + session: { ...setup.client.session, summarize } + }) + const runtime = embeddedRuntime(setup.client) + await collectRun(runtime) + + await expect( + runtime.compactConversation( + { + requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', + conversationId: 'conversation-1', + runtimeSelection: { provider: 'opencode' }, + history: [], + historyMessageIds: [] + }, + new AbortController().signal + ) + ).resolves.toEqual({ + result: { + provider: 'opencode', + strategy: 'native', + compacted: false, + detail: '当前 OpenCode 会话尚无可用于压缩的模型记录' + } + }) + expect(summarize).not.toHaveBeenCalled() + await runtime.dispose() + }) + it('reports when no managed OpenCode session can be compacted', async () => { const runtime = new OpenCodeRuntime(options()) diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index e9ba8b1..ea234e6 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -77,6 +77,7 @@ const MAX_NATIVE_MCP_SERVERS = runtimeNativeInventoryLimits.mcpServers const MAX_NATIVE_SKILLS = runtimeNativeInventoryLimits.skills const MAX_NATIVE_RESOURCES = runtimeNativeInventoryLimits.resources +const COMPACTION_USAGE_EVENT_GRACE_MS = 1_000 const EMBEDDED_SERVER_USERNAME = 'goodbuddy' const OPENCODE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u const TEMPORARY_MCP_PREFIXES = [ @@ -2407,27 +2408,120 @@ export class OpenCodeRuntime implements AgentRuntime { if (context.error || !context.data) { throw new Error('OpenCode 原生上下文不可用,无法执行 Compact') } - signal.throwIfAborted() - const compact = await client.v2.session.compact( - { sessionID: sessionId }, - { signal } - ) - if (compact.error) { - throw new Error( - opencodeErrorMessage( - compact.error, - 'OpenCode 原生 Compact 失败' - ) - ) - } - return { - result: { - provider: 'opencode', - strategy: 'native', - compacted: true, - detail: 'OpenCode 已完成原生上下文压缩' + const latestAssistant = [...context.data.data] + .reverse() + .find((message) => message.type === 'assistant') + const configuredModel = this.options.modelProfile + ? { + providerID: resolveOpenCodeProvider( + this.options.modelProfile + ).id, + modelID: this.options.modelProfile.modelName + } + : latestAssistant?.type === 'assistant' + ? { + providerID: latestAssistant.model.providerID, + modelID: latestAssistant.model.id + } + : undefined + if (!configuredModel) { + return { + result: { + provider: 'opencode', + strategy: 'native', + compacted: false, + detail: '当前 OpenCode 会话尚无可用于压缩的模型记录' + } } } + signal.throwIfAborted() + const subscriptionController = new AbortController() + const subscription = await client.event.subscribe( + { directory: this.options.defaultWorkspace }, + { + signal: AbortSignal.any([ + signal, + subscriptionController.signal + ]) + } + ) + const usageEvents: RuntimeModelUsageEvent[] = [] + const reportedMessageIds = new Set() + const usageCapture = (async () => { + for await (const event of subscription.stream) { + if ( + event.type === 'message.updated' && + event.properties.sessionID === sessionId && + event.properties.info.sessionID === sessionId && + event.properties.info.role === 'assistant' && + !reportedMessageIds.has(event.properties.info.id) + ) { + const usage = createUsageEvent( + request.requestId, + event.properties.info + ) + if (usage) { + reportedMessageIds.add(event.properties.info.id) + usageEvents.push(usage) + } + } + if ( + event.type === 'session.idle' && + event.properties.sessionID === sessionId + ) { + return + } + } + })() + try { + const compact = await client.session.summarize( + { + sessionID: sessionId, + directory: this.options.defaultWorkspace, + providerID: configuredModel.providerID, + modelID: configuredModel.modelID, + auto: false + }, + { signal } + ) + if (compact.error || compact.data !== true) { + throw new Error( + opencodeErrorMessage( + compact.error, + 'OpenCode 原生 Compact 失败' + ) + ) + } + let graceTimer: ReturnType | undefined + try { + await Promise.race([ + usageCapture, + new Promise((resolveGrace) => { + graceTimer = setTimeout( + resolveGrace, + COMPACTION_USAGE_EVENT_GRACE_MS + ) + graceTimer.unref?.() + }) + ]) + } finally { + if (graceTimer) { + clearTimeout(graceTimer) + } + } + return { + result: { + provider: 'opencode', + strategy: 'native', + compacted: true, + detail: 'OpenCode 已完成原生上下文压缩' + }, + ...(usageEvents.length > 0 ? { usageEvents } : {}) + } + } finally { + subscriptionController.abort() + await usageCapture.catch(() => undefined) + } } finally { releaseConversation?.() releaseEmbedded() diff --git a/src/main/agent/runtime-e2e.manual.test.ts b/src/main/agent/runtime-e2e.manual.test.ts index e751bc0..d095e5a 100644 --- a/src/main/agent/runtime-e2e.manual.test.ts +++ b/src/main/agent/runtime-e2e.manual.test.ts @@ -922,6 +922,103 @@ describe.runIf(enabled)('runtime end-to-end', () => { 180_000 ) + it( + 'compacts and continues a real bundled OpenCode session', + async () => { + const runtime = new OpenCodeRuntime({ + embedded: true, + binaryPath: '', + bundledBinaryPath: join( + portableRoot, + 'resources', + 'runtimes', + 'opencode', + 'opencode.exe' + ), + configPath: '', + defaultWorkspace: workspace, + modelProfile: { + id: crypto.randomUUID(), + name: 'E2E model', + baseUrl, + modelName, + apiKey, + protocol, + authentication: 'api-key' + } + }) + const conversationId = crypto.randomUUID() + const signal = new AbortController().signal + + try { + await expect( + collectText( + runtime.run( + { + requestId: crypto.randomUUID(), + conversationId, + workMode: 'ask', + prompt: + 'Remember that the verification codename is NATIVE-COMPACT-739. Reply with exactly OPENCODE_COMPACT_READY.' + }, + signal + ) + ) + ).resolves.toContain('OPENCODE_COMPACT_READY') + + await expect( + runtime.compactConversation( + { + requestId: crypto.randomUUID(), + conversationId, + runtimeSelection: { provider: 'opencode' }, + history: [ + { + role: 'user', + content: + 'The verification codename is NATIVE-COMPACT-739.' + }, + { + role: 'assistant', + content: 'OPENCODE_COMPACT_READY' + } + ], + historyMessageIds: [ + crypto.randomUUID(), + crypto.randomUUID() + ] + }, + signal + ) + ).resolves.toMatchObject({ + result: { + provider: 'opencode', + strategy: 'native', + compacted: true + } + }) + + await expect( + collectText( + runtime.run( + { + requestId: crypto.randomUUID(), + conversationId, + workMode: 'ask', + prompt: + 'Return exactly the verification codename from before and nothing else.' + }, + signal + ) + ) + ).resolves.toContain('NATIVE-COMPACT-739') + } finally { + await runtime.dispose() + } + }, + 180_000 + ) + it( 'completes an Execute file task through bundled Continue', async () => { diff --git a/src/main/desktop-notification.test.ts b/src/main/desktop-notification.test.ts index 877c0e7..c0125d0 100644 --- a/src/main/desktop-notification.test.ts +++ b/src/main/desktop-notification.test.ts @@ -1,23 +1,55 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { showDesktopNotificationWhenUnfocused } from './desktop-notification' +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + registerDesktopNotificationActivation, + showDesktopNotificationWhenUnfocused +} from './desktop-notification' const notificationMocks = vi.hoisted(() => ({ + activationHandler: undefined as (() => void) | undefined, + handleActivation: vi.fn((callback: () => void) => { + notificationMocks.activationHandler = callback + }), + close: vi.fn(), isSupported: vi.fn(() => true), - show: vi.fn() + show: vi.fn(), + instances: [] as Array<{ + emit: (event: string, ...args: unknown[]) => boolean + listenerCount: (event: string) => number + }> })) -vi.mock('electron', () => ({ - Notification: class { - static isSupported = notificationMocks.isSupported +vi.mock('electron', async () => { + const { EventEmitter } = await import('node:events') - show = notificationMocks.show + return { + Notification: class extends EventEmitter { + static handleActivation = notificationMocks.handleActivation + static isSupported = notificationMocks.isSupported + + close = notificationMocks.close + show = notificationMocks.show + + constructor() { + super() + notificationMocks.instances.push(this) + } + } } -})) +}) describe('showDesktopNotificationWhenUnfocused', () => { beforeEach(() => { vi.clearAllMocks() + notificationMocks.activationHandler = undefined notificationMocks.isSupported.mockReturnValue(true) + notificationMocks.instances.length = 0 + }) + + afterEach(() => { + for (const notification of notificationMocks.instances) { + notification.emit('close', {}) + } }) it('suppresses desktop notifications while GoodBuddy is focused', () => { @@ -45,4 +77,191 @@ describe('showDesktopNotificationWhenUnfocused', () => { expect(shown).toBe(true) expect(notificationMocks.show).toHaveBeenCalledOnce() }) + + it('registers Windows notification activation to restore GoodBuddy', () => { + const window = { + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => true), + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn() + } + + registerDesktopNotificationActivation(window as never, 'win32') + notificationMocks.activationHandler?.() + + expect(notificationMocks.handleActivation).toHaveBeenCalledOnce() + expect(window.restore).toHaveBeenCalledOnce() + expect(window.show).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + }) + + it('waits for the renderer before showing a cold-start activation', () => { + const webContents = new EventEmitter() as EventEmitter & { + getURL: ReturnType + isLoadingMainFrame: ReturnType + } + webContents.getURL = vi.fn(() => '') + webContents.isLoadingMainFrame = vi.fn(() => true) + const window = { + isDestroyed: vi.fn(() => false), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + webContents + } + + registerDesktopNotificationActivation(window as never, 'win32') + notificationMocks.activationHandler?.() + + expect(window.show).not.toHaveBeenCalled() + + webContents.getURL.mockReturnValue( + 'file:///D:/goodbuddy/out/renderer/index.html' + ) + webContents.isLoadingMainFrame.mockReturnValue(false) + webContents.emit('did-finish-load') + + expect(window.show).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + }) + + it('does not register native activation outside Windows', () => { + registerDesktopNotificationActivation( + { + isDestroyed: vi.fn(() => false) + } as never, + 'linux' + ) + + expect(notificationMocks.handleActivation).not.toHaveBeenCalled() + }) + + it('restores, shows, and focuses GoodBuddy when a notification is clicked', () => { + const window = { + isDestroyed: vi.fn(() => false), + isFocused: vi.fn(() => false), + isMinimized: vi.fn(() => true), + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn() + } + + showDesktopNotificationWhenUnfocused(window as never, { + title: '任务已完成' + }) + + notificationMocks.instances[0]!.emit('click', {}) + + expect(window.restore).toHaveBeenCalledOnce() + expect(window.show).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + expect( + notificationMocks.instances[0]!.listenerCount('click') + ).toBe(0) + }) + + it('does not operate on a window destroyed before the click', () => { + const window = { + isDestroyed: vi + .fn() + .mockReturnValueOnce(false) + .mockReturnValue(true), + isFocused: vi.fn(() => false), + isMinimized: vi.fn(), + restore: vi.fn(), + show: vi.fn(), + focus: vi.fn() + } + + showDesktopNotificationWhenUnfocused(window as never, { + title: '任务已完成' + }) + notificationMocks.instances[0]!.emit('click', {}) + + expect(window.isMinimized).not.toHaveBeenCalled() + expect(window.restore).not.toHaveBeenCalled() + expect(window.show).not.toHaveBeenCalled() + expect(window.focus).not.toHaveBeenCalled() + }) + + it('does not let window activation errors escape the native callback', () => { + const window = { + isDestroyed: vi.fn(() => false), + isFocused: vi.fn(() => false), + isMinimized: vi.fn(() => false), + restore: vi.fn(), + show: vi.fn(() => { + throw new Error('window closed') + }), + focus: vi.fn() + } + + showDesktopNotificationWhenUnfocused(window as never, { + title: '任务已完成' + }) + + expect(() => + notificationMocks.instances[0]!.emit('click', {}) + ).not.toThrow() + expect( + notificationMocks.instances[0]!.listenerCount('click') + ).toBe(0) + }) + + it.each(['close', 'failed'])( + 'releases retained notifications after %s', + (event) => { + showDesktopNotificationWhenUnfocused( + { + isDestroyed: vi.fn(() => false), + isFocused: vi.fn(() => false) + } as never, + { title: '任务已完成' } + ) + const notification = notificationMocks.instances[0]! + + notification.emit(event, {}) + + expect(notification.listenerCount('click')).toBe(0) + } + ) + + it('releases an unhandled notification after a bounded retention period', () => { + vi.useFakeTimers() + try { + showDesktopNotificationWhenUnfocused( + { + isDestroyed: vi.fn(() => false), + isFocused: vi.fn(() => false) + } as never, + { title: '任务已完成' } + ) + const notification = notificationMocks.instances[0]! + + expect(notification.listenerCount('click')).toBe(1) + vi.advanceTimersByTime(15 * 60_000) + expect(notificationMocks.close).toHaveBeenCalledOnce() + expect(notification.listenerCount('click')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('bounds retained notification instances during long-running sessions', () => { + for (let index = 0; index < 65; index += 1) { + showDesktopNotificationWhenUnfocused( + { + isDestroyed: vi.fn(() => false), + isFocused: vi.fn(() => false) + } as never, + { title: `任务已完成 ${index}` } + ) + } + + expect(notificationMocks.close).toHaveBeenCalledOnce() + expect(notificationMocks.instances[0]!.listenerCount('click')).toBe(0) + expect(notificationMocks.instances[64]!.listenerCount('click')).toBe(1) + }) }) diff --git a/src/main/desktop-notification.ts b/src/main/desktop-notification.ts index 8279786..db8eca7 100644 --- a/src/main/desktop-notification.ts +++ b/src/main/desktop-notification.ts @@ -3,6 +3,50 @@ import { type BrowserWindow, type NotificationConstructorOptions } from 'electron' +import { showWindow } from './window' + +const MAX_RETAINED_NOTIFICATIONS = 64 +const NOTIFICATION_RETENTION_MS = 15 * 60_000 +const activeNotifications = new Map void>() +const pendingWindowActivations = new WeakSet() + +function activateWindow(window: BrowserWindow): void { + try { + if (window.isDestroyed()) { + return + } + const webContents = window.webContents + if ( + webContents && + (!webContents.getURL() || + webContents.isLoadingMainFrame()) + ) { + if (!pendingWindowActivations.has(window)) { + pendingWindowActivations.add(window) + webContents.once('did-finish-load', () => { + pendingWindowActivations.delete(window) + activateWindow(window) + }) + } + return + } + showWindow(window) + } catch { + // The window can be destroyed between checks while a native callback runs. + } +} + +export function registerDesktopNotificationActivation( + window: BrowserWindow, + platform: NodeJS.Platform = process.platform +): void { + if (platform !== 'win32') { + return + } + Notification.handleActivation(() => { + activateWindow(window) + }) +} export function showDesktopNotificationWhenUnfocused( window: BrowserWindow, @@ -15,6 +59,53 @@ export function showDesktopNotificationWhenUnfocused( ) { return false } - new Notification(options).show() + + const notification = new Notification(options) + let retentionTimer: ReturnType | undefined + + const release = (): void => { + if (retentionTimer) { + clearTimeout(retentionTimer) + retentionTimer = undefined + } + activeNotifications.delete(notification) + notification.removeListener('click', handleClick) + notification.removeListener('close', release) + notification.removeListener('failed', release) + } + const dismiss = (): void => { + try { + notification.close() + } catch { + // The native notification may already have been dismissed. + } finally { + release() + } + } + const handleClick = (): void => { + try { + activateWindow(window) + } finally { + release() + } + } + + if (activeNotifications.size >= MAX_RETAINED_NOTIFICATIONS) { + activeNotifications.values().next().value?.() + } + activeNotifications.set(notification, dismiss) + retentionTimer = setTimeout(dismiss, NOTIFICATION_RETENTION_MS) + retentionTimer.unref?.() + + notification.once('click', handleClick) + notification.once('close', release) + notification.once('failed', release) + + try { + notification.show() + } catch (error) { + release() + throw error + } return true } diff --git a/src/main/index.ts b/src/main/index.ts index 5c754f2..d1cefb7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,7 @@ import { Menu, safeStorage, session, + shell, Tray, utilityProcess } from 'electron' @@ -88,6 +89,12 @@ import { DshNpmExtensionInstaller, DshNpmMarketplaceCatalog } from './agent/dsh-extension-marketplace' +import { registerDesktopNotificationActivation } from './desktop-notification' +import { + isInstalledWindowsBuild, + repairStaleWindowsNotificationShortcuts, + resolveWindowsAppUserModelId +} from './windows-notification-identity' const shortcut = 'CommandOrControl+Shift+Space' const mainModuleDirectory = dirname(fileURLToPath(import.meta.url)) @@ -99,8 +106,18 @@ const portableUserDataPath = resolvePortableUserDataPath({ if (portableUserDataPath) { app.setPath('userData', portableUserDataPath) } +const installedWindowsBuild = isInstalledWindowsBuild({ + packaged: app.isPackaged, + platform: process.platform, + executablePath: process.execPath +}) if (process.platform === 'win32') { - app.setAppUserModelId('live.digiman.goodbuddy') + app.setAppUserModelId( + resolveWindowsAppUserModelId({ + installed: installedWindowsBuild, + executablePath: process.execPath + }) + ) } const hasSingleInstanceLock = app.requestSingleInstanceLock() @@ -122,6 +139,7 @@ let globalTlsPolicy: GlobalTlsPolicy | undefined let documentOcrBroker: DocumentOcrBroker | undefined let documentOcrModelManager: DocumentOcrModelManager | undefined let stopRuntimeReconfiguration: (() => Promise) | undefined +let dshExtensionInstaller: DshNpmExtensionInstaller | undefined function createEmbeddingProvider( settings: ResolvedRuntimeSettings @@ -367,6 +385,7 @@ if (hasSingleInstanceLock) { ) mainWindow = createMainWindow(() => isQuitting) + registerDesktopNotificationActivation(mainWindow) tray = buildTray() const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir() const secureCipher = { @@ -452,7 +471,7 @@ if (hasSingleInstanceLock) { app.getPath('userData'), 'deepseek-harness' ) - const dshExtensionInstaller = new DshNpmExtensionInstaller({ + const startupDshExtensionInstaller = new DshNpmExtensionInstaller({ dshHome: deepSeekHarnessHome, npmCliPath: app.isPackaged ? join( @@ -470,11 +489,13 @@ if (hasSingleInstanceLock) { 'npm-cli.js' ) }) + dshExtensionInstaller = startupDshExtensionInstaller const runtimeExtensionStore = new RuntimeExtensionStore( app.getPath('userData'), { catalog: new DshNpmMarketplaceCatalog(), - install: (input) => dshExtensionInstaller.install(input) + install: (input) => + startupDshExtensionInstaller.install(input) } ) const launchDeepSeekHarness = @@ -713,6 +734,48 @@ if (hasSingleInstanceLock) { runtimeExtensionStore ) loadMainWindow(mainWindow) + setImmediate(() => { + void repairStaleWindowsNotificationShortcuts({ + platform: process.platform, + installed: installedWindowsBuild, + executablePath: process.execPath, + programsDirectory: join( + app.getPath('appData'), + 'Microsoft', + 'Windows', + 'Start Menu', + 'Programs' + ), + shortcutAccess: { + readShortcutLink: (shortcutPath) => + shell.readShortcutLink(shortcutPath), + writeShortcutLink: ( + shortcutPath, + operation, + options + ) => + shell.writeShortcutLink( + shortcutPath, + operation, + options + ) + } + }).then( + ({ failed }) => { + if (failed > 0) { + console.warn( + `Failed to repair ${failed} stale notification shortcut(s)` + ) + } + }, + (error: unknown) => { + console.warn( + 'Failed to inspect stale notification shortcuts', + error + ) + } + ) + }) app.on('activate', () => { if (mainWindow) { @@ -744,7 +807,10 @@ app.on('before-quit', (event) => { void (async () => { try { const cleanup = settleCleanupPhases([ - [() => removeIpcHandlers?.()], + [ + () => dshExtensionInstaller?.dispose(), + () => removeIpcHandlers?.() + ], [() => stopRuntimeReconfiguration?.()], [ () => runtime?.dispose(), diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 6767f25..a232166 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -2151,6 +2151,9 @@ describe('registerIpcHandlers Runtime customization', () => { state: 'complete' as const } ] + let persistedRuntimeSelection: + | { provider: 'opencode' } + | undefined = { provider: 'opencode' } const assistantDatabase = { claimDueSchedules: vi.fn(() => []), getProject: vi.fn(() => ({ @@ -2160,7 +2163,7 @@ describe('registerIpcHandlers Runtime customization', () => { getConversation: vi.fn(() => ({ id: conversationId, projectId, - runtimeSelection: { provider: 'opencode' as const }, + runtimeSelection: persistedRuntimeSelection, title: 'Runtime conversation', updatedAt: Date.now(), messages @@ -2290,13 +2293,54 @@ describe('registerIpcHandlers Runtime customization', () => { )?.(event, { ...compactInput, requestId: '00000000-0000-4000-8000-000000000606', + runtimeSelection: { provider: 'continue' } + }) + ).rejects.toThrow('对话 Runtime 或 Project 已更改') + await expect( + electronMocks.handlers.get( + ipcChannels.agentCompactConversation + )?.(event, { + ...compactInput, + requestId: '00000000-0000-4000-8000-000000000607', + projectId: '00000000-0000-4000-8000-000000000608' + }) + ).rejects.toThrow('对话 Runtime 或 Project 已更改') + + persistedRuntimeSelection = undefined + await expect( + electronMocks.handlers.get( + ipcChannels.agentCompactConversation + )?.(event, { + ...compactInput, + requestId: '00000000-0000-4000-8000-000000000609' + }) + ).resolves.toMatchObject({ + provider: 'opencode', + compacted: true + }) + await expect( + electronMocks.handlers.get( + ipcChannels.agentCompactConversation + )?.(event, { + ...compactInput, + requestId: '00000000-0000-4000-8000-000000000610', + runtimeSelection: { provider: 'continue' } + }) + ).rejects.toThrow('对话 Runtime 或 Project 已更改') + + await expect( + electronMocks.handlers.get( + ipcChannels.agentCompactConversation + )?.(event, { + ...compactInput, + requestId: '00000000-0000-4000-8000-000000000611', history: [ compactInput.history[0], { role: 'assistant', content: 'stale content' } ] }) ).rejects.toThrow('对话历史已更改') - expect(selectedRuntimes.compactConversation).toHaveBeenCalledOnce() + expect(selectedRuntimes.compactConversation).toHaveBeenCalledTimes(2) await dispose() }) }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6e99c88..445e0b6 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -127,6 +127,7 @@ import { import { agentRuntimeSelectionKey, agentRuntimeSelectionSchema, + getDefaultRuntimeSelection, type AgentRuntimeSelection } from '../shared/runtime-selection-contracts' import { @@ -2968,10 +2969,13 @@ export function registerIpcHandlers( const conversation = assistantDatabase.getConversation( request.conversationId ) + const settings = await settingsStore.getResolvedSettings() + const persistedRuntimeSelection = + conversation.runtimeSelection ?? + getDefaultRuntimeSelection(settings) if ( conversation.projectId !== request.projectId || - !conversation.runtimeSelection || - agentRuntimeSelectionKey(conversation.runtimeSelection) !== + agentRuntimeSelectionKey(persistedRuntimeSelection) !== agentRuntimeSelectionKey(request.runtimeSelection) ) { throw new Error('对话 Runtime 或 Project 已更改,请刷新后重试') @@ -2998,7 +3002,6 @@ export function registerIpcHandlers( contextCompressionState: conversation.contextCompressionState } - const settings = await settingsStore.getResolvedSettings() const selected = applyRuntimeSelection( settings, request.runtimeSelection diff --git a/src/main/release-notes-source.test.ts b/src/main/release-notes-source.test.ts index 13ff2c4..5434a6d 100644 --- a/src/main/release-notes-source.test.ts +++ b/src/main/release-notes-source.test.ts @@ -17,12 +17,51 @@ describe('packaged release notes', () => { expect.objectContaining({ version: '0.8.19' }) ) for (const release of parsed.releases) { - expect(release.notes['zh-CN'].features).toHaveLength( - release.notes['en-US'].features.length - ) - expect(release.notes['zh-CN'].fixes).toHaveLength( - release.notes['en-US'].fixes.length - ) + for (const section of [ + 'highlights', + 'features', + 'fixes', + 'notices' + ] as const) { + expect(release.notes['zh-CN'][section]).toHaveLength( + release.notes['en-US'][section].length + ) + } } + + const currentRelease = parsed.releases.find( + (release) => release.version === '0.10.0' + ) + expect(currentRelease).toBeDefined() + expect(currentRelease?.releasedAt).toBe('2026-08-17') + expect(currentRelease?.notes['zh-CN'].highlights).toHaveLength(1) + expect(currentRelease?.notes['zh-CN'].features).toHaveLength(7) + expect(currentRelease?.notes['zh-CN'].fixes).toHaveLength(6) + expect(currentRelease?.notes['zh-CN'].notices).toHaveLength(4) + expect( + currentRelease?.notes['zh-CN'].features.every((item) => + item.startsWith('**') + ) + ).toBe(true) + expect( + currentRelease?.notes['zh-CN'].fixes.every((item) => + item.startsWith('**') + ) + ).toBe(true) + expect( + currentRelease?.notes['zh-CN'].notices.every((item) => + item.startsWith('**') + ) + ).toBe(true) + expect(JSON.stringify(currentRelease)).not.toContain('官网') + expect(JSON.stringify(currentRelease)).not.toContain( + 'Website and product preview' + ) + + const legacyRelease = parsed.releases.find( + (release) => release.version === '0.9.3' + ) + expect(legacyRelease?.notes['zh-CN'].highlights).toEqual([]) + expect(legacyRelease?.notes['zh-CN'].notices).toEqual([]) }) }) diff --git a/src/main/windows-notification-identity.test.ts b/src/main/windows-notification-identity.test.ts new file mode 100644 index 0000000..d1b246c --- /dev/null +++ b/src/main/windows-notification-identity.test.ts @@ -0,0 +1,194 @@ +import { + mkdtemp, + rm, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ShortcutDetails } from 'electron' +import { + afterEach, + describe, + expect, + it, + vi +} from 'vitest' +import { + isInstalledWindowsBuild, + repairStaleWindowsNotificationShortcuts, + resolveWindowsAppUserModelId, + WINDOWS_APP_USER_MODEL_ID +} from './windows-notification-identity' + +const temporaryDirectories: string[] = [] + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-notification-identity-') + ) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +describe('Windows notification identity', () => { + it('reserves the production AUMID for an installed build', () => { + expect( + resolveWindowsAppUserModelId({ + installed: true, + executablePath: 'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe' + }) + ).toBe(WINDOWS_APP_USER_MODEL_ID) + }) + + it('uses stable path-scoped identities for standalone builds', () => { + const first = resolveWindowsAppUserModelId({ + installed: false, + executablePath: 'D:\\repo\\GoodBuddy.exe' + }) + + expect(first).not.toBe(WINDOWS_APP_USER_MODEL_ID) + expect( + resolveWindowsAppUserModelId({ + installed: false, + executablePath: 'd:\\REPO\\goodbuddy.exe' + }) + ).toBe(first) + expect( + resolveWindowsAppUserModelId({ + installed: false, + executablePath: 'D:\\other\\GoodBuddy.exe' + }) + ).not.toBe(first) + }) + + it('recognizes only packaged Windows layouts with an uninstaller', async () => { + const directory = await createTemporaryDirectory() + const executablePath = join(directory, 'GoodBuddy.exe') + await writeFile(join(directory, 'Uninstall GoodBuddy.exe'), '') + + expect( + isInstalledWindowsBuild({ + packaged: true, + platform: 'win32', + executablePath + }) + ).toBe(true) + expect( + isInstalledWindowsBuild({ + packaged: false, + platform: 'win32', + executablePath + }) + ).toBe(false) + expect( + isInstalledWindowsBuild({ + packaged: true, + platform: 'linux', + executablePath + }) + ).toBe(false) + expect( + isInstalledWindowsBuild({ + packaged: true, + platform: 'win32', + executablePath: '\0' + }) + ).toBe(false) + }) + + it('moves stale shortcuts off the production identity without changing their targets', async () => { + const programsDirectory = await createTemporaryDirectory() + const stalePath = join(programsDirectory, 'Electron.lnk') + const currentPath = join(programsDirectory, 'GoodBuddy.lnk') + await Promise.all([ + writeFile(stalePath, ''), + writeFile(currentPath, '') + ]) + + const staleDetails: ShortcutDetails = { + target: 'D:\\repo\\node_modules\\electron\\electron.exe', + appUserModelId: WINDOWS_APP_USER_MODEL_ID, + toastActivatorClsid: + '{6D4C974B-001A-47E5-AE4B-F8F12FFDA281}' + } + const details = new Map([ + [stalePath, staleDetails], + [ + currentPath, + { + target: + 'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe', + appUserModelId: WINDOWS_APP_USER_MODEL_ID + } + ] + ]) + const writeShortcutLink = vi.fn(() => true) + + await expect( + repairStaleWindowsNotificationShortcuts({ + platform: 'win32', + installed: true, + executablePath: + 'C:\\Program Files\\GoodBuddy\\GoodBuddy.exe', + programsDirectory, + shortcutAccess: { + readShortcutLink: (shortcutPath) => + details.get(shortcutPath)!, + writeShortcutLink + } + }) + ).resolves.toEqual({ + scanned: 2, + repaired: 1, + failed: 0 + }) + expect(writeShortcutLink).toHaveBeenCalledOnce() + expect(writeShortcutLink).toHaveBeenCalledWith( + stalePath, + 'update', + expect.objectContaining({ + target: staleDetails.target, + toastActivatorClsid: staleDetails.toastActivatorClsid, + appUserModelId: expect.not.stringMatching( + /^live\.digiman\.goodbuddy$/u + ) + }) + ) + }) + + it('does not touch shortcuts from development or portable builds', async () => { + const programsDirectory = await createTemporaryDirectory() + await writeFile(join(programsDirectory, 'Electron.lnk'), '') + const readShortcutLink = vi.fn() + const writeShortcutLink = vi.fn() + + await expect( + repairStaleWindowsNotificationShortcuts({ + platform: 'win32', + installed: false, + executablePath: 'D:\\repo\\GoodBuddy.exe', + programsDirectory, + shortcutAccess: { + readShortcutLink, + writeShortcutLink + } + }) + ).resolves.toEqual({ + scanned: 0, + repaired: 0, + failed: 0 + }) + expect(readShortcutLink).not.toHaveBeenCalled() + expect(writeShortcutLink).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/windows-notification-identity.ts b/src/main/windows-notification-identity.ts new file mode 100644 index 0000000..525fc7c --- /dev/null +++ b/src/main/windows-notification-identity.ts @@ -0,0 +1,133 @@ +import { createHash } from 'node:crypto' +import { statSync } from 'node:fs' +import { dirname, join, resolve, win32 } from 'node:path' +import type { ShortcutDetails } from 'electron' + +export const WINDOWS_APP_USER_MODEL_ID = 'live.digiman.goodbuddy' + +const windowsUninstallerName = 'Uninstall GoodBuddy.exe' +const standaloneIdentityPrefix = `${WINDOWS_APP_USER_MODEL_ID}.standalone` +const knownShortcutNames = ['Electron.lnk', 'GoodBuddy.lnk'] as const + +interface ShortcutAccess { + readShortcutLink(shortcutPath: string): ShortcutDetails + writeShortcutLink( + shortcutPath: string, + operation: 'update', + options: ShortcutDetails + ): boolean +} + +export interface WindowsNotificationShortcutRepairResult { + scanned: number + repaired: number + failed: number +} + +function normalizeWindowsExecutablePath(executablePath: string): string { + return win32.resolve(executablePath).toLocaleLowerCase('en-US') +} + +function resolveStandaloneWindowsAppUserModelId( + executablePath: string +): string { + const executableHash = createHash('sha256') + .update(normalizeWindowsExecutablePath(executablePath)) + .digest('hex') + .slice(0, 24) + return `${standaloneIdentityPrefix}.${executableHash}` +} + +export function isInstalledWindowsBuild(input: { + packaged: boolean + platform: NodeJS.Platform + executablePath: string +}): boolean { + if (!input.packaged || input.platform !== 'win32') { + return false + } + try { + const uninstallerPath = join( + dirname(resolve(input.executablePath)), + windowsUninstallerName + ) + return statSync(uninstallerPath, { + throwIfNoEntry: false + })?.isFile() === true + } catch { + return false + } +} + +export function resolveWindowsAppUserModelId(input: { + installed: boolean + executablePath: string +}): string { + return input.installed + ? WINDOWS_APP_USER_MODEL_ID + : resolveStandaloneWindowsAppUserModelId(input.executablePath) +} + +export async function repairStaleWindowsNotificationShortcuts(input: { + platform: NodeJS.Platform + installed: boolean + executablePath: string + programsDirectory: string + shortcutAccess: ShortcutAccess +}): Promise { + if (input.platform !== 'win32' || !input.installed) { + return { scanned: 0, repaired: 0, failed: 0 } + } + + const shortcutPaths = knownShortcutNames.map((shortcutName) => + join(input.programsDirectory, shortcutName) + ) + const currentExecutablePath = normalizeWindowsExecutablePath( + input.executablePath + ) + let scanned = 0 + let repaired = 0 + let failed = 0 + + for (const shortcutPath of shortcutPaths) { + let details: ShortcutDetails + try { + details = input.shortcutAccess.readShortcutLink(shortcutPath) + } catch { + continue + } + scanned += 1 + if ( + details.appUserModelId !== WINDOWS_APP_USER_MODEL_ID || + normalizeWindowsExecutablePath(details.target) === + currentExecutablePath + ) { + continue + } + + try { + const updated = input.shortcutAccess.writeShortcutLink( + shortcutPath, + 'update', + { + ...details, + appUserModelId: + resolveStandaloneWindowsAppUserModelId(details.target) + } + ) + if (updated) { + repaired += 1 + } else { + failed += 1 + } + } catch { + failed += 1 + } + } + + return { + scanned, + repaired, + failed + } +} diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 94bdc90..509afc2 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -948,10 +948,14 @@ describe('App', () => { expect(loading).toHaveAttribute('aria-busy', 'true') await act(async () => lazyRouteMocks.releaseKnowledgeRoute()) expect( - await screen.findByRole('heading', { - level: 1, - name: '知识库' - }) + await screen.findByRole( + 'heading', + { + level: 1, + name: '知识库' + }, + { timeout: 3000 } + ) ).toBeInTheDocument() expect( screen.queryByRole('status', { name: '正在加载页面…' }) @@ -1208,12 +1212,16 @@ describe('App', () => { releasedAt: '2026-08-11', notes: { 'zh-CN': { + highlights: ['多 Runtime 工作流更加连贯'], features: ['新增版本更新说明'], - fixes: ['修复重复显示'] + fixes: ['修复重复显示'], + notices: ['Ask 模式保持只读'] }, 'en-US': { + highlights: ['Multi-Runtime workflows are more cohesive'], features: ['Added release notes'], - fixes: ['Fixed repeated display'] + fixes: ['Fixed repeated display'], + notices: ['Ask remains read-only'] } } } diff --git a/src/renderer/src/MarkdownRenderer.test.tsx b/src/renderer/src/MarkdownRenderer.test.tsx index 311ac41..df708e4 100644 --- a/src/renderer/src/MarkdownRenderer.test.tsx +++ b/src/renderer/src/MarkdownRenderer.test.tsx @@ -15,7 +15,10 @@ import { vi } from 'vitest' import { changeUiLocale } from './i18n' -import { MarkdownRenderer } from './MarkdownRenderer' +import { + InlineMarkdown, + MarkdownRenderer +} from './MarkdownRenderer' const mermaidMock = vi.hoisted(() => ({ initialize: vi.fn(), @@ -41,6 +44,21 @@ describe('MarkdownRenderer', () => { vi.clearAllMocks() }) + it('renders bounded inline emphasis without links or raw HTML', () => { + const { container } = render( +

+ + {'**明确标题。** 查看[外部页面](https://example.com)。'} + +

+ ) + + expect(screen.getByText('明确标题。').tagName).toBe('STRONG') + expect(container).toHaveTextContent('外部页面') + expect(container.querySelector('a')).not.toBeInTheDocument() + expect(container.querySelector('script')).not.toBeInTheDocument() + }) + it('renders CommonMark and GitHub Flavored Markdown', () => { render( {`# 标题 diff --git a/src/renderer/src/MarkdownRenderer.tsx b/src/renderer/src/MarkdownRenderer.tsx index 9dbd2a7..cb7846b 100644 --- a/src/renderer/src/MarkdownRenderer.tsx +++ b/src/renderer/src/MarkdownRenderer.tsx @@ -92,6 +92,25 @@ type MarkdownRendererProps = { children: string } +const inlineMarkdownComponents: Components = { + p: ({ children }) => <>{children} +} + +export const InlineMarkdown = memo(function InlineMarkdown({ + children +}: MarkdownRendererProps): React.JSX.Element { + return ( + + {children} + + ) +}) + const wholeMarkdownFence = /^```(?:markdown|md)\s*\r?\n([\s\S]*?)\r?\n```$/iu diff --git a/src/renderer/src/ReleaseNotesDialog.test.tsx b/src/renderer/src/ReleaseNotesDialog.test.tsx index 1b1fdee..117412b 100644 --- a/src/renderer/src/ReleaseNotesDialog.test.tsx +++ b/src/renderer/src/ReleaseNotesDialog.test.tsx @@ -19,12 +19,18 @@ const snapshot: ReleaseNotesSnapshot = { releasedAt: '2026-08-11', notes: { 'zh-CN': { - features: ['新增双语界面'], - fixes: ['修复开关尺寸'] + highlights: ['多 Runtime 工作流更加连贯。'], + features: ['**Runtime 能力概览。** 查看实际可用能力。'], + fixes: ['**设置界面一致性。** 修复开关尺寸。'], + notices: ['**工作模式权限。** Ask 模式保持只读。'] }, 'en-US': { - features: ['Added a bilingual interface'], - fixes: ['Fixed switch dimensions'] + highlights: ['Multi-Runtime workflows are more cohesive.'], + features: [ + '**Runtime capability overview.** View actual capabilities.' + ], + fixes: ['**Settings consistency.** Fixed switch dimensions.'], + notices: ['**Work mode permissions.** Ask remains read-only.'] } } } @@ -71,8 +77,26 @@ describe('ReleaseNotesDialog', () => { name: 'GoodBuddy 0.8.18 更新内容' }) ).toBeInTheDocument() - expect(screen.getByText('新增双语界面')).toBeInTheDocument() - expect(screen.getByText('修复开关尺寸')).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: '本次亮点' }) + ).toBeInTheDocument() + expect( + screen.getByText('多 Runtime 工作流更加连贯。') + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: '功能更新' }) + ).toBeInTheDocument() + expect(screen.getByText('Runtime 能力概览。').tagName).toBe( + 'STRONG' + ) + expect(screen.getByText('查看实际可用能力。')).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: '问题修复' }) + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: '使用前请留意' }) + ).toBeInTheDocument() + expect(screen.getByText('工作模式权限。').tagName).toBe('STRONG') expect(screen.queryByRole('link')).not.toBeInTheDocument() expect( container.querySelector('.app-shell')?.inert @@ -115,9 +139,20 @@ describe('ReleaseNotesDialog', () => { }) ).toBeInTheDocument() expect( - screen.getByText('Added a bilingual interface') + screen.getByRole('heading', { name: 'Highlights' }) ).toBeInTheDocument() - expect(screen.getByText('Fixed switch dimensions')).toBeInTheDocument() + expect( + screen.getByText('Multi-Runtime workflows are more cohesive.') + ).toBeInTheDocument() + expect( + screen.getByText('Runtime capability overview.').tagName + ).toBe('STRONG') + expect( + screen.getByRole('heading', { name: 'Before You Start' }) + ).toBeInTheDocument() + expect(screen.getByText('Work mode permissions.').tagName).toBe( + 'STRONG' + ) expect( screen.getByRole('button', { name: 'Get Started' }) ).toBeInTheDocument() diff --git a/src/renderer/src/ReleaseNotesDialog.tsx b/src/renderer/src/ReleaseNotesDialog.tsx index 2d28930..51d2dc8 100644 --- a/src/renderer/src/ReleaseNotesDialog.tsx +++ b/src/renderer/src/ReleaseNotesDialog.tsx @@ -1,4 +1,10 @@ -import { Sparkles, Wrench, X } from 'lucide-react' +import { + Lightbulb, + Sparkles, + TriangleAlert, + Wrench, + X +} from 'lucide-react' import { useEffect, useId, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' @@ -8,6 +14,7 @@ import type { } from '../../shared/release-notes-contracts' import { activateModalFocus, trapTabFocus } from './dialog-focus' import type { UiLocale } from './i18n' +import { InlineMarkdown } from './MarkdownRenderer' type ReleaseNotesDialogProps = { locale: UiLocale @@ -16,6 +23,47 @@ type ReleaseNotesDialogProps = { onClose: () => void } +function ReleaseNotesSection({ + heading, + headingLevel, + icon, + items, + variant +}: { + heading: string + headingLevel: 'h3' | 'h4' + icon: React.ReactNode + items: string[] + variant?: 'notices' +}): React.JSX.Element | null { + if (items.length === 0) { + return null + } + const SectionHeading = headingLevel + return ( +
+ + {icon} + {heading} + +
    + {items.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ ) +} + function ReleaseSection({ locale, release, @@ -28,7 +76,7 @@ function ReleaseSection({ const { t } = useTranslation('app') const notes = release.notes[locale] const releaseHeadingId = useId() - const SectionHeading = showVersion ? 'h4' : 'h3' + const headingLevel = showVersion ? 'h4' : 'h3' return (
)} - {notes.features.length > 0 && ( -
- - -
    - {notes.features.map((feature) => ( -
  • {feature}
  • - ))} -
-
- )} - {notes.fixes.length > 0 && ( -
- - -
    - {notes.fixes.map((fix) => ( -
  • {fix}
  • - ))} -
-
- )} +
) } diff --git a/src/renderer/src/RuntimeCustomizationSection.tsx b/src/renderer/src/RuntimeCustomizationSection.tsx index de303f9..b5817ca 100644 --- a/src/renderer/src/RuntimeCustomizationSection.tsx +++ b/src/renderer/src/RuntimeCustomizationSection.tsx @@ -659,7 +659,7 @@ export const RuntimeCustomizationSection = forwardRef<

) : null} - {snapshot ? ( + {snapshot && snapshot.inventoryStatus !== 'available' ? ( ) : null} diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 329938c..225489b 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -2072,14 +2072,9 @@ describe('SettingsPanel runtime files', () => { const agent = await screen.findByLabelText('默认 Agent') expect(agent).toHaveValue('planner') - const nativeStatus = screen.getByRole('status') - expect(nativeStatus).toHaveTextContent('OpenCode 原生能力已就绪') expect( - Boolean( - nativeStatus.compareDocumentPosition(agent) & - Node.DOCUMENT_POSITION_FOLLOWING - ) - ).toBe(true) + screen.queryByText('OpenCode 原生能力已就绪') + ).not.toBeInTheDocument() expect(screen.getByText('能力与默认配置')).toBeInTheDocument() expect(screen.queryByText('Runtime 原生能力')).not.toBeInTheDocument() expect(screen.queryByText('OpenCode 默认 Agent')).not.toBeInTheDocument() @@ -2162,6 +2157,35 @@ describe('SettingsPanel runtime files', () => { ) }) + it.each([ + ['opencode', 'OpenCode 原生能力已就绪'], + [ + 'continue', + '内置 Continue CLI 已就绪;Rules 与 Prompts 来自原始静态配置;MCP Prompt 仅在 MCPService 运行并连接后可发现,非运行快照不会启动服务器。Continue MCPService 不提供 Resources。' + ], + [ + 'deepseek-harness', + '显示 DeepSeek Harness Host 与插件原生能力;GoodBuddy 分配的 Skill 和 MCP 不在此清单中。' + ] + ] as const)( + 'hides the redundant ready detail for %s', + async (provider, detail) => { + const fallbackSnapshot = await getRuntimeNativeSnapshot({ + provider + }) + getRuntimeNativeSnapshot.mockResolvedValueOnce({ + ...fallbackSnapshot, + detail + }) + + render() + + await screen.findByRole('tablist', { name: '能力清单' }) + expect(screen.queryByText(detail)).not.toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + } + ) + it('distinguishes external OpenCode connectivity from readable native inventory', async () => { const fallbackSnapshot = await getRuntimeNativeSnapshot({ provider: 'opencode' diff --git a/src/renderer/src/WorkspacePrimitives.test.tsx b/src/renderer/src/WorkspacePrimitives.test.tsx index cf2d664..86298a0 100644 --- a/src/renderer/src/WorkspacePrimitives.test.tsx +++ b/src/renderer/src/WorkspacePrimitives.test.tsx @@ -188,6 +188,12 @@ describe('WorkspacePrimitives', () => { ) }) + it('keeps DSH marketplace search text clear of its icon', () => { + expect(stylesheet).toMatch( + /\.field \.runtime-extension-marketplace__search-input > input\s*\{[^}]*padding-left:\s*34px;/u + ) + }) + it('separates model service fields from credential status', () => { expect(stylesheet).toMatch( /\.model-service-form\s*\{[^}]*display:\s*grid;[^}]*gap:\s*var\(--space-3\);/u diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index a66f64f..5167153 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -15,10 +15,11 @@ export const app = { releaseNotes: { eyebrow: 'VERSION UPDATE', title: "What's New in GoodBuddy {{version}}", - description: - 'This release includes the following features and bug fixes.', + description: 'Review the key changes and usage notes in this release.', + highlights: 'Highlights', features: 'Features', fixes: 'Bug Fixes', + notices: 'Before You Start', close: 'Close release notes', start: 'Get Started', closing: 'Closing…', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 8c01a83..024f0cf 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -12,9 +12,11 @@ export const app = { releaseNotes: { eyebrow: '版本更新', title: 'GoodBuddy {{version}} 更新内容', - description: '本次版本带来了以下功能更新与问题修复。', + description: '查看本次版本的主要更新与使用提示。', + highlights: '本次亮点', features: '功能更新', fixes: '问题修复', + notices: '使用前请留意', close: '关闭版本更新说明', start: '开始使用', closing: '正在关闭…', diff --git a/src/renderer/src/runtime-selection.ts b/src/renderer/src/runtime-selection.ts index bacfaf7..73f84a3 100644 --- a/src/renderer/src/runtime-selection.ts +++ b/src/renderer/src/runtime-selection.ts @@ -1,41 +1,4 @@ -import type { RuntimeSettings } from '../../shared/contracts' -import type { AgentRuntimeSelection } from '../../shared/runtime-selection-contracts' - -export function getRuntimeSelectionForProvider( - provider: 'model' | 'opencode' | 'continue' | 'deepseek-harness', - settings: RuntimeSettings -): AgentRuntimeSelection { - if (provider === 'model') { - return { - provider, - profileId: settings.defaultModelProfileId - } - } - const source = - provider === 'opencode' - ? settings.opencodeModelSource - : provider === 'continue' - ? settings.continueModelSource - : settings.deepseekHarnessModelSource ?? { kind: 'platform' } - return { - provider, - ...(source.kind === 'profile' ? { profileId: source.profileId } : {}) - } -} - -export function getDefaultRuntimeSelection( - settings: RuntimeSettings -): AgentRuntimeSelection { - const provider = settings.provider - if ( - provider === 'model' || - provider === 'opencode' || - provider === 'continue' || - provider === 'deepseek-harness' - ) { - return getRuntimeSelectionForProvider(provider, settings) - } - return settings.opencodeBaseUrl || settings.opencodeEmbedded - ? getRuntimeSelectionForProvider('opencode', settings) - : getRuntimeSelectionForProvider('model', settings) -} +export { + getDefaultRuntimeSelection, + getRuntimeSelectionForProvider +} from '../../shared/runtime-selection-contracts' diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 018c278..466c84b 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -4373,6 +4373,16 @@ button > svg { color: var(--accent); } +.release-notes-dialog__section li strong { + color: var(--text-primary); + font-weight: 600; +} + +.release-notes-dialog__section--notices :is(h3, h4) svg, +.release-notes-dialog__section--notices li::marker { + color: var(--warning); +} + .release-notes-dialog__footer { display: flex; align-items: center; @@ -5496,11 +5506,6 @@ button > svg { overflow-wrap: anywhere; } -.runtime-native-inventory__status--available { - border-color: color-mix(in srgb, var(--success) 35%, transparent); - background: var(--success-subtle); -} - .runtime-native-inventory__status--unavailable { border-color: var(--danger-border); background: var(--danger-subtle); @@ -5678,7 +5683,7 @@ button > svg { transform: translateY(-50%); } -.runtime-extension-marketplace__search-input > input { +.field .runtime-extension-marketplace__search-input > input { padding-left: 34px; } diff --git a/src/shared/release-notes-contracts.ts b/src/shared/release-notes-contracts.ts index 73612d2..b516753 100644 --- a/src/shared/release-notes-contracts.ts +++ b/src/shared/release-notes-contracts.ts @@ -7,16 +7,40 @@ export const releaseVersionSchema = z 'Release version must be a stable semantic version' ) -const localizedReleaseNotesSchema = z - .object({ - features: z.array(z.string().trim().min(1).max(240)).max(20), - fixes: z.array(z.string().trim().min(1).max(240)).max(20) - }) - .strict() - .refine( - (notes) => notes.features.length > 0 || notes.fixes.length > 0, - 'Release notes must contain at least one item' - ) +const releaseNoteItemSchema = z.string().trim().min(1).max(500) + +const localizedReleaseNotesSchema = z.preprocess( + (value) => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return value + } + const notes = value as Record + if ('highlights' in notes || 'notices' in notes) { + return value + } + return { + highlights: [], + ...notes, + notices: [] + } + }, + z + .object({ + highlights: z.array(releaseNoteItemSchema).max(3), + features: z.array(releaseNoteItemSchema).max(20), + fixes: z.array(releaseNoteItemSchema).max(20), + notices: z.array(releaseNoteItemSchema).max(20) + }) + .strict() + .refine( + (notes) => + notes.highlights.length > 0 || + notes.features.length > 0 || + notes.fixes.length > 0 || + notes.notices.length > 0, + 'Release notes must contain at least one item' + ) +) export const releaseNoteSchema = z .object({ @@ -48,17 +72,23 @@ export const releaseNotesFileSchema = z }) } versions.add(release.version) - if ( - release.notes['zh-CN'].features.length !== - release.notes['en-US'].features.length || - release.notes['zh-CN'].fixes.length !== - release.notes['en-US'].fixes.length - ) { - context.addIssue({ - code: 'custom', - message: 'Localized release-note sections must have matching counts', - path: ['releases', index, 'notes'] - }) + for (const section of [ + 'highlights', + 'features', + 'fixes', + 'notices' + ] as const) { + if ( + release.notes['zh-CN'][section].length !== + release.notes['en-US'][section].length + ) { + context.addIssue({ + code: 'custom', + message: + 'Localized release-note sections must have matching counts', + path: ['releases', index, 'notes', section] + }) + } } } }) diff --git a/src/shared/runtime-selection-contracts.ts b/src/shared/runtime-selection-contracts.ts index 18bc318..984f076 100644 --- a/src/shared/runtime-selection-contracts.ts +++ b/src/shared/runtime-selection-contracts.ts @@ -58,8 +58,69 @@ export type RuntimeSelectionRepairSettings = { | { kind: 'profile'; profileId: string } } +type RuntimeModelSource = + | { kind: 'platform' } + | { kind: 'profile'; profileId: string } + +export type RuntimeSelectionDefaultSettings = { + provider: AgentRuntimeSelection['provider'] + defaultModelProfileId: string + opencodeBaseUrl: string + opencodeEmbedded: boolean + opencodeModelSource?: RuntimeModelSource + continueModelSource?: RuntimeModelSource + deepseekHarnessModelSource?: RuntimeModelSource + opencodeModelProfile?: { id: string } + continueModelProfile?: { id: string } + deepseekHarnessModelProfile?: { id: string } +} + type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][number] +export function getRuntimeSelectionForProvider( + provider: Exclude, + settings: RuntimeSelectionDefaultSettings +): AgentRuntimeSelection { + if (provider === 'model') { + return { + provider, + profileId: settings.defaultModelProfileId + } + } + const source = + provider === 'opencode' + ? settings.opencodeModelSource + : provider === 'continue' + ? settings.continueModelSource + : settings.deepseekHarnessModelSource + const resolvedProfile = + provider === 'opencode' + ? settings.opencodeModelProfile + : provider === 'continue' + ? settings.continueModelProfile + : settings.deepseekHarnessModelProfile + return { + provider, + ...(source?.kind === 'profile' + ? { profileId: source.profileId } + : !source && resolvedProfile + ? { profileId: resolvedProfile.id } + : {}) + } +} + +export function getDefaultRuntimeSelection( + settings: RuntimeSelectionDefaultSettings +): AgentRuntimeSelection { + const provider = settings.provider + if (provider !== 'auto') { + return getRuntimeSelectionForProvider(provider, settings) + } + return settings.opencodeBaseUrl || settings.opencodeEmbedded + ? getRuntimeSelectionForProvider('opencode', settings) + : getRuntimeSelectionForProvider('model', settings) +} + export function isChannelModelProfileUsable( profile: ChannelModelProfile ): boolean { diff --git a/tests/build-release.test.ts b/tests/build-release.test.ts index 8de20b8..8e57f99 100644 --- a/tests/build-release.test.ts +++ b/tests/build-release.test.ts @@ -58,6 +58,28 @@ interface ReleaseBuilderModule { integrity: string filename: string } + lockedTargetRuntimePackage: ( + packageName: string, + packageJson?: { + dependencies?: Record + optionalDependencies?: Record + }, + packageLock?: { + packages?: Record< + string, + { + version?: string + resolved?: string + integrity?: string + optionalDependencies?: Record + } + > + } + ) => { + name: string + version: string + integrity: string + } replaceOutput: ( stagingDirectory: string, destination: string, @@ -66,6 +88,39 @@ interface ReleaseBuilderModule { targetRuntimePackageNames: ( options: ReleaseOptions ) => string[] + stageTargetRuntimeDependencies: ( + options: ReleaseOptions, + dependencies?: { + root: string + packageJson: { + dependencies?: Record + optionalDependencies?: Record + } + packageLock: { + packages?: Record< + string, + { + version?: string + resolved?: string + integrity?: string + optionalDependencies?: Record + } + > + } + npmInvocation: () => { + command: string + prefixArgs: string[] + } + runCapture: ( + command: string, + arguments_: string[] + ) => Promise + extractArchive: ( + archivePath: string, + destination: string + ) => Promise + } + ) => Promise<() => void> verifyHarnessPackage: ( resources: string, options: ReleaseOptions, @@ -354,14 +409,216 @@ describe('release build arguments', () => { ...windowsOptions, arch: 'arm64' }) - ).toEqual(['@koromix/koffi-win32-arm64']) + ).toEqual([ + '@koromix/koffi-win32-arm64', + '@napi-rs/canvas-win32-arm64-msvc' + ]) expect( releaseBuilder.targetRuntimePackageNames({ ...windowsOptions, platform: 'linux', formats: ['AppImage', 'deb'] }) - ).toEqual(['@koromix/koffi-linux-x64']) + ).toEqual([ + '@koromix/koffi-linux-x64', + '@napi-rs/canvas-linux-x64-gnu' + ]) + expect( + releaseBuilder.targetRuntimePackageNames({ + ...windowsOptions, + platform: 'macos', + arch: 'arm64', + formats: ['dmg', 'zip'] + }) + ).toEqual([ + '@koromix/koffi-darwin-arm64', + '@napi-rs/canvas-darwin-arm64' + ]) + }) + + it('stages a missing target Canvas package from locked metadata', async () => { + const runtimeRoot = mkdtempSync( + join(tmpdir(), 'goodbuddy-release-canvas-stage-') + ) + const canvasPackage = + '@napi-rs/canvas-win32-arm64-msvc' + const koffiPackage = '@koromix/koffi-win32-arm64' + const archiveContents = Buffer.from('locked Canvas archive') + const integrity = `sha512-${createHash('sha512') + .update(archiveContents) + .digest('base64')}` + const packageJson = { + dependencies: { + '@napi-rs/canvas': '1.0.3' + }, + optionalDependencies: { + [koffiPackage]: '3.1.4' + } + } + const packageLock = { + packages: { + 'node_modules/@napi-rs/canvas': { + version: '1.0.3', + optionalDependencies: { + [canvasPackage]: '1.0.3' + } + }, + [`node_modules/${canvasPackage}`]: { + version: '1.0.3', + resolved: 'https://registry.npmjs.org/locked-canvas.tgz', + integrity + }, + [`node_modules/${koffiPackage}`]: { + version: '3.1.4', + resolved: 'https://registry.npmjs.org/locked-koffi.tgz', + integrity: 'sha512-locked' + } + } + } + const koffiDirectory = join( + runtimeRoot, + 'node_modules', + ...koffiPackage.split('/') + ) + const canvasDirectory = join( + runtimeRoot, + 'node_modules', + ...canvasPackage.split('/') + ) + const invocations: string[][] = [] + let cleanup: () => void = () => undefined + + try { + mkdirSync(koffiDirectory, { recursive: true }) + writeFileSync( + join(koffiDirectory, 'package.json'), + JSON.stringify({ + name: koffiPackage, + version: '3.1.4' + }) + ) + + cleanup = + await releaseBuilder.stageTargetRuntimeDependencies( + { + ...windowsOptions, + arch: 'arm64' + }, + { + root: runtimeRoot, + packageJson, + packageLock, + npmInvocation: () => ({ + command: 'npm-test', + prefixArgs: [] + }), + runCapture: async (_command, arguments_) => { + invocations.push(arguments_) + const destination = arguments_[ + arguments_.indexOf('--pack-destination') + 1 + ] + if (!destination) { + throw new Error('pack destination is missing') + } + writeFileSync( + join(destination, 'locked-canvas.tgz'), + archiveContents + ) + return JSON.stringify([ + { + name: canvasPackage, + version: '1.0.3', + integrity, + filename: 'locked-canvas.tgz' + } + ]) + }, + extractArchive: async (_archive, destination) => { + writeFileSync( + join(destination, 'package.json'), + JSON.stringify({ + name: canvasPackage, + version: '1.0.3' + }) + ) + } + } + ) + + expect(invocations).toHaveLength(1) + expect(invocations[0]).toEqual( + expect.arrayContaining([ + 'pack', + `${canvasPackage}@1.0.3`, + '--ignore-scripts', + '--json' + ]) + ) + expect(invocations[0]).not.toContain( + `${canvasPackage}@latest` + ) + expect( + JSON.parse( + readFileSync( + join(canvasDirectory, 'package.json'), + 'utf8' + ) + ) + ).toMatchObject({ + name: canvasPackage, + version: '1.0.3' + }) + + cleanup() + cleanup = () => undefined + expect(existsSync(canvasDirectory)).toBe(false) + expect(existsSync(koffiDirectory)).toBe(true) + } finally { + cleanup() + rmSync(runtimeRoot, { recursive: true, force: true }) + } + }) + + it.each([ + [ + 'the target package version differs', + { + version: '1.0.4', + resolved: 'https://registry.npmjs.org/canvas.tgz', + integrity: 'sha512-locked' + } + ], + [ + 'target package integrity is missing', + { + version: '1.0.3', + resolved: 'https://registry.npmjs.org/canvas.tgz' + } + ] + ])('fails closed when %s in the lockfile', (_case, targetLock) => { + const canvasPackage = + '@napi-rs/canvas-win32-arm64-msvc' + expect(() => + releaseBuilder.lockedTargetRuntimePackage( + canvasPackage, + { + dependencies: { + '@napi-rs/canvas': '1.0.3' + } + }, + { + packages: { + 'node_modules/@napi-rs/canvas': { + version: '1.0.3', + optionalDependencies: { + [canvasPackage]: '1.0.3' + } + }, + [`node_modules/${canvasPackage}`]: targetLock + } + } + ) + ).toThrow('目标 Runtime 依赖未完整锁定') }) it('validates packed target dependency metadata and archive integrity', () => {