Compare commits

...
15 Commits
Author SHA1 Message Date
mesalogo e35ea7692f test: avoid disk-bound history limit fixtures
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build darwin arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Use real in-memory SQLite for the remote-message ordering and active-task
retention query tests. Both exceeded the CI five-second limit while seeding
502 independently committed records on disk.

Keep the record counts, public database calls, and assertions unchanged.
Disk persistence, transactional migration, and reopen tests remain disk-backed.
Production database behavior is unchanged.
2026-09-09 03:33:25 +08:00
mesalogo 5a77dfdb93 fix: prevent managed runtime stalls during file changes
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Automatic native Git snapshot diffs could block concurrent OpenCode sessions
after broad rewrites or line-ending conversions. Managed desktop and Agent
profiles now skip snapshots that GoodBuddy does not use, while preserving
file tools, native subagents, workspace Git operations, and Ask/Execute rules.

Drain otherwise-unused Continue utility-host stdout to prevent console output
from filling the pipe and blocking requests. Correct the lifecycle regression's
body observer to watch the underlying reader rather than a buffered wrapper.

Prepare Desktop 0.12.10 and Agent 0.11.22 with aligned bilingual notes and
feature documentation. Existing runtimes require restart or Agent update.
Full desktop UI before/after and Linux Agent production-path checks passed;
3626 tests passed, with 61 existing platform/manual skips.

Release note: 修复批量文件改写或换行转换后的托管 OpenCode 并行停顿,以及 Continue 控制台输出阻塞;本机升级重启后生效,远程项目请配套更新 Agent。
2026-09-09 03:07:55 +08:00
mesalogo 177ab2140e fix: align chat messages and composer without horizontal overflow
Move message avatars into outer gutters and align message content with the composer while preserving existing colors and styling. Keep scrollbar space in the outer gutter to prevent horizontal scrolling.

Release note: Chat avatars now sit outside message content, with the composer aligned to message edges and no extra horizontal scrollbar.
2026-09-09 01:51:27 +08:00
mesalogo cbb6398851 fix: resolve npm for portable runtime packaging 2026-09-09 01:43:53 +08:00
mesalogo b1ba14f98e fix: move todo source entry to detail pane 2026-09-09 01:26:10 +08:00
mesalogo e95c7a60d4 chore: prepare 0.12.9 desktop release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Record the approved bilingual notes for todo source entries, accurate
request status, parallel OpenCode reliability, image editing, native
questions, longer Continue requests, reasoning formatting, and activity
history notices. Keep all previously published release entries intact.

Synchronize package and feature-matrix versions for the standard six-target
desktop release. Agent remains 0.11.21; no Agent release or LoongArch preview
is included. Release tags remain pending exact-commit approval and CI.

Validation: 3618 tests passed, 61 skipped; typecheck, lint, and bilingual
release-note verification passed. No local production package was built.
2026-09-08 23:01:14 +08:00
mesalogo c82d0161a9 fix: show accurate request status in message footers
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Requests could appear to keep working while OpenCode waited to retry, and
retry backoff was described as an active attempt. Report native OpenCode
retry timing, distinguish direct-model backoff from dispatch, and show
preparation, tool activity, pending input, and terminal states accurately.

Keep only the message-footer indicator static. Conversation-list activity
indicators, send/stop controls, retry limits, and command replay behavior
remain unchanged. Align the feature documentation with verified behavior.

Validation: 3618 tests passed, 61 skipped; typecheck and lint passed.
Isolated desktop validation used five real text-model calls across three
profiles, with controlled local fault injection for retry transitions.

Release note: 消息底部现在准确区分请求准备、等待重试、开始重试和需要回答的状态,不再用闪烁暗示仍有进展;会话列表闪点及发送、停止行为保持不变。
2026-09-08 22:47:06 +08:00
mesalogo 9b7cf730d0 fix: raise Continue host body limit 2026-09-08 21:58:51 +08:00
mesalogo 22a60497cb fix: preserve reasoning markdown boundaries 2026-09-08 21:55:47 +08:00
mesalogo 78418a56fa fix: remove native question limits 2026-09-08 21:52:10 +08:00
mesalogo fbc0f5e9f3 feat: show source entries in todo view 2026-09-08 21:38:33 +08:00
mesalogo c1f4fd0580 fix: prevent parallel OpenCode conversations from hanging
Starting another conversation could replace a busy local OpenCode server
after a brief health-check failure, leaving the existing conversation
reconnecting to a dead endpoint.

Keep active conversations and native Compact operations on their current
server. Propagate server exit and shutdown to bound requests and cancel
event-stream retry waits so failures are explicit instead of hanging.
Do not automatically replay commands.

Release note: 修复同时使用多个本机 OpenCode 会话时旧会话可能一直等待的问题。服务异常退出时会明确提示失败,不会自动重复执行命令。
2026-09-08 21:10:35 +08:00
mesalogo 28de0c45ba fix: restore image editing requests
Image editing requests included a generation-only response_format field that some compatible providers reject. Editing now sends only supported defaults while preserving explicitly configured custom request fields.\n\nRelease note: 修复部分图像服务商修改图片时报错的问题;参考图编辑现在可以正常提交。
2026-09-08 18:53:15 +08:00
mesalogo 024e6fb4b2 fix: remove legacy activity history warning 2026-09-08 15:56:45 +08:00
mesalogo f95acdc000 fix: restore packaged runtimes and ship macOS DMG only
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Desktop 0.12.7 omitted OpenCode's offline node_modules because the resource
copier excludes node_modules at its source root. Copy from the parent and
verify every prepared configuration file in the final package. Bundle DSH
session projection with the unpacked Host instead of leaving an ASAR-only
external import, and reject external DeepSeek imports in shipped chunks.

Run packaged OpenCode plugin import and the real DSH UtilityProcess handshake
in Windows x64 CI before removing unpacked output. Add regressions using the
actual electron-builder copier and Vite externalization configuration.

Prepare approved Desktop 0.12.8 notes and change macOS defaults and CI to DMG
only. Update aggregation, website downloads and update readers for 12
installers and 20 assets. Current readers retain historical DMG/ZIP support;
the user accepts that older macOS GitHub readers and all older mirror readers
need manual upgrades. Agent remains 0.11.21, with no new Agent or LoongArch tag.

Validation: 3608 tests passed, 0 failed, 61 skipped across 339 files;
typecheck, lint, release-note validation, website static checks and 18 website
tests passed. No local production build or packaged-app launch was performed
at the user's request; actual packaged startup validation runs in CI.

Release note: 修复安装后 OpenCode 离线依赖缺失和 DeepSeek Harness Host 启动退出;macOS 改为仅提供 DMG,受影响旧版用户需从下载页手动升级。
2026-09-08 14:17:38 +08:00
75 changed files with 1586 additions and 257 deletions
+2 -2
View File
@@ -248,7 +248,7 @@ jobs:
- name: Build, sign and notarize macOS release packages
if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'true'
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --format dmg --skip-build
env:
ELECTRON_CACHE: ${{ runner.temp }}/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder
@@ -261,7 +261,7 @@ jobs:
- name: Build unsigned macOS release packages
if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'false'
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build --unsigned
run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --format dmg --skip-build --unsigned
env:
ELECTRON_CACHE: ${{ runner.temp }}/electron
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder
+6 -5
View File
@@ -219,6 +219,7 @@ package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host
桌面包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime:
- OpenCode 平台二进制来自 `.runtime-resources/<arch>`。
- Desktop 的离线配置复制以 `.runtime-resources` 为源、`opencode-config{,/**/*}` 为过滤器,避免 electron-builder 对复制源根目录下 `node_modules` 的固定排除。最终包逐文件核对离线配置与依赖;DSH bootstrap 和 chunk 不得残留外部 `@deepseek-ai/*` 导入。Windows x64 CI 在删除解包目录前实际导入包内 OpenCode 插件并运行隔离的 DSH UtilityProcess 握手与 npm 探针,失败即停止发布。
- OpenCode 离线配置位于 `.runtime-resources/opencode-config`,构建时按已安装 `@opencode-ai/plugin/package.json` 的版本复用,不依赖根 lockfile 或自定义 ready/integrity 标记。缺失、不可读或版本不匹配时,在 staging 中执行精确版本 npm 安装,成功后替换缓存;安装失败保留旧缓存并清理 staging。本机共享配置同样比较随包插件与缓存插件的版本,版本不同时通过 staging 替换。
- 离线依赖安装禁用 npm bin links;远程 Runtime 组包排除不使用的 `.bin` 命令链接,并把复制后的普通依赖文件统一为 `0644`,OpenCode 入口仍为 `0755`。目录元数据、每个依赖文件及许可证继续纳入签名校验。
- Continue Runtime 来自锁定版本的 `@continuedev/cli`。
@@ -397,8 +398,8 @@ DeepSeek Harness Runtime、`app.asar`、目标架构和安装包签名。
npm run release:package -- --platform macos --arch <x64|arm64> --unsigned
```
默认发布产物为 Windows 的 NSIS 安装包与 portable ZIP、macOS 的 DMG 与
ZIP,以及 Linux 的 AppImage、DEB 与 RPM。Linux 原生 Runner 必须安装
默认发布产物为 Windows 的 NSIS 安装包与 portable ZIP、macOS 的 DMG,
以及 Linux 的 AppImage、DEB 与 RPM。Linux 原生 Runner 必须安装
`rpm`/`rpmbuild` 工具后再调用 electron-builder。Windows portable ZIP 解压后可直接
运行 `GoodBuddy.exe`,并包含启用便携数据目录的
`.goodbuddy-portable.json`。每个目标目录都包含带文件大小和 SHA-256 的
@@ -434,8 +435,8 @@ Desktop 正式发布必须把候选分支推送与标签推送拆成两个阶段
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流通过
GitHub OIDC 获取短期 STS 凭据,将发布资产和 `site-release.json` 上传到
北京 OSS 的不可变版本目录,并公开校验 14 个安装包。验证通过后才创建或
更新 draft GitHub Release、上传 22 个 Release 资产并正式发布,最后原子
北京 OSS 的不可变版本目录,并公开校验 12 个安装包。验证通过后才创建或
更新 draft GitHub Release、上传 20 个 Release 资产并正式发布,最后原子
切换官网 `latest.json`。任一步失败都不会提前切换官网最新版本。
同一标签重跑时,工作流会根据 `resources/release-notes.json` 重新生成并
@@ -482,7 +483,7 @@ git push github "$tag"
macOS 发布 job 会先原子判断 Apple 凭据状态:以下五项 Actions Secrets 全部
存在时,使用 Developer ID Application 证书签名,并通过 App Store Connect
API Key 提交 Apple notarization;五项全部缺失时,明确生成未签名、未公证的
DMG 和 ZIP,并在 Actions 日志与摘要中警告 Gatekeeper 限制;只配置一部分时
DMG,并在 Actions 日志与摘要中警告 Gatekeeper 限制;只配置一部分时
任务失败,不能静默降级为未签名包。
- `MACOS_CERTIFICATE_BASE64`:包含证书及私钥的 `.p12` 文件经 Base64 编码后的内容。
+33 -9
View File
@@ -21,7 +21,7 @@ otherwise.
packages target compatible x64 and arm64 environments, including UOS, Kylin,
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
LoongArch has a separate experimental loong64 preview outside standard
releases and automatic updates; no preview is planned for 0.12.7. See the
releases and automatic updates; no preview is planned for 0.12.10. See the
[preview boundaries](./docs/development/loongarch-preview-build.md).
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
accelerator under Platform Features / General. The default remains
@@ -95,8 +95,8 @@ otherwise.
absolute, relative, or symbolic-link directories outside the workspace;
the workspace stays the default and relative-path base. Ask remains read-only.
- [x] **OpenCode and Continue**: Use isolated child processes, an environment
variable allowlist, unified configuration, cancellation, total execution
limits, bounded streaming output, and activity records. Shared process
variable allowlist, unified configuration, cancellation, startup and control-request
deadlines, bounded streaming output, and activity records. Shared process
cleanup preserves complete Windows process-tree termination and terminates
POSIX process groups when a child uses an independent process group. Chat
status checks use a one-shot Runtime probe that is immediately cleaned up
@@ -112,7 +112,12 @@ otherwise.
requests in the same conversation ordered, while different conversations in
the same project and different projects can run in parallel. Request-scoped
dynamic MCP tools remain isolated through default wildcard disablement and
explicit current-request enablement. OpenCode and Continue runs do not stop
explicit current-request enablement. Managed local and remote OpenCode skip
unused automatic Git snapshots to avoid synchronous diff stalls; file tools,
subagents, and workspace Git diffs remain available. Continue continuously
drains unused utility-host stdout so console output cannot fill its pipe.
These changes apply to newly started Runtimes, not external OpenCode Servers.
OpenCode and Continue runs do not stop
at a fixed tool-call or activity count, and every observed tool and Subagent
activity remains in the local conversation instead of being discarded while
the native task continues.
@@ -174,7 +179,7 @@ otherwise.
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
Prompt, provider, or tool replay observed. The current Agent source lock is
`0.11.21`, while the current Desktop release candidate is `0.12.7`; formal
`0.11.22`, while the current Desktop release candidate is `0.12.10`; formal
publication status follows the separate Agent and Desktop
release channels. Current macOS source has passed native package installation,
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
@@ -251,6 +256,12 @@ otherwise.
results without reconstructing missing progress.
- [x] **OpenCode event-stream cleanup**: Each completed or cancelled request
closes its own subscription without cancelling parallel conversations.
- [x] **Accurate message-footer status**: Distinguishes request preparation,
retry waiting, retry dispatch, tool activity, pending answers, and terminal
states. Local OpenCode reports native retry attempts and scheduled times;
direct models report their own backoff phase. Footer dots are static, and
unsupported Runtime retry details are not invented. Conversation-list
indicators and send/stop behavior remain unchanged.
- [x] **Role-bound model connections**: Each role can inherit the default model
or select an independent text-model connection. Invalid connections safely
fall back to the default; the synthesis role always inherits the default.
@@ -432,7 +443,10 @@ otherwise.
demand or before every response, show zero-result, degraded, failed, and
cancelled states, and expose cited context or safely open its source.
- [x] **Magic Notes**: A local-first notes and todo workbench with scope
management, editing, filtering, and controlled AI comments. The left
management, editing, filtering, and controlled AI comments. The todo view
displays the source entry inside the selected item's details, without
showing the previous item's source after a selection change. Failed reads
use application notifications, and missing sources have an explicit fallback. The left
navigation can show the incomplete-todo count, and create, save, and comment
results use application-wide notifications.
- [ ] **MCP Server Control Plane** (planned): Unified MCP lifecycle, health
@@ -449,8 +463,8 @@ otherwise.
duplicate chat Markdown is hidden from the artifact list but not physically
deleted. Run history is stored in Main SQLite without the previous 500-item,
4,000-character, or 2 MB Renderer limits; the page renders it in batches,
and a persistent warning remains when a migrated legacy cache may already
have lost older records or detail. Token usage is grouped by Runtime and
without a persistent legacy-cache truncation warning; previously lost
history is not restored. Token usage is grouped by Runtime and
model and normalizes the different OpenAI-compatible and Anthropic Messages
cache-reporting semantics when showing cache hit rate. Activity is grouped
by conversation and collapsed by default so long histories do not fill the
@@ -567,7 +581,17 @@ otherwise.
build commands.
- [x] **Six-target native release matrix**: Native runners build Windows,
macOS, and Linux `x64` and `arm64` targets with release manifests and SHA-256
hashes. Linux also produces AppImage, DEB, and RPM.
hashes. Windows provides NSIS and portable ZIP, macOS DMG only, and Linux
AppImage, DEB, and RPM, for 12 installers and 20 published assets.
- [x] **Desktop Runtime package verification**: Offline OpenCode dependencies
are copied without electron-builder's root node_modules exclusion; DSH
session projection is bundled into the unpacked Host. Final package checks
compare the offline dependency tree and reject external DeepSeek imports.
Windows x64 CI additionally imports the packaged OpenCode plugin and checks
the real DSH UtilityProcess handshake before publishing.
- [x] **DMG-only update manifests**: Current Desktop and website readers accept
DMG-only and historical DMG/ZIP manifests. Older macOS GitHub readers and all
older mirror readers require a manual upgrade from the download page.
### Open interfaces, team collaboration, and remote execution
+9 -6
View File
@@ -14,7 +14,7 @@
### 桌面基础、工作空间与上下文
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.12.7` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新;`0.12.10` 不构建该预览。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
@@ -30,7 +30,7 @@
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。编辑使用 multipart 上传附件图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node 和 OpenCode 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。macOS 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证;这不等于新版本已经发布。详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
- [ ] **SSH Host 环境准备真实 Host 验收**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵仍待验收,候选 CI/原生打包与系统休眠唤醒也尚未验证;部分开发证据不代表已经发布。
@@ -41,6 +41,7 @@
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个专家并行分析。专家继承父请求 Ask/Execute 模式,可使用已启用的本机直连模型工具,Ask 仍只读;这些工具不是远程 OpenCode 子会话。聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
- [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。
- [x] **准确的消息底部状态**:区分请求准备、等待重试、开始重试、工具活动、待回答和终态;本机 OpenCode 使用原生重试次数和计划时间,直连模型显示自身退避阶段。消息底部状态点保持静态,未接入的 Runtime 重试不编造提示,会话列表闪点及发送、停止行为不变。
- [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
@@ -71,13 +72,13 @@
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;来源记录直接显示在所选待办的详情中,切换事项不会混入上一项的来源内容,读取失败使用应用通知,来源缺失有明确提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
@@ -104,10 +105,12 @@
### 开源、构建与发布
- 当前源码候选为 Desktop `0.12.7`、Agent `0.11.21`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
- 当前源码候选为 Desktop `0.12.10`、Agent `0.11.22`,OpenCode 固定为 `1.18.29`;正式发布状态以 Desktop 与 Agent 的独立发布渠道为准。
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希;Linux 同时生成 AppImage、DEB 和 RPM。
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。Windows 提供 NSIS 与 portable ZIP,macOS 仅 DMG,Linux 提供 AppImage、DEB 和 RPM,共 12 个安装包、20 个发布资产。
- [x] **桌面 Runtime 包校验**:OpenCode 离线依赖复制避开 electron-builder 对源根目录 node_modules 的排除,DSH 会话投影组件打入解包后的 Host。最终包逐文件核对离线依赖并拒绝外部 DeepSeek 导入;Windows x64 CI 还会导入包内 OpenCode 插件、验证真实 DSH UtilityProcess 握手,通过后才发布。
- [x] **DMG-only 更新清单**:当前 Desktop 和官网读取器同时接受仅 DMG 与历史 DMG/ZIP 清单。旧版 macOS 的 GitHub 更新检查及所有旧版镜像更新检查需要从下载页手动升级。
### 开放接口、团队协作与远程执行
+2
View File
@@ -581,6 +581,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
### 13.1 聊天
- 使用 `reading` 壳层,消息流与输入区共享宽度。
- 消息头像放在正文区域外侧:助手头像居左,用户头像居右。输入框左边缘与助手正文左边缘对齐,
右边缘与用户气泡右边缘对齐;两侧为头像保留独立安全边距,滚动条出现或工作栏缩放不得改变对齐。
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.21",
"agentVersion": "0.11.22",
"protocol": {
"major": 2,
"minor": 0
+2 -2
View File
@@ -19,8 +19,8 @@ const manifestName = 'release-manifest.json'
const targetDefinitions = [
{ platform: 'windows', arch: 'x64', formats: ['nsis', 'portable'] },
{ platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'] },
{ platform: 'macos', arch: 'x64', formats: ['dmg', 'zip'] },
{ platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'] },
{ platform: 'macos', arch: 'x64', formats: ['dmg'] },
{ platform: 'macos', arch: 'arm64', formats: ['dmg'] },
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb', 'rpm'] },
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb', 'rpm'] }
]
+30 -2
View File
@@ -42,6 +42,10 @@ const {
const {
readZipCentralDirectory
} = require('./zip-central-directory.cjs')
const {
verifyOpenCodeConfig,
verifyHarnessBundleImports
} = require('./verify-desktop-runtimes.cjs')
const root = join(__dirname, '..')
const packageJson = JSON.parse(
@@ -90,6 +94,8 @@ const portableRequiredFiles = [
'resources/tray-icon.png',
'resources/tool-environment/managed-python-artifacts.json',
'resources/runtimes/opencode/opencode.exe',
'resources/runtimes/opencode-config/node_modules/@opencode-ai/plugin/package.json',
'resources/runtimes/opencode-config/node_modules/@opencode-ai/plugin/dist/index.js',
'resources/runtimes/continue/package.json',
'resources/runtimes/npm/bin/npm-cli.js',
'resources/runtimes/npm/bin/npx-cli.js',
@@ -126,7 +132,7 @@ const platformDefinitions = {
},
macos: {
builderFlag: '--mac',
defaultFormats: ['dmg', 'zip'],
defaultFormats: ['dmg'],
supportedFormats: ['dmg', 'zip'],
unpackedPattern: /^mac(?:-.+)?$/u,
executable: [
@@ -820,6 +826,10 @@ function verifyHarnessPackage(
join(unpackedRoot, ...harnessHostEntry.split('/')),
'DeepSeek Harness 可执行 Host'
)
verifyHarnessBundleImports(
readFileSync(join(unpackedRoot, ...harnessHostEntry.split('/')), 'utf8'),
harnessHostEntry
)
const harnessLlmChunk = [...entries]
.map((entry) => entry.slice(1).split(sep).join('/'))
.find((entry) =>
@@ -868,6 +878,10 @@ function verifyHarnessPackage(
join(unpackedRoot, ...chunkPath.split('/')),
'DeepSeek Harness 可执行 module chunk'
)
verifyHarnessBundleImports(
readFileSync(join(unpackedRoot, ...chunkPath.split('/')), 'utf8'),
chunkPath
)
}
for (const [packageName, expectedVersion] of Object.entries(
harnessPackageVersions
@@ -1080,6 +1094,10 @@ function verifyUnpackedOutput(directory, options) {
'Remote Runtime 锁定清单'
)
assertFile(runtimeExecutable, 'OpenCode Runtime')
verifyOpenCodeConfig(
resources,
join(root, '.runtime-resources', 'opencode-config')
)
assertFile(
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
'Continue Runtime'
@@ -1567,7 +1585,7 @@ function printHelp() {
默认格式:
windows: nsis, portable (ZIP)
macos: dmg, zip
macos: dmg
linux: AppImage, deb, rpm`)
}
@@ -1629,6 +1647,16 @@ async function main(argv = process.argv.slice(2)) {
stagingDirectory,
options
)
if (
process.env.GITHUB_ACTIONS === 'true' &&
options.platform === 'windows' &&
options.arch === 'x64'
) {
await run(process.execPath, [
join(root, 'build', 'run-packaged-deepseek-harness-smoke.cjs'),
unpackedDirectory
])
}
if (
options.platform === 'windows' &&
options.formats.includes('portable')
+33
View File
@@ -0,0 +1,33 @@
const { existsSync } = require("node:fs");
const { join } = require("node:path");
function npmInvocation(projectDir, environment = process.env) {
const npmCli = environment.npm_execpath;
if (npmCli) {
return {
command: process.execPath,
prefixArgs: [npmCli],
};
}
const bundledNpmCli = join(
projectDir,
"node_modules",
"npm",
"bin",
"npm-cli.js",
);
if (existsSync(bundledNpmCli)) {
return {
command: process.execPath,
prefixArgs: [bundledNpmCli],
};
}
return {
command: process.platform === "win32" ? "npm.cmd" : "npm",
prefixArgs: [],
};
}
module.exports = { npmInvocation };
+2 -12
View File
@@ -8,21 +8,11 @@ const {
} = require("node:fs");
const { join } = require("node:path");
const { spawnSync } = require("node:child_process");
const { npmInvocation } = require("./npm-invocation.cjs");
const opencodeVersion = "1.18.29";
const opencodePluginPackage = "@opencode-ai/plugin";
function npmInvocation() {
const npmCli = process.env.npm_execpath;
if (npmCli) {
return { command: process.execPath, prefixArgs: [npmCli] };
}
if (process.platform === "win32") {
throw new Error("npm_execpath is required to prepare OpenCode config");
}
return { command: "npm", prefixArgs: [] };
}
function prepareBundledOpenCodeConfig(projectDir) {
const targetDirectory = join(
projectDir,
@@ -68,7 +58,7 @@ function prepareBundledOpenCodeConfig(projectDir) {
"utf8",
);
writeFileSync(join(stagingDirectory, ".gitignore"), "node_modules\n");
const npm = npmInvocation();
const npm = npmInvocation(projectDir);
const environment = { ...process.env };
delete environment.NODE_TLS_REJECT_UNAUTHORIZED;
const result = spawnSync(
@@ -13,6 +13,7 @@ const {
const { statSync } = require('node:fs')
const { tmpdir } = require('node:os')
const { delimiter, join, resolve } = require('node:path')
const { pathToFileURL } = require('node:url')
const unpackedPath = process.argv[2]
? resolve(process.argv[2])
@@ -109,6 +110,20 @@ async function main() {
const npmProject = join(root, 'npm-project')
const npmFixture = join(root, 'npm-fixture')
await mkdir(project, { recursive: true })
const pluginUrl = pathToFileURL(join(
unpackedPath, 'resources', 'runtimes', 'opencode-config',
'node_modules', '@opencode-ai', 'plugin', 'dist', 'index.js'
)).href
const importedPlugin = await run(
executable,
['--input-type=module', '--eval', `await import(${JSON.stringify(pluginUrl)})`],
{ ...process.env, ELECTRON_RUN_AS_NODE: '1' },
root
)
if (importedPlugin.exitCode !== 0 || importedPlugin.signal) {
throw new Error(`Packaged OpenCode plugin import failed: ${importedPlugin.output.trim()}`)
}
console.log('Packaged OpenCode offline plugin import: ready')
await copyFile(
resolve('build/deepseek-harness-utility-smoke.cjs'),
+2 -18
View File
@@ -17,6 +17,7 @@ const {
opencodeVersion,
prepareBundledOpenCodeConfig,
} = require("./opencode-config.cjs");
const { npmInvocation } = require("./npm-invocation.cjs");
const architectureNames = {
1: "x64",
@@ -48,23 +49,6 @@ async function lockedIntegrity(projectDir, packageName) {
return entry.integrity;
}
function npmInvocation() {
const npmCli = process.env.npm_execpath;
if (npmCli) {
return {
command: process.execPath,
prefixArgs: [npmCli],
};
}
if (process.platform === "win32") {
throw new Error("npm_execpath is required to prepare bundled runtimes");
}
return {
command: "npm",
prefixArgs: [],
};
}
async function downloadPackage(projectDir, packageName, integrity) {
const cacheDirectory = join(projectDir, ".runtime-resources", "cache");
await mkdir(cacheDirectory, { recursive: true });
@@ -80,7 +64,7 @@ async function downloadPackage(projectDir, packageName, integrity) {
await rm(archivePath, { force: true });
}
const npm = npmInvocation();
const npm = npmInvocation(projectDir);
const result = spawnSync(
npm.command,
[
+39
View File
@@ -0,0 +1,39 @@
const { readdirSync, readFileSync, statSync } = require('node:fs')
const { join, relative } = require('node:path')
const { opencodeVersion } = require('./opencode-config.cjs')
function verifyOpenCodeConfig(resources, source) {
const destination = join(resources, 'runtimes', 'opencode-config')
const manifestPath = join(destination, 'node_modules', '@opencode-ai', 'plugin', 'package.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (manifest.name !== '@opencode-ai/plugin' || manifest.version !== opencodeVersion) {
throw new Error('Packaged OpenCode plugin version is invalid')
}
const pending = [source]
let files = 0
while (pending.length > 0) {
const directory = pending.pop()
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const file = join(directory, entry.name)
if (entry.isDirectory()) {
pending.push(file)
} else if (entry.isFile()) {
const target = join(destination, relative(source, file))
if (!statSync(target, { throwIfNoEntry: false })?.isFile() ||
!readFileSync(target).equals(readFileSync(file))) {
throw new Error(`Packaged OpenCode dependency is missing or changed: ${relative(source, file)}`)
}
files++
}
}
}
return files
}
function verifyHarnessBundleImports(source, name) {
if (/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?|\brequire\s*\(\s*)["']@deepseek-ai\//u.test(source)) {
throw new Error(`DeepSeek Harness bundle contains an external DeepSeek dependency: ${name}`)
}
}
module.exports = { verifyOpenCodeConfig, verifyHarnessBundleImports }
+17 -7
View File
@@ -38,9 +38,19 @@ below.
`npm run release:package -- --platform <platform> --arch <arch>`. It only
packages for the native host and writes to
`dist/release/<platform>-<arch>`.
- Default deliverables are NSIS and portable ZIP for Windows, DMG and ZIP for
- Default deliverables are NSIS and portable ZIP for Windows, DMG for
macOS, and AppImage, DEB, and RPM for Linux. Every target includes
`release-manifest.json` with SHA-256 hashes.
- Starting with Desktop 0.12.8, macOS releases contain DMG only. Older macOS
GitHub update readers and all older mirror readers require ZIP and cannot
validate this new matrix; affected users must upgrade manually from the
download page. The user approved this immediate format change. Current
readers accept both the historical DMG/ZIP matrix and the DMG-only matrix.
- Final Desktop package verification compares every bundled OpenCode
configuration file with the prepared dependency tree and rejects external
DeepSeek imports in the unpacked Host and chunks. Windows x64 CI also
imports the packaged OpenCode plugin and starts an isolated packaged
UtilityProcess before publishing; source-only tests do not replace it.
- `build/build-release.cjs` verifies the unpacked application, `app.asar`,
bundled Continue and OpenCode runtimes, executable architecture, and package
signatures before atomically replacing a release directory.
@@ -211,12 +221,12 @@ surface together before a new release.
It must be able to write immutable version objects and the final latest
pointer without granting unrelated administration privileges.
- Upload release assets and `site-release.json` under the immutable
`releases/<tag>/` prefix first. Verify all 14 installer URLs publicly before
`releases/<tag>/` prefix first. Verify all 12 installer URLs publicly before
creating or publishing the GitHub Release. Update
`releases/latest.json` only after the GitHub Release is public and all prior
checks succeeded.
- The expected GitHub Release contains 22 assets: 14 installers (two formats
for each Windows/macOS target and three formats for each Linux target), six
- The expected GitHub Release contains 20 assets: 12 installers (two formats
for each Windows target, DMG for each macOS target, and three formats for each Linux target), six
renamed target manifests, one aggregate `release-manifest.json`, and one `SHA256SUMS`.
`site-release.json` is an OSS publication artifact, not a GitHub Release
asset.
@@ -269,17 +279,17 @@ Do not report a release complete until all of the following are verified:
2. The public GitHub Release is non-draft, non-prerelease, marked Latest, and
uses the expected tag and title. Its body must exactly match the Markdown
generated from the approved packaged bilingual notes.
3. GitHub release metadata contains exactly the expected 22 uploaded asset
3. GitHub release metadata contains exactly the expected 20 uploaded asset
names, including the expected Windows x64 and arm64 assets. Do not download,
hash, launch, or send individual network probes to published installers
unless the user explicitly requests that additional validation.
4. The Beijing `releases/latest.json` returns HTTP 200, has the expected stable
version, exact six targets and 14 installer entries, the trusted Beijing
version, exact six targets and 12 installer entries, the trusted Beijing
URLs, and the GitHub fallback URL. It must match the immutable
`releases/<tag>/site-release.json`. Fetching and comparing these small JSON
metadata files is sufficient; rely on the successful publication job for
its per-asset public checks.
5. The live website successfully fetches the index and produces the 14 correct
5. The live website successfully fetches the index and produces the 12 correct
platform/architecture/format links from that metadata.
6. Both remote `main` refs and both peeled tag refs still equal the approved
release commit, and the local working tree is clean.
+1 -1
View File
@@ -9,7 +9,7 @@
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
| [Runtime 交互边界](./runtime-interactions.md) | 现有原生权限、结构化问答、后台拒绝和非结构化终端交互的适配范围 |
| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 |
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
+70 -3
View File
@@ -1,5 +1,68 @@
# 工作栏实现与验证进度
## 2026-09-09 托管 Runtime 并行阻塞
- Windows 完整隔离测试程序通过界面发送 OpenCode 原生双子代理、直连与 Continue 文件
任务;随后三路上游请求在 917 ms 内发出并重叠,全部完成。普通并发没有复现全局排队。
- 在同一程序中,让真实模型执行一次 4,000 行、约 98 KB 文件的 CRLF → LF 转换,
同时运行另一个会话。原生进程的无凭据存活探针两次延迟 3,301 ms、3,085 ms;
原生热线程持续使用 CPU,而 Main 没有同步满载。操作不是模拟事件或直接调用
Runtime 的脚本,模型请求来自实际输入框和发送按钮。
- 托管 OpenCode 关闭未消费的自动 Git 快照后,完整程序重复相同操作和并行会话,
90 秒观察中的最大原生探针延迟为 210 ms,没有超过 500 ms 的探针,两条请求均完成。
该对照验证已复现的快照阻塞得到消除,不声称已经证明历史 90–125 秒事件的唯一原因。
- Continue 的实际 Electron 原生宿主在不排空 stdout 时被注入的 128 KiB 输出阻塞;
使用当前生产适配器后,输出在 5 ms 内完成,20 次状态读取全部成功,最大 5 ms。
这证明管道修复有效,不代表普通启动约 640 字节的输出就是历史卡顿原因。
- 修复版完整程序再次完成原生双子代理与 Continue 文件任务,实际请求重叠,三个页面
都通过计数按钮功能检查。Windows 诊断与程序验证合计 40 次成功模型调用;
另一次 UI 请求返回账户额度不足的 402,用户充值后才继续,没有把失败当成通过。
- 当前源码 Linux x64 Agent 经桌面生产管理链路完成 Ask 读取和 Execute 换行转换,
共 4 次成功模型调用;最终原生配置确认 `snapshot: false` 且权限仍为 `ask`/`allow`。
此验证覆盖实际 Agent、模型桥、工具完成和关闭,不量化 Linux 提速,也未注入取消。
- 定向回归先验证 7 项失败,再验证 124 项通过、5 项 Windows 平台跳过;
全项目类型检查和修改文件 lint 已通过。首次全量回归为 3625 通过、1 失败、61 跳过;
失败来自生命周期测试的包装流在底层 reader 关闭后仍计数为打开,已通过独立流实验
复现并改为观察 `reader.closed`,没有因此修改生产取消逻辑。最终候选全量回归为
3626 通过、0 失败、61 跳过,`npm run typecheck`、`npm run lint`、
`npm run release:notes:verify`、差异空白与文档相对链接检查通过。本机和远端的
测试进程已回收,安装版和正式 Host 环境未改动;正式发布仍需候选 CI 与资产核实。
运行策略见
[托管 Runtime 并行与输出](./runtime-interactions.md#托管-runtime-的并行与输出)。
## 2026-09-08 消息底部请求状态
- 已接入本机 OpenCode 父会话的原生重试等待及开始事件;直连模型把退避等待与实际发起
重试分开显示,Continue 初始文案改为中性的请求处理。显示规则和未接入边界见
[消息底部请求状态](./runtime-interactions.md#消息底部请求状态)。
- 消息底部按真实事件显示准备、工具、待回答及终态;有新进展时清除旧状态,结束后不
接受迟到状态覆盖。仅该处状态点改为静态,会话列表闪点、发送和停止按钮未改动。
- Windows 隔离开发桌面通过实际 UI 发送验证 OpenCode 的 503/Retry-After 等待、
原生重试开始、真实模型回复与完成;直连模型通过派发前连接拒绝、500 ms 等待、
重试、真实回复与完成。故障由本机测试代理注入,不冒充服务商实际故障。
三个隔离测试配置总计 5 次真实文本模型调用;另有一次直连重试被测试代理的调用
上限明确拒绝,界面正确显示失败,最终单次补测完成。未执行用户命令或修改安装版。
- DOM 状态记录确认底部无闪烁、完成后无旧重试文案,浅色截图可见原生重试计划与原因;
底部 WCAG 2 A/AA 定向检查无违规。最小化窗口的补充截图超时,未作为通过证据。
隔离窗口及测试代理已结束。
- 最终 `npm test -- --reporter=json --outputFile=<临时报告路径>` 为 3618 项通过、
0 项失败、61 项按平台或手动条件跳过;首轮的压缩状态旧断言已修正,途中被用户中断
的运行不计为通过。`npm run typecheck`、`npm run lint`、
`npm run release:notes:verify`、差异空白与相关文档相对链接检查通过。
## 2026-09-08 Desktop 0.12.8 安装包修复
- 真实 `0.12.7` 安装目录确认 OpenCode 配置缺少整个 `node_modules`。新增测试调用实际
electron-builder 资源复制器,复现相同 ENOENT;把复制源上移到 `.runtime-resources`
后,插件及传递依赖逐文件复制验证通过。
- 真实安装的 DSH bootstrap 存在 `dsh-session-projection` 外部导入,而 Host 解包目录没有
对应依赖。将其加入 Vite bundling,配置回归从失败转为通过;最终包会拒绝同类外部导入。
- Windows x64 CI 已接入包内 OpenCode 插件导入、真实 DSH UtilityProcess 握手及 npm
探针,位于解包目录清理和发布之前。用户要求不做本地打包;CI 探针结果尚待本候选执行,
不能把当前源码回归通过当作真实发布包已通过。
- 本次不改变 Agent `0.11.21` 源码或包。macOS 改为 DMG-only,用户明确接受旧版客户端
手动升级影响;格式与发布规则见[发布手册](../../development/release-runbook.md)。
## 2026-09-08 Execute 目录权限等待修复
- 本机 OpenCode 的内部配置显式允许默认工具权限;每次 Ask 仍设置 deny-all 会话规则并
@@ -33,9 +96,13 @@
## 2026-09-08 OpenCode 复用与原生控件恢复
- `OpenCodeRuntime` 复用缓存的 embedded server 前执行有超时的健康检查;健康则保留
client,检查失败、抛错或超时则清除 client、会话映射、会话初始化及待答问题,关闭旧
server 后重建。并发调用共享检查与重建,调用方取消只结束自身等待。
- `OpenCodeRuntime` 仅在没有活动请求时探测缓存的 embedded server;活动对话或
Compact 期间直接复用服务,避免新会话的一次短探测失败关闭其他会话正在使用的进程。
空闲探测失败时仍清除旧 client 和会话映射并重建;并发调用共享检查与重建。
进程退出或服务关闭会中止绑定它的对话与 Compact,事件流重连等待也可立即取消,
请求明确报错,不自动重放已执行命令。本次修复后的真实模型验证共 6 次:正常并发、
已复现场景下的共享服务保护均正常完成;主动结束测试进程后旧请求明确失败,新请求
在替换服务上完成。此前“新会话完成、旧会话持续等待”的故障不再出现。
- 请求级 MCP 注册与断开通过实例内 `mutateMcp` 队列串行执行。仅实际发出 `mcp.add`
的名称进入清理列表;注册尚在排队时取消不追加断开。断开的 1 秒超时从出队后开始。
- `App` 按本机/远程、连接方式、项目和 Runtime 选择组成 scope 缓存原生清单,用请求
@@ -22,4 +22,35 @@ ACP 已提供相同业务问答接口。
使用其非交互参数,或由用户在终端完成;不能把权限确认和业务决策混为一谈。Shell
工具保留其现有取消与超时,不新增日志解析或通用交互代理。
## 托管 Runtime 的并行与输出
- 本机和 Agent 管理的 OpenCode 启动配置都显式设置 `snapshot: false`。GoodBuddy 不消费
原生自动 Git 快照、快照差异摘要或快照撤销;关闭这条内部路径,避免大行级差异的同步
计算阻塞同一原生进程中的其他会话。
- 文件工具、Shell、原生子代理、工作区 Git 状态和单文件差异保持可用;不新增全局会话
队列、请求重放或总执行截止时间。外部 OpenCode Server 的配置不由 GoodBuddy 改写,
已运行的旧原生进程也不会热更新配置。
- Continue 使用 HTTP 传递响应和状态;Electron UtilityProcess 启动后持续排空不用展示的
stdout,避免控制台输出填满管道后阻塞宿主。stderr 的有界错误处理、请求取消与进程
回收保持不变,不把诊断输出复制进聊天或持久日志。
- 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义,
模型桥生成最终配置时同样保留上述快照策略。
## 消息底部请求状态
- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示
“正在准备请求”;没有更具体的活动事件时显示“等待后续进展”,不声称模型正在思考。
- 本机 OpenCode 只把当前父会话的 `session.status.retry` 映射为等待重试,显示原生
`attempt`、计划重试时间和有界脱敏原因;后续 `busy` 才显示已开始重试。其他会话的
状态不串入当前消息,不猜测总重试次数,不把 SDK 事件流重连当作模型重试。
- 显示优先级为待回答、待确认、进行中的上下文压缩、明确的请求状态、待执行或执行中的
工具、未完成的子任务、等待后续进展。压缩沿用已有活动块,不重复增加底部提示。
正文、推理或工具进展会清除旧状态;子任务更新不覆盖父请求的重试提示。
- 完成、失败、取消后保留真实终态,迟到的状态事件不能把请求恢复成处理中。底部状态点
为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。
- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入
的原生重试状态。直连模型的重试等待与请求开始文案见
[直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何
Runtime 的重试上限、请求重放或取消策略。
实现与真实 Runtime 验证证据见[进度记录](./progress.md)。
@@ -141,6 +141,9 @@ Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不
Harness Session 只在当前 Runtime 进程生命周期内存在。释放 GoodBuddy 会话时必须同步释放对应 Harness Agent。
固定 Host 显式加载 `dsh-session-projection` 的内存 SessionProjectionRegistry,这是
`0.1.2-rc.1` AgentLoop 的必需依赖;不加载 Session persistence,也不新增磁盘会话副本。
该依赖与其余 DSH 依赖一起由 Vite 打入 Host,不能从 `app.asar.unpacked` 外部导入
仅存在于 `app.asar` 的 npm 包。最终打包检查及 Windows CI 的真实 UtilityProcess
启动探针见 [Runtime 资源与打包](../../../BUILD.md#runtime-资源)。
## 6. 总体架构
@@ -117,8 +117,12 @@ GoodBuddy 服务崩溃。
- Runtime 初始化、Shell 探测或全局服务失败可通过应用通知提示,并在设置状态卡提供重试。
- 同一错误不同时显示活动块副本和通知副本。
- 失败后保留用户 Prompt、命令摘要、工作目录和已有输出。
- 直连模型自动重试通过现有聊天状态显示“模型网络请求失败,正在重试(n/3)”,不增加
独立按钮、弹窗或通知副本;用户仍可使用当前停止操作取消请求。
- 直连模型自动重试在消息底部区分“模型网络请求失败,等待重试(n/3,s 秒后)”与
“〈模型名〉正在重试请求(n/3),等待响应”;只有退避结束且未取消才显示后者。
初始请求显示“〈模型名〉请求处理中”,不把尚无输出的等待描述成思考。
- 不增加独立按钮、弹窗或通知副本;用户仍可使用当前停止操作取消请求。
底部静态状态点、工具进展、待回答及终态优先规则由
[消息底部请求状态](../assistant-workbar/runtime-interactions.md#消息底部请求状态)统一定义。
## 7. 键盘与无障碍
+16 -3
View File
@@ -2,7 +2,7 @@
## 状态
本文记录截至 2026-09-05 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
本文记录截至 2026-09-09 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
详细事务与验收边界见
[SSH Host 远程环境准备与直连下载设计](./environment-provisioning-technical-design.md);
@@ -11,8 +11,9 @@ Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Ho
Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
Agent 源码 lock 为 `0.11.21`,Desktop 候选为 `0.12.7`,等待各自独立发布审批。
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前源码
候选为 Agent `0.11.22`、Desktop `0.12.10`,包含托管 OpenCode 自动快照策略修复。
正式发布状态以 Agent 与 Desktop 独立发布渠道为准。
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
@@ -67,6 +68,10 @@ Detached GoodBuddy Agent
- Host 地址、用户或 Host Key generation 变化时关闭旧连接,并定向退役依赖旧 Host identity 的 Workspace 和 Runtime 会话。下一次选择使用 Host 管理的当前连接记录解析 current Agent/Runtime,不读取项目中的旧 revision 或组件 identity;当前 registry、连接或 capability 无效时才要求显式修复。
## Agent 安装与生命周期
- 托管 OpenCode 的直接 ACP profile 与模型桥最终配置都设置 `snapshot: false`,避免
GoodBuddy 不使用的自动 Git 快照摘要阻塞原生执行;策略和保留的文件、Git、子代理能力
见 [Runtime 并行与输出边界](../assistant-workbar/runtime-interactions.md#托管-runtime-的并行与输出)。
此设置随新启动的 Agent Runtime 生效,不改写用户仓库或已运行进程的配置。
- Agent bundle 通过 manifest、Ed25519 签名、payload digest、平台和架构校验。
- 外层 `agent-package.json` 描述符上限为 4 MiB,覆盖包含 OpenCode 离线依赖的实际包
@@ -347,6 +352,14 @@ Execute 直接启动已签名 Runtime:
## Agent 开发期间的真实 Host 验证
2026-09-09 已在隔离 Linux x64 Host 验证当前源码的快照配置修复:桌面
`ManagedRemoteExecutionServices` 经 managed ACP、真实 detached Agent 和模型桥启动
OpenCode `1.18.29`,Ask 完成原生读取,Execute 完成 4,000 行文件的 CRLF → LF 转换。
4 次实际 Provider 请求全部为 HTTP 200,最终原生配置在两种模式均为 `snapshot: false`,
权限仍为 `ask`/`allow`,transcript ACK 分别为 15/15 和 12/12。测试 Agent、模型桥、
Runtime 与 relay 均已回收,原有 Host registry 未改变。这是实际链路和关闭验证,
不是 Linux 前后性能对照,也未注入取消;不代替正式发布资产校验。
任何会改变已部署 GoodBuddy Agent 或桌面到 Agent 生产链路的源码改动,都必须在开发期间
使用共享 Linux x64 真实测试 Host 验证。适用范围包括 Agent daemon、Agent protocol
及共享 contract、Agent 组包与安装、attach/update、Runtime 启动与进程归属、Workspace、
+1
View File
@@ -90,6 +90,7 @@ export default defineConfig({
'@deepseek-ai/dsh-sandbox',
'@deepseek-ai/dsh-sandbox-policy',
'@deepseek-ai/dsh-session',
'@deepseek-ai/dsh-session-projection',
'@deepseek-ai/dsh-shell-env',
'@deepseek-ai/dsh-skill',
'@deepseek-ai/dsh-subprocess-local',
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.12.7",
"version": "0.12.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.12.7",
"version": "0.12.10",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.12.7",
"version": "0.12.10",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
@@ -128,10 +128,10 @@
]
},
{
"from": ".runtime-resources/opencode-config",
"to": "runtimes/opencode-config",
"from": ".runtime-resources",
"to": "runtimes",
"filter": [
"**/*"
"opencode-config{,/**/*}"
]
},
{
+104
View File
@@ -1,6 +1,110 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.12.10",
"releasedAt": "2026-09-09",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**OpenCode 并行执行更顺畅**:修复批量改写文件或转换换行符后,原生自动 Git 快照计算可能拖住其他会话的问题。GoodBuddy 托管的本机与远端 OpenCode 不再生成界面未使用的自动快照,文件工具、子代理和工作区 Git 差异仍然可用。",
"**Continue 输出不再堵住宿主**:修复控制台输出较多时可能填满子进程管道、使请求无法继续的问题。现在会持续排空不用展示的输出,正常回复、工具进度和取消仍沿用原有通道。",
"**聊天内容与输入框对齐**:头像移到消息正文外侧,消息边缘与输入框保持一致。滚动条空间留在外侧,避免聊天区域出现多余的横向滚动。",
"**待办来源就在详情里**:魔法笔记把来源记录放入所选待办的详情区域,查看事项和原文不必再找底部面板。切换待办时不会混入上一项的来源内容,读取失败通过应用通知提示。",
"**便携包构建**:修复 Windows 打包准备 Runtime 时依赖特定 npm 环境变量的问题。构建流程现在也能使用项目内 npm 或系统 npm,避免因此中断便携包构建。"
],
"notices": [
"本机修复随升级并重启 Desktop 生效;远程项目请先升级 Desktop 至 0.12.10,再更新 Agent 至 0.11.22。已运行的旧 Runtime 不会热更新,外部 OpenCode Server 的配置不受此调整影响。",
"本次使用 Windows、macOS、Linux 的 x64/arm64 六平台桌面发布矩阵,不构建 LoongArch 实验预览。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Smoother parallel OpenCode execution**: Fixed automatic native Git snapshot calculations that could stall other conversations after broad file rewrites or line-ending conversions. GoodBuddy-managed local and remote OpenCode no longer generate snapshots unused by the interface; file tools, subagents, and workspace Git diffs remain available.",
"**Continue output no longer blocks its host**: Fixed larger console output filling a child-process pipe and preventing requests from progressing. Unused output is now continuously drained, while replies, tool progress, and cancellation keep using their existing channels.",
"**Aligned messages and composer**: Avatars now sit outside message content, with message edges aligned to the composer. Scrollbar space stays in the outer gutter to avoid unnecessary horizontal scrolling in the chat area.",
"**Todo sources inside their details**: Magic Notes now shows the source entry within the selected todo's detail area instead of a separate bottom panel. Switching todos cannot show the previous item's source content, and failed reads use application notifications.",
"**Portable package builds**: Fixed Windows Runtime preparation depending on a particular npm environment variable. Builds can now also use the project's bundled npm or the system npm instead of failing portable packaging for that reason."
],
"notices": [
"Local fixes take effect after upgrading and restarting Desktop. For remote projects, upgrade Desktop to 0.12.10 before updating Agent to 0.11.22. Already-running Runtimes are not hot-updated, and external OpenCode Server configuration is unchanged.",
"This release uses the six standard Windows, macOS, and Linux x64/arm64 desktop targets, without a LoongArch experimental preview."
]
}
}
},
{
"version": "0.12.9",
"releasedAt": "2026-09-08",
"notes": {
"zh-CN": {
"highlights": [],
"features": [
"**待办来源记录**:在魔法笔记的待办视图选择事项后,可直接查看对应的来源记录。遇到读取失败可以重试,不必来回切换查找笔记。"
],
"fixes": [
"**消息状态更准确**:回复底部会区分准备请求、等待重试、开始重试、工具活动和等待回答,本机 OpenCode 还会显示原生重试次数与计划时间。没有新进展时不再用闪烁暗示模型仍在工作,会话列表闪点及发送、停止按钮保持不变。",
"**并行 OpenCode 会话**:修复开启另一条本机会话时,已有会话可能一直等待的问题。服务异常退出时会明确提示失败,不会自动重复执行已运行的命令。",
"**参考图编辑**:修复部分兼容图像服务商拒绝图片编辑请求的问题。使用参考图修改图片时,不再附带只适用于生成接口的默认参数。",
"**原生问答与长上下文**:修复 OpenCode、Continue 的较长问题、较多选项或较长自定义回答被截断或拒绝的问题,并提高 Continue 的请求正文容量。用户可以继续通过现有问答卡片回答,较长上下文不再受旧的小容量限制阻断。",
"**推理排版**:修复 OpenCode 流式推理片段拼接时丢失 Markdown 分段的问题。推理过程中的段落和换行会按原始内容保留。",
"**运行记录提示**:移除常驻的旧版活动历史截断警告,减少与当前执行无关的提示。此调整不会恢复旧版本已经丢失的历史记录。"
],
"notices": []
},
"en-US": {
"highlights": [],
"features": [
"**Todo source entries**: Select an item in the Magic Notes todo view to read its source entry directly. Failed reads can be retried without switching back and forth to find the original note."
],
"fixes": [
"**More accurate message status**: The reply footer distinguishes request preparation, retry waiting, retry dispatch, tool activity, and pending answers; local OpenCode also reports native retry attempts and scheduled times. The footer no longer pulses to imply progress when none is reported, while conversation-list indicators and send/stop buttons remain unchanged.",
"**Parallel OpenCode conversations**: Fixed an issue where opening another local conversation could leave an existing one waiting indefinitely. Unexpected server exits now produce an explicit failure without automatically replaying commands that already ran.",
"**Reference-image editing**: Fixed editing requests rejected by some compatible image providers. Reference-image edits no longer include default parameters that belong only to the generation endpoint.",
"**Native questions and longer context**: Fixed longer questions, larger option lists, and longer custom answers being truncated or rejected in OpenCode and Continue, and increased Continue's request-body capacity. Users can keep answering through the existing question card without longer context hitting the previous small request limit.",
"**Reasoning formatting**: Fixed lost Markdown boundaries when streamed OpenCode reasoning fragments were joined. Paragraphs and line breaks now retain their original structure.",
"**Run-history notices**: Removed the persistent legacy activity-history truncation warning to reduce notices unrelated to current work. This does not restore history already lost in older versions."
],
"notices": []
}
}
},
{
"version": "0.12.8",
"releasedAt": "2026-09-08",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**修复 OpenCode 启动失败。** 安装包补齐离线配置依赖,避免因缺少 `@opencode-ai/plugin/package.json` 无法启动。升级后无需手动安装 Node.js 或 npm 依赖。",
"**修复 DeepSeek Harness Host 启动退出。** 将必需的会话组件完整打入 Host,避免安装后提示“Host 启动前退出(code 1)”。现有模型配置保持不变。",
"**macOS 仅提供 DMG。** Intel 与 Apple 芯片版本不再构建或发布 ZIP。Windows 和 Linux 的安装包格式保持不变。"
],
"notices": [
"旧版 macOS 的 GitHub 更新检查,以及所有平台的旧版镜像更新检查可能无法识别新清单,请从官方下载页手动升级至 `0.12.8`。",
"Agent 继续使用 `0.11.21`,本次不重发 Agent,也不构建 LoongArch 实验预览。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Fixed OpenCode startup failures.** Desktop packages now include the offline configuration dependencies, avoiding startup failures caused by a missing `@opencode-ai/plugin/package.json`. No manual Node.js or npm dependency installation is needed after upgrading.",
"**Fixed DeepSeek Harness Host startup exits.** Required session components are bundled into the Host, preventing the “Host exited before startup (code 1)” error after installation. Existing model settings remain unchanged.",
"**DMG-only macOS releases.** Intel and Apple silicon versions no longer build or publish ZIP packages. Windows and Linux package formats are unchanged."
],
"notices": [
"Older macOS GitHub update checks and older mirror update checks on all platforms may reject the new manifest. Upgrade manually to `0.12.8` from the official download page.",
"Agent remains at `0.11.21`; this release does not republish the Agent or include a LoongArch preview."
]
}
}
},
{
"version": "0.12.7",
"releasedAt": "2026-09-08",
+2 -2
View File
@@ -81,7 +81,7 @@ https://github.com/mesalogo/goodbuddy/releases/latest
校验规则与桌面更新检查保持一致:索引只能指向稳定 SemVer 版本,必须恰好
包含 Windows、macOS、Linux 的 x64 / arm64 六个匹配目标;每个目标必须提供
准确的两种格式和扩展名、正的安全整数大小、64 字符小写十六进制 SHA-256、
准确的格式和扩展名(Windows 两种、macOS 仅 DMG、Linux 三种),正的安全整数大小、64 字符小写十六进制 SHA-256、
全局唯一文件名,以及位于
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/v${version}/`
下、对文件名进行 URL 编码的精确地址。校验清单和 GitHub 回退地址也必须
@@ -90,7 +90,7 @@ https://github.com/mesalogo/goodbuddy/releases/latest
安装包文件名必须与发布生成器完全一致:Windows 使用
`GoodBuddy-${version}-windows-${arch}-setup.exe` 与
`GoodBuddy-${version}-windows-${arch}-portable.zip`;macOS 使用
`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage、DEB 与
`GoodBuddy-${version}-mac-${arch}.dmg`;读取器仍接受历史 DMG/ZIP 清单,但当前页面不提供 macOS ZIP 选项。Linux x64 的 AppImage、DEB 与
RPM 分别使用 electron-builder 的 `x86_64`、`amd64` 与 `x86_64` 架构名。
Linux arm64 的 AppImage/DEB 使用 `arm64`,RPM 使用 `aarch64`。
Linux 处理器选择器另外提供龙芯 `loong64` 实验预览;选中后安装包类型固定为
+1 -1
View File
@@ -261,7 +261,7 @@
<path d="M16.8 12.7c0-2.7 2.2-4 2.3-4.1A5 5 0 0 0 15.2 6c-1.7-.2-3.2 1-4.1 1-.9 0-2.2-1-3.6-1-1.8 0-3.5 1.1-4.5 2.7-2 3.5-.5 8.7 1.4 11.5.9 1.4 2 2.8 3.5 2.7 1.4 0 1.9-.9 3.7-.9 1.7 0 2.2.9 3.7.9s2.5-1.4 3.4-2.7a10 10 0 0 0 1.6-3.3 4.6 4.6 0 0 1-3.5-4.2ZM14.1 4.3A4.7 4.7 0 0 0 15.2 1a4.8 4.8 0 0 0-3.1 1.6A4.4 4.4 0 0 0 11 5.8c1.2.1 2.3-.5 3.1-1.5Z" />
</svg>
</div>
<div><h3>macOS</h3><p>Apple silicon / Intel · DMG / ZIP</p></div>
<div><h3>macOS</h3><p>Apple silicon / Intel · DMG</p></div>
<a
class="button button--download"
href="https://github.com/mesalogo/goodbuddy/releases/latest"
+1 -2
View File
@@ -285,7 +285,7 @@
<path d="M16.8 12.7c0-2.7 2.2-4 2.3-4.1A5 5 0 0 0 15.2 6c-1.7-.2-3.2 1-4.1 1-.9 0-2.2-1-3.6-1-1.8 0-3.5 1.1-4.5 2.7-2 3.5-.5 8.7 1.4 11.5.9 1.4 2 2.8 3.5 2.7 1.4 0 1.9-.9 3.7-.9 1.7 0 2.2.9 3.7.9s2.5-1.4 3.4-2.7a10 10 0 0 0 1.6-3.3 4.6 4.6 0 0 1-3.5-4.2ZM14.1 4.3A4.7 4.7 0 0 0 15.2 1a4.8 4.8 0 0 0-3.1 1.6A4.4 4.4 0 0 0 11 5.8c1.2.1 2.3-.5 3.1-1.5Z" />
</svg>
</div>
<div><h3>macOS</h3><p>Apple 芯片 / Intel · DMG / ZIP</p></div>
<div><h3>macOS</h3><p>Apple 芯片 / Intel · DMG</p></div>
<div class="download-options" data-download-card="macos">
<label>
<span>处理器</span>
@@ -298,7 +298,6 @@
<span>类型</span>
<select data-download-format aria-label="macOS 安装包类型">
<option value="dmg">DMG(推荐)</option>
<option value="zip">ZIP</option>
</select>
</label>
</div>
+7 -4
View File
@@ -27,12 +27,12 @@
"macos-x64": Object.freeze({
platform: "macos",
arch: "x64",
formats: Object.freeze(["dmg", "zip"]),
formats: Object.freeze(["dmg"]),
}),
"macos-arm64": Object.freeze({
platform: "macos",
arch: "arm64",
formats: Object.freeze(["dmg", "zip"]),
formats: Object.freeze(["dmg"]),
}),
"linux-x64": Object.freeze({
platform: "linux",
@@ -143,6 +143,9 @@
for (const key of targetKeys) {
const definition = targetDefinitions[key];
const target = index.targets[key];
const formats = definition.platform === "macos" && target?.files?.zip
? ["dmg", "zip"]
: definition.formats;
assert(
hasExactKeys(target, ["platform", "arch", "files"]) &&
target.platform === definition.platform &&
@@ -150,11 +153,11 @@
`发布目标与键不匹配:${key}`,
);
assert(
hasExactKeys(target.files, definition.formats),
hasExactKeys(target.files, formats),
`发布目标文件格式无效:${key}`,
);
for (const format of definition.formats) {
for (const format of formats) {
const file = target.files[format];
assert(
hasExactKeys(file, ["name", "size", "sha256", "url"]),
+8
View File
@@ -128,6 +128,14 @@ test("accepts the canonical stable six-target release index", () => {
assert.equal(validateReleaseIndex(index), index);
});
test("accepts DMG-only releases with twelve installer entries", () => {
const index = validIndex();
delete index.targets["macos-x64"].files.zip;
delete index.targets["macos-arm64"].files.zip;
assert.equal(validateReleaseIndex(index), index);
assert.equal(Object.values(index.targets).flatMap((target) => Object.values(target.files)).length, 12);
});
test("rejects unstable or non-strict versions and extra top-level fields", () => {
for (const version of ["v1.2.3", "01.2.3", "1.2", "1.2.3-rc.1"]) {
expectRejected((index) => {
+1 -1
View File
@@ -538,7 +538,7 @@ const expectedDownloadOptions = {
},
macos: {
arches: ["arm64", "x64"],
formats: ["dmg", "zip"],
formats: ["dmg"],
},
linux: {
arches: ["x64", "arm64", "loong64"],
+2
View File
@@ -48,6 +48,7 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
export type OpenCodeModelBridgeProviderConfig = {
model: string
snapshot: false
permission: 'ask' | 'allow'
agent: {
title: {
@@ -521,6 +522,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
const baseURL = `${origin}/v1`
return {
model: openCodeModelBridgeModelId(input.protocol, model),
snapshot: false,
permission: input.workMode === 'ask' ? 'ask' : 'allow',
// GoodBuddy owns conversation titles. A first-prompt title request can
// race the real ACP prompt, abandon its Provider response, and correctly
+1
View File
@@ -134,6 +134,7 @@ describe('model bridge loopback helper', () => {
expect(options.env.NODE_OPTIONS).toBeUndefined()
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
expect(config.model).toBe('goodbuddy-anthropic/private-model')
expect(config.snapshot).toBe(false)
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
expect(config.plugin).toHaveLength(1)
}
@@ -24,6 +24,19 @@ afterEach(() => {
})
describe('OpenCode direct launch profile', () => {
it.each(['ask', 'execute'] as const)(
'disables unused automatic Git snapshots for %s',
(workMode) => {
const profile = createOpenCodeLaunchProfile({
...createFixture(),
workMode
})
const config = JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
expect(config.snapshot).toBe(false)
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
}
)
it('runs Ask directly in the Workspace', () => {
const fixture = createFixture()
const profile = createOpenCodeLaunchProfile({
@@ -146,6 +146,8 @@ export function createOpenCodeLaunchProfile(input: {
const environment = {
...environmentBase,
OPENCODE_CONFIG_CONTENT: JSON.stringify({
// Native snapshot summaries are not consumed by GoodBuddy.
snapshot: false,
permission,
agent: { build: { permission } },
}),
+1 -1
View File
@@ -143,7 +143,7 @@ describe('ContinueHostAdapter', () => {
'isHeadless:e.interactivePermissions?!1:e.headless'
)
expect(bundle).toContain('GOODBUDDY_CONTINUE_HOST_TOKEN')
expect(bundle).toContain('json({limit:"20mb"})')
expect(bundle).toContain('json({limit:"100mb"})')
expect(bundle).toContain('listen(i,"127.0.0.1"')
expect(bundle).toContain(
'GOODBUDDY_DISABLE_CONTINUE_UPDATES'
+5 -6
View File
@@ -120,14 +120,13 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
const continueHostQuestionSchema = z
.object({
requestId: z.string().min(1).max(128),
requestId: z.string().min(1),
timestamp: z.number().finite().optional(),
question: z.string().trim().min(1).max(2_000),
question: z.string().trim().min(1),
options: z
.array(z.string().trim().min(1).max(200))
.max(20)
.array(z.string().trim().min(1))
.optional(),
defaultAnswer: z.string().trim().max(2_000).optional()
defaultAnswer: z.string().trim().optional()
})
.strict()
@@ -925,7 +924,7 @@ export class ContinueHostAdapter {
patched = replaceExactly(
patched,
serverMarker,
'let j=(0,atn.default)();if(!process.env.GOODBUDDY_CONTINUE_HOST_TOKEN)throw new Error("Missing GoodBuddy host token");j.use((we,Te,ue)=>{we.headers.authorization===`Bearer ${process.env.GOODBUDDY_CONTINUE_HOST_TOKEN}`?ue():Te.status(401).json({error:"Unauthorized"})}),j.use(atn.default.json({limit:"20mb"})),j.post("/goodbuddy/question-answer",(we,Te)=>{let{requestId:ue,answer:ce,isCustomAnswer:de}=we.body??{};typeof ue==="string"&&ue.length>0&&ue.length<=128&&typeof ce==="string"&&ce.length>0&&ce.length<=2e3?Lbe.answerQuestion(ue,ce,de===!0)?Te.json({success:!0}):Te.status(404).json({error:"Question not pending"}):Te.status(400).json({error:"Invalid question answer"})}),j.get("/state"'
'let j=(0,atn.default)();if(!process.env.GOODBUDDY_CONTINUE_HOST_TOKEN)throw new Error("Missing GoodBuddy host token");j.use((we,Te,ue)=>{we.headers.authorization===`Bearer ${process.env.GOODBUDDY_CONTINUE_HOST_TOKEN}`?ue():Te.status(401).json({error:"Unauthorized"})}),j.use(atn.default.json({limit:"100mb"})),j.post("/goodbuddy/question-answer",(we,Te)=>{let{requestId:ue,answer:ce,isCustomAnswer:de}=we.body??{};typeof ue==="string"&&ue.length>0&&typeof ce==="string"&&ce.length>0?Lbe.answerQuestion(ue,ce,de===!0)?Te.json({success:!0}):Te.status(404).json({error:"Question not pending"}):Te.status(400).json({error:"Invalid question answer"})}),j.get("/state"'
)
patched = replaceExactly(
patched,
+4
View File
@@ -133,6 +133,10 @@ describe('ContinueAgentRuntime', () => {
const events = await collectEvents(runtime)
expect(events[0]).toMatchObject({
type: 'status',
message: 'Continue 正在处理请求'
})
expect(mocks.detectRuntimeBinary).toHaveBeenCalledWith({
binaryPath: '',
bundledPath: undefined,
+1 -1
View File
@@ -504,7 +504,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
yield {
requestId: request.requestId,
type: 'status',
message: 'Continue 正在生成回复'
message: 'Continue 正在处理请求'
}
const execute = request.workMode === 'execute'
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import {
createContinueUtilityProcessChild,
@@ -6,11 +7,12 @@ import {
} from './continue-utility-process-adapter'
import { waitForProcessExit } from './child-process-termination'
function createSource(): ContinueUtilityProcessSource & EventEmitter {
function createSource(stdout?: PassThrough): ContinueUtilityProcessSource & EventEmitter {
const emitter =
new EventEmitter() as ContinueUtilityProcessSource & EventEmitter
Object.defineProperties(emitter, {
pid: { value: 42 },
stdout: { value: stdout },
stderr: { value: undefined }
})
emitter.kill = vi.fn(() => true)
@@ -33,6 +35,17 @@ function createSource(): ContinueUtilityProcessSource & EventEmitter {
}
describe('Continue utility process adapter', () => {
it('drains unused stdout so native output cannot block the host', async () => {
const stdout = new PassThrough({ highWaterMark: 1024 })
createContinueUtilityProcessChild(createSource(stdout))
expect(stdout.readableFlowing).toBe(true)
await new Promise<void>((resolve) => {
stdout.end(Buffer.alloc(128 * 1024), resolve)
})
expect(stdout.readableLength).toBe(0)
})
it('maps Electron exit to close and completes helper waits immediately', async () => {
const source = createSource()
const child = createContinueUtilityProcessChild(source)
@@ -8,6 +8,7 @@ type UtilityErrorListener = (
export type ContinueUtilityProcessSource = {
readonly pid?: number
readonly stdout?: Pick<NodeJS.ReadableStream, 'resume'> | null
readonly stderr?: ContinueHostChild['stderr']
kill(): boolean
onExit(listener: (code: number) => void): void
@@ -20,6 +21,8 @@ export type ContinueUtilityProcessSource = {
export function createContinueUtilityProcessChild(
utility: ContinueUtilityProcessSource
): ContinueHostChild {
// Responses arrive over HTTP, but an unread console pipe can block the host.
utility.stdout?.resume()
let exitCode: number | null = null
let killed = false
const closeListeners = new Map<
+14 -7
View File
@@ -1483,9 +1483,16 @@ describe('ModelAgentRuntime', () => {
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
})
)
const retryWaiting = events.findIndex((event) =>
event.type === 'status' && event.message.includes('等待重试'))
const retryStarted = events.findIndex((event) =>
event.type === 'status' && event.message.includes('正在重试请求'))
const responseStarted = events.findIndex((event) => event.type === 'text')
expect(retryStarted).toBeGreaterThan(retryWaiting)
expect(responseStarted).toBeGreaterThan(retryStarted)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
@@ -1561,7 +1568,7 @@ describe('ModelAgentRuntime', () => {
)
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
message: expect.stringContaining('等待重试')
})
)
})
@@ -1598,7 +1605,7 @@ describe('ModelAgentRuntime', () => {
expect(fetcher).toHaveBeenCalledOnce()
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
message: expect.stringContaining('等待重试')
})
)
})
@@ -1628,12 +1635,12 @@ describe('ModelAgentRuntime', () => {
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status', message: 'sonnet-5 正在思考' }
value: { type: 'status', message: 'sonnet-5 请求处理中' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
}
})
const pendingRetry = stream.next()
@@ -2051,7 +2058,7 @@ describe('ModelAgentRuntime', () => {
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
})
)
expect(events).toContainEqual(
@@ -5548,7 +5555,7 @@ describe('ModelAgentRuntime', () => {
expect(form.get('prompt')).toBe('把背景改成白色')
expect(form.get('n')).toBe('1')
expect(form.get('quality')).toBe('medium')
expect(form.get('response_format')).toBe('b64_json')
expect(form.get('response_format')).toBeNull()
expect(form.get('background')).toBe('transparent')
const uploaded = form.get('image')
expect(uploaded).toBeInstanceOf(Blob)
+18 -4
View File
@@ -1793,7 +1793,12 @@ export class ModelAgentRuntime implements AgentRuntime {
images: NonNullable<AgentExecutionRequest['images']>
): FormData {
const form = new FormData()
const fields = this.createImageGenerationRequest(prompt)
const fields = this.createRequestBody({
model: this.options.model,
prompt: prompt.slice(0, 100_000),
n: 1,
quality: this.options.imageGenerationQuality ?? 'auto'
})
for (const [name, value] of Object.entries(fields)) {
form.append(
name,
@@ -1893,13 +1898,22 @@ export class ModelAgentRuntime implements AgentRuntime {
requestId,
type: 'status',
message:
`模型网络请求失败,正在重试(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length})`
`模型网络请求失败,等待重试(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length},` +
`${modelRequestRetryDelaysMs[retryCount]! / 1_000} 秒后)`
}
await waitForModelRetry(
modelRequestRetryDelaysMs[retryCount]!,
signal
)
signal.throwIfAborted()
yield {
requestId,
type: 'status',
message:
`${this.options.model} 正在重试请求(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length}),等待响应`
}
}
throw new Error('模型请求重试状态无效')
}
@@ -3952,7 +3966,7 @@ export class ModelAgentRuntime implements AgentRuntime {
yield {
requestId: request.requestId,
type: 'status',
message: `${this.options.model} 正在思考`
message: `${this.options.model} 请求处理中`
}
const system = [
@@ -71,6 +71,8 @@ it.skipIf(!existsSync(binaryPath))(
if (!closed) openEventBodies--
closed = true
}
// The wrapper may stay full after fetch aborts, with no further pull.
void reader.closed.then(close, close)
return new Response(new ReadableStream({
async pull(controller) {
try {
+370 -2
View File
@@ -288,6 +288,87 @@ async function collectRun(
}
describe("OpenCodeRuntime embedded launcher", () => {
it.each(["native", "profile"] as const)(
"disables unused automatic Git snapshots with %s model configuration",
async (configuration) => {
const { deps, spawnMock } = dependencies(fakeChild());
const runtime = new OpenCodeRuntime(
options({
modelProfile: configuration === "profile"
? {
id: "00000000-0000-4000-8000-000000000011",
name: "Test model",
baseUrl: "https://model.example/v1",
modelName: "test-model",
protocol: "openai-responses",
authentication: "none",
supportsImageInput: false,
}
: undefined,
}),
deps,
);
try {
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true,
});
const config = JSON.parse(
spawnMock.mock.calls[0]?.[2]?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
);
expect(config.snapshot).toBe(false);
expect(config.permission["*"]).toBe("allow");
} finally {
await runtime.dispose();
}
},
);
it("forwards retry scheduling and resumes only on a matching native busy event", async () => {
const next = Date.now() + 120_000;
const setup = runClient([
{
type: "session.status",
properties: { sessionID: "another-session", status: {
type: "retry", attempt: 9, message: "Unrelated retry", next,
} },
},
{
type: "session.status",
properties: { sessionID: "session-1", status: {
type: "retry", attempt: 2, message: "HTTP 503", next,
} },
},
{
type: "session.status",
properties: { sessionID: "another-session", status: { type: "busy" } },
},
{
type: "session.status",
properties: { sessionID: "session-1", status: { type: "busy" } },
},
{
type: "session.status",
properties: { sessionID: "session-1", status: { type: "busy" } },
},
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
const runtime = embeddedRuntime(setup.client);
try {
const events = await collectRun(runtime);
const statuses = events.filter((event) => event.type === "status");
expect(statuses.map((event) => event.message)).toEqual([
"OpenCode 正在处理请求",
`OpenCode 等待第 2 次重试(计划 ${
new Date(next).toLocaleTimeString("zh-CN", { hour12: false })
}):HTTP 503`,
"OpenCode 已开始重试,等待后续进展",
]);
expect(events.at(-1)).toMatchObject({ type: "done" });
} finally {
await runtime.dispose();
}
});
it("checks embedded availability without starting OpenCode", async () => {
const child = fakeChild();
const {
@@ -594,6 +675,7 @@ describe("OpenCodeRuntime embedded launcher", () => {
spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
) as Record<string, unknown>;
expect(config).toEqual({
snapshot: false,
skills: {
paths: [join(configDirectory!, "skills")],
urls: [],
@@ -1227,6 +1309,179 @@ describe("OpenCodeRuntime embedded launcher", () => {
}
});
it("does not replace a shared server while another conversation is running", async () => {
const setup = runClient([
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
vi.mocked(setup.event.subscribe).mockImplementationOnce(async () => ({
stream: (async function* () {
await gate;
yield { type: "session.idle", properties: { sessionID: "session-1" } };
})(),
}) as never);
const child = fakeChild();
const replacement = fakeChild(43);
const { deps, checkServerHealth, spawnMock } = dependencies(child, {
createClient: () => setup.client,
});
spawnMock.mockReturnValueOnce(child).mockReturnValue(replacement);
const runtime = new OpenCodeRuntime(options(), deps);
const first = collectRun(runtime);
try {
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce());
checkServerHealth.mockResolvedValueOnce(false);
const peerEvents: RuntimeEvent[] = [];
for await (const event of runtime.run(
{
requestId: "peer", conversationId: "peer-conversation",
prompt: "test", workMode: "execute",
},
new AbortController().signal,
)) peerEvents.push(event);
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
expect(peerEvents.at(-1)).toMatchObject({ type: "done" });
expect(checkServerHealth).toHaveBeenCalledOnce();
expect(child.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledOnce();
release();
await expect(first).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
expect(child.kill).toHaveBeenCalledOnce();
expect(spawnMock).toHaveBeenCalledTimes(2);
} finally {
release();
await first.catch(() => undefined);
await runtime.dispose();
}
});
it.each(["exit", "dispose"])(
"settles an active request when its embedded server ends through %s",
async (ending) => {
const setup = runClient([]);
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
stream: (async function* () {
const signal = options!.signal!;
await new Promise<void>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
yield { type: "session.idle", properties: { sessionID: "session-1" } };
})(),
}) as never);
const child = fakeChild();
const { deps } = dependencies(child, { createClient: () => setup.client });
const runtime = new OpenCodeRuntime(options(), deps);
const controller = new AbortController();
let settled = false;
const events: RuntimeEvent[] = [];
const result = (async () => {
try {
for await (const event of runtime.run(
{ requestId: "active", conversationId: "active", prompt: "test", workMode: "execute" },
controller.signal,
)) events.push(event);
} catch (error) {
return error;
} finally {
settled = true;
}
})();
try {
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce());
if (ending === "exit") closeChild(child, 1);
else await runtime.dispose();
await vi.waitFor(() => expect(settled).toBe(true), { timeout: 300 });
await expect(result).resolves.toMatchObject({
message: expect.stringMatching(/OpenCode 服务.*(?:退出|关闭)/u),
});
expect(controller.signal.aborted).toBe(false);
expect(events.some((event) => event.type === "done")).toBe(false);
} finally {
controller.abort();
await result;
await runtime.dispose();
}
},
);
it("cancels an event-stream retry delay when its server exits", async () => {
const setup = runClient([]);
let retrying = false;
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
stream: (async function* () {
retrying = true;
await (options as { sseSleepFn: (milliseconds: number) => Promise<void> })
.sseSleepFn(30_000);
yield { type: "session.idle", properties: { sessionID: "session-1" } };
})(),
}) as never);
const child = fakeChild();
const { deps } = dependencies(child, { createClient: () => setup.client });
const runtime = new OpenCodeRuntime(options(), deps);
const result = collectRun(runtime).catch((error: unknown) => error);
try {
await vi.waitFor(() => expect(retrying).toBe(true));
closeChild(child, 1);
await expect(result).resolves.toMatchObject({
message: expect.stringContaining("OpenCode 服务已退出"),
});
} finally {
await runtime.dispose();
}
});
it.each(["complete", "exit"])(
"protects an active Compact until it ends through %s",
async (ending) => {
const setup = runClient([
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
let release!: () => void;
const summarize = vi.fn((_input, options: { signal: AbortSignal }) =>
new Promise((resolve, reject) => {
release = () => resolve({ data: true });
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
}));
Object.assign(setup.client, {
v2: { session: { context: vi.fn().mockResolvedValue({
data: { data: [{ type: "assistant", model: { providerID: "test", id: "test" } }] },
}) } },
});
Object.assign(setup.client.session, { summarize });
const child = fakeChild();
const { deps, checkServerHealth } = dependencies(child, { createClient: () => setup.client });
const runtime = new OpenCodeRuntime(options(), deps);
try {
await collectRun(runtime);
const compact = runtime.compactConversation({
requestId: "compact", conversationId: "conversation-1",
runtimeSelection: { provider: "opencode" }, history: [], historyMessageIds: [],
}, new AbortController().signal).catch((error: unknown) => error);
await vi.waitFor(() => expect(summarize).toHaveBeenCalledOnce());
const probes = checkServerHealth.mock.calls.length;
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
expect(checkServerHealth).toHaveBeenCalledTimes(probes);
expect(child.kill).not.toHaveBeenCalled();
if (ending === "exit") {
closeChild(child, 1);
await expect(compact).resolves.toMatchObject({
message: expect.stringContaining("OpenCode 服务已退出"),
});
} else {
release();
await expect(compact).resolves.toMatchObject({ result: { compacted: true } });
await runtime.testConnection();
expect(checkServerHealth).toHaveBeenCalledTimes(probes + 1);
}
} finally {
release?.();
await runtime.dispose();
}
},
);
it.each(["unhealthy", "rejected", "hung"])(
"replaces a %s cached server once for concurrent callers",
async (failure) => {
@@ -1902,7 +2157,26 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
);
it.each(["parent", "child"] as const)("parses %s OpenCode questions and sends selected answers back", async (owner) => {
const longQuestion = `请选择实现方式\n${"发布说明内容。".repeat(6_000)}`;
const setup = runClient([
{
id: "question-tool-running",
type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "part-question-1",
callID: "call-question-1",
type: "tool",
tool: "question",
state: {
status: "running",
input: { questions: [] },
time: { start: 1 },
},
},
},
},
{
id: "question-event",
type: "question.asked",
@@ -1912,7 +2186,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
questions: [
{
header: "实现方式",
question: "请选择实现方式",
question: longQuestion,
options: [
{
label: "直接修改",
@@ -1933,6 +2207,25 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
},
},
},
{
id: "question-tool-completed",
type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "part-question-1",
callID: "call-question-1",
type: "tool",
tool: "question",
state: {
status: "completed",
input: { questions: [] },
output: "Answered",
time: { start: 1, end: 2 },
},
},
},
},
{
id: "idle",
type: "session.idle",
@@ -1963,7 +2256,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
questions: [
{
header: "实现方式",
question: "请选择实现方式",
question: longQuestion,
multiple: false,
custom: true,
},
@@ -3245,6 +3538,81 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("preserves markdown boundaries between native reasoning parts", async () => {
const setup = runClient([
{
id: "reasoning-part-1",
type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "reasoning-1",
sessionID: "session-1",
messageID: "message-1",
type: "reasoning",
text: "",
time: { start: 1 },
},
},
},
{
id: "reasoning-delta-1",
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "message-1",
partID: "reasoning-1",
field: "text",
delta: "**Evaluating request limits**",
},
},
{
id: "reasoning-part-2",
type: "message.part.updated",
properties: {
sessionID: "session-1",
part: {
id: "reasoning-2",
sessionID: "session-1",
messageID: "message-1",
type: "reasoning",
text: "",
time: { start: 2 },
},
},
},
{
id: "reasoning-delta-2",
type: "message.part.delta",
properties: {
sessionID: "session-1",
messageID: "message-1",
partID: "reasoning-2",
field: "text",
delta: "**Planning limit removals**",
},
},
{
id: "idle",
type: "session.idle",
properties: { sessionID: "session-1" },
},
]);
const runtime = embeddedRuntime(setup.client);
const events = await collectRun(runtime);
expect(
events
.filter((event) => event.type === "reasoning")
.map((event) => event.type === "reasoning" ? event.delta : ""),
).toEqual([
"**Evaluating request limits**",
"\n\n**Planning limit removals**",
]);
await runtime.dispose();
});
it("auto-allows bounded fallback permission requests without GoodBuddy approval", async () => {
const { client, permissionReply } = runClient([
permissionEvent(),
+114 -36
View File
@@ -20,6 +20,7 @@ import {
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
import type {
AgentQuestionAnswer,
@@ -80,9 +81,6 @@ const MAX_PERMISSION_PATTERN_LENGTH = 1_024;
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024;
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024;
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024;
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024;
const MAX_QUESTIONS_PER_REQUEST = 4;
const MAX_QUESTION_OPTIONS = 20;
const MAX_NATIVE_AGENTS = runtimeNativeInventoryLimits.agents;
const MAX_NATIVE_TOOLS = runtimeNativeInventoryLimits.tools;
const MAX_NATIVE_COMMANDS = runtimeNativeInventoryLimits.commands;
@@ -168,6 +166,8 @@ type OpenCodeProviderDescriptor = {
type OpenCodeServer = {
url: string;
authorization: string;
signal: AbortSignal;
activeRequests: number;
close: () => Promise<void>;
};
@@ -287,6 +287,14 @@ function executionDeadlineLabel(milliseconds: number): string {
: `${milliseconds} 毫秒`;
}
function eventSubscriptionOptions(signal: AbortSignal) {
return {
signal,
sseSleepFn: (milliseconds: number) =>
delay(milliseconds, undefined, { signal }),
};
}
function awaitWithAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
@@ -373,29 +381,22 @@ function parseQuestionRequest(
if (
typeof id !== "string" ||
id.length === 0 ||
id.length > MAX_PERMISSION_NAME_LENGTH ||
!Array.isArray(questions) ||
questions.length === 0 ||
questions.length > MAX_QUESTIONS_PER_REQUEST ||
!questions.every(
(question) =>
isRecord(question) &&
typeof question.question === "string" &&
question.question.trim().length > 0 &&
question.question.length <= 2_000 &&
typeof question.header === "string" &&
question.header.trim().length > 0 &&
question.header.length <= 120 &&
Array.isArray(question.options) &&
question.options.length <= MAX_QUESTION_OPTIONS &&
question.options.every(
(option) =>
isRecord(option) &&
typeof option.label === "string" &&
option.label.trim().length > 0 &&
option.label.length <= 200 &&
typeof option.description === "string" &&
option.description.length <= 1_000,
typeof option.description === "string",
) &&
(question.multiple === undefined ||
typeof question.multiple === "boolean") &&
@@ -405,22 +406,11 @@ function parseQuestionRequest(
(!isRecord(tool) ||
typeof tool.messageID !== "string" ||
tool.messageID.length === 0 ||
tool.messageID.length > 256 ||
typeof tool.callID !== "string" ||
tool.callID.length === 0 ||
tool.callID.length > 256))
tool.callID.length === 0))
) {
throw new Error("OpenCode 提问请求格式无效");
}
let serialized: string;
try {
serialized = JSON.stringify(properties);
} catch {
throw new Error("OpenCode 提问请求无法序列化");
}
if (!byteLengthWithin(serialized, MAX_QUESTION_REQUEST_BYTES)) {
throw new Error("OpenCode 提问请求超过安全限制");
}
return properties as QuestionRequest;
}
@@ -1099,14 +1089,12 @@ export class OpenCodeRuntime implements AgentRuntime {
env.XDG_DATA_HOME = join(registration.root, "xdg-data");
env.XDG_STATE_HOME = join(registration.root, "xdg-state");
const skillConfig = createOpenCodeSkillConfig(registration, skillIds);
env.OPENCODE_CONFIG_CONTENT = JSON.stringify(
profile
? {
...createOpenCodeProviderConfig(profile),
...skillConfig,
}
: skillConfig,
);
// GoodBuddy does not use native Git snapshots or their synchronous diffs.
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
...(profile ? createOpenCodeProviderConfig(profile) : {}),
...skillConfig,
snapshot: false,
});
if (!profile && this.options.configPath.trim()) {
env.OPENCODE_CONFIG = resolve(this.options.configPath);
}
@@ -1171,10 +1159,24 @@ export class OpenCodeRuntime implements AgentRuntime {
}
stdout?.resume();
stderr?.resume();
const lifetime = new AbortController();
child.once("close", (code: number | null) => {
lifetime.abort(new Error(
`OpenCode 服务已退出(code ${code ?? "unknown"}),请重试当前请求`,
));
});
child.once("error", () => {
lifetime.abort(new Error(
"OpenCode 服务发生错误并已关闭,请重试当前请求",
));
});
resolveServer({
url,
authorization,
signal: lifetime.signal,
activeRequests: 0,
close: async () => {
lifetime.abort(new Error("OpenCode 服务已关闭,请重试当前请求"));
try {
const exited = this.waitForExit(child);
this.terminate(child);
@@ -1315,6 +1317,10 @@ export class OpenCodeRuntime implements AgentRuntime {
signal?: AbortSignal,
): Promise<OpencodeClient> {
if (this.client && this.server) {
// A short probe must not replace a server still serving another request.
if (this.server.activeRequests > 0 && !this.server.signal.aborted) {
return this.client;
}
const probeSignal = AbortSignal.any([
...(signal ? [signal] : []),
AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS),
@@ -2027,6 +2033,27 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error("当前模型连接未启用图像输入");
}
const client = await this.getClient(signal);
const server = this.server;
const requestSignal = server
? AbortSignal.any([signal, server.signal])
: signal;
if (server) server.activeRequests += 1;
try {
requestSignal.throwIfAborted();
yield* this.runWithClient(request, requestSignal, client);
} catch (error) {
requestSignal.throwIfAborted();
throw error;
} finally {
if (server) server.activeRequests -= 1;
}
}
private async *runWithClient(
request: AgentExecutionRequest,
signal: AbortSignal,
client: OpencodeClient,
): AsyncGenerator<RuntimeEvent, void, void> {
const directory = this.options.defaultWorkspace;
const runtimeControl =
request.runtimeControl?.provider === "opencode"
@@ -2290,7 +2317,10 @@ export class OpenCodeRuntime implements AgentRuntime {
const subscription = await this.controlRequest(
"订阅事件流",
(controlSignal) =>
client.event.subscribe({ directory }, { signal: controlSignal }),
client.event.subscribe(
{ directory },
eventSubscriptionOptions(controlSignal),
),
AbortSignal.any([signal, subscriptionController.signal]),
);
@@ -2316,6 +2346,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
>();
const reasoningPartIds = new Set<string>();
let lastReasoningPartId: string | undefined;
const subagentProgress = new OpenCodeSubagentProgress(
request.requestId,
sessionId,
@@ -2433,6 +2464,7 @@ export class OpenCodeRuntime implements AgentRuntime {
return false;
};
const reportedMessageIds = new Set<string>();
let waitingForRetry = false;
for await (const event of subscription.stream) {
const childProgress = subagentProgress.update(event);
if (
@@ -2446,6 +2478,32 @@ export class OpenCodeRuntime implements AgentRuntime {
if (task) task.subagent = childProgress;
yield childProgress;
}
if (
event.type === "session.status" &&
event.properties.sessionID === sessionId
) {
const status = event.properties.status;
if (status.type === "retry") {
waitingForRetry = true;
const scheduled = Number.isSafeInteger(status.next) && status.next > 0
? `(计划 ${new Date(status.next).toLocaleTimeString("zh-CN", { hour12: false })})`
: "";
yield {
requestId: request.requestId,
type: "status",
message: `OpenCode 等待第 ${status.attempt} 次重试${scheduled}:${
safeToolErrorDetail(status.message, 1_000) || "模型请求失败"
}`,
};
} else if (status.type === "busy" && waitingForRetry) {
waitingForRetry = false;
yield {
requestId: request.requestId,
type: "status",
message: "OpenCode 已开始重试,等待后续进展",
};
}
}
if (
event.type === "message.updated" &&
event.properties.sessionID === sessionId &&
@@ -2484,7 +2542,16 @@ export class OpenCodeRuntime implements AgentRuntime {
) {
hasResponseTextAfterFailure = true;
}
const retained = retainOutputDelta(event.properties.delta);
const delta =
reasoning &&
lastReasoningPartId !== undefined &&
lastReasoningPartId !== event.properties.partID
? `\n\n${event.properties.delta}`
: event.properties.delta;
if (reasoning) {
lastReasoningPartId = event.properties.partID;
}
const retained = retainOutputDelta(delta);
if (retained.delta) {
yield {
requestId: request.requestId,
@@ -2516,6 +2583,7 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error("OpenCode 工具调用 ID 格式无效");
}
const toolName = part.tool.slice(0, 200);
const nativeControlTool = toolName.trim().toLowerCase() === "question";
const state =
part.state.status === "error" ? "failed" : part.state.status;
if (state === "failed") {
@@ -2556,7 +2624,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const retained = subagentProgress.retain(subagent);
toolStates.get(callId)!.subagent = retained;
yield retained;
} else {
} else if (!nativeControlTool) {
yield {
requestId: request.requestId,
type: "tool",
@@ -2942,10 +3010,11 @@ export class OpenCodeRuntime implements AgentRuntime {
configuredTimeout,
);
deadlineTimer?.unref?.();
const executionSignal = deadline
let executionSignal = deadline
? AbortSignal.any([signal, deadline.signal])
: signal;
let releaseConversation: (() => void) | undefined;
let server: OpenCodeServer | undefined;
try {
releaseConversation = await this.acquireConversationRun(
request.conversationId,
@@ -2954,6 +3023,13 @@ export class OpenCodeRuntime implements AgentRuntime {
const client = this.sessions.has(request.conversationId)
? await this.getClient(executionSignal)
: undefined;
if (client) {
server = this.server;
if (server) {
server.activeRequests += 1;
executionSignal = AbortSignal.any([executionSignal, server.signal]);
}
}
const sessionId = this.sessions.get(request.conversationId);
if (!sessionId || !client) {
return {
@@ -3012,7 +3088,7 @@ export class OpenCodeRuntime implements AgentRuntime {
(controlSignal) =>
client.event.subscribe(
{ directory: this.options.defaultWorkspace },
{ signal: controlSignal },
eventSubscriptionOptions(controlSignal),
),
subscriptionSignal,
);
@@ -3094,11 +3170,13 @@ export class OpenCodeRuntime implements AgentRuntime {
if (deadline?.signal.aborted && !signal.aborted) {
throw deadline.signal.reason;
}
executionSignal.throwIfAborted();
throw error;
} finally {
if (deadlineTimer) {
clearTimeout(deadlineTimer);
}
if (server) server.activeRequests -= 1;
releaseConversation?.();
}
}
@@ -1965,8 +1965,10 @@ describe('AssistantDatabase', () => {
database.close()
})
it('returns the latest 500 remote messages in chronological order', async () => {
const database = await createDatabase()
it('returns the latest 500 remote messages in chronological order', () => {
// Exercise the query boundary without 502 disk-synchronized fixture writes.
const database = new AssistantDatabase(':memory:')
database.initialize('C:\\Workspace')
const project = database.ensureChannelProjects(
'C:\\Users\\test',
channelDefaultProfileId
+1
View File
@@ -389,6 +389,7 @@ const launchContinueHost: ContinueHostLauncher = (
get pid() {
return utilityChild.pid
},
stdout: utilityChild.stdout,
stderr: utilityChild.stderr,
kill: () => utilityChild.kill(),
onExit: (listener) => {
@@ -621,8 +621,11 @@ describe('KnowledgeDatabase', () => {
})
})
it('lists every active task in addition to the terminal history limit', async () => {
const { database } = await createDatabase()
it('lists every active task in addition to the terminal history limit', () => {
// This checks retention, not the disk latency of 502 independent writes.
const database = new KnowledgeDatabase(':memory:')
database.initialize()
openDatabases.push(database)
const library = database.createKnowledgeBase({
name: 'Task listing',
storageMode: 'reference'
+40
View File
@@ -99,6 +99,46 @@ function successfulFetch(): ReturnType<typeof vi.fn<typeof fetch>> {
})
}
describe('DMG-only release updates', () => {
it.each([false, true])('accepts macOS GitHub releases (legacy ZIP: %s)', async (legacyZip) => {
const formats = legacyZip ? ['dmg', 'zip'] : ['dmg']
const macFiles = formats.map((format, index) => ({
name: `GoodBuddy-${latestVersion}-mac-arm64.${format}`,
size: 100 + index,
sha256: 'a'.repeat(64)
}))
const manifest = manifestPayload({
targets: [{
platform: 'macos', arch: 'arm64', formats,
manifest: 'release-manifest-macos-arm64.json', files: macFiles
}],
files: macFiles.map((file) => ({ ...file, platform: 'macos', arch: 'arm64' }))
})
const fetch = vi.fn<typeof globalThis.fetch>(async (input) =>
jsonResponse(String(input) === GOODBUDDY_LATEST_RELEASE_API_URL
? releasePayload() : manifest)
)
const result = await checkForUpdates({
currentVersion: '1.0.0', platform: 'darwin', arch: 'arm64', fetch
})
expect(result.target.formats).toEqual(formats)
})
it('accepts a DMG-only mirror for Windows and macOS clients', async () => {
const index = mirrorIndexPayload()
delete index.targets['macos-x64']!.files.zip
delete index.targets['macos-arm64']!.files.zip
for (const platform of ['win32', 'darwin'] as const) {
const result = await checkMirrorForUpdates({
currentVersion: '1.0.0', platform, arch: 'x64',
fetch: vi.fn(async () => jsonResponse(index))
})
expect(result.updateAvailable).toBe(true)
if (platform === 'darwin') expect(result.target.formats).toEqual(['dmg'])
}
})
})
type MirrorTestFile = {
name: string
size: number
+11 -5
View File
@@ -336,10 +336,16 @@ function isCanonicalReleaseAssetApiUrl(value: string): boolean {
const expectedFormats: Record<ReleasePlatform, string[]> = {
windows: ['nsis', 'portable'],
macos: ['dmg', 'zip'],
macos: ['dmg'],
linux: ['AppImage', 'deb', 'rpm']
}
function formatsForTarget(platform: ReleasePlatform, includesZip: boolean): string[] {
return platform === 'macos' && includesZip
? ['dmg', 'zip']
: expectedFormats[platform]
}
function hasExpectedFileFormats(
platform: ReleasePlatform,
files: ReleaseFile[]
@@ -352,7 +358,7 @@ function hasExpectedFileFormats(
}
const extensions =
platform === 'macos'
? ['.dmg', '.zip']
? files.length === 2 ? ['.dmg', '.zip'] : ['.dmg']
: ['.AppImage', '.deb', '.rpm']
return extensions.every(
(extension) =>
@@ -418,7 +424,7 @@ function validateMirrorIndex(
if (!target || `${target.platform}-${target.arch}` !== key) {
throw new Error(`Mirror release target is invalid: ${key}`)
}
const formats = expectedFormats[target.platform]
const formats = formatsForTarget(target.platform, Boolean(target.files.zip))
if (
Object.keys(target.files).length !== formats.length ||
formats.some((format) => !target.files[format])
@@ -454,7 +460,7 @@ function validateMirrorIndex(
if (!target) {
throw new Error(`Mirror release target is missing: ${platform}/${arch}`)
}
const formats = expectedFormats[platform]
const formats = formatsForTarget(platform, Boolean(target.files.zip))
return {
platform,
arch,
@@ -487,7 +493,7 @@ function validateCurrentTarget(
if (!target) {
throw new Error('Release manifest target is missing')
}
const formats = expectedFormats[platform]
const formats = formatsForTarget(platform, target.formats.includes('zip'))
if (
target.formats.length !== formats.length ||
!formats.every((format, index) => target.formats[index] === format) ||
-18
View File
@@ -663,24 +663,6 @@ describe('ActivityPanel', () => {
expect(screen.getByText('活动 500')).toBeInTheDocument()
})
it('warns when the migrated legacy history may already be incomplete', () => {
render(
<ActivityPanel
legacyHistoryMayBeIncomplete
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[makeRecord(1)]}
tokenUsage={makeTokenUsage()}
/>
)
expect(
screen.getByText(
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。'
)
).toHaveAttribute('role', 'status')
})
it('shows totals without double-counting cache tokens', () => {
render(
<ActivityPanel
-8
View File
@@ -35,7 +35,6 @@ type ActivityActorKind =
const activityRenderBatchSize = 500
export type ActivityPanelProps = {
legacyHistoryMayBeIncomplete?: boolean
projects?: readonly AssistantProject[]
records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary
@@ -240,7 +239,6 @@ function groupActivityRecordsByProject(
}
export function ActivityPanel({
legacyHistoryMayBeIncomplete = false,
projects = [],
records,
tokenUsage,
@@ -702,12 +700,6 @@ export function ActivityPanel({
scope={{ kind: 'all-projects' }}
title={t('header.title')}
/>
{legacyHistoryMayBeIncomplete && (
<p className="activity-panel__legacy-warning" role="status">
{t('records.legacyHistoryMayBeIncomplete')}
</p>
)}
<PageTabs
ariaLabel={t('tabs.ariaLabel')}
idPrefix="activity-view"
-1
View File
@@ -122,7 +122,6 @@ export function AgentQuestionCard({
<span>{t('question.otherAnswer')}</span>
<input
disabled={submitting}
maxLength={2_000}
onChange={(event) => {
const answer = event.target.value
setCustom((current) =>
+46 -19
View File
@@ -3378,9 +3378,9 @@ describe("App", () => {
});
expect(
screen
.getByText("正在连接 Agent Runtime")
.getByText("正在准备请求")
.querySelector(".message__status-dot"),
).toHaveClass("message__status-dot--active");
).not.toHaveClass("message__status-dot--active");
const userMessage = screen
.getAllByText("帮我分析项目")
.map((element) => element.closest("article"))
@@ -3824,6 +3824,41 @@ describe("App", () => {
expect(screen.getByText("正在分析真实推理内容")).toBeVisible();
});
it("keeps request status truthful through retry, progress, and completion", async () => {
render(<App />);
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "检查请求状态" },
});
await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled());
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const request = run.mock.calls[0]![0];
const retry = "OpenCode 等待第 2 次重试(计划 12:34:56):HTTP 503";
expect(screen.getByRole("status", { name: "正在准备请求" })).toBeVisible();
act(() => agentListener?.({
requestId: request.requestId, type: "status", message: retry,
}));
const footer = screen.getByRole("status", { name: retry });
expect(footer).toBeVisible();
expect(footer.querySelector(".message__status-dot--active")).toBeNull();
expect(screen.queryByText("正在准备请求")).not.toBeInTheDocument();
act(() => agentListener?.({
requestId: request.requestId, type: "text", delta: "恢复后的实际回复",
}));
expect(screen.queryByText(retry)).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "等待后续进展" })).toBeVisible();
act(() => agentListener?.({
requestId: request.requestId, type: "done",
}));
expect(screen.queryByText("等待后续进展")).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "任务执行完成" })).toBeVisible();
act(() => agentListener?.({
requestId: request.requestId, type: "status", message: retry,
}));
expect(screen.queryByText(retry)).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "任务执行完成" })).toBeVisible();
});
it("updates context usage after model responses and keeps compression status", async () => {
const settings = await api.settings.getRuntime();
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
@@ -3903,11 +3938,11 @@ describe("App", () => {
agentListener?.({
requestId: request.requestId,
type: "status",
message: "sonnet-5 正在思考",
message: "sonnet-5 请求处理中",
});
});
expect(screen.getByText("正在压缩较早对话…")).toBeInTheDocument();
expect(screen.getByText("sonnet-5 正在思考")).toBeInTheDocument();
expect(screen.queryByText("sonnet-5 请求处理中")).not.toBeInTheDocument();
act(() => {
agentListener?.({
@@ -6281,9 +6316,9 @@ describe("App", () => {
}
expect(
screen
.getByText("正在连接 Agent Runtime")
.getByText("正在准备请求")
.querySelector(".message__status-dot"),
).toHaveClass("message__status-dot--active");
).not.toHaveClass("message__status-dot--active");
expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument();
vi.mocked(api.conversationQueue.ready).mockClear();
return {
@@ -6350,7 +6385,7 @@ describe("App", () => {
).toBeInTheDocument();
await waitFor(() =>
expect(
screen.queryByText("正在连接 Agent Runtime"),
screen.queryByText("正在准备请求"),
).not.toBeInTheDocument(),
);
expect(document.querySelector(".message__status-dot--active")).toBeNull();
@@ -6397,7 +6432,7 @@ describe("App", () => {
await screen.findByText("远端 Agent 执行失败"),
).toBeInTheDocument();
expect(
screen.queryByText("正在连接 Agent Runtime"),
screen.queryByText("正在准备请求"),
).not.toBeInTheDocument();
expect(document.querySelector(".message__status-dot--active")).toBeNull();
await waitFor(() =>
@@ -6427,7 +6462,7 @@ describe("App", () => {
expect(api.conversations.list).toHaveBeenCalledTimes(2),
);
// The local streaming copy still wins over a non-terminal snapshot.
expect(screen.getByText("正在连接 Agent Runtime")).toBeInTheDocument();
expect(screen.getByText("正在准备请求")).toBeInTheDocument();
expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument();
expect(api.conversationQueue.ready).not.toHaveBeenCalled();
@@ -6447,7 +6482,7 @@ describe("App", () => {
await screen.findByText("聚焦后读到的最终回复"),
).toBeInTheDocument();
expect(
screen.queryByText("正在连接 Agent Runtime"),
screen.queryByText("正在准备请求"),
).not.toBeInTheDocument();
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
@@ -10639,7 +10674,7 @@ describe("App", () => {
);
});
it("migrates legacy activity and keeps its possible-loss warning visible", async () => {
it("migrates legacy activity", async () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([
@@ -10673,14 +10708,6 @@ describe("App", () => {
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull();
});
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
expect(
await screen.findByText(
"旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。",
{},
{ timeout: 3_000 },
),
).toBeInTheDocument();
});
it("marks the current primary navigation page and hides decorative icons", async () => {
+2 -4
View File
@@ -4431,6 +4431,7 @@ function App(): React.JSX.Element {
...message,
tools,
blocks,
status: undefined,
};
});
} else if (event.type === "subagent") {
@@ -7015,7 +7016,7 @@ function App(): React.JSX.Element {
blocks: [],
createdAt: Date.now(),
state: "streaming",
status: t("runtime.connecting"),
status: t("chat.status.preparingRequest"),
};
activeRuns.current.set(requestId, {
@@ -10600,9 +10601,6 @@ function App(): React.JSX.Element {
}
>
<ActivityPanel
legacyHistoryMayBeIncomplete={
legacyActivityHistoryMayBeIncomplete
}
onClear={() => {
legacyActivityHistoryMayBeIncompleteRef.current = false;
setLegacyActivityHistoryMayBeIncomplete(false);
+48 -4
View File
@@ -66,6 +66,48 @@ describe('ChatTimeline', () => {
vi.clearAllMocks()
})
it('shows only known waiting and tool states in a static message footer', () => {
const props = {
artifactById: new Map(), conversationId: 'status-conversation',
hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const,
messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1
}
const message: Message = {
id: 'status-message', role: 'assistant', content: '已有内容',
createdAt: Date.now(), state: 'streaming'
}
const { rerender } = render(<ChatTimeline {...props} messages={[message]} />)
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
expect(document.querySelector('.message__status-dot--active')).toBeNull()
const tool = { callId: 'read-1', name: 'read', summary: 'Read file' }
rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'pending' }]
}]} />)
expect(screen.getByRole('status', { name: '工具待执行:read' })).toBeVisible()
rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'running' }]
}]} />)
expect(screen.getByRole('status', { name: '工具执行中:read' })).toBeVisible()
rerender(<ChatTimeline {...props} messages={[{
...message, tools: [{ ...tool, state: 'completed' }]
}]} />)
expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible()
rerender(<ChatTimeline {...props} messages={[{
...message, status: 'OpenCode 正在处理请求',
question: {
requestId: 'question-request', type: 'question', questionId: 'q-1',
questions: [{ header: '确认', question: '继续吗?', options: [], custom: true, multiple: false }]
}
}]} />)
expect(screen.getByRole('status', { name: '等待你的回答' })).toBeVisible()
expect(screen.queryByText('OpenCode 正在处理请求')).not.toBeInTheDocument()
rerender(<ChatTimeline {...props} messages={[{
...message, state: 'error', status: '模型请求失败'
}]} />)
expect(screen.getByRole('alert', { name: '模型请求失败' })).toBeVisible()
expect(screen.queryByText('等待后续进展')).not.toBeInTheDocument()
})
it('uses actual message heights for the conversation scroll range', () => {
const rule = stylesheet.match(/\.message\s*\{([^}]*)\}/u)?.[1]
@@ -74,11 +116,11 @@ describe('ChatTimeline', () => {
expect(rule).not.toContain('contain-intrinsic')
})
it('places message identity above the message body', () => {
it.each(['assistant', 'user'] as const)('places the %s avatar outside the header and body', (role) => {
const messages: Message[] = [
{
id: 'assistant-message',
role: 'assistant',
role,
content: 'Answer',
createdAt: 1_775_000_000_000,
state: 'complete'
@@ -103,9 +145,11 @@ describe('ChatTimeline', () => {
const header = message?.querySelector('.message__header')
const body = message?.querySelector('.message__body')
expect(header).toContainElement(message?.querySelector('.message__avatar') ?? null)
const avatar = message?.querySelector('.message__avatar')
expect(avatar).toHaveAttribute('aria-hidden', 'true')
expect(header).toContainElement(message?.querySelector('.message__meta') ?? null)
expect(Array.from(message?.children ?? [])).toEqual([header, body])
expect(Array.from(message?.children ?? [])).toEqual([avatar, header, body])
})
it('enables HTML rendering only for completed Agent output', () => {
+32 -15
View File
@@ -525,6 +525,27 @@ function ChatMessageRowView({
? t('chat.contextCompression.agentFailed')
: t('chat.contextCompression.failed')
const activeTool = message.tools?.find((tool) =>
tool.state === 'pending' || tool.state === 'running')
const activeSubagent = message.subagents?.some((subagent) =>
subagent.state === 'queued' || subagent.state === 'running')
const activeCompression = compressionMarkers.find((marker) =>
marker.state === 'compressing')
const statusText = message.role === 'assistant' && message.state === 'streaming'
? message.question
? t('chat.status.waitingForAnswer')
: message.approval
? t('chat.status.waitingForApproval')
: activeCompression
? undefined
: message.status || (activeTool
? t(activeTool.state === 'running'
? 'chat.status.runningTool' : 'chat.status.pendingTool', { name: activeTool.name })
: activeSubagent
? t('chat.status.waitingForSubagent')
: t('chat.status.waitingForProgress'))
: message.status
return (
<>
<article
@@ -532,14 +553,14 @@ function ChatMessageRowView({
ref={(element) => onArticleRef(message.id, element)}
tabIndex={-1}
>
<div aria-hidden="true" className="message__avatar">
{message.role === 'assistant' ? (
<Bot size={18} />
) : (
<UserRound size={18} />
)}
</div>
<div className="message__header">
<div className="message__avatar">
{message.role === 'assistant' ? (
<Bot size={18} />
) : (
<UserRound size={18} />
)}
</div>
<div className="message__meta">
<strong>
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
@@ -1024,10 +1045,10 @@ function ChatMessageRowView({
{t('chat.status.displayCaptureTruncated')}
</div>
)}
{message.status && (
{statusText && (
<div
aria-atomic="true"
aria-label={message.status}
aria-label={statusText}
aria-live={
message.knowledgeRetrieval || compressionMarkers.length > 0
? undefined
@@ -1050,13 +1071,9 @@ function ChatMessageRowView({
>
<span
aria-hidden="true"
className={
message.state === 'streaming'
? 'message__status-dot message__status-dot--active'
: 'message__status-dot'
}
className="message__status-dot"
/>
{message.status}
{statusText}
</div>
)}
{canRetry && (
+15 -1
View File
@@ -463,6 +463,15 @@ describe('MagicNotesWorkspace', () => {
expect(
screen.getByRole('button', { name: '打开原笔记修改' })
).toBeInTheDocument()
const sourceEntry = screen.getByRole('region', {
name: '对应的笔记记录'
})
const todoDetail = screen.getByRole('region', { name: '待办详情' })
const aiComments = screen.getByRole('complementary', { name: 'AI 评论' })
expect(todoDetail).toContainElement(sourceEntry)
expect(aiComments).not.toContainElement(sourceEntry)
expect(within(sourceEntry).getByText('发布笔记')).toBeInTheDocument()
expect(within(sourceEntry).getByText('记录正文')).toBeInTheDocument()
})
it('keeps history editing contained and de-emphasizes note deletion', async () => {
@@ -1169,7 +1178,7 @@ describe('MagicNotesWorkspace', () => {
expect(
screen.queryByRole('group', { name: '待办列表方式' })
).not.toBeInTheDocument()
expect(screen.getByText('发布笔记')).toBeInTheDocument()
expect(screen.getAllByText('发布笔记')).not.toHaveLength(0)
expect(screen.getByText('演示笔记')).toBeInTheDocument()
expect(screen.getByText('准备演示')).toBeInTheDocument()
})
@@ -1203,6 +1212,11 @@ describe('MagicNotesWorkspace', () => {
message: '待办已完成'
})
)
expect(
within(
screen.getByRole('region', { name: '对应的笔记记录' })
).getByTestId('magic-note-checklist-state')
).toHaveTextContent('checked')
fireEvent.click(screen.getByRole('tab', { name: '笔记' }))
expect(
+65
View File
@@ -483,6 +483,8 @@ export function MagicNotesWorkspace({
const [selectedNoteId, setSelectedNoteId] = useState('')
const [selectedTodoId, setSelectedTodoId] = useState('')
const [detail, setDetail] = useState<MagicNoteDetail>()
const [todoSourceDetail, setTodoSourceDetail] =
useState<MagicNoteDetail>()
const [loadStatus, setLoadStatus] = useState<LoadStatus>('loading')
const [loadError, setLoadError] = useState('')
const [refreshError, setRefreshError] = useState('')
@@ -537,6 +539,7 @@ export function MagicNotesWorkspace({
const [pendingDraftSwitch, setPendingDraftSwitch] =
useState<DraftSwitchTarget>()
const detailRequestRef = useRef(0)
const todoSourceRequestRef = useRef(0)
const requestedNoteIdRef = useRef('')
const refreshRequestRef = useRef(0)
const hasLoadedRef = useRef(false)
@@ -1398,7 +1401,40 @@ export function MagicNotesWorkspace({
() => todos.find((todo) => todo.id === selectedTodoId),
[selectedTodoId, todos]
)
const selectedTodoNoteId = selectedTodo?.noteId
const selectedTodoEntryId = selectedTodo?.entryId
useEffect(() => {
if (libraryView !== 'todos' || !selectedTodoNoteId) {
return
}
const requestId = ++todoSourceRequestRef.current
void window.goodbuddy.magicNotes
.get(selectedTodoNoteId)
.then((sourceDetail) => {
if (todoSourceRequestRef.current === requestId) {
setTodoSourceDetail(sourceDetail)
}
})
.catch((sourceError: unknown) => {
if (todoSourceRequestRef.current === requestId) {
notifyError(sourceError)
}
})
return () => {
todoSourceRequestRef.current += 1
}
}, [libraryView, notifyError, selectedTodoNoteId])
const selectedTodoSourceEntry = useMemo(
() =>
todoSourceDetail && todoSourceDetail.id === selectedTodoNoteId
? todoSourceDetail.entries.find(
(entry) => entry.id === selectedTodoEntryId
)
: undefined,
[selectedTodoEntryId, selectedTodoNoteId, todoSourceDetail]
)
const reloadTodos = useCallback(async (): Promise<void> => {
const snapshot = await window.goodbuddy.magicNotes.listTodos()
setTodos(snapshot.todos)
@@ -1493,6 +1529,10 @@ export function MagicNotesWorkspace({
expectedRevision: todo.revision
})
applyTodo(result.todo)
if (selectedTodoId === todo.id) {
todoSourceRequestRef.current += 1
setTodoSourceDetail(result.note)
}
if (requestedNoteIdRef.current === result.note.id) {
applyDetail(result.note)
} else {
@@ -2684,6 +2724,31 @@ export function MagicNotesWorkspace({
{t('actions.openSourceNote')}
</button>
</div>
<section
aria-label={t('todos.sourceEntryLabel')}
className="magic-todo-source-entry"
>
<header>
<div>
<strong>{t('todos.sourceEntryHeading')}</strong>
<small>{selectedTodo.noteTitle}</small>
</div>
{selectedTodoSourceEntry && (
<time dateTime={selectedTodoSourceEntry.updatedAt}>
{dateFormatter.format(
new Date(selectedTodoSourceEntry.updatedAt)
)}
</time>
)}
</header>
{selectedTodoSourceEntry ? (
<MagicNoteContent content={selectedTodoSourceEntry.content} />
) : (
<p className="magic-notes-muted">
{t('todos.sourceEntryMissing')}
</p>
)}
</section>
</section>
)}
</section>
@@ -248,7 +248,7 @@ describe('WorkspacePrimitives', () => {
/@media \(max-width: 719px\)\s*\{(?:[^{}]|\{[^{}]*\})*?\.assistant-sidebar/u
)
expect(stylesheet).toMatch(
/\.composer-wrap\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*min-width:\s*0;[^}]*padding:\s*var\(--space-3\)\s*max\(var\(--page-gutter\),\s*calc\(\(100% - var\(--content-reading\)\) \/ 2\)\)\s*var\(--space-2\);/u
/\.composer-wrap\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*min-width:\s*0;[^}]*padding:\s*var\(--space-3\)\s*var\(--chat-inline-padding\)\s*var\(--space-2\);/u
)
expect(stylesheet).toMatch(
/\.assistant-sidebar__tabs\s*\{[^}]*grid-template-columns:\s*repeat\(5,\s*minmax\(max-content,\s*1fr\)\);/u
@@ -115,8 +115,6 @@ export const activity = {
unknownTime: 'Unknown time',
interruptedOnRestart:
'This activity had not finished when the app restarted.',
legacyHistoryMayBeIncomplete:
'Older versions limited local run history and detail length, so earlier records or parts of their details may already be missing. New records are saved in full.',
unavailableScope:
'The scope could not be determined when this activity was recorded.',
conversation: 'Conversation: {{title}}',
@@ -335,6 +335,13 @@ export const app = {
loadEarlierMessages: 'Load earlier messages ({{count}} remaining)',
scrollToBottom: 'Scroll to bottom',
status: {
preparingRequest: 'Preparing request',
waitingForProgress: 'Waiting for further progress',
waitingForAnswer: 'Waiting for your answer',
waitingForApproval: 'Waiting for your confirmation',
pendingTool: 'Tool pending: {{name}}',
runningTool: 'Tool running: {{name}}',
waitingForSubagent: 'Waiting for subtasks to finish',
responseTruncated: 'The response was too long and was truncated locally',
displayCaptureTruncated:
'Later activity details were not retained locally; the Runtime task continued',
@@ -115,6 +115,9 @@ export const magicNotes = {
emptySelectionDescription:
'Select a to-do on the left. All to-dos come from note checklists.',
sourceNote: 'From note: {{title}}',
sourceEntryLabel: 'Related note entry',
sourceEntryHeading: 'Related entry',
sourceEntryMissing: 'The note entry for this to-do could not be found.',
defaultInstructions: 'This to-do comes from a checklist in a note.',
markComplete: 'Mark complete: {{title}}',
markIncomplete: 'Mark incomplete: {{title}}'
@@ -111,8 +111,6 @@ export const activity = {
records: {
unknownTime: '时间未知',
interruptedOnRestart: '应用重启时此活动尚未结束。',
legacyHistoryMayBeIncomplete:
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
unavailableScope:
'创建此活动记录时未能确定其归属范围。',
conversation: '对话:{{title}}',
@@ -318,6 +318,13 @@ export const app = {
loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)',
scrollToBottom: '到底部',
status: {
preparingRequest: '正在准备请求',
waitingForProgress: '等待后续进展',
waitingForAnswer: '等待你的回答',
waitingForApproval: '等待你的确认',
pendingTool: '工具待执行:{{name}}',
runningTool: '工具执行中:{{name}}',
waitingForSubagent: '等待子任务完成',
responseTruncated: '回答过长,已在本地截断显示',
displayCaptureTruncated:
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行',
@@ -108,6 +108,9 @@ export const magicNotes = {
emptySelectionTitle: '还没有选择待办',
emptySelectionDescription: '从左侧选择待办;待办统一来自笔记中的清单。',
sourceNote: '来自笔记:{{title}}',
sourceEntryLabel: '对应的笔记记录',
sourceEntryHeading: '对应记录',
sourceEntryMissing: '未找到这条待办对应的笔记记录。',
defaultInstructions: '此待办来自笔记正文中的待办清单。',
markComplete: '标记为已完成:{{title}}',
markIncomplete: '标记为未完成:{{title}}'
+80 -14
View File
@@ -1397,6 +1397,58 @@
padding: var(--space-4);
}
.magic-todo-source-entry {
display: grid;
gap: var(--space-3);
}
.magic-todo-source-entry > header {
display: flex;
min-width: 0;
align-items: start;
justify-content: space-between;
color: var(--text-muted);
font-size: var(--font-caption);
gap: var(--space-2);
}
.magic-todo-source-entry > header > div {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.magic-todo-source-entry > header strong {
color: var(--text-primary);
font-size: var(--font-body);
}
.magic-todo-source-entry > header small {
overflow: hidden;
color: var(--text-muted);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.magic-todo-source-entry > header time {
flex: none;
}
.magic-todo-source-entry > .magic-note-content {
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
}
.magic-todo-source-entry > .magic-note-content .ql-editor {
padding: var(--space-3);
}
.magic-todo-source-entry > .magic-notes-muted {
padding-inline: 0;
}
.magic-notes-ai-controls {
display: grid;
padding: var(--space-3) 0;
@@ -5138,12 +5190,21 @@ button > svg {
display: none;
}
.chat,
.composer-wrap {
--message-avatar-size: 29px;
--chat-inline-padding: max(
calc(var(--space-4) + var(--message-avatar-size) + var(--space-2)),
calc((100cqw - var(--content-reading)) / 2)
);
}
.chat {
height: 100%;
min-height: 0;
padding:
var(--page-gutter)
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2))
0
10px;
overflow-y: auto;
background: var(--surface-raised);
@@ -5281,6 +5342,13 @@ button > svg {
line-height: 1.5;
}
.chat > .welcome,
.message-list {
/* Keep the scrollbar inside the outer gutter without adding trailing scroll padding. */
width: calc(100cqw - 2 * var(--chat-inline-padding));
margin-left: var(--chat-inline-padding);
}
.message-list {
display: flex;
min-width: 0;
@@ -5311,9 +5379,10 @@ button > svg {
}
.message {
position: relative;
display: flex;
min-width: 0;
padding: 17px 8px;
padding: 17px 0;
flex-direction: column;
gap: 8px;
}
@@ -5321,6 +5390,7 @@ button > svg {
.message__header {
display: flex;
min-width: 0;
min-height: var(--message-avatar-size);
align-items: center;
gap: 12px;
}
@@ -5382,10 +5452,13 @@ button > svg {
}
.message__avatar {
position: absolute;
top: 17px;
left: calc(-1 * (var(--message-avatar-size) + var(--space-2)));
display: grid;
flex: 0 0 auto;
width: 29px;
height: 29px;
width: var(--message-avatar-size);
height: var(--message-avatar-size);
place-items: center;
border: 1px solid var(--border-default);
border-radius: 9px;
@@ -5394,6 +5467,8 @@ button > svg {
}
.message--user .message__avatar {
right: calc(-1 * (var(--message-avatar-size) + var(--space-2)));
left: auto;
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
@@ -6459,7 +6534,7 @@ button > svg {
min-width: 0;
padding:
var(--space-3)
max(var(--page-gutter), calc((100% - var(--content-reading)) / 2))
var(--chat-inline-padding)
var(--space-2);
background: var(--surface-raised);
}
@@ -13960,15 +14035,6 @@ details.settings-section > :not(summary) + :not(summary) {
padding: var(--space-4) 0;
}
.activity-panel__legacy-warning {
margin: var(--space-3) 0 0;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--warning) 45%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--warning) 10%, transparent);
color: var(--text-secondary);
}
.activity-panel__load-more {
align-self: center;
margin-top: var(--space-3);
+23 -1
View File
@@ -15,7 +15,10 @@ import {
projectExecutionSpaceSchema,
projectUpdateSchema
} from './assistant-contracts'
import { subagentEventSchema } from './contracts'
import {
agentQuestionResponseSchema,
subagentEventSchema
} from './contracts'
const untouchedProject: AssistantProject = {
id: '00000000-0000-4000-8000-000000000101',
@@ -34,6 +37,25 @@ const untouchedProject: AssistantProject = {
updatedAt: '2026-08-01T00:00:00.000Z'
}
describe('agent question contracts', () => {
it('does not impose GoodBuddy-specific question answer limits', () => {
const answers = Array.from({ length: 5 }, (_, questionIndex) =>
Array.from(
{ length: 21 },
(_, optionIndex) =>
`${questionIndex}:${optionIndex}:${'回答内容'.repeat(600)}`
)
)
expect(
agentQuestionResponseSchema.parse({
questionId: `question-${'id'.repeat(100)}`,
answers
}).answers
).toEqual(answers)
})
})
describe('activity history contracts', () => {
it('accepts more than 500 records and details longer than 4,000 characters', () => {
const records = Array.from({ length: 501 }, (_, index) => ({
+3 -4
View File
@@ -305,13 +305,12 @@ export const workspaceOpenPathRequestSchema = z
.strict()
export const agentQuestionAnswerSchema = z
.array(z.string().trim().min(1).max(2_000))
.max(20)
.array(z.string().trim().min(1))
export const agentQuestionResponseSchema = z
.object({
questionId: z.string().trim().min(1).max(128),
answers: z.array(agentQuestionAnswerSchema).max(4)
questionId: z.string().trim().min(1),
answers: z.array(agentQuestionAnswerSchema)
})
.strict()
+2 -2
View File
@@ -156,7 +156,7 @@ describe('release asset aggregation', () => {
])
const outputNames = readdirSync(output)
expect(outputNames).toHaveLength(22)
expect(outputNames).toHaveLength(20)
expect(outputNames).toContain('release-manifest.json')
expect(outputNames).toContain('SHA256SUMS')
expect(outputNames).not.toContain('builder-debug.yml')
@@ -180,7 +180,7 @@ describe('release asset aggregation', () => {
join(output, 'SHA256SUMS'),
'utf8'
)
expect(sums.trim().split('\n')).toHaveLength(21)
expect(sums.trim().split('\n')).toHaveLength(19)
expect(sums).toContain(
'release-manifest-windows-x64.json'
)
+5 -1
View File
@@ -235,6 +235,10 @@ function machO(cpuType: number): Buffer {
function portableDirectory(parent: string): string {
const directory = join(parent, 'portable')
const plugin = join(directory, 'resources', 'runtimes', 'opencode-config', 'node_modules', '@opencode-ai', 'plugin')
mkdirSync(join(plugin, 'dist'), { recursive: true })
writeFileSync(join(plugin, 'package.json'), '{"name":"@opencode-ai/plugin","version":"1.18.29"}')
writeFileSync(join(plugin, 'dist', 'index.js'), 'export const tool = true')
mkdirSync(
join(directory, 'resources', 'runtimes', 'opencode'),
{ recursive: true }
@@ -319,7 +323,7 @@ function endOfCentralDirectory(
describe('release build arguments', () => {
it.each([
['win32', 'x64', 'windows', ['nsis', 'portable']],
['darwin', 'arm64', 'macos', ['dmg', 'zip']],
['darwin', 'arm64', 'macos', ['dmg']],
['linux', 'x64', 'linux', ['AppImage', 'deb', 'rpm']]
])(
'uses %s host defaults',
+92
View File
@@ -0,0 +1,92 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Plugin, UserConfig } from 'vite'
import config from '../electron.vite.config'
const require = createRequire(import.meta.url)
const packageJson = require('../package.json')
const { getFileMatchers, copyFiles } = require('app-builder-lib/out/fileMatcher')
const { verifyOpenCodeConfig, verifyHarnessBundleImports } = require('../build/verify-desktop-runtimes.cjs')
describe('desktop Runtime packaging', () => {
it('runs packaged Windows Runtime checks in CI before removing the unpacked output', () => {
const release = readFileSync(join(process.cwd(), 'build', 'build-release.cjs'), 'utf8')
const smoke = readFileSync(join(process.cwd(), 'build', 'run-packaged-deepseek-harness-smoke.cjs'), 'utf8')
expect(release).toContain("process.env.GITHUB_ACTIONS === 'true'")
expect(release.indexOf('run-packaged-deepseek-harness-smoke.cjs'))
.toBeLessThan(release.indexOf('rmSync(unpackedDirectory'))
expect(smoke).toContain('Packaged OpenCode offline plugin import: ready')
expect(smoke).toContain("result.status !== 'ready'")
})
it('copies offline OpenCode dependencies through the real electron-builder resource copier', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-desktop-runtime-copy-'))
const output = join(root, 'output', 'resources')
const source = join(root, '.runtime-resources', 'opencode-config')
const files = {
'package.json': '{"private":true}',
'.gitignore': 'node_modules\n',
'node_modules/@opencode-ai/plugin/package.json': '{"name":"@opencode-ai/plugin","version":"1.18.29"}',
'node_modules/@opencode-ai/plugin/dist/index.js': 'export const tool = true\n',
'node_modules/zod/package.json': '{"name":"zod","version":"4.1.8"}',
'node_modules/zod/index.js': 'export const z = {}\n'
}
try {
for (const [name, content] of Object.entries(files)) {
const file = join(source, name)
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, content)
}
const resource = packageJson.build.extraResources.find(
(entry: { from: string }) => entry.from.includes('.runtime-resources') &&
JSON.stringify(entry).includes('opencode-config')
)
expect(resource).toBeDefined()
const matchers = getFileMatchers(
{ extraResources: [resource] }, 'extraResources', output,
{ defaultSrc: root, globalOutDir: join(root, 'output'), customBuildOptions: {}, macroExpander: (value: string) => value }
)
await copyFiles(matchers)
for (const [name, content] of Object.entries(files)) {
expect(readFileSync(join(output, 'runtimes', 'opencode-config', name), 'utf8')).toBe(content)
}
expect(verifyOpenCodeConfig(output, source)).toBe(Object.keys(files).length)
rmSync(join(output, 'runtimes', 'opencode-config', 'node_modules', 'zod', 'index.js'))
expect(() => verifyOpenCodeConfig(output, source)).toThrow('dependency is missing or changed')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it('bundles every direct DeepSeek dependency into the unpacked Host instead of leaving ASAR-only imports', () => {
const plugin = (config.main?.plugins as Plugin[]).find(
(entry) => entry.name === 'vite:externalize-deps'
)
expect(plugin).toBeDefined()
const buildConfig: UserConfig = {}
const hook = plugin!.config
if (typeof hook !== 'function') throw new Error('Expected externalization config hook')
Reflect.apply(hook, {}, [buildConfig, { command: 'build', mode: 'production' }])
const external = buildConfig.build?.rollupOptions?.external as Array<string | RegExp>
for (const dependency of Object.keys(packageJson.dependencies).filter(
(name) => name.startsWith('@deepseek-ai/')
)) {
expect(external.some((entry) => typeof entry === 'string'
? entry === dependency
: entry.test(dependency)), dependency).toBe(false)
}
})
it.each([
'import Registry from "@deepseek-ai/dsh-session-projection";',
'export { Registry } from "@deepseek-ai/dsh-session-projection";',
'await import("@deepseek-ai/dsh-session-projection");'
])('rejects an external DeepSeek dependency in a shipped bootstrap or chunk', (source) => {
expect(() => verifyHarnessBundleImports(source, 'host.js')).toThrow('external DeepSeek dependency')
expect(() => verifyHarnessBundleImports('import { x } from "./chunks/host.js";', 'host.js')).not.toThrow()
})
})
+60
View File
@@ -0,0 +1,60 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
interface NpmInvocationModule {
npmInvocation(
projectDir: string,
environment?: NodeJS.ProcessEnv
): {
command: string
prefixArgs: string[]
}
}
const require = createRequire(import.meta.url)
const { npmInvocation } = require(
'../build/npm-invocation.cjs'
) as NpmInvocationModule
let directory: string
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'goodbuddy-npm-invocation-'))
})
afterEach(() => {
rmSync(directory, { recursive: true, force: true })
})
describe('npm invocation', () => {
it('uses npm_execpath when the package manager provides it', () => {
expect(
npmInvocation(directory, { npm_execpath: 'provided-npm-cli.js' })
).toEqual({
command: process.execPath,
prefixArgs: ['provided-npm-cli.js']
})
})
it('uses the project npm CLI when npm_execpath is absent', () => {
const npmCli = join(
directory,
'node_modules',
'npm',
'bin',
'npm-cli.js'
)
mkdirSync(join(directory, 'node_modules', 'npm', 'bin'), {
recursive: true
})
writeFileSync(npmCli, '')
expect(npmInvocation(directory, {})).toEqual({
command: process.execPath,
prefixArgs: [npmCli]
})
})
})
+12
View File
@@ -50,6 +50,18 @@ function jobNeeds(name: string): string[] {
}
describe('desktop packages workflow', () => {
it('builds only DMG for both signed and unsigned macOS jobs', () => {
const steps = parsed.jobs.package?.steps?.filter(
(step) => step.name?.endsWith('macOS release packages') &&
!step.name.includes('non-macOS')
)
expect(steps).toHaveLength(2)
for (const step of steps ?? []) {
expect(step.run).toContain('--format dmg')
expect(step.run).not.toContain('--format dmg,zip')
}
})
it('uses valid YAML, pinned action majors, and only desktop release jobs', () => {
expect(() => parse(workflow)).not.toThrow()
expect(Object.keys(parsed.jobs)).toEqual([
+4 -4
View File
@@ -46,8 +46,8 @@ const verifier = require(
const targetDefinitions = [
['windows', 'x64', ['nsis', 'portable']],
['windows', 'arm64', ['nsis', 'portable']],
['macos', 'x64', ['dmg', 'zip']],
['macos', 'arm64', ['dmg', 'zip']],
['macos', 'x64', ['dmg']],
['macos', 'arm64', ['dmg']],
['linux', 'x64', ['AppImage', 'deb', 'rpm']],
['linux', 'arm64', ['AppImage', 'deb', 'rpm']]
] as const
@@ -145,7 +145,7 @@ describe('site release manifest', () => {
await expect(
verifier.verifySiteRelease(manifest, request)
).resolves.toBe(14)
expect(request).toHaveBeenCalledTimes(14)
).resolves.toBe(12)
expect(request).toHaveBeenCalledTimes(12)
})
})