Compare commits

...
5 Commits
Author SHA1 Message Date
mesalogo 482cd0c467 chore: prepare GoodBuddy 0.12.4 releases
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 Agent Release / Authorize Agent release (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (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
Bump Desktop to 0.12.4 and the separately published GoodBuddy Agent to 0.11.18. Add the approved bilingual release notes for HTML previews, long-run and queue reliability, remote terminal-state convergence, and faster OpenCode follow-up on Linux ARM.

This candidate uses the standard six-platform x64/arm64 matrix and does not request a LoongArch experimental preview.
2026-09-05 10:01:45 +08:00
mesalogo 5a1afb00f7 fix: reuse Runtime after native inventory load
GoodBuddy Agents / Validate Agent source (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
Native capability loading previously cold-started a temporary Runtime and disposed it immediately, so Linux ARM users paid the OpenCode startup cost again on their first request. Native inventory loading now uses the cached execution Runtime, preserving normal idle eviction and lifecycle cleanup.

Release note: 优化 Linux ARM 上切换 OpenCode 后的响应速度;原生能力清单与后续对话现在复用同一 Runtime,避免重复冷启动。
2026-09-05 09:45:23 +08:00
mesalogo a4b64a1641 feat: preview Agent HTML in conversations and unblock long replies
Agent replies that produced HTML could only be read as source, so users had
to copy the markup into an external file to see the result. Completed Agent
and Subagent output now renders complete HTML documents, single-root HTML
replies, and `html` / `htm` code blocks as an isolated static preview in
place. Each preview keeps a persistent notice that scripts and network are
disabled and offers the same icon actions as Mermaid diagrams: a source
toggle and a full-screen preview. The new "render HTML in conversations"
switch under Platform Features / General is on by default, applies to the
open conversation immediately, and falls back to HTML source when disabled.
Previews cannot run scripts, reach the network, submit forms, open windows,
or load remote media; author stylesheets and inline styles are preserved so
the intended layout survives, and remote resource loading is refused by the
preview CSP instead.

Direct-model replies no longer fail once they grow large. The byte caps on
streaming responses, single stream blocks, non-streaming replies, model tool
arguments, tool request context, aggregate tool results, and accumulated
answer text are removed, so users stop seeing "模型接口流式响应超过安全限制"
on long answers. Response length is now governed by the request timeout,
cancellation, and context compression. Image generation responses keep their
decodable-image validation.

A message sent after a failed or interrupted run stayed in the pending queue
instead of running. The conversation is now released whenever a run reaches
any terminal state, not only when the renderer reports readiness again, and a
dispatched queue item whose run never starts releases its reservation and
returns to pending. Later messages dispatch without extra user action.

Release note: Agent 回复中的 HTML 现在可直接在会话内预览,支持查看源码和全屏预览,默认开启并可在“平台功能 / 通用设置”关闭;直连模型的长回复不再因大小限制中断;上一次运行失败或被中断后,新发送的消息会立即执行而不再排队等待。
2026-09-05 09:10:44 +08:00
mesalogo 9588c4b901 fix: preserve long run activity and large previews
Long Runtime sessions could silently stop retaining tool and Subagent activity, truncate Run history details, and reject workspace previews over 256 KB. Run history now persists in Main SQLite without the former fixed record or detail caps, surfaces possible loss from legacy caches, and renders long histories in batches.

Local and remote workspace previews now load UTF-8-safe 256 KiB pages on demand instead of rejecting larger text files. The legacy migration, clear-data path, IPC contracts, tests, and feature documentation are updated with the resulting behavior.

Release note: 长时间 Runtime 运行现在会完整保留工具和 Subagent 活动;运行记录迁移到 SQLite,并提示旧版可能已丢失的内容。超过 256KB 的本地及远程文本文件可通过分页面预览继续加载。
2026-09-05 00:31:42 +08:00
mesalogo d180f63abd fix: converge remote run state on persisted terminal messages
After a managed SSH disconnect the renderer kept showing the remote
Agent as processing even though the recovered task had already been
persisted as completed. Local streaming copies shadowed the persisted
terminal state in mergePersistedConversations, and recovery completion
never asked the renderer to re-read conversations.

- Prefer the persisted terminal state over a local streaming message
- Settle active runs whose messages already reached a persisted terminal
  state and release the conversation queue once
- Refresh conversations on window focus and visibility changes
- Publish a conversation change before recovery reports completed/failed
2026-09-04 16:54:35 +08:00
69 changed files with 3434 additions and 809 deletions
+22 -12
View File
@@ -32,9 +32,13 @@ otherwise.
Host heading and only the remote path on each project row.
- [x] **File, screenshot, window, and clipboard context**: Added to model
context only after explicit user selection.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math, and
constrained Mermaid diagrams. Large diagrams can be zoomed, panned, or
viewed as source, and failed renders preserve the original diagram code.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
constrained Mermaid diagrams, and static in-conversation HTML previews.
Complete HTML and HTML code blocks can be previewed in place after an Agent
reply finishes, with icon actions to reveal the source or open a full-screen
preview. The feature is on by default and can be disabled under Platform
Features / General; previews cannot run scripts, access the network, submit
forms, or open windows.
- [x] **AI response and full-conversation copy**: Completed AI responses expose
a bottom action that copies Markdown source without reasoning, tool logs, or
citation metadata. Full-conversation copy uses the same validated
@@ -80,14 +84,16 @@ otherwise.
rather than entering the model-and-project execution cache. For managed
local OpenCode, this passive check validates the selected path and model
credential without starting a throwaway server; the explicit settings test,
native inventory, and first real use still perform full startup and health
checks. Execution Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
native inventory still performs full startup and health checks, then shares
that execution Runtime with the first real request instead of cold-starting
another server. Execution Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
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
at a fixed tool-call or activity count, and exceeding local display capture
capacity truncates the displayed copy without cancelling the native task.
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.
Interactive questions are answered only by foreground conversations; scheduled tasks, remote channels,
delegated work, and other background runs fail immediately with guidance to
rerun in the foreground
@@ -142,7 +148,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.17`, while the current Desktop release candidate is `0.12.3`; formal
`0.11.18`, while the current Desktop release candidate is `0.12.4`; formal
publication status follows the separate Agent and Desktop
release channels.
- [x] **Manual SSH Host environment provisioning source path**: After Host Key,
@@ -393,10 +399,14 @@ otherwise.
activity, and independent artifact files. Ordinary chat responses remain
only in the conversation and are no longer copied into Artifacts; existing
duplicate chat Markdown is hidden from the artifact list but not physically
deleted. 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 page.
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
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
page.
- [x] **Task and custom-task experience**: Each product-level Task belongs to
one Conversation, while one Conversation can contain multiple Tasks. The
left conversation list exposes Task children through a leading expand
+4 -4
View File
@@ -18,7 +18,7 @@
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:右侧工作栏使用持久“+”能力目录和应用 Tab;用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记列表支持鼠标与键盘调宽,魔法笔记列表可隐藏并记住布局,窄宽度下自动堆叠。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
@@ -28,8 +28,8 @@
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试、原生能力清单和首次实际使用仍执行完整启动及健康检查;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.17`,Desktop 发布候选为 `0.12.3`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.18`,Desktop 发布候选为 `0.12.4`;正式发布状态以 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 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
@@ -71,7 +71,7 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。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)。
+10
View File
@@ -580,6 +580,13 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 已结束且包含正文的助手回复在底部提供带可访问名称和工具提示的复制图标;复制 Markdown
原文,不包含推理过程、工具日志或引用元数据,结果通过应用通知反馈。流式回复完成前不显示
复制入口。
- 默认开启“会话 HTML 渲染”时,已完成 Agent 回复中的完整 HTML 与 `html` / `htm`
代码块在原位置显示隔离静态预览;用户消息、推理过程和流式中的回复不得执行 HTML。
每个预览持续标注脚本和网络已禁用,并在标题右侧复用与 Mermaid 图表相同的图标按钮组:
源码按钮使用代码图标并暴露 `aria-expanded`,全屏按钮使用放大图标打开统一 Modal。
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
或保留旧预览。
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
@@ -690,6 +697,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
更新,保存失败时保留最后确认状态。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.17",
"agentVersion": "0.11.18",
"protocol": {
"major": 2,
"minor": 0
+5 -2
View File
@@ -360,7 +360,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
面板提供:
- 有界目录树和文本文件预览。
- 有界目录树和分页文本文件预览;文件超过单页传输大小时先显示已读取内容,并允许用户继续加载后续内容,不因文件总大小超过单页上限而拒绝预览。
- 当前选择、规范化路径和执行空间。
- 可选 Git 状态、Diff 和仓库信息。
- 显式打开、下载副本或在终端中打开。
@@ -756,7 +756,10 @@ remote_operations
终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。
- 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。
- Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。
- 当前 Renderer `localStorage` 活动记录不能作为 Runtime、监督或进程的权威来源。
- 运行记录由 Main SQLite 持久化,Renderer 只保留当前内存视图。升级时迁移旧
`localStorage` 记录;若旧缓存达到原数量、详情或总大小边界,页面持续说明更早记录或
部分详情可能已经丢失。新记录不再按固定活动数或详情字符数丢弃,长列表只做可继续展开的
分批渲染。
### 11.3 IPC 与 Preload
@@ -0,0 +1,29 @@
# 会话 HTML 渲染
会话 HTML 渲染让用户直接查看 Agent 回复中的网页结果,而不必先复制代码到外部文件。
该能力默认开启,也可以在“设置 > 平台功能 > 通用设置”中关闭。
## 功能边界
- 只渲染已完成的 Agent 正文和已完成的子 Agent 输出。
- 支持完整 HTML 文档、单一 HTML 根元素,以及 Markdown 中的 `html` / `htm` 代码块。
- 使用无脚本、无网络、无表单提交和无窗口操作权限的静态隔离预览。
- 每个预览提供源码切换和全屏预览两个图标操作;全屏预览沿用同一清理和隔离边界。
- 用户消息、推理过程和流式中的回复不执行 HTML;关闭功能后继续显示 HTML 源码。
- 本功能不把普通聊天回复复制为成果,也不替代助手工作栏中独立成果的预览流程。
## 文档
- [产品需求](./prd.md):功能范围、状态规则和验收标准。
- [技术设计](./technical-design.md):设置持久化、渲染边界与静态隔离方案。
- [实现进度](./progress.md):已验证实现和验证记录。
## 术语
- **HTML 源码**:Agent 回复中原始、可复制的 HTML 文本。
- **静态预览**:在隔离 `iframe` 中显示的 HTML;脚本、联网和外部操作均被禁用。
- **全屏预览**:在应用级 Modal 中显示同一份静态预览文档,用于查看完整页面布局。
全局聊天和设置界面规则由根目录的
[统一界面设计系统](../../../UI-DESIGN.md)定义。独立成果预览仍由
[助手工作栏](../assistant-workbar/README.md)负责。
@@ -0,0 +1,69 @@
# 会话 HTML 渲染产品需求
## 用户问题
Agent 可以生成网页、报告和交互原型的 HTML,但此前会话只能显示源码或忽略原始标签。
用户必须把内容复制到外部文件后才能查看结果,打断了会话内的阅读和迭代流程。
## 目标
- 让 Agent 生成的 HTML 在回复完成后直接显示为网页预览。
- 保留一键查看原始 HTML 的能力。
- 允许用户通过平台功能开关恢复为只看源码。
- 预览不取得 GoodBuddy Renderer 或外部网络能力。
## 非目标
- 不执行 Agent 提供的 JavaScript。
- 不提供联网网页、表单、弹窗或 Electron `webview`。
- 不在流式输出中反复加载不完整页面。
- 不自动把普通会话回复保存为成果。
## 功能需求
### FR-1 默认设置
“会话 HTML 渲染”是应用级持久设置,默认开启。用户可在“设置 > 平台功能 >
通用设置”中立即关闭或重新开启;保存失败时保留最后确认的状态并显示错误。
### FR-2 可识别输出
开启后,已完成 Agent 回复中的以下内容显示为静态预览:
1. 完整 HTML 文档。
2. 由单一 HTML 根元素构成的完整回复。
3. Markdown 中标记为 `html` 或 `htm` 的代码块。
普通 Markdown 继续按原有规则显示。一个回复中的多个 HTML 代码块各自保持原有位置。
### FR-3 回复状态和角色
- 已完成的主 Agent 正文和已完成的子 Agent 输出可以预览 HTML。
- 用户消息和 Agent 推理只显示文本或源码。
- 流式 Agent 正文在完成前显示源码,完成后原位切换为预览。
### FR-4 源码、全屏与关闭行为
每个预览持续显示“静态安全预览,脚本和网络已禁用”,并在标题右侧提供两个图标操作:
1. 源码按钮,就地展开或收起原始 HTML。
2. 全屏按钮,在应用级 Modal 中查看同一份预览文档。
嵌入预览使用固定高度并在框内滚动,需要更大画面时使用全屏预览。全屏预览可通过关闭
按钮、Escape 或点击遮罩退出,退出后焦点回到全屏按钮。关闭平台功能后,完整 HTML 回复
和 HTML 代码块继续显示源码,不能变成空白。
### FR-5 静态隔离
预览必须禁用脚本、网络请求、表单提交、嵌套页面、插件对象和窗口操作。远程链接和远程
媒体不得从预览加载;内联样式和有界的内联位图可用于还原静态视觉。
## 产品验收
- 新安装和从旧设置升级后,开关均为开启。
- 完整 HTML 和 fenced HTML 在已完成的 Agent 回复中显示预览。
- 关闭开关后,当前会话立即恢复显示源码,重启后选择仍保留。
- 流式回复、推理和用户消息从不执行 HTML。
- 预览可通过键盘访问源码和全屏按钮,并具有明确的 iframe 标题。
- 全屏预览隔离背景、初始聚焦关闭按钮,Escape 退出后焦点回到全屏按钮。
- 恶意脚本、事件属性、远程资源、表单和嵌套 frame 不进入可执行预览。
@@ -0,0 +1,39 @@
# 会话 HTML 渲染实现进度
## 已实现
- [x] 应用级持久开关,默认开启,并提供旧设置迁移。
- [x] 平台功能通用设置中的共享 Switch。
- [x] 已完成 Agent 回复和子 Agent 输出中的完整 HTML 与 fenced HTML 识别。
- [x] 隔离静态预览、持续安全说明和源码切换。
- [x] 预览保留作者 `<style>` 与 `style` 属性,样式不再被整块丢弃。
- [x] 与 Mermaid 一致的图标按钮组:源码切换与全屏预览 Modal。
- [x] 嵌入预览固定 480px 高度,源码面板同值上限。
- [x] 用户消息、推理、流式回复和关闭状态的源码边界。
- [x] 设置变更后当前会话立即重新渲染。
## 验证记录
2026-09-05:
- `npm run typecheck`:通过。
- 聚焦测试(应用设置、Markdown、会话时间线、设置中心、App 集成):306 项通过。
- 全量 `npm test`:`3493 passed, 55 skipped`。
- `npm run lint`:通过。
- `npm run build`:Main、Preload、Renderer 与控制面安装器均成功构建。
修复样式丢失后复验:
- 原实现只要 `<style>` 或 `style` 属性中出现 `@import` / `url()` 就整块删除,带渐变或
背景图的回复因此完全失去样式;现改为保留作者样式,外部资源加载仍由 iframe CSP 拒绝。
- `npx vitest run src/renderer/src/MarkdownRenderer.test.tsx`:`19 passed`。
- 聚焦测试(会话时间线、App 集成、设置中心):`260 passed`。
- `npm run typecheck`、`npm run lint`:通过。
新增全屏预览与图标化源码按钮后复验:
- 源码按钮改为代码图标并暴露 `aria-expanded`;新增放大图标打开全屏 Modal。
- 嵌入预览高度由 `min-height: 320px` 改为固定 `height: 480px`,源码面板上限同步为 480px。
- 新增回归测试覆盖全屏 Modal 的初始焦点、背景 inert、Sandbox/CSP 与 Escape 焦点恢复。
- 聚焦测试(Markdown、会话时间线、App 集成、设置中心):`280 passed`。
- `npm run typecheck`、`npm run lint`:通过。
@@ -0,0 +1,68 @@
# 会话 HTML 渲染技术设计
## 进程和设置边界
`conversationHtmlRenderingEnabled` 属于现有 `ApplicationSettings`。Main 继续通过
版本化 JSON 文件持久化应用设置,IPC 继续复用已验证的
`settings:application:get/update` 通道,Preload 只暴露类型化设置方法。
应用设置格式升级到版本 11。版本 10 及更早设置在读取时补入默认值 `true`,无需保留第二份
状态或独立迁移文件。
## Renderer 数据流
1. `App` 启动时读取应用设置,并把当前值传给每个保活的会话视图。
2. 平台功能 Switch 保存成功后立即更新 `App` 状态。
3. `ChatTimeline` 只为 `role === "assistant"` 且 `state === "complete"` 的正文传入
HTML 渲染权限;推理始终不传入该权限。
4. `MarkdownRenderer` 识别完整 HTML 回复以及 `html` / `htm` fenced code。
5. 关闭权限或回复仍在流式阶段时,HTML 保持代码文本。
## 静态预览
`StaticHtmlPreview` 不使用 `dangerouslySetInnerHTML`。它先用 DOMPurify 清理文档,再通过
`DOMParser` 移除事件属性、非锚点 `href` 和非内联图片的 `src` / `poster`,最后把结果写入
`<iframe sandbox="" srcDoc>`。
作者样式必须保留:`<style>` 元素和 `style` 属性原样传入预览,不按 `@import` 或 `url()`
关键字整块删除,否则任何带渐变或背景图的回复都会退化成无样式文档。外部样式表和远程
字体、图片的实际加载由 iframe 文档的 CSP 拒绝,不需要在清理阶段重复拦截。
iframe 文档注入严格 CSP:
- `default-src 'none'`
- `script-src 'none'`
- `connect-src 'none'`
- `frame-src 'none'`
- `object-src 'none'`
- `form-action 'none'`
- 只允许内联样式以及 `data:` 字体和受支持的内联位图
同时设置 `referrerPolicy="no-referrer"`。Sandbox 不授予 scripts、forms、popups、
same-origin 或 navigation 权限。预览只在回复完成后创建,并使用 lazy loading。
## UI 与无障碍
预览沿用聊天阅读宽度、语义颜色和共享 `icon-button`。`figure` / `figcaption` 表达预览及其
说明,iframe 具有本地化标题。标题右侧是一个 `role="group"` 图标按钮组,与 Mermaid 图表
操作保持一致:源码按钮暴露 `aria-expanded` 与 `aria-controls`,全屏按钮打开 Modal。
嵌入预览高度固定为 480px,超出部分在框内滚动;查看源码面板使用同一数值作为最大高度。
iframe 无法按内容自适应高度,因为 `sandbox=""` 不含 `allow-same-origin` 或 `allow-scripts`,
主文档既读不到内部文档尺寸,也不能接收上报;全屏预览覆盖需要更多空间的场景。
全屏预览复用 `dialog-focus` 的 `activateModalFocus` 与 `trapTabFocus`,遵循统一 Modal 的
背景 inert、初始焦点落在关闭按钮、Tab 循环、Escape 关闭和关闭后焦点恢复。它渲染的是与
嵌入预览完全相同的清理后文档,不额外放宽 Sandbox 或 CSP。切换回复时,源码面板与全屏
预览按当前 source 派生开合状态,不使用会引发级联渲染的重置 effect。
## 验证范围
- 应用设置默认值、关闭持久化和版本 10 迁移。
- fenced HTML、完整 HTML、关闭后的源码回退。
- 作者 `<style>` 与 `style` 属性在预览中保留。
- 脚本、远程资源、表单和事件属性清理,以及 iframe Sandbox/CSP。
- 源码按钮的 `aria-expanded` 切换,以及全屏预览的初始焦点、背景 inert、Escape 关闭和
焦点恢复。
- 主 Agent、子 Agent、用户、推理和流式状态边界。
- 从设置 Switch 到当前会话重新渲染的应用级集成路径。
@@ -338,7 +338,7 @@ GoodBuddy conversationId -> Harness sessionId + process generation
### 9.4 释放与退出
- 原生能力清单通过一次性 Runtime 探测,取得有界快照后立即 dispose,不得因浏览设置或切换项目把 Host 缓存在执行 Runtime 池中。
- 原生能力清单由 `SelectedRuntimeManager` 缓存的执行 Runtime 提供,与随后的对话请求共用同一实例,不再为清单单独启动并销毁一次 Host;该实例遵循执行缓存的空闲淘汰、设置变更 reset 和退出 dispose 规则。
- 删除或释放对话时调用 `goodbuddy/session/release`。
- Runtime dispose 时先拒绝新请求,再取消所有 Session。
- Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。
+14 -2
View File
@@ -6,8 +6,8 @@
| --- | --- |
| 文档类型 | 功能进度 |
| 状态 | 已实施,本机验收完成 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 版本 | 0.4 |
| 日期 | 2026-09-05 |
| 关联入口 | [直连模型 Agent 能力](./README.md) |
## 当前结论
@@ -126,6 +126,18 @@
- `npm run build`:Main、Preload、Renderer 和控制面安装器均成功构建。2026-09-02 按产品
决策移除未对应实际性能问题的 Renderer bundle 基线门禁后复验通过。
## 2026-09-05 移除直连模型响应字节上限
- 直连模型不再对流式响应总量、单个流式块、非流式回复、模型工具参数、工具请求上下文、
聚合工具结果和累计回答长度设置字节上限;长回复和大工具结果由请求超时、取消和上下文
压缩控制。用户不再看到“模型接口流式响应超过安全限制”。
- 图像生成响应仍按可解码图片大小校验,Provider 错误响应仍读取有界文本。
- 回归测试改为验证超过旧上限的长流式回复可以完整返回,以及超过 1 MB 的图片工具结果会
进入下一轮模型请求。
- `npx vitest run src/main/agent/model-runtime.test.ts`:`74 passed, 1 skipped`。
- 全量 `npm test`:`3493 passed, 55 skipped`。
- `npm run typecheck`、`npm run lint` 和 `npm run build`:通过。
## 进度维护要求
- 只有真实生产路径完成并验证后才能勾选实施项。
@@ -327,11 +327,15 @@ type SubagentActor =
不要用宽泛 catch 抹掉取消、超时、Provider HTTP 状态或进程退出上下文。
模型请求重试由 `ModelAgentRuntime` 的统一流式请求边界处理,覆盖普通问答、工具轮次和复用
模型请求重试由 `ModelAgentRuntime` 的统一流式请求处理,覆盖普通问答、工具轮次和复用
同一 Runtime 的上下文摘要。重试分类检查错误及有界 `cause` 链中的 Node/Undici 网络错误
码;每次尝试创建独立超时信号。退避等待监听父请求取消。只有完整模型轮次返回后才执行
工具,因此未产生可见文本或推理内容的重试不会重复执行工具。
模型回复本身不设固定字节上限:流式响应、非流式回复、模型工具参数、请求上下文和工具
结果总量都由请求超时、取消和上下文压缩控制,长回复和大工具结果不会被中断。图像生成
响应仍按可解码图片大小校验。
## 11. 性能
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
+13 -9
View File
@@ -2,7 +2,7 @@
## 状态
本文记录截至 2026-09-03 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
本文记录截至 2026-09-05 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
详细事务与验收边界见
[SSH Host 远程环境准备与直连下载设计](./environment-provisioning-technical-design.md);
@@ -12,7 +12,7 @@ Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
Agent 源码 lock 为 `0.11.17`,Desktop 候选为 `0.12.3`,等待各自独立发布审批。
Agent 源码 lock 为 `0.11.18`,Desktop 候选为 `0.12.4`,等待各自独立发布审批。
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
@@ -168,7 +168,7 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、
- 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。
- Ask Workspace handle 不暴露写入方法。
- Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。
- 文件预览、搜索、Git diff、目录项和显式文件传输保持字节、条目数和路径长度上限。
- 文件预览按有界页面传输并保持 UTF-8 字符边界;超过单页大小时先返回当前页,用户可继续加载,不把单页传输上限误作文件总预览上限。搜索、Git diff、目录项和显式文件传输继续保持各自的字节、条目数和路径长度上限。
## Runtime
@@ -225,11 +225,11 @@ Execute 直接启动已签名 Runtime:
调度、跨 channel 超车、批发送或第二套拥塞控制。
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
- Runtime 事件仍对单项展示字段做有界截断,但不按固定工具调用数或 Desktop 累计展示量
中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生 Runtime。
Renderer 和 Main 为每条回复保留最多 500 条工具与子 Agent 活动以及 2,000 个交错
展示块;超过后显示本地截断提示,但继续执行 Runtime,也不把展示容量当作 Prompt
工具调用上限。已保留活动的后续状态更新仍会正常归并。
- Runtime 事件仍对协议定义的单项传输字段应用既有边界,但不按固定工具调用数或 Desktop
累计展示量中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生
Runtime。Main 和 Renderer 不再按 500 条工具/子 Agent 活动或 2,000 个交错展示块丢弃
对话详情;独立运行记录也改由 Main SQLite 完整保存并按批渲染。旧版已经触发本地上限的
历史继续显示可能不完整的提示,不能通过迁移隐藏既有数据缺口。
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
@@ -354,7 +354,7 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件;
当前源码 lock 固定为 `0.11.17`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
当前源码 lock 固定为 `0.11.18`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
- 2026-08-30 在共享 Linux x64 Host 的隔离测试 HOME 中验证当前 `0.11.13` 源码候选:
签名 Runtime 清单的测试墙钟上限为 1 秒,模型桥首轮故意延迟 2.515 秒后 Prompt 仍在
5.770 秒正常完成;随后 OpenCode 原生 Task 依次产生 `running`、`completed` 子 Agent
@@ -413,6 +413,10 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
Host 的唯一 `/tmp/goodbuddy-e2e-*` 目录完成 Runtime 激活和 Agent 本机模型 gateway
验证。模型 gateway 通过所选 Anthropic 连接返回预期随机哨兵,HTTP 状态为 200,实际
文本模型调用恰好 1 次;Host 临时目录随后删除,共享 Agent/Runtime 安装和进程未修改。
- 2026-09-04 使用当前源码 Desktop 和共享 Linux x64 Host 的既有 Agent/Workspace
生产链路验证分页文件预览。专用测试文本共 262,166 bytes,首个 256 KiB 页面在三字节
UTF-8 字符前回退并显示 262,143 bytes;继续加载后完整追加剩余文本,页面不再显示加载
按钮且没有错误。测试未调用模型;专用远端文件、目录和本机隔离用户数据均已清理。
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
@@ -231,8 +231,10 @@ type TimeTrigger =
同一 Conversation 的普通消息和 Scheduled Task occurrence 使用同一 FIFO 队列。Agent
正在回复时,到期 occurrence 只显示为待执行,不中断当前输出;当前执行结束后才认领下一项。
用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项成为
下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史结果。
执行以失败、取消或中断结束时同样立即释放该 Conversation,下一项无需用户再次操作即会
执行。用户显式选择“立即中断并插入”时,系统取消当前 Conversation 的活动请求,并让所选项
成为下一项。删除待执行 occurrence 只取消该次运行,不删除稳定 Task、Conversation 或历史
结果。
## 8. 一次触发的对象关系
@@ -365,6 +367,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模
- [x] 当前回复期间可以继续发送普通消息,并在 Composer 上沿查看、删除或提升待发送项。
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文;已写入 Conversation 的用户输入
通过队列项身份完成恢复对账,不会再次进入待发送队列。
- [x] 执行失败、取消或中断后立即释放会话,下一项和随后发送的新消息无需用户额外操作
即会执行。
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
- [ ] 应用重启不自动重放结果未知的副作用。
+2 -1
View File
@@ -277,7 +277,8 @@ GoodBuddy 应能够:
### 阶段 0:持久化基础
- 新增 `assistant.sqlite` 和迁移框架。
- 将会话与活动从 `localStorage` 迁移到主进程数据库。
- 会话与运行记录已从 `localStorage` 迁移到主进程数据库;旧运行记录可能触及历史上限时
保留可见的不完整提示。
- 拆分共享契约和 IPC 注册。
- 保持现有对话、知识库、设置和审批行为不变。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.12.3",
"version": "0.12.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.12.3",
"version": "0.12.4",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.12.3",
"version": "0.12.4",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+44
View File
@@ -1,6 +1,50 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.12.4",
"releasedAt": "2026-09-05",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.12.4 新增会话内 HTML 静态预览,并改善长任务活动、大文件预览、长回复与消息队列、远程任务终态,以及 Linux ARM 上的 OpenCode 后续响应速度。"
],
"features": [
"**会话内 HTML 静态预览。** Agent 和 Subagent 完成回复后,完整 HTML 文档及 HTML 代码块可直接在会话中预览,并支持查看源码和全屏展示。该能力默认开启,可在“平台功能 / 通用设置”中关闭;预览不会执行脚本、访问网络、提交表单或打开窗口。"
],
"fixes": [
"**长任务活动与大文件预览更完整。** Runtime 工具和 Subagent 活动现在持久保存在 Main SQLite 中,不再受旧版条目数、详情长度或缓存总量限制;旧数据可能已被截断时会明确提示。本地和远程超过 256 KiB 的 UTF-8 文本文件可按页继续加载,不再直接拒绝预览。",
"**长回复和消息队列不再意外中断。** 直连模型的流式及非流式长回复、工具参数和累计答案不再因固定字节上限失败。上一次运行失败、被中断或未成功启动后,后续消息会释放并继续调度,不再长期停留在等待队列。",
"**远程任务终态显示更准确。** 托管 SSH 任务断线恢复后,已持久化的完成或失败状态会覆盖旧的本地流式状态;窗口重新聚焦或恢复可见时也会重新读取会话,避免 Agent 已结束但界面仍显示处理中。",
"**Linux ARM 上 OpenCode 后续响应更快。** 原生能力清单与后续对话请求现在复用同一个 Runtime,不再先启动并销毁临时 OpenCode Server、再为首条消息重复冷启动。"
],
"notices": [
"**远程项目仍为技术预览。** Host 需要单独发布的 Agent `0.11.18`;Desktop `0.12.4` 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 表示授权所选 SSH 账号可用的完整工具、进程、网络和写入权限。",
"**HTML 预览是静态展示。** 预览保留作者样式,但禁止脚本和外部资源;关闭该功能后继续显示 HTML 源码。",
"**发布平台。** GoodBuddy 使用 Windows、macOS、Linux 的 `x64`/`arm64` 标准发布矩阵。本次不构建独立的 LoongArch 实验预览。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.12.4 adds static HTML previews in conversations and improves long-run activity, large-file previews, long replies and message queues, remote terminal state, and OpenCode follow-up speed on Linux ARM."
],
"features": [
"**Static HTML previews in conversations.** Completed Agent and Subagent replies can render complete HTML documents and HTML code blocks directly in the conversation, with source and full-screen views. The feature is enabled by default and can be disabled under Platform Features / General; previews cannot run scripts, access the network, submit forms, or open windows."
],
"fixes": [
"**Complete long-run activity and large-file previews.** Runtime tool and Subagent activity is now persisted in Main SQLite without the former item-count, detail-length, or total-cache limits, with a clear warning when legacy data may already have been truncated. Local and remote UTF-8 text files larger than 256 KiB can continue loading page by page instead of being rejected.",
"**Long replies and message queues no longer stop unexpectedly.** Direct-model streaming and non-streaming replies, tool arguments, and accumulated answers no longer fail at fixed byte limits. After a previous run fails, is interrupted, or never starts, later messages are released and dispatched instead of remaining queued indefinitely.",
"**Accurate terminal state after remote recovery.** After a managed SSH disconnect, persisted completion or failure now replaces stale local streaming state. Conversations are also refreshed when the window regains focus or visibility, avoiding a Processing state after the Agent has already finished.",
"**Faster follow-up after switching to OpenCode on Linux ARM.** Native capability discovery and later conversation requests now share one Runtime instead of starting and destroying a temporary OpenCode Server before cold-starting another one for the first message."
],
"notices": [
"**Remote Projects remain a technical preview.** Hosts require the separately published Agent `0.11.18`; Desktop `0.12.4` does not embed the Agent or Remote Runtime. Ask remains read-only, while Execute authorizes the full tools, processes, network access, and writable paths available to the selected SSH account.",
"**HTML previews are static.** Author styles are preserved, but scripts and external resources are blocked. Disabling the feature keeps the HTML source visible.",
"**Release platforms.** GoodBuddy uses the standard Windows, macOS, and Linux `x64`/`arm64` release matrix. This release does not include a separate experimental LoongArch preview."
]
}
}
},
{
"version": "0.12.3",
"releasedAt": "2026-09-04",
+105 -62
View File
@@ -1788,19 +1788,41 @@ describe('ModelAgentRuntime', () => {
}
})
it('bounds the total ordinary streaming response size', async () => {
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: 'x'.repeat(65_000)
}
})}\n\n`
)
it('streams long ordinary responses without a size limit', async () => {
const encoder = new TextEncoder()
const deltaChunks = 48
const deltaText = 'x'.repeat(65_000)
const body = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(chunk)
start(controller) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'message_start',
message: {
id: 'message-long',
model: 'sonnet-5',
usage: { input_tokens: 5 }
}
})}\n\n`
)
)
for (let index = 0; index < deltaChunks; index += 1) {
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: deltaText
}
})}\n\n`
)
)
}
controller.enqueue(
encoder.encode('data: {"type":"message_stop"}\n\n')
)
controller.close()
}
})
const runtime = new ModelAgentRuntime({
@@ -1816,22 +1838,26 @@ describe('ModelAgentRuntime', () => {
})
)
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
let streamedText = ''
let done = false
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
if (event.type === 'text') {
streamedText += event.delta
}
if (event.type === 'done') {
done = true
}
}
await expect(consume()).rejects.toThrow(
'流式响应超过安全限制'
)
expect(done).toBe(true)
expect(streamedText.length).toBe(deltaChunks * deltaText.length)
})
it('preserves bounded provider error messages', async () => {
@@ -5192,29 +5218,44 @@ describe('ModelAgentRuntime', () => {
await replacement.dispose()
})
it('counts image base64 data against the aggregate tool context limit', async () => {
const response = {
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
}
it('keeps an oversized image tool result in the model round', async () => {
const imageData = Buffer.alloc(1024 * 1024 + 1).toString('base64')
const fetcher = vi.fn<typeof fetch>(async () => Response.json(response))
const fetcher = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call-large-image',
type: 'function',
function: {
name: 'workspace_read_text',
arguments: '{}'
}
}
]
}
}
]
})
)
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: '图片已读取。'
}
}
]
})
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
@@ -5234,23 +5275,25 @@ describe('ModelAgentRuntime', () => {
}))
})
})
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
void _event
}
const events = []
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'run',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
await expect(consume()).rejects.toThrow('结果总量超过 1MB')
expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(fetcher.mock.calls[1]?.[1]?.body as string).toContain(
imageData
)
})
it('performs and validates a real image generation when testing an image connection', async () => {
+31 -95
View File
@@ -189,10 +189,6 @@ type ModelToolResponse = {
const maxGeneratedImageBytes = 3_900_000
const maxImageResponseBytes = 5_300_000
const maxChatResponseBytes = 2 * 1024 * 1024
const maxStreamBlockBytes = 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const defaultModelRequestTimeoutMs = 10 * 60_000
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
const retryablePreDispatchNetworkErrorCodes = new Set([
@@ -734,12 +730,16 @@ function parseGeneratedImage(value: unknown): {
throw new Error('图像生成接口返回了不支持的图片格式')
}
async function readResponseText(response: Response): Promise<string> {
if (!response.body) {
throw new Error('模型接口未返回响应内容')
}
return response.text()
}
function parseToolArguments(value: unknown): Record<string, unknown> {
let parsed = value
if (typeof value === 'string') {
if (Buffer.byteLength(value) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
try {
parsed = JSON.parse(value)
} catch (error) {
@@ -751,15 +751,11 @@ function parseToolArguments(value: unknown): Record<string, unknown> {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('模型工具参数必须是 JSON object')
}
let serialized: string
try {
serialized = JSON.stringify(parsed)
JSON.stringify(parsed)
} catch (error) {
throw new Error('模型工具参数无法序列化', { cause: error })
}
if (Buffer.byteLength(serialized) > maxToolArgumentBytes) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
return parsed as Record<string, unknown>
}
@@ -1232,7 +1228,7 @@ function parseSseData(
}
}
async function* readBoundedSseBlocks(
async function* readSseBlocks(
response: Response
): AsyncGenerator<string, void, void> {
if (!response.body) {
@@ -1242,19 +1238,11 @@ async function* readBoundedSseBlocks(
const decoder = new TextDecoder()
let buffer = ''
let completed = false
let receivedBytes = 0
try {
while (true) {
const { done, value } = await reader.read()
receivedBytes += value?.byteLength ?? 0
if (receivedBytes > maxChatResponseBytes) {
throw new Error('模型接口流式响应超过安全限制')
}
buffer += decoder.decode(value, { stream: !done })
buffer = buffer.replaceAll('\r\n', '\n')
if (Buffer.byteLength(buffer) > maxStreamBlockBytes) {
throw new Error('模型接口流式响应块超过安全限制')
}
const blocks = buffer.split('\n\n')
buffer = blocks.pop() ?? ''
@@ -1288,7 +1276,7 @@ async function* readOpenAIResponsesToolStream(
reported: false
}
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1423,7 +1411,7 @@ async function* readAnthropicToolStream(
let reasoning = ''
let stopReason: unknown
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
break
@@ -1566,12 +1554,6 @@ async function* readAnthropicToolStream(
throw new Error('Anthropic 返回了无效流式工具参数增量')
}
current.partialJson += delta.partial_json
if (
Buffer.byteLength(current.partialJson) >
maxToolArgumentBytes
) {
throw new Error('模型工具参数超过 128KB 安全限制')
}
}
continue
}
@@ -1952,13 +1934,14 @@ export class ModelAgentRuntime implements AgentRuntime {
)
)
})
const responseText = await readBoundedResponseText(response, {
maxBytes: response.ok && imageGeneration
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText =
response.ok && imageGeneration
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
if (!response.ok) {
let detail: string | undefined
try {
@@ -2660,13 +2643,13 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = modelRequest.response
let responseText: string
try {
responseText = await readBoundedResponseText(response, {
maxBytes: response.ok
? maxImageResponseBytes
: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
responseText = response.ok
? await readBoundedResponseText(response, {
maxBytes: maxImageResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
: await readResponseText(response)
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
@@ -2828,9 +2811,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
)
)
if (Buffer.byteLength(body) > 2 * 1024 * 1024) {
throw new Error('模型工具请求上下文超过 2MB 安全限制')
}
const request = await this.fetchWithTimeout(
this.getEndpoint(),
{
@@ -2843,11 +2823,7 @@ export class ModelAgentRuntime implements AgentRuntime {
const response = request.response
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -2890,7 +2866,7 @@ export class ModelAgentRuntime implements AgentRuntime {
let reasoning = ''
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseSseData(block)
if (parsed.stopped) {
receivedStop = true
@@ -2972,16 +2948,6 @@ export class ModelAgentRuntime implements AgentRuntime {
? functionDelta.name
: current.name
}
if (
next.id.length > 256 ||
next.name.length > 128 ||
Buffer.byteLength(next.arguments) >
maxToolArgumentBytes
) {
throw new Error(
'OpenAI 模型接口返回的流式工具调用超过安全限制'
)
}
streamedToolCalls.set(index as number, next)
}
}
@@ -3025,11 +2991,7 @@ export class ModelAgentRuntime implements AgentRuntime {
streamed: true
}
}
const responseText = await readBoundedResponseText(response, {
maxBytes: maxChatResponseBytes,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let payload: unknown
try {
payload = responseText.trim()
@@ -3115,11 +3077,7 @@ export class ModelAgentRuntime implements AgentRuntime {
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
const responseText = await readResponseText(response)
let detail: string | undefined
try {
detail = getErrorMessage(
@@ -3136,7 +3094,7 @@ export class ModelAgentRuntime implements AgentRuntime {
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
for await (const block of readSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
@@ -3619,9 +3577,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
if (response.text) {
answer += response.text
if (Buffer.byteLength(answer) > 1024 * 1024) {
throw new Error('直连模型回答超过 1MB 安全限制')
}
if (!response.streamed) {
yield {
requestId: request.requestId,
@@ -3844,25 +3799,6 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
roundContextBytes += validateToolResult(result)
const retainedContextBytes = compressionState.rounds.reduce(
(total, retainedRound) =>
total + retainedRound.contextBytes,
roundContextBytes
)
if (retainedContextBytes > maxToolContextBytes) {
if (showToolActivity) {
yield {
requestId: request.requestId,
type: 'tool',
callId: call.id,
name: displayName,
state: 'failed',
summary: `直连模型工具结果超过限制:${displayName}`,
input
}
}
throw new Error('直连模型工具结果总量超过 1MB 安全限制')
}
if (responses) {
responsesResults.push({
type: 'function_call_output',
+33 -10
View File
@@ -318,13 +318,9 @@ describe('SelectedRuntimeManager', () => {
await manager.dispose()
})
it('disposes a native-inventory runtime without caching it', async () => {
const inspected = runtime()
const cached = runtime()
const create = vi
.fn()
.mockResolvedValueOnce(inspected.value)
.mockResolvedValueOnce(cached.value)
it('serves the native inventory from the cached execution runtime', async () => {
const shared = runtime()
const create = vi.fn(async () => shared.value)
const manager = new SelectedRuntimeManager(create)
const selection = { provider: 'opencode' as const }
@@ -337,15 +333,42 @@ describe('SelectedRuntimeManager', () => {
provider: 'opencode',
inventoryStatus: 'available'
})
expect(inspected.getNativeSnapshot).toHaveBeenCalledOnce()
expect(inspected.dispose).toHaveBeenCalledOnce()
expect(shared.getNativeSnapshot).toHaveBeenCalledOnce()
expect(shared.dispose).not.toHaveBeenCalled()
await manager.getRuntime(
const controller = await manager.getRuntime(
selection,
executionSpace('local:project-one')
)
expect(create).toHaveBeenCalledOnce()
await expect(controller.getStatus()).resolves.toMatchObject({
available: true
})
await manager.getNativeSnapshot(
selection,
executionSpace('local:project-two')
)
expect(create).toHaveBeenCalledTimes(2)
await manager.dispose()
expect(shared.dispose).toHaveBeenCalled()
})
it('rejects the native inventory when the runtime lacks it', async () => {
const plain = runtime()
const withoutInventory: AgentRuntime = { ...plain.value }
delete withoutInventory.getNativeSnapshot
const manager = new SelectedRuntimeManager(
vi.fn(async () => withoutInventory)
)
await expect(
manager.getNativeSnapshot({
provider: 'model',
profileId: '00000000-0000-4000-8000-000000000001'
})
).rejects.toThrow('当前 Runtime 不支持原生能力清单')
await manager.dispose()
})
it('waits for a pending connection-test runtime during shutdown', async () => {
+5 -12
View File
@@ -355,18 +355,11 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
selection: AgentRuntimeSelection,
executionSpace?: ExecutionSpaceDescriptor
): Promise<RuntimeNativeSnapshot> {
const runtime = await this.createRuntime(selection, executionSpace)
try {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')
}
if (!runtime.getNativeSnapshot) {
throw new Error('当前 Runtime 不支持原生能力清单')
}
return await runtime.getNativeSnapshot()
} finally {
await runtime.dispose()
}
// The inventory runs on the same cached Runtime that later serves the
// conversation, so a slow cold start (embedded OpenCode on Linux ARM)
// is paid once instead of once per inventory and again per request.
const controller = await this.getRuntime(selection, executionSpace)
return controller.getNativeSnapshot()
}
private async startRetiring(
+68 -10
View File
@@ -65,6 +65,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -76,6 +77,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -83,11 +85,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: false,
@@ -116,7 +119,7 @@ describe('ApplicationSettingsStore', () => {
remoteProjectsEnabled: true
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
lastSeenReleaseNotesVersion: null,
...defaultApplicationSettings,
remoteProjectsEnabled: true
@@ -139,6 +142,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -165,6 +169,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -191,6 +196,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -217,6 +223,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -245,11 +252,12 @@ describe('ApplicationSettingsStore', () => {
createApplicationSettingsStore(filePath).getLastSeenReleaseNotesVersion()
).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -279,6 +287,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -306,6 +315,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -314,9 +324,10 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -324,9 +335,10 @@ describe('ApplicationSettingsStore', () => {
await store.update({ modelDownloadSource: 'hugging-face' })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSix,
version: 10,
version: 11,
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -351,6 +363,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -359,8 +372,9 @@ describe('ApplicationSettingsStore', () => {
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionSeven,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesShowIncompleteTodoCount: true
})
@@ -388,6 +402,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'mirror',
modelDownloadSource: 'hugging-face',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -397,17 +412,19 @@ describe('ApplicationSettingsStore', () => {
await expect(store.getLastSeenReleaseNotesVersion()).resolves.toBe('0.8.18')
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
await store.update({ checkUpdatesOnStartup: true })
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionEight,
version: 10,
version: 11,
checkUpdatesOnStartup: true,
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
})
@@ -445,6 +462,7 @@ describe('ApplicationSettingsStore', () => {
magicNotesShowIncompleteTodoCount: false,
magicNoteCommentMode: 'after-save-manual',
magicNoteCommentFormat: 'narrative',
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -459,7 +477,8 @@ describe('ApplicationSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8'))
expect(persisted).toEqual({
...versionNine,
version: 10,
version: 11,
conversationHtmlRenderingEnabled: true,
localToolEnvironment: {
node: {
source: 'custom',
@@ -523,6 +542,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -546,6 +566,41 @@ describe('ApplicationSettingsStore', () => {
})
})
it('persists disabling conversation HTML rendering', async () => {
const { filePath, store } = await createStore()
await expect(
store.update({ conversationHtmlRenderingEnabled: false })
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
await expect(
createApplicationSettingsStore(filePath).get()
).resolves.toEqual({
...defaultApplicationSettings,
conversationHtmlRenderingEnabled: false
})
})
it('enables conversation HTML rendering when migrating version 10 settings', async () => {
const { filePath, store } = await createStore()
const versionTenSettings: Record<string, unknown> = {
...defaultApplicationSettings,
version: 10,
lastSeenReleaseNotesVersion: '0.8.18'
}
delete versionTenSettings.conversationHtmlRenderingEnabled
await writeFile(filePath, JSON.stringify(versionTenSettings), 'utf8')
await expect(store.get()).resolves.toEqual(defaultApplicationSettings)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
...versionTenSettings,
version: 11,
conversationHtmlRenderingEnabled: true
})
})
it.each([
'{not-json',
JSON.stringify({
@@ -634,6 +689,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -641,11 +697,12 @@ describe('ApplicationSettingsStore', () => {
magicNoteCommentFormat: 'combined'
})
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({
version: 10,
version: 11,
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -674,6 +731,7 @@ describe('ApplicationSettingsStore', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultApplicationSettings.localToolEnvironment,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+44 -4
View File
@@ -27,7 +27,7 @@ export {
} from '../shared/application-settings-contracts'
export type { ApplicationSettings } from '../shared/application-settings-contracts'
const CURRENT_SETTINGS_VERSION = 10
const CURRENT_SETTINGS_VERSION = 11
const legacyStoredApplicationSettingsSchema = z
.object({
@@ -58,6 +58,7 @@ const versionFourStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -71,6 +72,7 @@ const versionFiveStoredApplicationSettingsSchema = applicationSettingsSchema
updateSource: true,
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -84,6 +86,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
modelDownloadSource: true,
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -96,6 +99,7 @@ const versionSixStoredApplicationSettingsSchema = applicationSettingsSchema
const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true,
magicNotesShowIncompleteTodoCount: true
})
@@ -108,6 +112,7 @@ const versionSevenStoredApplicationSettingsSchema = applicationSettingsSchema
const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: true
})
.extend({
@@ -117,13 +122,25 @@ const versionEightStoredApplicationSettingsSchema = applicationSettingsSchema
.strict()
const versionNineStoredApplicationSettingsSchema = applicationSettingsSchema
.omit({ localToolEnvironment: true })
.omit({
localToolEnvironment: true,
conversationHtmlRenderingEnabled: true
})
.extend({
version: z.literal(9),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const versionTenStoredApplicationSettingsSchema =
applicationSettingsSchema
.omit({ conversationHtmlRenderingEnabled: true })
.extend({
version: z.literal(10),
lastSeenReleaseNotesVersion: releaseVersionSchema.nullable()
})
.strict()
const storedApplicationSettingsSchema = applicationSettingsSchema
.extend({
version: z.literal(CURRENT_SETTINGS_VERSION),
@@ -215,6 +232,7 @@ export const defaultApplicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -236,8 +254,13 @@ export class ApplicationSettingsStore {
private async migrateLegacy(
settings: Omit<
StoredApplicationSettings,
'localToolEnvironment' | 'version'
> & { version: number }
| 'conversationHtmlRenderingEnabled'
| 'localToolEnvironment'
| 'version'
> & {
conversationHtmlRenderingEnabled?: boolean
version: number
}
): Promise<StoredApplicationSettings> {
const resolved = await this.legacyLocalToolEnvironmentResolver()
const selection = (
@@ -249,6 +272,8 @@ export class ApplicationSettingsStore {
: { source: 'managed' }
const next: StoredApplicationSettings = {
...settings,
conversationHtmlRenderingEnabled:
settings.conversationHtmlRenderingEnabled ?? true,
version: CURRENT_SETTINGS_VERSION,
localToolEnvironment: {
node: selection(resolved.nodeExecutablePath),
@@ -303,6 +328,17 @@ export class ApplicationSettingsStore {
)
const result = storedApplicationSettingsSchema.safeParse(parsed)
if (!result.success) {
const versionTenResult =
versionTenStoredApplicationSettingsSchema.safeParse(parsed)
if (versionTenResult.success) {
const next: StoredApplicationSettings = {
...versionTenResult.data,
version: CURRENT_SETTINGS_VERSION,
conversationHtmlRenderingEnabled: true
}
await this.persist(next)
return next
}
const versionNineResult =
versionNineStoredApplicationSettingsSchema.safeParse(parsed)
if (versionNineResult.success) {
@@ -443,6 +479,8 @@ export class ApplicationSettingsStore {
updateSource: stored.updateSource,
modelDownloadSource: stored.modelDownloadSource,
localToolEnvironment: stored.localToolEnvironment,
conversationHtmlRenderingEnabled:
stored.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: stored.remoteProjectsEnabled,
magicNotesEnabled: stored.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
@@ -478,6 +516,8 @@ export class ApplicationSettingsStore {
updateSource: next.updateSource,
modelDownloadSource: next.modelDownloadSource,
localToolEnvironment: next.localToolEnvironment,
conversationHtmlRenderingEnabled:
next.conversationHtmlRenderingEnabled,
remoteProjectsEnabled: next.remoteProjectsEnabled,
magicNotesEnabled: next.magicNotesEnabled,
magicNotesShowIncompleteTodoCount:
+91 -11
View File
@@ -159,11 +159,11 @@ describe('AssistantDatabase', () => {
unchanged.close()
})
it('persists more than 100 tool activities in one message', async () => {
it('persists more than 500 tool activities in one message', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000901'
const messageId = '00000000-0000-4000-8000-000000000902'
const tools = Array.from({ length: 101 }, (_, index) => ({
const tools = Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
@@ -192,7 +192,79 @@ describe('AssistantDatabase', () => {
expect(
database.getConversation(conversationId)?.messages[0]?.tools
).toHaveLength(101)
).toHaveLength(501)
database.close()
})
it('persists complete Run history beyond the former Renderer limits', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-activity-history-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const records = Array.from({ length: 501 }, (_, index) => ({
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
scope: { kind: 'global' as const },
kind: 'tool' as const,
title: `工具调用 ${index}`,
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件',
status: 'completed' as const,
createdAt: index
}))
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.replaceActivityHistory({
records,
legacyHistoryMayBeIncomplete: true
})
initial.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
const history = reopened.getActivityHistory()
expect(history.records).toHaveLength(501)
expect(history.records.at(-1)?.detail).toHaveLength(4_001)
expect(history.legacyHistoryMayBeIncomplete).toBe(true)
reopened.clearAssistantData()
expect(reopened.getActivityHistory()).toEqual({
records: [],
legacyHistoryMayBeIncomplete: false
})
reopened.close()
})
it('keeps the truthful truncation marker on legacy messages', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000911'
database.saveLocalConversations([
{
header: {
id: conversationId,
title: '旧版截断记录',
updatedAt: 1
},
messages: [
{
id: '00000000-0000-4000-8000-000000000912',
role: 'assistant',
content: '',
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true
}
]
}
])
expect(
database.getConversation(conversationId).messages[0]
?.displayCaptureTruncated
).toBe(true)
database.close()
})
@@ -239,7 +311,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 32', async () => {
it('migrates existing databases to schema version 33', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -268,7 +340,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -374,6 +446,14 @@ describe('AssistantDatabase', () => {
)
.get()
).toEqual({ name: 'conversations_branch_source_idx' })
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'activity_history'`
)
.get()
).toEqual({ name: 'activity_history' })
current.close()
})
@@ -430,7 +510,7 @@ describe('AssistantDatabase', () => {
enableForeignKeyConstraints: true
})
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
@@ -528,7 +608,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -605,7 +685,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -855,7 +935,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -993,7 +1073,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 32 })
).toEqual({ user_version: 33 })
expect(
inspected
.prepare(
@@ -2690,7 +2770,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('PRAGMA table_info(task_events)').all()
+81 -84
View File
@@ -3,12 +3,11 @@ import { DatabaseSync } from 'node:sqlite'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
assistantIdSchema,
conversationMessageSchema,
conversationSnapshotSchema,
expertCreateSchema,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
persistedProjectExecutionSpaceSchema,
projectCreateSchema,
@@ -24,6 +23,7 @@ import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ActivityHistorySnapshot,
ConversationQueueItem,
ConversationBranchInput,
ConversationMessageBlock,
@@ -95,7 +95,7 @@ import {
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 32
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
export type RemoteTaskEventInput = {
taskId: string
@@ -1123,6 +1123,8 @@ function toConversationSnapshot(
blocks: interrupted
? interruptActiveToolBlocks(metadata.blocks)
: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
@@ -1192,9 +1194,6 @@ function appendRecoveredMessageBlock(
)
}
} else {
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: randomUUID(),
type,
@@ -1225,9 +1224,6 @@ function upsertRecoveredToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'tool' as const, tool }
@@ -1267,9 +1263,6 @@ function upsertRecoveredSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
@@ -1302,16 +1295,6 @@ function failedToolRepresentsError(
)
}
function canRetainRecoveredActivity(
message: ConversationMessage
): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function reduceRecoveredAgentEvent(
message: ConversationMessage,
event: RemoteConversationTaskEventInput['event']
@@ -1332,9 +1315,6 @@ function reduceRecoveredAgentEvent(
...message,
content: `${message.content}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status:
event.delta.length > remaining
? '回答过长,已在本地截断显示'
@@ -1355,9 +1335,6 @@ function reduceRecoveredAgentEvent(
...message,
reasoning: `${reasoning}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status: undefined
}
} else if (event.type === 'status') {
@@ -1405,25 +1382,14 @@ function reduceRecoveredAgentEvent(
)
if (index >= 0) {
tools[index] = tool
} else if (!canRetainRecoveredActivity(message)) {
next = {
...message,
displayCaptureTruncated: true
}
}
if (index < 0 && next === message) {
} else {
tools.push(tool)
}
if (next === message) {
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks
}
} else if (event.type === 'subagent') {
const commonSubagent = {
@@ -1448,49 +1414,25 @@ function reduceRecoveredAgentEvent(
const index = subagents.findIndex(
(candidate) => candidate.childTaskId === event.childTaskId
)
let retainSubagent = true
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainRecoveredActivity(message)
) {
retainSubagent = false
} else {
subagents.push(subagent)
}
subagents.push(subagent)
}
if (!retainSubagent) {
next = {
...message,
displayCaptureTruncated: true
}
} else {
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks
}
} else if (event.type === 'artifact') {
next = {
@@ -2087,6 +2029,11 @@ export class AssistantDatabase {
]) {
database.exec(`DELETE FROM ${table}`)
}
database.exec(
`UPDATE activity_history
SET records_json = '[]', legacy_history_may_be_incomplete = 0
WHERE singleton = 1`
)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
@@ -4283,6 +4230,40 @@ export class AssistantDatabase {
return rows.map(toTask)
}
getActivityHistory(): ActivityHistorySnapshot {
const row = this.requireDatabase()
.prepare(
`SELECT records_json, legacy_history_may_be_incomplete
FROM activity_history
WHERE singleton = 1`
)
.get() as
| {
records_json: string
legacy_history_may_be_incomplete: number
}
| undefined
return activityHistorySnapshotSchema.parse({
records: row ? JSON.parse(row.records_json) : [],
legacyHistoryMayBeIncomplete:
row?.legacy_history_may_be_incomplete === 1
})
}
replaceActivityHistory(input: ActivityHistorySnapshot): void {
const snapshot = activityHistorySnapshotSchema.parse(input)
this.requireDatabase()
.prepare(
`UPDATE activity_history
SET records_json = ?, legacy_history_may_be_incomplete = ?
WHERE singleton = 1`
)
.run(
JSON.stringify(snapshot.records),
Number(snapshot.legacyHistoryMayBeIncomplete)
)
}
createTask(input: {
id: string
projectId?: string
@@ -9648,6 +9629,22 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 33) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS activity_history (
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
records_json TEXT NOT NULL,
legacy_history_may_be_incomplete INTEGER NOT NULL DEFAULT 0
CHECK(legacy_history_may_be_incomplete IN (0, 1))
);
INSERT OR IGNORE INTO activity_history
(singleton, records_json, legacy_history_may_be_incomplete)
VALUES (1, '[]', 0);
PRAGMA user_version = 33;
COMMIT;
`)
}
}
private requireDatabase(): DatabaseSync {
@@ -95,7 +95,10 @@ describe('workspace file browsing', () => {
path: 'remote.md',
name: 'remote.md',
content: '# fake\n',
size: 7
size: 7,
offsetBytes: 0,
bytesRead: 7,
truncated: false
}))
const getChanges = vi.fn(async () => ({
rootPath: '/remote',
@@ -136,7 +139,9 @@ describe('workspace file browsing', () => {
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.md',
maximumBytes: 256 * 1024
offsetBytes: 0,
maximumBytes: 256 * 1024,
allowTruncated: true
})
)
})
@@ -171,15 +176,11 @@ describe('workspace file browsing', () => {
})
})
it('rejects traversal, unsupported files, invalid UTF-8, and oversized files', async () => {
it('rejects traversal, unsupported files, and invalid UTF-8', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
await writeFile(join(directory, 'image.bin'), Buffer.from([0, 1, 2]))
await writeFile(join(directory, 'invalid.txt'), Buffer.from([0xff]))
await writeFile(
join(directory, 'large.txt'),
Buffer.alloc(256 * 1024 + 1, 97)
)
await expect(
readWorkspaceFile(directory, '../outside.txt')
@@ -190,8 +191,33 @@ describe('workspace file browsing', () => {
await expect(
readWorkspaceFile(directory, 'invalid.txt')
).rejects.toThrow('有效 UTF-8')
await expect(
readWorkspaceFile(directory, 'large.txt')
).rejects.toThrow('超过 256KB')
})
it('reads large UTF-8 files page by page without a total preview limit', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
const firstPage = 'a'.repeat(256 * 1024 - 1)
const content = `${firstPage}你\n第二页`
await writeFile(join(directory, 'large.txt'), content)
const first = await readWorkspaceFile(directory, 'large.txt')
expect(first).toMatchObject({
content: firstPage,
offsetBytes: 0,
nextOffsetBytes: 256 * 1024 - 1,
truncated: true
})
const second = await readWorkspaceFile(
directory,
'large.txt',
first.nextOffsetBytes
)
expect(second).toMatchObject({
content: '你\n第二页',
offsetBytes: first.nextOffsetBytes,
truncated: false
})
expect(`${first.content}${second.content}`).toBe(content)
})
})
@@ -10,7 +10,7 @@ import {
} from '../workspace'
const MAX_DIRECTORY_ENTRIES = 500
const MAX_PREVIEW_BYTES = 256 * 1024
const PREVIEW_PAGE_BYTES = 256 * 1024
const previewExtensions = new Set([
'.c',
'.cpp',
@@ -92,6 +92,7 @@ export class WorkspaceChangesService {
async readFile(
inputPath: string,
offsetBytes = 0,
signal?: AbortSignal
): Promise<WorkspaceFilePreview> {
const entry = await this.workspace.stat({
@@ -110,8 +111,9 @@ export class WorkspaceChangesService {
}
const preview = await this.workspace.readText({
path: inputPath,
maximumBytes: MAX_PREVIEW_BYTES,
tooLargeMessage: '工作区文件超过 256KB 预览限制',
offsetBytes,
maximumBytes: PREVIEW_PAGE_BYTES,
allowTruncated: true,
invalidUtf8Message: '工作区文件不是有效 UTF-8 文本',
signal
})
@@ -119,6 +121,9 @@ export class WorkspaceChangesService {
path: preview.path,
name: preview.name,
content: preview.content,
offsetBytes: preview.offsetBytes,
nextOffsetBytes: preview.offsetBytes + preview.bytesRead,
truncated: preview.truncated,
mimeType:
extension === '.md' || extension === '.markdown'
? 'text/markdown'
@@ -177,12 +182,16 @@ export async function listWorkspaceDirectory(
export async function readWorkspaceFile(
workspace: string | WorkspaceAccess,
inputPath: string
inputPath: string,
offsetBytes = 0
): Promise<WorkspaceFilePreview> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).readFile(inputPath)
return new WorkspaceChangesService(workspace).readFile(
inputPath,
offsetBytes
)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).readFile(inputPath)
new WorkspaceChangesService(access).readFile(inputPath, offsetBytes)
)
}
+229
View File
@@ -6229,6 +6229,20 @@ describe('registerIpcHandlers agent terminal state', () => {
})
)
)
// The renderer converges on the persisted terminal state by re-reading
// conversations, so the change notice must land before `completed`.
const sendCalls = harness.webContents.send.mock.calls
const completedIndex = sendCalls.findIndex(
([channel, payload]) =>
channel === ipcChannels.remoteProjectRecoveryProgress &&
(payload as { stage?: string }).stage === 'completed'
)
expect(completedIndex).toBeGreaterThan(0)
expect(
sendCalls
.slice(0, completedIndex)
.some(([channel]) => channel === ipcChannels.conversationsChanged)
).toBe(true)
await harness.dispose()
})
@@ -6696,6 +6710,221 @@ describe('registerIpcHandlers agent terminal state', () => {
await harness.dispose()
})
it('releases the queue reservation when a dispatched run cannot start', async () => {
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
async *run(request: { requestId: string }) {
yield {
requestId: request.requestId,
type: 'done'
} as const
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000745'
const itemId = '00000000-0000-4000-8000-000000000746'
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt: '需要重新排队',
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
const item = {
id: itemId,
conversationId,
source: 'user' as const,
label: input.prompt,
createdAt: '2026-08-21T00:02:00.000Z'
}
harness.assistantDatabase.enqueueConversationUserInput.mockReturnValue(
item
)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem.mockReturnValueOnce(
{
source: 'user',
item,
payloadJson: JSON.stringify({
input,
serializedContexts: '[]'
})
}
)
harness.assistantDatabase.getConversationQueueItem.mockReturnValue(
item
)
harness.assistantDatabase.createTask.mockImplementationOnce(() => {
throw new Error('会话记录不可用')
})
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item, input }
)
)
await expect(
harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000747',
conversationId,
queueItemId: itemId,
runtimeSelection: input.runtimeSelection,
prompt: input.prompt,
workMode: input.workMode,
knowledgeLibraryIds: []
})
).rejects.toThrow('会话记录不可用')
expect(
harness.assistantDatabase.releaseConversationUserQueueItem
).toHaveBeenCalledWith(itemId)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000748',
conversationId,
runtimeSelection: { provider: 'auto' },
prompt: '失败后的新消息',
workMode: 'ask',
knowledgeLibraryIds: []
})
await harness.dispose()
})
it('dispatches a message queued during a run that later fails', async () => {
let failRun: ((error: Error) => void) | undefined
const runtimeStarted = vi.fn()
const runtime = {
runtimeId: 'model',
capability: 'chat',
supportsToolExecution: false,
// eslint-disable-next-line require-yield
async *run(request: { requestId: string }) {
runtimeStarted(request.requestId)
throw await new Promise<Error>((resolve) => {
failRun = resolve
})
}
}
const harness = createHarness(runtime)
const conversationId =
'00000000-0000-4000-8000-000000000751'
const createItem = (id: string, prompt: string) => {
const input = {
conversationId,
runtimeSelection: { provider: 'auto' as const },
workMode: 'ask' as const,
includeMemoryContext: true,
prompt,
attachments: [],
knowledgeLibraryIds: [],
knowledgeRetrievalMode: 'auto' as const
}
return {
input,
item: {
id,
conversationId,
source: 'user' as const,
label: prompt,
createdAt: '2026-08-21T00:03:00.000Z'
}
}
}
const first = createItem(
'00000000-0000-4000-8000-000000000752',
'会失败的消息'
)
const second = createItem(
'00000000-0000-4000-8000-000000000753',
'失败之后的新消息'
)
harness.assistantDatabase.enqueueConversationUserInput
.mockReturnValueOnce(first.item)
.mockReturnValueOnce(second.item)
harness.assistantDatabase.listConversationQueueItems
.mockReturnValueOnce([first.item])
.mockReturnValueOnce([second.item])
.mockReturnValue([])
harness.assistantDatabase.claimConversationQueueItem
.mockReturnValueOnce({
source: 'user',
item: first.item,
payloadJson: JSON.stringify({
input: first.input,
serializedContexts: '[]'
})
})
.mockReturnValueOnce({
source: 'user',
item: second.item,
payloadJson: JSON.stringify({
input: second.input,
serializedContexts: '[]'
})
})
harness.assistantDatabase.getConversationQueueItem.mockImplementation(
(id: string) =>
id === first.item.id ? first.item : second.item
)
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), first.input)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: first.item, input: first.input }
)
)
await harness.handler?.(trustedEvent(harness.webContents), {
requestId: '00000000-0000-4000-8000-000000000754',
conversationId,
queueItemId: first.item.id,
runtimeSelection: first.input.runtimeSelection,
prompt: first.input.prompt,
workMode: first.input.workMode,
knowledgeLibraryIds: []
})
await vi.waitFor(() => expect(runtimeStarted).toHaveBeenCalled())
// The next message arrives while the run is still in flight, so the
// renderer never reports readiness for it afterwards.
electronMocks.handlers.get(
ipcChannels.conversationQueueEnqueueUser
)?.(trustedEvent(harness.webContents), second.input)
failRun?.(new Error('模型接口请求失败'))
await vi.waitFor(() =>
expect(
harness.assistantDatabase.updateTaskStatus
).toHaveBeenCalledWith(
'00000000-0000-4000-8000-000000000754',
'failed',
expect.any(String)
)
)
await vi.waitFor(() =>
expect(harness.webContents.send).toHaveBeenCalledWith(
ipcChannels.conversationQueueDispatch,
{ item: second.item, input: second.input }
)
)
await harness.dispose()
})
it('publishes Runtime usage as context metrics with one settings read', async () => {
const runtime = {
runtimeId: 'continue',
+54 -4
View File
@@ -186,6 +186,7 @@ import {
} from '../shared/magic-notes-contracts'
import {
assistantIdSchema,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationSnapshotsSchema,
localConversationSaveBatchSchema,
@@ -1935,12 +1936,16 @@ export function registerIpcHandlers(
for (const task of tasks) {
await recoverRemoteTask(task, requestId)
}
// Recovery may terminalize the task without a trailing checkpoint;
// let the renderer converge on the persisted terminal state.
publishConversationChange()
publishRemoteProjectRecovery({
projectId,
requestId,
stage: 'completed'
})
} catch (error) {
publishConversationChange()
publishRemoteProjectRecovery({
projectId,
requestId,
@@ -4522,9 +4527,11 @@ export function registerIpcHandlers(
pendingGoodBuddyConfigReload = true
}
await flushGoodBuddyConfigReload().catch(() => undefined)
if (readyConversationQueues.has(request.conversationId)) {
void pumpConversationQueue(request.conversationId)
}
// The renderer also reports readiness after a finished run, but a
// run that fails before publishing a terminal event never triggers
// that call, which would leave later messages queued forever.
readyConversationQueues.add(request.conversationId)
void pumpConversationQueue(request.conversationId)
}
})()
if (managedSshExecution) {
@@ -4537,6 +4544,35 @@ export function registerIpcHandlers(
}
} finally {
preparingRequestConversations.delete(parsedInput.requestId)
// A dispatched queue item whose run never started keeps the
// conversation reserved, so later messages would stay queued
// forever instead of running.
if (
parsedInput.queueItemId &&
reservedConversationQueueItems.get(
parsedInput.conversationId
) === parsedInput.queueItemId
) {
reservedConversationQueueItems.delete(
parsedInput.conversationId
)
const dispatchTimeout = queueDispatchTimers.get(
parsedInput.queueItemId
)
if (dispatchTimeout) {
clearTimeout(dispatchTimeout)
queueDispatchTimers.delete(parsedInput.queueItemId)
}
try {
assistantDatabase.releaseConversationUserQueueItem(
parsedInput.queueItemId
)
} catch {
// The renderer may have released the item already.
}
readyConversationQueues.add(parsedInput.conversationId)
publishConversationQueueChange(parsedInput.conversationId)
}
}
})
@@ -6594,7 +6630,8 @@ export function registerIpcHandlers(
try {
return await readWorkspaceFile(
executionSpace.workspaceAccess,
value.path
value.path,
value.offsetBytes
)
} finally {
await executionSpace.workspaceAccess.dispose()
@@ -6645,6 +6682,19 @@ export function registerIpcHandlers(
parsed.status
)
})
registerHandler(ipcChannels.activityHistoryGet, (event) => {
assertTrustedSender(event, window)
return assistantDatabase.getActivityHistory()
})
registerHandler(
ipcChannels.activityHistoryReplace,
(event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.replaceActivityHistory(
activityHistorySnapshotSchema.parse(input)
)
}
)
registerHandler(ipcChannels.tokenUsageSummary, (event) => {
assertTrustedSender(event, window)
+78
View File
@@ -67,6 +67,84 @@ export async function readBoundedUtf8File(
}
}
export async function readUtf8FileRange(
filePath: string,
offsetBytes: number,
maximumBytes: number,
invalidUtf8Message: string
): Promise<{
content: string
size: number
offsetBytes: number
bytesRead: number
truncated: boolean
}> {
const handle = await open(filePath, 'r')
try {
const metadata = await handle.stat()
if (
!metadata.isFile() ||
!Number.isSafeInteger(offsetBytes) ||
offsetBytes < 0 ||
offsetBytes > metadata.size
) {
throw new Error('工作区读取范围无效')
}
const requestedBytes = Math.min(
maximumBytes,
metadata.size - offsetBytes
)
const data = Buffer.alloc(requestedBytes)
let bytesRead = 0
while (bytesRead < data.length) {
const result = await handle.read(
data,
bytesRead,
data.length - bytesRead,
offsetBytes + bytesRead
)
if (result.bytesRead === 0) {
break
}
bytesRead += result.bytesRead
}
const selected = data.subarray(0, bytesRead)
const reachesEnd = offsetBytes + bytesRead >= metadata.size
let decoded: string | undefined
let decodedBytes = bytesRead
for (
let trailingBytes = 0;
trailingBytes <= (reachesEnd ? 0 : 3);
trailingBytes += 1
) {
decodedBytes = bytesRead - trailingBytes
if (decodedBytes < 0) {
break
}
try {
decoded = new TextDecoder('utf-8', { fatal: true }).decode(
selected.subarray(0, decodedBytes)
)
break
} catch {
// A page may end in the middle of one UTF-8 character.
}
}
if (decoded === undefined) {
throw new Error(invalidUtf8Message)
}
return {
content: decoded,
size: metadata.size,
offsetBytes,
bytesRead: decodedBytes,
truncated: offsetBytes + decodedBytes < metadata.size
}
} finally {
await handle.close()
}
}
export async function readBoundedFile(
filePath: string,
maximumBytes: number,
+29 -1
View File
@@ -23,6 +23,7 @@ import {
isPathInside,
listBoundedDirectoryEntries,
readBoundedUtf8File,
readUtf8FileRange,
resolveExistingWorkspacePath
} from '../workspace-file-access'
import type {
@@ -562,6 +563,30 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
'工作区读取上限无效'
)
const file = await this.resolveExisting(input.path, 'file')
const offsetBytes = input.offsetBytes ?? 0
if (input.allowTruncated || offsetBytes > 0) {
const preview = await readUtf8FileRange(
file.canonicalPath,
offsetBytes,
maximumBytes,
input.invalidUtf8Message ??
'工作区读取目标不是有效 UTF-8 文本'
)
if (preview.truncated && !input.allowTruncated) {
throw new Error(
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
)
}
if (preview.truncated && preview.bytesRead === 0) {
throw new Error('工作区读取分页无法继续')
}
input.signal?.throwIfAborted()
return {
path: file.path,
name: basename(file.canonicalPath),
...preview
}
}
const preview = await readBoundedUtf8File(
file.canonicalPath,
maximumBytes,
@@ -573,7 +598,10 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
path: file.path,
name: basename(file.canonicalPath),
content: preview.content,
size: preview.size
size: preview.size,
offsetBytes: 0,
bytesRead: preview.size,
truncated: false
}
}
@@ -168,7 +168,10 @@ describe('RemoteWorkspaceAccess', () => {
path: 'README.md',
name: 'README.md',
content: 'hello',
size: 5
size: 5,
offsetBytes: 0,
bytesRead: 5,
truncated: false
})
await expect(
access.search({
@@ -365,6 +368,70 @@ describe('RemoteWorkspaceAccess', () => {
expect(transport.acquireLease).not.toHaveBeenCalled()
})
it('returns remote file pages and keeps UTF-8 boundaries intact', async () => {
const invalidBoundary = Object.assign(
new Error('Workspace file is not valid UTF-8'),
{ data: { code: 'invalid-utf8' } }
)
const readWorkspaceText = vi
.fn()
.mockRejectedValueOnce(invalidBoundary)
.mockResolvedValueOnce({
relativePath: 'large.txt',
content: '你a',
offsetBytes: 0,
bytesRead: 4,
totalBytes: 7,
digest,
truncated: true
})
.mockResolvedValueOnce({
relativePath: 'large.txt',
content: '好',
offsetBytes: 4,
bytesRead: 3,
totalBytes: 7,
digest,
truncated: false
})
const lease = createLease({ readWorkspaceText })
const { access } = createAccess(lease)
const first = await access.readText({
path: 'large.txt',
maximumBytes: 5,
allowTruncated: true
})
const second = await access.readText({
path: 'large.txt',
offsetBytes: first.offsetBytes + first.bytesRead,
maximumBytes: 5,
allowTruncated: true
})
expect(first).toMatchObject({
content: '你a',
offsetBytes: 0,
bytesRead: 4,
truncated: true
})
expect(second).toMatchObject({
content: '好',
offsetBytes: 4,
truncated: false
})
expect(
readWorkspaceText.mock.calls.map(([request]) => ({
offsetBytes: request.offsetBytes,
maximumBytes: request.maximumBytes
}))
).toEqual([
{ offsetBytes: 0, maximumBytes: 5 },
{ offsetBytes: 0, maximumBytes: 4 },
{ offsetBytes: 4, maximumBytes: 5 }
])
})
it('retries one inconsistent Git snapshot and maps status and diff', async () => {
const getGitStatus = vi
.fn()
+65 -13
View File
@@ -149,6 +149,20 @@ function boundedInteger(
return resolved
}
function isInvalidUtf8RpcError(error: unknown): boolean {
if (
!error ||
typeof error !== 'object' ||
!('data' in error) ||
!error.data ||
typeof error.data !== 'object' ||
!('code' in error.data)
) {
return false
}
return error.data.code === 'invalid-utf8'
}
function assertTransportBinding(
expected: RemoteWorkspaceProjectBinding,
actual: RemoteWorkspaceTransportBinding
@@ -419,16 +433,43 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
'工作区读取上限无效'
)
const opened = await this.open(input.signal)
const request = remoteWorkspaceReadTextRequestSchema.parse({
...this.reference(opened),
relativePath: input.path,
offsetBytes: 0,
maximumBytes
})
const rawResult = await opened.lease.readWorkspaceText(
request,
input.signal
)
const offsetBytes = input.offsetBytes ?? 0
let rawResult: RemoteWorkspaceReadTextResult | undefined
let boundaryError: unknown
const maximumTrailingBytes = input.allowTruncated
? Math.min(3, maximumBytes - 1)
: 0
for (
let trailingBytes = 0;
trailingBytes <= maximumTrailingBytes;
trailingBytes += 1
) {
const request = remoteWorkspaceReadTextRequestSchema.parse({
...this.reference(opened),
relativePath: input.path,
offsetBytes,
maximumBytes: maximumBytes - trailingBytes
})
try {
rawResult = await opened.lease.readWorkspaceText(
request,
input.signal
)
break
} catch (error) {
if (
!input.allowTruncated ||
trailingBytes === maximumTrailingBytes ||
!isInvalidUtf8RpcError(error)
) {
throw error
}
boundaryError = error
}
}
if (!rawResult) {
throw boundaryError
}
let result: RemoteWorkspaceReadTextResult
try {
result = remoteWorkspaceReadTextResultSchema.parse(rawResult)
@@ -444,21 +485,32 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
}
if (
result.relativePath !== input.path ||
result.offsetBytes !== 0
result.offsetBytes !== offsetBytes ||
result.offsetBytes + result.bytesRead > result.totalBytes ||
(!result.truncated &&
result.offsetBytes + result.bytesRead !== result.totalBytes) ||
(result.truncated &&
result.offsetBytes + result.bytesRead >= result.totalBytes)
) {
throw new Error('远程工作区返回了不匹配的文件')
}
if (result.truncated || result.bytesRead !== result.totalBytes) {
if (result.truncated && !input.allowTruncated) {
throw new Error(
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
)
}
if (result.truncated && result.bytesRead === 0) {
throw new Error('远程工作区读取分页无法继续')
}
const name = input.path.split('/').at(-1) ?? input.path
return {
path: input.path,
name,
content: result.content,
size: result.totalBytes
size: result.totalBytes,
offsetBytes: result.offsetBytes,
bytesRead: result.bytesRead,
truncated: result.truncated
}
}
+5
View File
@@ -39,7 +39,9 @@ export type WorkspaceEntry = WorkspaceDirectoryEntry & {
}
export type WorkspaceReadInput = WorkspacePathInput & {
offsetBytes?: number
maximumBytes?: number
allowTruncated?: boolean
tooLargeMessage?: string
invalidUtf8Message?: string
}
@@ -49,6 +51,9 @@ export type FilePreview = {
name: string
content: string
size: number
offsetBytes: number
bytesRead: number
truncated: boolean
}
export type WorkspaceWriteInput = WorkspacePathInput & {
+24 -2
View File
@@ -49,6 +49,8 @@ import type {
AssistantHeartbeatRun,
AssistantExpert,
AssistantTask,
ActivityHistorySnapshot,
ActivityRecord,
TokenUsageSummary,
ConversationBranchInput,
ConversationSnapshot,
@@ -1096,10 +1098,15 @@ const desktopApi: DesktopApi = {
projectId,
path
}) as Promise<WorkspaceDirectoryListing>,
readFile: (projectId: string, path: string) =>
readFile: (
projectId: string,
path: string,
offsetBytes?: number
) =>
ipcRenderer.invoke(ipcChannels.workspaceFileRead, {
projectId,
path
path,
offsetBytes
}) as Promise<WorkspaceFilePreview>,
openPath: async (
projectId: string,
@@ -1126,6 +1133,21 @@ const desktopApi: DesktopApi = {
})
}
},
activityHistory: {
get: () =>
ipcRenderer.invoke(
ipcChannels.activityHistoryGet
) as Promise<ActivityHistorySnapshot>,
replace: async (
records: ActivityRecord[],
legacyHistoryMayBeIncomplete: boolean
) => {
await ipcRenderer.invoke(ipcChannels.activityHistoryReplace, {
records,
legacyHistoryMayBeIncomplete
})
}
},
usage: {
getTokenSummary: () =>
ipcRenderer.invoke(
+27 -6
View File
@@ -12,10 +12,7 @@ import {
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
} from './activity-store'
import { type ActivityRecord } from './activity-store'
import i18n from './i18n'
function makeRecord(
@@ -642,9 +639,9 @@ describe('ActivityPanel', () => {
expect(within(detail).getByText('项目:项目甲')).toBeInTheDocument()
})
it('never renders more than 500 records', () => {
it('reveals retained records beyond the initial render batch', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
{ length: 501 },
(_, index) => makeRecord(index)
)
render(
@@ -658,6 +655,30 @@ describe('ActivityPanel', () => {
expect(screen.getByText('活动 499')).toBeInTheDocument()
expect(screen.queryByText('活动 500')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '继续显示(剩余 1 条)'
})
)
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', () => {
+49 -20
View File
@@ -6,10 +6,7 @@ import type {
TokenUsageSummary
} from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
} from './activity-store'
import { type ActivityRecord } from './activity-store'
import {
getTokenUsageTotals,
groupTokenUsage,
@@ -35,7 +32,10 @@ type ActivityActorKind =
| 'tool'
| 'approval'
const activityRenderBatchSize = 500
export type ActivityPanelProps = {
legacyHistoryMayBeIncomplete?: boolean
projects?: readonly AssistantProject[]
records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary
@@ -240,6 +240,7 @@ function groupActivityRecordsByProject(
}
export function ActivityPanel({
legacyHistoryMayBeIncomplete = false,
projects = [],
records,
tokenUsage,
@@ -256,6 +257,9 @@ export function ActivityPanel({
const [confirmingClear, setConfirmingClear] = useState(false)
const [selectedTimelineRecordId, setSelectedTimelineRecordId] =
useState<string>()
const [visibleRecordCount, setVisibleRecordCount] = useState(
activityRenderBatchSize
)
const dateTimeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', {
@@ -378,13 +382,13 @@ export function ActivityPanel({
}),
[projectDisplayNames, tokenUsage]
)
const visibleRecords = useMemo(
() => displayRecords.slice(0, MAX_ACTIVITY_RECORDS),
[displayRecords]
const matchingRecords = useMemo(
() => displayRecords.filter((record) => matchesFilter(record, filter)),
[displayRecords, filter]
)
const filteredRecords = useMemo(
() => visibleRecords.filter((record) => matchesFilter(record, filter)),
[filter, visibleRecords]
() => matchingRecords.slice(0, visibleRecordCount),
[matchingRecords, visibleRecordCount]
)
const projectGroups = useMemo(
() => groupActivityRecordsByProject(filteredRecords, displayRecords),
@@ -418,8 +422,8 @@ export function ActivityPanel({
() => getConversationTitles(displayRecords),
[displayRecords]
)
const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length
const activeCount = displayRecords.filter(isActive).length
const failedCount = displayRecords.filter(isFailed).length
const filters: ReadonlyArray<{
value: ActivityFilter
label: string
@@ -427,8 +431,8 @@ export function ActivityPanel({
{
value: 'all',
label: t('filters.all', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})
},
{
@@ -620,19 +624,19 @@ export function ActivityPanel({
/>
<DestructiveConfirmActions
confirmAriaLabel={t('clear.confirmAriaLabel', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
confirmLabel={t('clear.confirmLabel', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
confirming={confirmingClear}
disabled={!confirmingClear && visibleRecords.length === 0}
disabled={!confirmingClear && displayRecords.length === 0}
icon={<Trash2 aria-hidden="true" size={15} />}
message={t('clear.message', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
onCancel={() => setConfirmingClear(false)}
onConfirm={() => {
@@ -667,6 +671,24 @@ export function ActivityPanel({
}
/>
)
const loadMoreRecords =
filteredRecords.length < matchingRecords.length ? (
<button
className="secondary-button activity-panel__load-more"
onClick={() =>
setVisibleRecordCount(
(current) => current + activityRenderBatchSize
)
}
type="button"
>
{t('records.loadMore', {
remaining: formatCount(
matchingRecords.length - filteredRecords.length
)
})}
</button>
) : null
return (
<section
@@ -680,6 +702,11 @@ 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')}
@@ -781,6 +808,7 @@ export function ActivityPanel({
</div>
</section>
))}
{loadMoreRecords}
</div>
)}
</div>
@@ -957,6 +985,7 @@ export function ActivityPanel({
{renderRecordCard(selectedTimelineRecord, true)}
</aside>
)}
{loadMoreRecords}
</div>
)}
</div>
+430 -16
View File
@@ -112,7 +112,7 @@ vi.mock('./TerminalPanel', () => ({
}))
import App from './App'
import { loadActivityRecords } from './activity-store'
import { ACTIVITY_STORAGE_KEY } from './activity-store'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
@@ -541,7 +541,10 @@ const api: DesktopApi = {
name: path.split('/').at(-1) ?? path,
content: '',
mimeType: 'text/plain' as const,
size: 0
size: 0,
offsetBytes: 0,
nextOffsetBytes: 0,
truncated: false
})),
openPath: vi.fn(async () => {})
},
@@ -549,6 +552,13 @@ const api: DesktopApi = {
list: vi.fn(async () => []),
setStatus: vi.fn(async () => {})
},
activityHistory: {
get: vi.fn(async () => ({
records: [],
legacyHistoryMayBeIncomplete: false
})),
replace: vi.fn(async () => {})
},
usage: {
getTokenSummary: vi.fn(async () => ({
totals: {
@@ -997,6 +1007,7 @@ function installRemoteProjectsSetting(enabled: boolean): {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: enabled,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -1137,6 +1148,15 @@ describe('App', () => {
}
})
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
vi.mocked(api.activityHistory.get)
.mockReset()
.mockResolvedValue({
records: [],
legacyHistoryMayBeIncomplete: false
})
vi.mocked(api.activityHistory.replace)
.mockReset()
.mockResolvedValue()
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
api.channels = undefined
api.updates = undefined
@@ -2176,6 +2196,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -2277,6 +2298,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2288,6 +2310,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2389,6 +2412,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -2400,6 +2424,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -3694,7 +3719,7 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps a bounded display copy without stopping a long tool chain', async () => {
it('keeps every activity detail in a long tool chain', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
@@ -3713,21 +3738,38 @@ describe('App', () => {
requestId: request.requestId,
type: 'tool',
callId: `call-${index + 1}`,
name: 'read',
name: index === 500 ? `read${'长'.repeat(300)}` : 'read',
state: 'completed',
summary: `OpenCode 工具:read ${index + 1}`
summary:
index === 500
? `OpenCode 工具:read 501 ${'详情'.repeat(2_100)}`
: `OpenCode 工具:read ${index + 1}`
})
}
})
expect(
screen.getByRole('region', { name: '工具执行,共 500 项' })
screen.getByRole('region', { name: '工具执行,共 501 项' })
).toBeInTheDocument()
expect(
screen.getByText(
screen.queryByText(
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行'
)
).toBeInTheDocument()
).not.toBeInTheDocument()
await waitFor(
() => {
const saved =
vi.mocked(api.activityHistory.replace).mock.calls.at(-1)?.[0] ??
[]
expect(
saved.filter((record) => record.kind === 'tool')
).toHaveLength(501)
const lastTool = saved.find((record) => record.callId === 'call-501')
expect(lastTool?.detail.length).toBeGreaterThan(4_000)
expect(lastTool?.title).toHaveLength(240)
},
{ timeout: 3_000 }
)
})
it('keeps adjacent streamed text blocks exact in StrictMode', async () => {
@@ -4419,6 +4461,7 @@ describe('App', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -5593,7 +5636,10 @@ describe('App', () => {
name: 'README.md',
content: '# 工作区说明',
mimeType: 'text/markdown',
size: 19
size: 19,
offsetBytes: 0,
nextOffsetBytes: 19,
truncated: false
})
render(<App />)
@@ -5614,7 +5660,8 @@ describe('App', () => {
).not.toBeInTheDocument()
expect(api.workspace.readFile).toHaveBeenCalledWith(
projectId,
'README.md'
'README.md',
0
)
fireEvent.click(
screen.getByRole('button', { name: '返回工作区' })
@@ -6466,6 +6513,236 @@ describe('App', () => {
)
})
describe('persisted terminal state convergence', () => {
let conversationsChangedListener: (() => void) | undefined
const startStreamingRun = async (): Promise<{
requestId: string
conversationId: string
assistantMessageId: string
userMessageId: string
}> => {
conversationsChangedListener = undefined
vi.mocked(api.conversations.onChanged).mockImplementation(
(listener) => {
conversationsChangedListener = listener
return () => {
if (conversationsChangedListener === listener) {
conversationsChangedListener = undefined
}
}
}
)
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]
if (
!request?.currentAssistantMessageId ||
!request.currentUserMessageId
) {
throw new Error('Missing request')
}
expect(
screen
.getByText('正在连接 Agent Runtime')
.querySelector('.message__status-dot')
).toHaveClass('message__status-dot--active')
expect(screen.getByLabelText('加入待发送队列')).toBeInTheDocument()
vi.mocked(api.conversationQueue.ready).mockClear()
return {
requestId: request.requestId,
conversationId: request.conversationId,
assistantMessageId: request.currentAssistantMessageId,
userMessageId: request.currentUserMessageId
}
}
const persistedSnapshot = (
input: {
conversationId: string
userMessageId: string
assistantMessageId: string
},
assistant: {
state: 'streaming' | 'complete' | 'error'
content: string
status?: string
}
): ConversationSnapshot => ({
id: input.conversationId,
projectId,
runtimeSelection: { provider: 'model', profileId: modelProfileId },
title: '断网期间继续执行',
updatedAt: 1_000,
messages: [
{
id: input.userMessageId,
role: 'user',
content: '断网期间继续执行',
createdAt: 900,
state: 'complete'
},
{
id: input.assistantMessageId,
role: 'assistant',
content: assistant.content,
createdAt: 950,
state: assistant.state,
status: assistant.status
}
]
})
it('converges a streaming message onto the persisted completed state after reconnecting', async () => {
const started = await startStreamingRun()
expect(conversationsChangedListener).toBeDefined()
vi.mocked(api.conversations.list).mockResolvedValue([
persistedSnapshot(started, {
state: 'complete',
content: '远端在断线期间已经完成的回复',
status: '任务已完成'
})
])
act(() => {
conversationsChangedListener?.()
})
expect(
await screen.findByText('远端在断线期间已经完成的回复')
).toBeInTheDocument()
await waitFor(() =>
expect(
screen.queryByText('正在连接 Agent Runtime')
).not.toBeInTheDocument()
)
expect(
document.querySelector('.message__status-dot--active')
).toBeNull()
expect(screen.getByLabelText('发送')).toBeInTheDocument()
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
started.conversationId
)
)
// A late terminal event for the settled run must be ignored rather
// than resurrecting the message or double-releasing the queue.
vi.mocked(api.conversationQueue.ready).mockClear()
act(() => {
agentListener?.({
requestId: started.requestId,
type: 'error',
status: 'failed',
message: '迟到的连接中断错误'
})
})
expect(
screen.queryByText('迟到的连接中断错误')
).not.toBeInTheDocument()
expect(
screen.getByText('远端在断线期间已经完成的回复')
).toBeInTheDocument()
expect(api.conversationQueue.ready).not.toHaveBeenCalled()
})
it('converges a streaming message onto the persisted failed state after reconnecting', async () => {
const started = await startStreamingRun()
vi.mocked(api.conversations.list).mockResolvedValue([
persistedSnapshot(started, {
state: 'error',
content: '',
status: '远端 Agent 执行失败'
})
])
act(() => {
conversationsChangedListener?.()
})
expect(
await screen.findByText('远端 Agent 执行失败')
).toBeInTheDocument()
expect(
screen.queryByText('正在连接 Agent Runtime')
).not.toBeInTheDocument()
expect(
document.querySelector('.message__status-dot--active')
).toBeNull()
await waitFor(() =>
expect(screen.getByLabelText('发送')).toBeInTheDocument()
)
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
started.conversationId
)
)
})
it('keeps the run in flight while the persisted message is still streaming and refreshes on focus', async () => {
const started = await startStreamingRun()
vi.mocked(api.conversations.list).mockResolvedValue([
persistedSnapshot(started, {
state: 'streaming',
content: '',
status: '远端 Agent 正在处理请求'
})
])
act(() => {
conversationsChangedListener?.()
})
await waitFor(() =>
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.getByLabelText('加入待发送队列')).toBeInTheDocument()
expect(api.conversationQueue.ready).not.toHaveBeenCalled()
// Regaining focus re-reads the store and converges once the remote
// terminal state has landed, even without another change event.
vi.mocked(api.conversations.list).mockResolvedValue([
persistedSnapshot(started, {
state: 'complete',
content: '聚焦后读到的最终回复',
status: '任务已完成'
})
])
act(() => {
window.dispatchEvent(new Event('focus'))
})
expect(
await screen.findByText('聚焦后读到的最终回复')
).toBeInTheDocument()
expect(
screen.queryByText('正在连接 Agent Runtime')
).not.toBeInTheDocument()
await waitFor(() =>
expect(api.conversationQueue.ready).toHaveBeenCalledWith(
started.conversationId
)
)
// The live done event now targets an already-settled run: no-op.
act(() => {
agentListener?.({ requestId: started.requestId, type: 'done' })
})
expect(
screen.getByText('聚焦后读到的最终回复')
).toBeInTheDocument()
})
})
it('retries a failed recovery with a new request ID and cleans up its listener', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
@@ -10415,11 +10692,14 @@ describe('App', () => {
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(
loadActivityRecords().find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
vi
.mocked(api.activityHistory.replace)
.mock.calls.at(-1)?.[0]
.find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
).toEqual({
kind: 'project',
projectId,
@@ -10428,6 +10708,50 @@ describe('App', () => {
)
})
it('migrates legacy activity and keeps its possible-loss warning visible', async () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([
{
id: 'legacy-activity',
conversationId: 'legacy-conversation',
requestId: 'legacy-request',
scope: { kind: 'global' },
kind: 'tool',
title: '旧版工具记录',
detail: 'x'.repeat(4_000),
status: 'completed',
createdAt: 1
}
])
)
render(<App />)
await screen.findByText('项目:默认项目')
await waitFor(() => {
expect(api.activityHistory.replace).toHaveBeenCalledWith(
[
expect.objectContaining({
id: 'legacy-activity',
detail: 'x'.repeat(4_000)
})
],
true
)
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 () => {
render(<App />)
@@ -10635,6 +10959,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10646,6 +10971,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10666,7 +10992,11 @@ describe('App', () => {
fireEvent.click(magicNotesEntry)
expect(
await screen.findByRole('heading', { name: '魔法笔记' })
await screen.findByRole(
'heading',
{ name: '魔法笔记' },
{ timeout: 3000 }
)
).toBeInTheDocument()
expect(screen.getByText('全局')).toBeInTheDocument()
expect(
@@ -10693,6 +11023,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -10747,6 +11078,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: false,
@@ -10780,6 +11112,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10791,6 +11124,7 @@ describe('App', () => {
updateSource: 'github' as const,
modelDownloadSource: 'modelscope' as const,
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -10821,12 +11155,92 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('renders Agent HTML by default and applies the platform switch immediately', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000471'
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
title: 'HTML 预览会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000472',
role: 'assistant',
content: `\`\`\`html
<main><h1>Rendered dashboard</h1></main>
\`\`\``,
createdAt: 1_775_000_000_000,
state: 'complete'
}
]
}
])
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
const updateSettings = vi.fn<
NonNullable<DesktopApi['updates']>['updateSettings']
>(async (input) => {
applicationSettings = { ...applicationSettings, ...input }
return { ...applicationSettings }
})
api.updates = {
getSettings: vi.fn(async () => ({ ...applicationSettings })),
updateSettings,
check: vi.fn(),
openReleasePage: vi.fn(async () => {}),
onResult: vi.fn(() => () => {})
}
render(<App />)
expect(
await screen.findByTitle('Agent 回复 HTML 静态预览')
).toHaveAttribute('sandbox', '')
fireEvent.click(
await screen.findByRole('button', { name: /本地工作区/u })
)
fireEvent.click(
await screen.findByRole('tab', { name: '平台功能' })
)
const toggle = await screen.findByRole('switch', {
name: '在会话中渲染 HTML'
})
expect(toggle).toBeChecked()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateSettings).toHaveBeenCalledWith({
conversationHtmlRenderingEnabled: false
})
)
await waitFor(() =>
expect(
screen.queryByTitle('Agent 回复 HTML 静态预览')
).not.toBeInTheDocument()
)
expect(screen.getByText(/Rendered dashboard/u)).toBeInTheDocument()
})
it('keeps platform-feature switches in Settings without navigating', async () => {
let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
+260 -118
View File
@@ -94,6 +94,7 @@ import {
getRuntimeSelectionForProvider
} from './runtime-selection'
import type {
ActivityHistorySnapshot,
AssistantProject,
AssistantArtifact,
AssistantMemory,
@@ -128,8 +129,6 @@ import {
conversationMessageBlocksSchema,
conversationSubagentActivitySchema,
interactiveWorkModes,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
@@ -141,9 +140,10 @@ import {
type ToolActivity
} from './ChatTimeline'
import {
loadActivityRecords,
clearLegacyActivityHistory,
loadLegacyActivityHistory,
mergeActivityRecords,
reconcileActivityRecords,
saveActivityRecords,
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
@@ -638,9 +638,6 @@ function appendMessageContentBlock(
}
return current
}
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: crypto.randomUUID(),
type,
@@ -670,9 +667,6 @@ function upsertMessageToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -718,9 +712,6 @@ function upsertMessageSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -749,14 +740,6 @@ function terminalizeMessageToolBlocks(
)
}
function canRetainMessageActivity(message: Message): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function isErrorRepresentedByFailedTool(
tools: ToolActivity[] | undefined,
errorMessage: string
@@ -863,6 +846,7 @@ type ChatScrollSnapshot = {
function ChatHistoryPane({
active,
artifactById,
conversationHtmlRenderingEnabled,
conversation,
locale,
onCopyMessage,
@@ -883,6 +867,7 @@ function ChatHistoryPane({
}: {
active: boolean
artifactById: ReadonlyMap<string, AssistantArtifact>
conversationHtmlRenderingEnabled: boolean
conversation: Conversation
locale: TimeFormatLocale
onCopyMessage: (content: string) => Promise<void>
@@ -1186,6 +1171,7 @@ function ChatHistoryPane({
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === 'user'
? conversation.messages.at(-2)?.content
@@ -1482,6 +1468,19 @@ function mergeArtifacts(
)
}
/**
* The persisted store is the authority for a message's terminal state.
* A locally streaming message (or a locally interrupted one that the
* remote recovery later completed) must never shadow a persisted
* terminal state, even when the local conversation was touched later.
*/
function persistedTerminalStateOverridesLocal(
local: Message,
persisted: Message
): boolean {
return persisted.state !== 'streaming' && local.state !== 'complete'
}
function mergePersistedConversations(
current: readonly Conversation[],
incoming: readonly ConversationSnapshot[],
@@ -1510,12 +1509,19 @@ function mergePersistedConversations(
conversation.messages.map((message) => message.id)
)
const messages = [
...conversation.messages.map(
(message) =>
localIsNewer
? localMessageById.get(message.id) ?? message
: message
),
...conversation.messages.map((message) => {
if (!localIsNewer) {
return message
}
const localMessage = localMessageById.get(message.id)
if (
!localMessage ||
persistedTerminalStateOverridesLocal(localMessage, message)
) {
return message
}
return localMessage
}),
...local.messages.filter(
(message) => !serverMessageIds.has(message.id)
)
@@ -2545,6 +2551,10 @@ function App(): React.JSX.Element {
useState<ProjectChannel>()
const [remoteProjectsEnabled, setRemoteProjectsEnabled] =
useState(false)
const [
conversationHtmlRenderingEnabled,
setConversationHtmlRenderingEnabled
] = useState(true)
const [magicNotesEnabled, setMagicNotesEnabled] = useState(false)
const [
magicNotesShowIncompleteTodoCount,
@@ -2668,10 +2678,19 @@ function App(): React.JSX.Element {
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
const [legacyActivityHistory] = useState(loadLegacyActivityHistory)
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords
legacyActivityHistory.records
)
const [
legacyActivityHistoryMayBeIncomplete,
setLegacyActivityHistoryMayBeIncomplete
] = useState(legacyActivityHistory.historyMayBeIncomplete)
const [activityHistoryReady, setActivityHistoryReady] = useState(false)
const activityRecordsRef = useRef(activityRecords)
const legacyActivityHistoryMayBeIncompleteRef = useRef(
legacyActivityHistoryMayBeIncomplete
)
const activeRuns = useRef(new Map<string, ActiveRun>())
const preparingConversations = useRef(new Set<string>())
const [activeConversationIds, setActiveConversationIds] = useState<
@@ -3065,6 +3084,9 @@ function App(): React.JSX.Element {
void updates
.getSettings()
.then(async (settings) => {
setConversationHtmlRenderingEnabled(
settings.conversationHtmlRenderingEnabled !== false
)
setRemoteProjectsEnabled(settings.remoteProjectsEnabled)
setMagicNotesEnabled(settings.magicNotesEnabled)
setMagicNotesShowIncompleteTodoCount(
@@ -3920,6 +3942,7 @@ function App(): React.JSX.Element {
setActivityRecords((current) =>
upsertActivityRecord(current, {
...record,
title: record.title.slice(0, 240),
scope,
id: crypto.randomUUID(),
createdAt: Date.now()
@@ -3941,7 +3964,7 @@ function App(): React.JSX.Element {
? {
...record,
status,
detail: detail?.slice(0, 4_000) ?? record.detail
detail: detail ?? record.detail
}
: record
)
@@ -4140,6 +4163,39 @@ function App(): React.JSX.Element {
[]
)
const releaseConversationQueueAfterRun = useCallback(
(run: Pick<ActiveRun, 'conversationId' | 'projectId'>): void => {
requestAnimationFrame(() => {
if (
run.projectId &&
((!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find(
(project) => project.id === run.projectId
)
)) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[run.projectId]
))
) {
return
}
void window.goodbuddy.conversationQueue
.ready(run.conversationId)
.catch(() => {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationQueueResumeFailed'
),
dedupeKey: 'conversation-queue-resume'
})
})
})
},
[notify]
)
const handleAgentEvent = useCallback(
(event: AgentEvent): void => {
const run = activeRuns.current.get(event.requestId)
@@ -4345,13 +4401,6 @@ function App(): React.JSX.Element {
...message,
content: `${message.content}${acceptedDelta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
),
status:
event.delta.length > remaining
? tRef.current('chat.status.responseTruncated')
@@ -4377,14 +4426,7 @@ function App(): React.JSX.Element {
...message,
reasoning: `${currentReasoning}${acceptedDelta}`,
status: undefined,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
)
blocks
}
})
} else if (event.type === 'context-metrics') {
@@ -4495,8 +4537,7 @@ function App(): React.JSX.Element {
title: event.name,
detail: [event.summary, event.error]
.filter(Boolean)
.join('\n')
.slice(0, 4_000),
.join('\n'),
status:
event.state === 'pending'
? 'pending'
@@ -4522,23 +4563,14 @@ function App(): React.JSX.Element {
}
if (index >= 0) {
tools[index] = tool
} else if (!canRetainMessageActivity(message)) {
return {
...message,
displayCaptureTruncated: true
}
}
if (index < 0) {
} else {
tools.push(tool)
}
const blocks = upsertMessageToolBlock(message.blocks, tool)
return {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
blocks
}
})
} else if (event.type === 'subagent') {
@@ -4635,8 +4667,7 @@ function App(): React.JSX.Element {
event.error
]
.filter(Boolean)
.join(' · ')
.slice(0, 4_000),
.join(' · '),
status:
event.state === 'queued'
? 'pending'
@@ -4669,21 +4700,6 @@ function App(): React.JSX.Element {
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainMessageActivity(message)
) {
return {
...message,
displayCaptureTruncated: true
}
}
subagents.push(subagent)
}
const runtimeCallId = event.runtimeCallId
@@ -4700,10 +4716,7 @@ function App(): React.JSX.Element {
(tool) => tool.callId !== runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
blocks
}
})
} else if (event.type === 'approval') {
@@ -4712,7 +4725,7 @@ function App(): React.JSX.Element {
requestId: event.requestId,
kind: 'approval',
title: event.title,
detail: event.description.slice(0, 4_000),
detail: event.description,
status: 'pending'
})
updateMessage(run.conversationId, run.messageId, (message) => ({
@@ -4826,7 +4839,7 @@ function App(): React.JSX.Element {
event.type === 'done'
? incompleteActivityDetail
: event.message
}`.slice(0, 4_000)
}`
}
: record
)
@@ -4841,7 +4854,7 @@ function App(): React.JSX.Element {
: tRef.current('chat.status.taskCompleted'),
detail:
event.type === 'error'
? event.message.slice(0, 4_000)
? event.message
: tRef.current('chat.status.runtimeCompleted'),
status: terminalStatus
})
@@ -4959,33 +4972,7 @@ function App(): React.JSX.Element {
})
activeRuns.current.delete(event.requestId)
setConversationActivity(run.conversationId, false)
requestAnimationFrame(() => {
if (
run.projectId &&
((!projectRecoverySnapshotReadyRef.current &&
isManagedSshProject(
projectsRef.current.find(
(project) => project.id === run.projectId
)
)) ||
isProjectRecoveryUnsettled(
projectRecoveryByProjectIdRef.current[run.projectId]
))
) {
return
}
void window.goodbuddy.conversationQueue
.ready(run.conversationId)
.catch(() => {
notify({
tone: 'error',
message: tRef.current(
'notices.conversationQueueResumeFailed'
),
dedupeKey: 'conversation-queue-resume'
})
})
})
releaseConversationQueueAfterRun(run)
flushConversationPersistenceAfterRenderRef.current = true
}
},
@@ -4993,6 +4980,7 @@ function App(): React.JSX.Element {
recordActivity,
loadWorkspaceChanges,
refreshTokenUsage,
releaseConversationQueueAfterRun,
setConversationActivity,
updateMessage,
updateRequestActivity
@@ -5081,10 +5069,28 @@ function App(): React.JSX.Element {
persistLocalConversationChanges
])
const persistActivityHistory = useCallback(async (): Promise<void> => {
if (!activityHistoryReady) {
return
}
try {
await window.goodbuddy.activityHistory.replace(
activityRecordsRef.current,
legacyActivityHistoryMayBeIncompleteRef.current
)
} catch {
notify({
tone: 'error',
message: tRef.current('notices.activityHistoryPersistenceFailed'),
dedupeKey: 'activity-history-persistence'
})
}
}, [activityHistoryReady])
useEffect(
() =>
window.goodbuddy.app.onBeforeQuit(async () => {
saveActivityRecords(activityRecordsRef.current)
await persistActivityHistory()
if (!conversationStoreReady) {
return
}
@@ -5092,7 +5098,11 @@ function App(): React.JSX.Element {
persistLocalConversationChanges()
await conversationPersistenceQueueRef.current
}),
[conversationStoreReady, persistLocalConversationChanges]
[
conversationStoreReady,
persistActivityHistory,
persistLocalConversationChanges
]
)
useEffect(() => {
@@ -5115,6 +5125,36 @@ function App(): React.JSX.Element {
refresh()
}, 50)
}
// A run whose assistant message has already reached a persisted
// terminal state (for example a remote task that finished while the
// desktop was disconnected) must stop counting as in-flight, otherwise
// the renderer keeps showing "processing" forever.
const settleActiveRunsFromPersistedMessages = (
persisted: readonly ConversationSnapshot[]
): void => {
if (activeRuns.current.size === 0) {
return
}
const persistedMessageStates = new Map<string, Message['state']>()
for (const conversation of persisted) {
for (const message of conversation.messages) {
persistedMessageStates.set(
`${conversation.id}\0${message.id}`,
message.state
)
}
}
for (const [requestId, run] of activeRuns.current) {
const state = persistedMessageStates.get(
`${run.conversationId}\0${run.messageId}`
)
if (state !== undefined && state !== 'streaming') {
activeRuns.current.delete(requestId)
setConversationActivity(run.conversationId, false)
releaseConversationQueueAfterRun(run)
}
}
}
const refresh = (): void => {
if (refreshInFlight) {
refreshQueued = true
@@ -5215,6 +5255,7 @@ function App(): React.JSX.Element {
persistedLocalConversationsRef.current
)
)
settleActiveRunsFromPersistedMessages(persisted)
})
.catch(() => {
if (active && sequence === refreshSequence) {
@@ -5238,15 +5279,28 @@ function App(): React.JSX.Element {
}
})
}
const refreshWhenVisible = (): void => {
if (document.visibilityState !== 'hidden') {
queueRefresh()
}
}
const remove = window.goodbuddy.conversations.onChanged(queueRefresh)
window.addEventListener('focus', refreshWhenVisible)
document.addEventListener('visibilitychange', refreshWhenVisible)
return () => {
active = false
if (refreshTimer !== undefined) {
window.clearTimeout(refreshTimer)
}
remove()
window.removeEventListener('focus', refreshWhenVisible)
document.removeEventListener('visibilitychange', refreshWhenVisible)
}
}, [conversationStoreReady])
}, [
conversationStoreReady,
releaseConversationQueueAfterRun,
setConversationActivity
])
useEffect(() => {
let active = true
@@ -5365,19 +5419,88 @@ function App(): React.JSX.Element {
}
}, [])
useEffect(() => {
let active = true
void (async () => {
let snapshot: ActivityHistorySnapshot
try {
snapshot = await window.goodbuddy.activityHistory.get()
} catch {
if (active) {
notify({
tone: 'error',
message: tRef.current('notices.activityHistoryReadFailed'),
dedupeKey: 'activity-history-read'
})
}
return
}
if (!active) {
return
}
const legacyHistoryMayBeIncomplete =
legacyActivityHistory.historyMayBeIncomplete ||
snapshot.legacyHistoryMayBeIncomplete
if (
legacyActivityHistory.records.length > 0 ||
legacyActivityHistory.historyMayBeIncomplete
) {
try {
await window.goodbuddy.activityHistory.replace(
mergeActivityRecords(
legacyActivityHistory.records,
snapshot.records
),
legacyHistoryMayBeIncomplete
)
clearLegacyActivityHistory()
} catch {
notify({
tone: 'error',
message: tRef.current(
'notices.activityHistoryPersistenceFailed'
),
dedupeKey: 'activity-history-persistence'
})
}
if (!active) {
return
}
}
setActivityRecords((current) =>
mergeActivityRecords(current, snapshot.records)
)
setLegacyActivityHistoryMayBeIncomplete(legacyHistoryMayBeIncomplete)
setActivityHistoryReady(true)
})()
return () => {
active = false
}
}, [legacyActivityHistory])
useEffect(() => {
activityRecordsRef.current = activityRecords
legacyActivityHistoryMayBeIncompleteRef.current =
legacyActivityHistoryMayBeIncomplete
if (!activityHistoryReady) {
return
}
const timeout = window.setTimeout(() => {
saveActivityRecords(activityRecords)
void persistActivityHistory()
}, 250)
return () => window.clearTimeout(timeout)
}, [activityRecords])
}, [
activityHistoryReady,
activityRecords,
legacyActivityHistoryMayBeIncomplete,
persistActivityHistory
])
useEffect(
() => () => {
saveActivityRecords(activityRecordsRef.current)
void persistActivityHistory()
},
[]
[persistActivityHistory]
)
const resumeProjectConversationQueues = useCallback(
@@ -5697,11 +5820,15 @@ function App(): React.JSX.Element {
)
const loadWorkspaceFile = useCallback(
async (path: string) => {
async (path: string, offsetBytes = 0) => {
if (!activeProjectId) {
throw new Error(tRef.current('notices.selectProject'))
}
return window.goodbuddy.workspace.readFile(activeProjectId, path)
return window.goodbuddy.workspace.readFile(
activeProjectId,
path,
offsetBytes
)
},
[activeProjectId]
)
@@ -8101,6 +8228,8 @@ function App(): React.JSX.Element {
persistedLocalConversationsRef.current.clear()
setConversations([conversation])
setActiveId(conversation.id)
legacyActivityHistoryMayBeIncompleteRef.current = false
setLegacyActivityHistoryMayBeIncomplete(false)
setActivityRecords([])
setAssistantTasks([])
setTokenUsage(emptyTokenUsage)
@@ -9253,6 +9382,9 @@ function App(): React.JSX.Element {
view === 'chat' && conversation.id === activeId
}
artifactById={assistantArtifactById}
conversationHtmlRenderingEnabled={
conversationHtmlRenderingEnabled
}
conversation={conversation}
key={conversation.id}
locale={locale}
@@ -10712,6 +10844,9 @@ function App(): React.JSX.Element {
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onConversationHtmlRenderingEnabledChange={
setConversationHtmlRenderingEnabled
}
onAppearanceThemeChange={setAppearanceTheme}
onBrandingPreferencesChange={(preferences) => {
if (!saveBrandingPreferences(preferences)) {
@@ -10799,7 +10934,14 @@ function App(): React.JSX.Element {
}
>
<ActivityPanel
onClear={() => setActivityRecords([])}
legacyHistoryMayBeIncomplete={
legacyActivityHistoryMayBeIncomplete
}
onClear={() => {
legacyActivityHistoryMayBeIncompleteRef.current = false
setLegacyActivityHistoryMayBeIncomplete(false)
setActivityRecords([])
}}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
+59 -1
View File
@@ -12,10 +12,20 @@ import {
} from './ChatTimeline'
const markdownRenderProbe = vi.hoisted(() => vi.fn())
const htmlRenderProbe = vi.hoisted(() => vi.fn())
vi.mock('./MarkdownRenderer', () => ({
MarkdownRenderer: ({ children }: { children: string }) => {
MarkdownRenderer: ({
children,
renderHtml
}: {
children: string
renderHtml?: boolean
}) => {
markdownRenderProbe(children)
if (renderHtml) {
htmlRenderProbe(children)
}
return <span>{children}</span>
}
}))
@@ -50,6 +60,54 @@ describe('ChatTimeline', () => {
vi.clearAllMocks()
})
it('enables HTML rendering only for completed Agent output', () => {
const messages: Message[] = [
{
id: 'user-message',
role: 'user',
content: '<div>User HTML</div>',
createdAt: 1_775_000_000_000,
state: 'complete'
},
{
id: 'streaming-message',
role: 'assistant',
content: '<div>Streaming HTML</div>',
createdAt: 1_775_000_001_000,
state: 'streaming'
},
{
id: 'complete-message',
role: 'assistant',
content: '<div>Complete HTML</div>',
reasoning: '<div>Reasoning HTML</div>',
createdAt: 1_775_000_002_000,
state: 'complete'
}
]
render(
<ChatTimeline
artifactById={new Map()}
conversationId="conversation-1"
hiddenMessageCount={0}
isUnusedConversation={false}
locale="zh-CN"
messageStartIndex={0}
messages={messages}
{...callbacks}
renderAssistantHtml
retryContent=""
totalMessageCount={messages.length}
/>
)
expect(htmlRenderProbe).toHaveBeenCalledTimes(1)
expect(htmlRenderProbe).toHaveBeenCalledWith(
'<div>Complete HTML</div>'
)
})
it('renders only the immutably changed streaming row and retains unchanged DOM state', () => {
const messages = createMessages()
const props = {
+38 -4
View File
@@ -252,8 +252,10 @@ function ToolExecutionList({
}
const SubagentStatusCard = memo(function SubagentStatusCard({
renderHtml,
subagent
}: {
renderHtml: boolean
subagent: SubagentActivity
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -334,7 +336,13 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
<section>
<strong>{t('chat.subagents.output')}</strong>
<div className="markdown-content">
<MarkdownRenderer>{subagent.output}</MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderHtml && subagent.state === 'completed'
}
>
{subagent.output}
</MarkdownRenderer>
</div>
</section>
) : (
@@ -355,8 +363,10 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
})
function SubagentStatusList({
renderHtml,
subagents
}: {
renderHtml: boolean
subagents: SubagentActivity[]
}): React.JSX.Element {
const { t } = useTranslation('app')
@@ -369,6 +379,7 @@ function SubagentStatusList({
{subagents.map((subagent) => (
<SubagentStatusCard
key={subagent.childTaskId}
renderHtml={renderHtml}
subagent={subagent}
/>
))}
@@ -383,6 +394,7 @@ type ChatMessageRowProps = {
greeting: boolean
locale: TimeFormatLocale
message: Message
renderAssistantHtml?: boolean
onArticleRef: (messageId: string, element: HTMLElement | null) => void
onCopyMessage: (content: string) => Promise<void>
onDownloadImage: (item: ImageViewerItem) => void
@@ -416,6 +428,7 @@ function ChatMessageRowView({
greeting,
locale,
message,
renderAssistantHtml = false,
onArticleRef,
onCopyMessage,
onDownloadImage,
@@ -594,6 +607,7 @@ function ChatMessageRowView({
) : item.kind === 'subagents' ? (
<SubagentStatusList
key={item.id}
renderHtml={renderAssistantHtml}
subagents={item.childTaskIds.flatMap((childTaskId) => {
const subagent = subagentsById.get(childTaskId)
return subagent ? [subagent] : []
@@ -610,7 +624,15 @@ function ChatMessageRowView({
className="markdown-content message__content"
key={item.block.id}
>
<MarkdownRenderer>{item.block.content}</MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderAssistantHtml &&
message.role === 'assistant' &&
message.state === 'complete'
}
>
{item.block.content}
</MarkdownRenderer>
</div>
)
)}
@@ -626,7 +648,13 @@ function ChatMessageRowView({
)}
{message.content && (
<div className="markdown-content message__content">
<MarkdownRenderer>
<MarkdownRenderer
renderHtml={
renderAssistantHtml &&
message.role === 'assistant' &&
message.state === 'complete'
}
>
{greeting ? t('conversation.greeting') : message.content}
</MarkdownRenderer>
</div>
@@ -634,7 +662,10 @@ function ChatMessageRowView({
</>
)}
{unorderedSubagents && unorderedSubagents.length > 0 && (
<SubagentStatusList subagents={unorderedSubagents} />
<SubagentStatusList
renderHtml={renderAssistantHtml}
subagents={unorderedSubagents}
/>
)}
{message.artifactIds?.map((artifactId) => {
const candidate = artifactById.get(artifactId)
@@ -1089,6 +1120,7 @@ type ChatTimelineProps = {
) => Promise<void>
onRetry: (content: string) => void
onRevealEarlier: () => void
renderAssistantHtml?: boolean
retryContent?: string
totalMessageCount: number
}
@@ -1111,6 +1143,7 @@ export function ChatTimeline({
onRespondQuestion,
onRetry,
onRevealEarlier,
renderAssistantHtml = false,
retryContent,
totalMessageCount
}: ChatTimelineProps): React.JSX.Element {
@@ -1152,6 +1185,7 @@ export function ChatTimeline({
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
renderAssistantHtml={renderAssistantHtml}
retryContent={retryContent}
/>
)
@@ -193,6 +193,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -209,6 +210,7 @@ beforeEach(() => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1233,6 +1235,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1268,6 +1271,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
@@ -1386,6 +1390,7 @@ describe('MagicNotesWorkspace', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+136
View File
@@ -109,6 +109,142 @@ const ready = true
expect(container.querySelector('script')).not.toBeInTheDocument()
})
it('renders fenced HTML as an isolated static preview when enabled', () => {
render(
<MarkdownRenderer renderHtml>{`\`\`\`html
<!doctype html>
<html>
<head>
<style>body { color: navy; }</style>
<script>window.bad = true</script>
</head>
<body>
<h1>HTML dashboard</h1>
<img src="https://tracker.example/pixel.png" onerror="bad()">
<form action="https://example.com"><button>Send</button></form>
</body>
</html>
\`\`\``}</MarkdownRenderer>
)
const preview = screen.getByTitle('Agent 回复 HTML 静态预览')
const source = preview.getAttribute('srcdoc') ?? ''
expect(preview).toHaveAttribute('sandbox', '')
expect(preview).toHaveAttribute('referrerpolicy', 'no-referrer')
expect(source).toContain('HTML dashboard')
expect(source).toContain("default-src 'none'")
expect(source).not.toContain('<script')
expect(source).not.toContain('<form')
expect(source).not.toContain('https://tracker.example')
expect(
screen.getByText('静态安全预览,脚本和网络已禁用')
).toBeInTheDocument()
const sourceButton = screen.getByRole('button', {
name: '查看源码'
})
expect(sourceButton).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(sourceButton)
expect(screen.getByText(/window\.bad = true/u)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: '隐藏源码' })
).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: '隐藏源码' }))
expect(
screen.queryByText(/window\.bad = true/u)
).not.toBeInTheDocument()
})
it('opens and closes the full-screen HTML preview', () => {
render(
<div className="app-shell">
<MarkdownRenderer renderHtml>{`\`\`\`html
<section><h1>Full screen page</h1></section>
\`\`\``}</MarkdownRenderer>
</div>
)
const viewerButton = screen.getByRole('button', {
name: '全屏预览'
})
viewerButton.focus()
fireEvent.click(viewerButton)
const dialog = screen.getByRole('dialog', {
name: 'HTML 全屏预览'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
const closeButton = screen.getByRole('button', {
name: '关闭全屏预览'
})
expect(closeButton).toHaveFocus()
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
const frame = screen.getByTitle('Agent 回复 HTML 全屏静态预览')
expect(frame).toHaveAttribute('sandbox', '')
expect(frame).toHaveAttribute('referrerpolicy', 'no-referrer')
expect(frame.getAttribute('srcdoc') ?? '').toContain(
'Full screen page'
)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(viewerButton).toHaveFocus()
})
it('keeps author stylesheets and inline styles in the preview', () => {
render(
<MarkdownRenderer renderHtml>{`\`\`\`html
<!doctype html>
<html>
<head>
<style>
body { background: #101014; color: #f5f5f7; }
.hero { background-image: url(https://cdn.example/bg.png); }
@import url("https://fonts.example/inter.css");
</style>
</head>
<body>
<section class="hero" style="padding: 24px; background: url(https://cdn.example/b.png)">
Styled hero
</section>
</body>
</html>
\`\`\``}</MarkdownRenderer>
)
const source =
screen
.getByTitle('Agent 回复 HTML 静态预览')
.getAttribute('srcdoc') ?? ''
expect(source).toContain('background: #101014')
expect(source).toContain('.hero {')
expect(source).toContain('padding: 24px')
})
it('renders a complete HTML reply directly and keeps HTML as code when disabled', () => {
const source = '<html><body><main>Complete page</main></body></html>'
const { rerender } = render(
<MarkdownRenderer renderHtml>{source}</MarkdownRenderer>
)
expect(
screen.getByTitle('Agent 回复 HTML 静态预览')
).toHaveAttribute('sandbox', '')
rerender(
<MarkdownRenderer>{source}</MarkdownRenderer>
)
expect(
screen.queryByTitle('Agent 回复 HTML 静态预览')
).not.toBeInTheDocument()
expect(
screen.getByText(source, { exact: true })
).toBeInTheDocument()
})
it('renders a whole Markdown fence as formatted content', () => {
const { container } = render(
<MarkdownRenderer>{`\`\`\`markdown
+55 -5
View File
@@ -12,6 +12,7 @@ import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import type { Components } from 'react-markdown'
import { useTranslation } from 'react-i18next'
import { StaticHtmlPreview } from './StaticHtmlPreview'
const MermaidDiagram = lazy(() =>
import('./MermaidDiagram').then((module) => ({
@@ -34,7 +35,8 @@ const linkComponent: Components['a'] = ({
function markdownComponents(
tableAriaLabel: string,
mermaidLoadingLabel: string
mermaidLoadingLabel: string,
renderHtml: boolean
): Components {
return {
a: linkComponent,
@@ -70,6 +72,22 @@ function markdownComponents(
</Suspense>
)
}
if (
renderHtml &&
isValidElement<{
children?: React.ReactNode
className?: string
}>(child) &&
/(?:^|\s)language-html?(?:\s|$)/iu.test(
child.props.className ?? ''
)
) {
const source = String(child.props.children ?? '').replace(
/\n$/u,
''
)
return <StaticHtmlPreview source={source} />
}
return <pre {...properties}>{children}</pre>
},
table: ({ children, node, ...properties }) => {
@@ -90,6 +108,7 @@ function markdownComponents(
type MarkdownRendererProps = {
children: string
renderHtml?: boolean
}
const inlineMarkdownComponents: Components = {
@@ -213,18 +232,49 @@ function unwrapMarkdownFence(content: string): string {
return fencedMarkdown?.[1] ?? content
}
function standaloneHtmlSource(content: string): string | undefined {
const trimmed = content.trim()
if (
/^(?:<!doctype\s+html(?:\s[^>]*)?>\s*)?<html(?:\s|>)/iu.test(
trimmed
) ||
/^<[a-z][a-z0-9:-]*(?:\s[^<>]*?)?>[\s\S]*<\/[a-z][a-z0-9:-]*>\s*$/iu.test(
trimmed
)
) {
return trimmed
}
return undefined
}
export const MarkdownRenderer = memo(function MarkdownRenderer({
children
children,
renderHtml = false
}: MarkdownRendererProps): React.JSX.Element {
const { t } = useTranslation('app')
const normalizedContent = normalizeLatexDelimiters(
unwrapMarkdownFence(children)
)
const components = useMemo(
() =>
markdownComponents(
t('markdown.scrollableTable'),
t('markdown.mermaidLoading')
t('markdown.mermaidLoading'),
renderHtml
),
[t]
[renderHtml, t]
)
const standaloneHtml = standaloneHtmlSource(normalizedContent)
if (standaloneHtml) {
return renderHtml ? (
<StaticHtmlPreview source={standaloneHtml} />
) : (
<pre>
<code className="language-html">{standaloneHtml}</code>
</pre>
)
}
return (
<ReactMarkdown
@@ -245,7 +295,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
]}
skipHtml
>
{normalizeLatexDelimiters(unwrapMarkdownFence(children))}
{normalizedContent}
</ReactMarkdown>
)
})
@@ -44,6 +44,9 @@ import {
import { displayErrorMessage } from './error-message'
type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: (
enabled: boolean
) => void
onMagicNotesEnabledChange: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange: (
enabled: boolean
@@ -74,6 +77,7 @@ const shortcutErrorTranslationKeys: Record<
}
export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange,
onMagicNotesEnabledChange,
onMagicNotesShowIncompleteTodoCountChange,
onRemoteProjectsEnabledChange,
@@ -486,6 +490,34 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeConversationHtmlRendering = async (
enabled: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
conversationHtmlRenderingEnabled: enabled
})
setSettings(nextSettings)
onConversationHtmlRenderingEnabledChange(
nextSettings.conversationHtmlRenderingEnabled
)
} catch {
setError(
t(
'platformFeatures.errors.saveConversationHtmlRenderingFailed'
)
)
} finally {
setSaving(false)
}
}
const changeRemoteProjects = async (
enabled: boolean
): Promise<void> => {
@@ -723,6 +755,36 @@ export function PlatformFeaturesSettingsSection({
</p>
)}
</article>
{settings && (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t('platformFeatures.conversationHtml.title')}
</strong>
<small>
{t('platformFeatures.conversationHtml.description')}
</small>
</div>
</div>
<label className="toggle-row">
<input
checked={settings.conversationHtmlRenderingEnabled}
disabled={saving}
onChange={(event) =>
void changeConversationHtmlRendering(
event.target.checked
)
}
role="switch"
type="checkbox"
/>
<span>
{t('platformFeatures.conversationHtml.enabled')}
</span>
</label>
</article>
)}
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
@@ -55,7 +55,8 @@ function renderSidebar({
}>
onLoadArtifact?: (artifactId: string) => Promise<void>
onLoadWorkspaceFile?: (
path: string
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
@@ -330,7 +331,10 @@ describe('RightAssistantSidebar resizing', () => {
name: 'README.md',
content: 'Recovered preview',
mimeType: 'text/plain',
size: 17
size: 17,
offsetBytes: 0,
nextOffsetBytes: 17,
truncated: false
})
renderSidebar({
tab: 'workspace',
@@ -372,7 +376,70 @@ describe('RightAssistantSidebar resizing', () => {
expect(await screen.findByText('Recovered preview'))
.toBeInTheDocument()
expect(onLoadWorkspaceFile).toHaveBeenCalledTimes(2)
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(2, 'README.md')
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
2,
'README.md',
0
)
})
it('loads and appends the rest of a large workspace file', async () => {
const onLoadWorkspaceFile = vi
.fn()
.mockResolvedValueOnce({
path: 'large.txt',
name: 'large.txt',
content: 'first page\n',
mimeType: 'text/plain',
size: 30,
offsetBytes: 0,
nextOffsetBytes: 11,
truncated: true
})
.mockResolvedValueOnce({
path: 'large.txt',
name: 'large.txt',
content: 'second page',
mimeType: 'text/plain',
size: 30,
offsetBytes: 11,
nextOffsetBytes: 30,
truncated: false
})
renderSidebar({
tab: 'workspace',
workspaceProjectId: 'project-1',
onListWorkspaceDirectory: vi.fn(async (path: string) => ({
path,
entries: [
{
name: 'large.txt',
path: 'large.txt',
type: 'file' as const
}
],
truncated: false
})),
onLoadWorkspaceFile
})
fireEvent.click(
await screen.findByRole('button', { name: 'large.txt' })
)
expect(await screen.findByText('first page')).toBeInTheDocument()
expect(screen.getByText('已加载 11 / 30 字节')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续加载' }))
expect(await screen.findByText(/first page\s+second page/u))
.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '继续加载' }))
.not.toBeInTheDocument()
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
2,
'large.txt',
11
)
})
it('previews a result without switching to a separate tab', () => {
+109 -11
View File
@@ -120,7 +120,10 @@ type RightAssistantSidebarProps = {
onListWorkspaceDirectory: (
path: string
) => Promise<WorkspaceDirectoryListing>
onLoadWorkspaceFile: (path: string) => Promise<WorkspaceFilePreview>
onLoadWorkspaceFile: (
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
onOpenWorkspaceEntry: (
path: string,
type: 'file' | 'directory'
@@ -622,6 +625,10 @@ export function RightAssistantSidebar({
}
>()
const workspacePreviewRequest = useRef(0)
const [workspacePreviewLoadingMore, setWorkspacePreviewLoadingMore] =
useState(false)
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
useState('')
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
const [taskFilter, setTaskFilter] = useState<
'attention' | 'active' | 'paused' | 'finished'
@@ -1037,8 +1044,10 @@ export function RightAssistantSidebar({
workspacePreviewRequest.current = requestId
const projectId = workspaceProjectId
setWorkspacePreview({ projectId, path, state: 'loading' })
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
void onLoadWorkspaceFile(path)
void onLoadWorkspaceFile(path, 0)
.then((file) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreview({
@@ -1065,6 +1074,59 @@ export function RightAssistantSidebar({
})
}
const loadMoreWorkspaceFile = (): void => {
const preview = currentWorkspacePreview
if (
preview?.state !== 'ready' ||
!preview.file.truncated ||
workspacePreviewLoadingMore
) {
return
}
const requestId = workspacePreviewRequest.current
const expectedOffset = preview.file.nextOffsetBytes
setWorkspacePreviewLoadingMore(true)
setWorkspacePreviewLoadMoreError('')
void onLoadWorkspaceFile(preview.path, expectedOffset)
.then((nextPage) => {
if (workspacePreviewRequest.current !== requestId) {
return
}
if (
nextPage.path !== preview.file.path ||
nextPage.offsetBytes !== expectedOffset ||
nextPage.size !== preview.file.size ||
nextPage.mimeType !== preview.file.mimeType
) {
throw new Error(t('sidebar.errors.workspacePreview'))
}
setWorkspacePreview({
projectId: preview.projectId,
path: preview.path,
state: 'ready',
file: {
...nextPage,
content: `${preview.file.content}${nextPage.content}`,
offsetBytes: preview.file.offsetBytes
}
})
})
.catch((reason: unknown) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadMoreError(
reason instanceof Error
? reason.message
: t('sidebar.errors.workspacePreview')
)
}
})
.finally(() => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadingMore(false)
}
})
}
const runAction = async (
action: () => Promise<void>,
fallback: string,
@@ -1426,6 +1488,8 @@ export function RightAssistantSidebar({
onClick={() => {
workspacePreviewRequest.current += 1
setWorkspacePreview(undefined)
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
}}
type="button"
@@ -1471,16 +1535,50 @@ export function RightAssistantSidebar({
</button>
</div>
) : (
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
<>
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
)}
</div>
{currentWorkspacePreview.file.truncated && (
<div className="assistant-sidebar__preview-more">
<p>
{t('sidebar.workspace.partialPreview', {
loaded: currentWorkspacePreview.file
.nextOffsetBytes.toLocaleString(locale),
total:
currentWorkspacePreview.file.size.toLocaleString(
locale
)
})}
</p>
<button
className="secondary-button"
disabled={workspacePreviewLoadingMore}
onClick={loadMoreWorkspaceFile}
type="button"
>
{workspacePreviewLoadingMore
? t('sidebar.workspace.loadingMore')
: t('sidebar.workspace.loadMore')}
</button>
</div>
)}
</div>
{workspacePreviewLoadMoreError && (
<p
className="assistant-sidebar__preview-more-error"
role="alert"
>
{workspacePreviewLoadMoreError}
</p>
)}
</>
)}
</section>
) : (
+35
View File
@@ -546,6 +546,7 @@ let applicationSettings: ApplicationSettings = {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -836,6 +837,7 @@ describe('SettingsPanel runtime files', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: false,
magicNotesShowIncompleteTodoCount: true,
@@ -1612,6 +1614,39 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('disables conversation HTML rendering from General settings', async () => {
const onConversationHtmlRenderingEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
const toggle = await screen.findByRole('switch', {
name: '在会话中渲染 HTML'
})
expect(toggle).toBeChecked()
fireEvent.click(toggle)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
conversationHtmlRenderingEnabled: false
})
)
expect(
onConversationHtmlRenderingEnabledChange
).toHaveBeenCalledWith(false)
expect(toggle).not.toBeChecked()
})
it('does not guess a model download source when settings fail to load', async () => {
getApplicationSettings.mockRejectedValueOnce(
new Error('read failed')
+7
View File
@@ -237,6 +237,9 @@ type SettingsPanelProps = {
) => boolean
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onConversationHtmlRenderingEnabledChange?: (
enabled: boolean
) => void
onMagicNotesEnabledChange?: (enabled: boolean) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onMagicNotesShowIncompleteTodoCountChange?: (
@@ -646,6 +649,7 @@ export function SettingsPanel({
onBrandingPreferencesChange = () => false,
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {},
onMagicNotesEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onMagicNotesShowIncompleteTodoCountChange = () => {},
@@ -2255,6 +2259,9 @@ export function SettingsPanel({
{activeTab === 'platform-features' && (
<PlatformFeaturesSettingsSection
onDirtyChange={setPlatformFeaturesDirty}
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
onMagicNotesEnabledChange={onMagicNotesEnabledChange}
onMagicNotesShowIncompleteTodoCountChange={
onMagicNotesShowIncompleteTodoCountChange
+255
View File
@@ -0,0 +1,255 @@
import DOMPurify from 'dompurify'
import { Code2, FileCode2, Maximize2, X } from 'lucide-react'
import {
memo,
useEffect,
useId,
useMemo,
useRef,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
const forbiddenTags = [
'base',
'embed',
'form',
'iframe',
'link',
'meta',
'object',
'script'
]
const forbiddenAttributes = [
'action',
'formaction',
'ping',
'srcset'
]
const staticPreviewPolicy = [
"default-src 'none'",
"base-uri 'none'",
"connect-src 'none'",
'font-src data:',
"form-action 'none'",
"frame-src 'none'",
'img-src data:',
"media-src 'none'",
"object-src 'none'",
"script-src 'none'",
"style-src 'unsafe-inline'"
].join('; ')
const safeInlineImagePattern =
/^data:image\/(?:gif|jpeg|png|webp);base64,[a-z0-9+/=\s]+$/iu
function createStaticHtmlDocument(source: string): string {
const sanitized = DOMPurify.sanitize(source, {
ALLOW_UNKNOWN_PROTOCOLS: false,
FORBID_ATTR: forbiddenAttributes,
FORBID_TAGS: forbiddenTags,
WHOLE_DOCUMENT: true
})
const documentNode = new DOMParser().parseFromString(
sanitized,
'text/html'
)
for (const element of documentNode.querySelectorAll('*')) {
for (const attribute of [...element.attributes]) {
const name = attribute.name.toLowerCase()
if (name.startsWith('on')) {
element.removeAttribute(attribute.name)
continue
}
if (name === 'href' || name === 'xlink:href') {
if (!attribute.value.trim().startsWith('#')) {
element.removeAttribute(attribute.name)
}
continue
}
if (name === 'src' || name === 'poster') {
if (
element.localName !== 'img' ||
!safeInlineImagePattern.test(attribute.value.trim())
) {
element.removeAttribute(attribute.name)
}
}
}
}
const policy = documentNode.createElement('meta')
policy.httpEquiv = 'Content-Security-Policy'
policy.content = staticPreviewPolicy
documentNode.head.prepend(policy)
const viewport = documentNode.createElement('meta')
viewport.name = 'viewport'
viewport.content = 'width=device-width, initial-scale=1'
documentNode.head.append(viewport)
return `<!doctype html>\n${documentNode.documentElement.outerHTML}`
}
function StaticHtmlViewer({
documentSource,
onClose
}: {
documentSource: string
onClose: () => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const titleId = useId()
const hintId = useId()
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(
() => activateModalFocus(() => closeRef.current),
[]
)
return createPortal(
<div
className="html-preview-viewer-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<section
aria-describedby={hintId}
aria-labelledby={titleId}
aria-modal="true"
className="html-preview-viewer"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header className="html-preview-viewer__header">
<div>
<strong id={titleId}>
{t('markdown.htmlViewerTitle')}
</strong>
<small id={hintId}>
{t('markdown.htmlPreviewNotice')}
</small>
</div>
<button
aria-label={t('markdown.htmlCloseViewer')}
className="icon-button"
onClick={onClose}
ref={closeRef}
title={t('markdown.htmlCloseViewer')}
type="button"
>
<X aria-hidden="true" size={18} />
</button>
</header>
<iframe
referrerPolicy="no-referrer"
sandbox=""
srcDoc={documentSource}
title={t('markdown.htmlViewerFrame')}
/>
</section>
</div>,
document.body
)
}
export const StaticHtmlPreview = memo(function StaticHtmlPreview({
source
}: {
source: string
}): React.JSX.Element {
const { t } = useTranslation('app')
const sourceId = useId()
// Tracking the source these panels were opened for resets both when the
// reply changes, without an effect that cascades renders.
const [visibleSource, setVisibleSource] = useState<string>()
const [viewerSource, setViewerSource] = useState<string>()
const sourceVisible = visibleSource === source
const viewerOpen = viewerSource === source
const documentSource = useMemo(
() => createStaticHtmlDocument(source),
[source]
)
const sourceLabel = t(
sourceVisible
? 'markdown.htmlHideSource'
: 'markdown.htmlViewSource'
)
return (
<figure className="message-html-preview">
<figcaption className="message-html-preview__header">
<span>
<FileCode2 aria-hidden="true" size={15} />
<span>
<strong>{t('markdown.htmlPreview')}</strong>
<small>{t('markdown.htmlPreviewNotice')}</small>
</span>
</span>
<div
aria-label={t('markdown.htmlActions')}
className="message-html-preview__actions"
role="group"
>
<button
aria-controls={sourceId}
aria-expanded={sourceVisible}
aria-label={sourceLabel}
className={`icon-button${
sourceVisible ? ' icon-button--active' : ''
}`}
onClick={() =>
setVisibleSource(sourceVisible ? undefined : source)
}
title={sourceLabel}
type="button"
>
<Code2 aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.htmlOpenViewer')}
className="icon-button"
onClick={() => setViewerSource(source)}
title={t('markdown.htmlOpenViewer')}
type="button"
>
<Maximize2 aria-hidden="true" size={17} />
</button>
</div>
</figcaption>
<iframe
loading="lazy"
referrerPolicy="no-referrer"
sandbox=""
srcDoc={documentSource}
title={t('markdown.htmlPreviewFrame')}
/>
{sourceVisible && <pre id={sourceId}>{source}</pre>}
{viewerOpen && (
<StaticHtmlViewer
documentSource={documentSource}
onClose={() => setViewerSource(undefined)}
/>
)}
</figure>
)
})
@@ -25,6 +25,7 @@ describe('UpdateSettingsSection', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+61 -45
View File
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
ACTIVITY_STORAGE_KEY,
MAX_ACTIVITY_DETAIL_LENGTH,
MAX_ACTIVITY_RECORDS,
loadActivityRecords,
clearLegacyActivityHistory,
loadLegacyActivityHistory,
mergeActivityRecords,
reconcileActivityRecords,
saveActivityRecords,
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
@@ -32,14 +31,14 @@ describe('activity-store', () => {
it('returns an empty history for inaccessible or corrupt storage', () => {
localStorage.setItem(ACTIVITY_STORAGE_KEY, '{invalid')
expect(loadActivityRecords()).toEqual([])
expect(loadLegacyActivityHistory().records).toEqual([])
const inaccessibleStorage = {
getItem: () => {
throw new Error('blocked')
}
} as unknown as Storage
expect(loadActivityRecords(inaccessibleStorage)).toEqual([])
expect(loadLegacyActivityHistory(inaccessibleStorage).records).toEqual([])
})
it('keeps only schema-valid records from untrusted storage', () => {
@@ -49,12 +48,11 @@ describe('activity-store', () => {
JSON.stringify([
validRecord,
{ ...validRecord, status: 'unknown' },
{ ...validRecord, detail: 'x'.repeat(MAX_ACTIVITY_DETAIL_LENGTH + 1) },
null
])
)
expect(loadActivityRecords()).toEqual([validRecord])
expect(loadLegacyActivityHistory().records).toEqual([validRecord])
})
it('loads legacy records with an explicit unavailable scope', () => {
@@ -65,7 +63,7 @@ describe('activity-store', () => {
JSON.stringify([legacyRecord])
)
expect(loadActivityRecords()).toEqual([
expect(loadLegacyActivityHistory().records).toEqual([
expect.objectContaining({
id: legacyRecord.id,
scope: { kind: 'unavailable' }
@@ -89,50 +87,51 @@ describe('activity-store', () => {
])
)
expect(loadActivityRecords()).toEqual([])
expect(loadLegacyActivityHistory().records).toEqual([])
})
it('persists no more than the record limit', () => {
it('loads more than the former record limit without shortening details', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
(_, index) => makeRecord(index)
)
expect(saveActivityRecords(records)).toBe(true)
expect(loadActivityRecords()).toHaveLength(MAX_ACTIVITY_RECORDS)
expect(loadActivityRecords().at(-1)?.id).toBe(
`activity-${MAX_ACTIVITY_RECORDS - 1}`
)
})
it('drops the oldest records until the stored JSON fits the load bound', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS },
{ length: 501 },
(_, index) => ({
...makeRecord(index),
detail: 'x'.repeat(4_000)
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件'
})
)
localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records))
expect(saveActivityRecords(records)).toBe(true)
const serialized = localStorage.getItem(ACTIVITY_STORAGE_KEY)
expect(serialized?.length).toBeLessThanOrEqual(2_000_000)
const restored = loadActivityRecords()
expect(restored.length).toBeGreaterThan(0)
expect(restored.length).toBeLessThan(MAX_ACTIVITY_RECORDS)
expect(restored[0]?.id).toBe('activity-0')
const legacy = loadLegacyActivityHistory()
expect(legacy.records).toHaveLength(501)
expect(legacy.records.at(-1)?.detail).toHaveLength(4_001)
expect(legacy.historyMayBeIncomplete).toBe(true)
})
it('reports rejected writes without throwing', () => {
const rejectingStorage = {
setItem: () => {
throw new Error('quota exceeded')
}
} as unknown as Storage
expect(saveActivityRecords([makeRecord(1)], rejectingStorage)).toBe(
false
it('marks a legacy maximum-length detail as possibly incomplete', () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([{ ...makeRecord(1), detail: 'x'.repeat(4_000) }])
)
expect(loadLegacyActivityHistory()).toMatchObject({
historyMayBeIncomplete: true
})
})
it('clears the legacy migration source without throwing', () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([makeRecord(1)])
)
clearLegacyActivityHistory()
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull()
expect(() =>
clearLegacyActivityHistory({
removeItem: () => {
throw new Error('blocked')
}
} as unknown as Storage)
).not.toThrow()
})
it('upserts transitions for one call while preserving distinct calls', () => {
@@ -183,7 +182,7 @@ describe('activity-store', () => {
).toEqual(first.scope)
})
it('persists and upserts Subagent state transitions', () => {
it('upserts Subagent state transitions', () => {
const queued: ActivityRecord = {
...makeRecord(1),
kind: 'subagent',
@@ -206,8 +205,25 @@ describe('activity-store', () => {
status: 'completed'
})
])
expect(saveActivityRecords(completed)).toBe(true)
expect(loadActivityRecords()).toEqual(completed)
})
it('keeps more than 500 distinct activity records', () => {
const records = Array.from({ length: 501 }, (_, index) =>
makeRecord(index)
)
expect(
upsertActivityRecord(records, makeRecord(501))
).toHaveLength(502)
})
it('merges migrated and persisted records without duplicating IDs', () => {
expect(
mergeActivityRecords(
[makeRecord(2), makeRecord(1)],
[makeRecord(1), makeRecord(0)]
).map((record) => record.id)
).toEqual(['activity-2', 'activity-1', 'activity-0'])
})
it('reconciles stale active records with durable task outcomes', () => {
+62 -180
View File
@@ -1,47 +1,24 @@
import i18n from './i18n'
import type { AssistantTask } from '../../shared/assistant-contracts'
import {
activityRecordSchema,
type ActivityRecord as SharedActivityRecord,
type AssistantTask
} from '../../shared/assistant-contracts'
export const ACTIVITY_STORAGE_KEY = 'goodbuddy.activity-records.v1'
export const MAX_ACTIVITY_RECORDS = 500
export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const MAX_STORED_JSON_LENGTH = 2_000_000
const MAX_ID_LENGTH = 256
const MAX_TITLE_LENGTH = 240
const MAX_PROJECT_NAME_LENGTH = 120
const LEGACY_MAX_ACTIVITY_RECORDS = 500
const LEGACY_MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const LEGACY_MAX_STORED_JSON_LENGTH = 2_000_000
const LEGACY_NEAR_STORAGE_LIMIT_LENGTH =
LEGACY_MAX_STORED_JSON_LENGTH - 10_000
const activityKinds = [
'request',
'tool',
'approval',
'subagent',
'result'
] as const
const activityStatuses = [
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled',
'interrupted'
] as const
export type ActivityRecord = SharedActivityRecord
export type ActivityRecord = {
id: string
conversationId: string
requestId: string
callId?: string
scope:
| { kind: 'global' }
| { kind: 'project'; projectId: string; projectName: string }
| { kind: 'unavailable' }
kind: (typeof activityKinds)[number]
title: string
detail: string
status: (typeof activityStatuses)[number]
createdAt: number
export type LegacyActivityHistory = {
records: ActivityRecord[]
historyMayBeIncomplete: boolean
}
function getLocalStorage(): Storage | undefined {
@@ -52,94 +29,16 @@ function getLocalStorage(): Storage | undefined {
}
}
function isBoundedString(
value: unknown,
maximumLength: number,
allowEmpty = false
): value is string {
return (
typeof value === 'string' &&
value.length <= maximumLength &&
(allowEmpty || value.length > 0)
)
}
function parseActivityScope(
value: unknown
): ActivityRecord['scope'] | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
if (candidate.kind === 'global') {
return { kind: 'global' }
}
if (candidate.kind === 'unavailable') {
return { kind: 'unavailable' }
}
if (
candidate.kind === 'project' &&
isBoundedString(candidate.projectId, MAX_ID_LENGTH) &&
isBoundedString(candidate.projectName, MAX_PROJECT_NAME_LENGTH)
) {
return {
kind: 'project',
projectId: candidate.projectId,
projectName: candidate.projectName
}
}
return undefined
}
function parseActivityRecord(value: unknown): ActivityRecord | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
if (
!isBoundedString(candidate.id, MAX_ID_LENGTH) ||
!isBoundedString(candidate.conversationId, MAX_ID_LENGTH) ||
!isBoundedString(candidate.requestId, MAX_ID_LENGTH) ||
(candidate.callId !== undefined &&
!isBoundedString(candidate.callId, MAX_ID_LENGTH)) ||
!activityKinds.some((kind) => kind === candidate.kind) ||
!isBoundedString(candidate.title, MAX_TITLE_LENGTH) ||
!isBoundedString(
candidate.detail,
MAX_ACTIVITY_DETAIL_LENGTH,
true
) ||
!activityStatuses.some((status) => status === candidate.status) ||
typeof candidate.createdAt !== 'number' ||
!Number.isFinite(candidate.createdAt) ||
candidate.createdAt < 0
) {
return undefined
}
const scope =
candidate.scope === undefined
? { kind: 'unavailable' as const }
: parseActivityScope(candidate.scope)
if (!scope) {
return undefined
}
return {
id: candidate.id,
conversationId: candidate.conversationId,
requestId: candidate.requestId,
...(candidate.callId === undefined
? {}
: { callId: candidate.callId }),
scope,
kind: candidate.kind as ActivityRecord['kind'],
title: candidate.title,
detail: candidate.detail,
status: candidate.status as ActivityRecord['status'],
createdAt: candidate.createdAt
}
const parsed = activityRecordSchema.safeParse({
...candidate,
scope: candidate.scope ?? { kind: 'unavailable' }
})
return parsed.success ? parsed.data : undefined
}
export function upsertActivityRecord(
@@ -150,7 +49,7 @@ export function upsertActivityRecord(
(incoming.kind !== 'tool' && incoming.kind !== 'subagent') ||
!incoming.callId
) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
return [incoming, ...records]
}
const existingIndex = records.findIndex(
@@ -160,7 +59,7 @@ export function upsertActivityRecord(
record.callId === incoming.callId
)
if (existingIndex < 0) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
return [incoming, ...records]
}
const existing = records[existingIndex]!
@@ -172,7 +71,21 @@ export function upsertActivityRecord(
scope: existing.scope
},
...records.filter((_, index) => index !== existingIndex)
].slice(0, MAX_ACTIVITY_RECORDS)
]
}
export function mergeActivityRecords(
primary: readonly ActivityRecord[],
secondary: readonly ActivityRecord[]
): ActivityRecord[] {
const seen = new Set<string>()
return [...primary, ...secondary].filter((record) => {
if (seen.has(record.id)) {
return false
}
seen.add(record.id)
return true
})
}
function taskTerminalStatus(
@@ -227,38 +140,33 @@ export function reconcileActivityRecords(
? `${record.detail}\n${i18n.t(
'records.interruptedOnRestart',
{ ns: 'activity' }
)}`.slice(
0,
MAX_ACTIVITY_DETAIL_LENGTH
)
)}`
: record.detail
}
})
}
/**
* Loads only records matching the persisted activity schema. Corrupt storage,
* inaccessible storage and oversized payloads are treated as an empty history.
* Reads the previous Renderer-owned history once for migration to SQLite.
* The legacy writer silently stopped at fixed record and payload limits, so
* reaching one of those boundaries is retained as durable uncertainty.
*/
export function loadActivityRecords(
export function loadLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): ActivityRecord[] {
): LegacyActivityHistory {
if (!storage) {
return []
return { records: [], historyMayBeIncomplete: false }
}
try {
const serialized = storage.getItem(ACTIVITY_STORAGE_KEY)
if (
serialized === null ||
serialized.length > MAX_STORED_JSON_LENGTH
) {
return []
if (serialized === null) {
return { records: [], historyMayBeIncomplete: false }
}
const parsed: unknown = JSON.parse(serialized)
if (!Array.isArray(parsed)) {
return []
return { records: [], historyMayBeIncomplete: false }
}
const records: ActivityRecord[] = []
@@ -267,57 +175,31 @@ export function loadActivityRecords(
if (record) {
records.push(record)
}
if (records.length === MAX_ACTIVITY_RECORDS) {
break
}
}
return records
return {
records,
historyMayBeIncomplete:
records.length >= LEGACY_MAX_ACTIVITY_RECORDS ||
serialized.length >= LEGACY_NEAR_STORAGE_LIMIT_LENGTH ||
records.some(
(record) =>
record.detail.length === LEGACY_MAX_ACTIVITY_DETAIL_LENGTH
)
}
} catch {
return []
return { records: [], historyMayBeIncomplete: false }
}
}
/**
* Persists as many of the newest schema-valid records as fit within the
* bounded storage payload. Returns false if storage is unavailable or rejects
* the write.
*/
export function saveActivityRecords(
records: readonly ActivityRecord[],
export function clearLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): boolean {
): void {
if (!storage) {
return false
return
}
const serializedRecords: string[] = []
let serializedLength = 2
for (const record of records) {
const safeRecord = parseActivityRecord(record)
if (safeRecord) {
const serializedRecord = JSON.stringify(safeRecord)
const nextLength =
serializedLength +
serializedRecord.length +
(serializedRecords.length > 0 ? 1 : 0)
if (nextLength > MAX_STORED_JSON_LENGTH) {
break
}
serializedRecords.push(serializedRecord)
serializedLength = nextLength
}
if (serializedRecords.length === MAX_ACTIVITY_RECORDS) {
break
}
}
try {
storage.setItem(
ACTIVITY_STORAGE_KEY,
`[${serializedRecords.join(',')}]`
)
return true
storage.removeItem(ACTIVITY_STORAGE_KEY)
} catch {
return false
// A failed cleanup leaves the migration source intact for the next start.
}
}
@@ -115,12 +115,15 @@ 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}}',
activityCount: 'Activity items: {{formattedCount}}',
projectSummary:
'{{conversationCount}} tasks or conversations · {{activityCount}} activity items',
loadMore: 'Show more ({{remaining}} remaining)',
openConversation: 'Open conversation'
}
} satisfies TranslationShape<typeof chineseActivity>
+16 -1
View File
@@ -643,6 +643,9 @@ export const app = {
'Failed to resume this conversation’s send queue',
conversationPersistenceFailed:
'Failed to save conversations. Check local storage.',
activityHistoryPersistenceFailed:
'Failed to save run history. Check local storage.',
activityHistoryReadFailed: 'Failed to read run history',
remoteConversationRefreshFailed:
'Failed to refresh remote channel conversations',
projectReadFailed: 'Failed to read projects',
@@ -700,6 +703,18 @@ export const app = {
mermaidZoomIn: 'Zoom in diagram',
mermaidResetZoom: 'Reset zoom',
mermaidZoomLevel: 'Current zoom level',
mermaidCloseViewer: 'Close large Mermaid diagram'
mermaidCloseViewer: 'Close large Mermaid diagram',
htmlPreview: 'HTML preview',
htmlPreviewNotice:
'Static safe preview with scripts and network access disabled',
htmlPreviewFrame: 'Static HTML preview from the Agent reply',
htmlActions: 'HTML preview actions',
htmlViewSource: 'View source',
htmlHideSource: 'Hide source',
htmlOpenViewer: 'Full-screen preview',
htmlViewerTitle: 'Full-screen HTML preview',
htmlViewerFrame:
'Full-screen static HTML preview from the Agent reply',
htmlCloseViewer: 'Close full-screen preview'
}
} satisfies TranslationShape<typeof chineseApp>
@@ -540,6 +540,8 @@ export const settingsSections = {
serviceUnavailable:
'Application settings are not available in this version',
readFailed: 'Could not load platform feature settings',
saveConversationHtmlRenderingFailed:
'Could not save the conversation HTML rendering setting. Try again.',
saveRemoteProjectsFailed:
'Could not save the Remote Projects setting. Try again.',
saveMagicNotesFailed: 'Could not save Magic Notes settings. Try again.',
@@ -558,6 +560,12 @@ export const settingsSections = {
general: 'General',
magicNotes: 'Magic Notes'
},
conversationHtml: {
title: 'Conversation HTML rendering',
description:
'Shows complete HTML documents and HTML code blocks in Agent replies as static previews. Previews cannot run scripts, access the network, submit forms, or open windows.',
enabled: 'Render HTML in conversations'
},
remoteProjects: {
title: 'Remote Projects (Technical Preview)',
description:
@@ -327,6 +327,9 @@ export const workspace = {
fileSize: '{{formattedSize}} bytes',
fileFallback: 'Project workspace file',
reading: 'Reading file…',
partialPreview: 'Loaded {{loaded}} / {{total}} bytes',
loadMore: 'Load more',
loadingMore: 'Loading…',
refreshAriaLabel: 'Refresh workspace files',
refresh: 'Refresh',
gitUnavailable: 'Git status unavailable: {{error}}',
@@ -111,12 +111,15 @@ export const activity = {
records: {
unknownTime: '时间未知',
interruptedOnRestart: '应用重启时此活动尚未结束。',
legacyHistoryMayBeIncomplete:
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
unavailableScope:
'创建此活动记录时未能确定其归属范围。',
conversation: '对话:{{title}}',
activityCount: '{{formattedCount}} 条活动',
projectSummary:
'{{conversationCount}} 个任务或会话 · {{activityCount}} 条活动',
loadMore: '继续显示(剩余 {{remaining}} 条)',
openConversation: '打开所属对话'
}
} as const
+13 -1
View File
@@ -595,6 +595,8 @@ export const app = {
conversationQueueResumeFailed:
'无法继续执行当前对话的待发送队列',
conversationPersistenceFailed: '会话持久化失败,请检查本地存储',
activityHistoryPersistenceFailed: '运行记录保存失败,请检查本地存储',
activityHistoryReadFailed: '运行记录读取失败',
remoteConversationRefreshFailed: '远程通道会话刷新失败',
projectReadFailed: '项目读取失败',
expertsReadFailed: '专家角色读取失败',
@@ -645,6 +647,16 @@ export const app = {
mermaidZoomIn: '放大图表',
mermaidResetZoom: '重置缩放',
mermaidZoomLevel: '当前缩放比例',
mermaidCloseViewer: '关闭 Mermaid 大图'
mermaidCloseViewer: '关闭 Mermaid 大图',
htmlPreview: 'HTML 预览',
htmlPreviewNotice: '静态安全预览,脚本和网络已禁用',
htmlPreviewFrame: 'Agent 回复 HTML 静态预览',
htmlActions: 'HTML 预览操作',
htmlViewSource: '查看源码',
htmlHideSource: '隐藏源码',
htmlOpenViewer: '全屏预览',
htmlViewerTitle: 'HTML 全屏预览',
htmlViewerFrame: 'Agent 回复 HTML 全屏静态预览',
htmlCloseViewer: '关闭全屏预览'
}
} as const
@@ -515,6 +515,8 @@ export const settingsSections = {
errors: {
serviceUnavailable: '当前版本未提供应用设置服务',
readFailed: '读取平台功能设置失败',
saveConversationHtmlRenderingFailed:
'保存会话 HTML 渲染设置失败,请重试',
saveRemoteProjectsFailed: '保存远程项目设置失败,请重试',
saveMagicNotesFailed: '保存魔法笔记设置失败,请重试',
saveIncompleteTodoCountFailed:
@@ -529,6 +531,12 @@ export const settingsSections = {
general: '通用设置',
magicNotes: '魔法笔记'
},
conversationHtml: {
title: '会话 HTML 渲染',
description:
'将 Agent 回复中的完整 HTML 或 HTML 代码块直接显示为静态预览。预览不会执行脚本、联网、提交表单或打开窗口。',
enabled: '在会话中渲染 HTML'
},
remoteProjects: {
title: '远程项目(技术预览)',
description: '启用 SSH Host 管理和由 GoodBuddy 托管的远程项目。',
@@ -318,6 +318,9 @@ export const workspace = {
fileSize: '{{formattedSize}} 字节',
fileFallback: '项目工作区文件',
reading: '正在读取文件…',
partialPreview: '已加载 {{loaded}} / {{total}} 字节',
loadMore: '继续加载',
loadingMore: '正在加载…',
refreshAriaLabel: '刷新工作区文件',
refresh: '刷新',
gitUnavailable: 'Git 状态不可用:{{error}}',
+153
View File
@@ -4959,6 +4959,25 @@ button > svg {
font-size: 9px;
}
.assistant-sidebar__preview-more {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
gap: var(--space-3);
}
.assistant-sidebar__preview-more p,
.assistant-sidebar__preview-more-error {
margin: 0;
color: var(--text-muted);
font-size: 10px;
}
.assistant-sidebar__preview-more-error {
color: var(--danger);
}
.assistant-sidebar__import {
display: flex;
align-items: center;
@@ -5579,6 +5598,126 @@ button > svg {
margin: 0;
}
.message-html-preview {
display: grid;
min-width: 0;
margin: 0;
gap: var(--space-2);
}
.message-html-preview__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.message-html-preview__header > span {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.message-html-preview__header > span > span {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.message-html-preview__header small {
color: var(--text-secondary);
}
.message-html-preview__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
}
.message-html-preview > iframe {
width: 100%;
height: 480px;
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.message-html-preview > pre {
max-height: 480px;
margin: 0;
overflow: auto;
}
.html-preview-viewer-backdrop {
position: fixed;
z-index: 120;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.html-preview-viewer {
display: grid;
width: min(1280px, 100%);
height: min(860px, calc(100vh - 70px));
min-height: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr);
}
.html-preview-viewer__header {
display: flex;
min-width: 0;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
justify-content: space-between;
gap: var(--space-3);
}
.html-preview-viewer__header > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.html-preview-viewer__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.html-preview-viewer__header small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.4;
}
.html-preview-viewer > iframe {
width: 100%;
height: 100%;
min-height: 0;
border: 0;
border-radius: 0 0 var(--radius-card) var(--radius-card);
background: var(--surface-raised);
}
@media (max-width: 720px) {
.html-preview-viewer-backdrop {
padding: var(--space-2);
}
.html-preview-viewer {
height: calc(100vh - 54px);
}
}
.markdown-content > :first-child {
margin-top: 0;
}
@@ -13729,6 +13868,20 @@ 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);
}
.activity-panel__tab-panel {
min-width: 0;
padding-bottom: var(--space-6);
@@ -36,6 +36,7 @@ const applicationPreferencesSchema = z
updateSource: updateSourceSchema,
modelDownloadSource: modelDownloadSourceSchema,
localToolEnvironment: localToolEnvironmentSettingsSchema,
conversationHtmlRenderingEnabled: z.boolean(),
remoteProjectsEnabled: z.boolean(),
magicNotesEnabled: z.boolean(),
magicNotesShowIncompleteTodoCount: z.boolean(),
+31 -19
View File
@@ -3,6 +3,7 @@ import type { AssistantProject } from './assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationMessageSchema,
conversationSubagentActivitySchema,
@@ -33,6 +34,29 @@ const untouchedProject: AssistantProject = {
updatedAt: '2026-08-01T00:00:00.000Z'
}
describe('activity history contracts', () => {
it('accepts more than 500 records and details longer than 4,000 characters', () => {
const records = Array.from({ length: 501 }, (_, index) => ({
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
scope: { kind: 'global' as const },
kind: 'tool' as const,
title: `Activity ${index}`,
detail: index === 500 ? 'x'.repeat(4_001) : '',
status: 'completed' as const,
createdAt: index
}))
expect(
activityHistorySnapshotSchema.parse({
records,
legacyHistoryMayBeIncomplete: true
}).records
).toHaveLength(501)
})
})
describe('isUntouchedBuiltInDefaultProject', () => {
it('requires the persistent marker and exact seed text', () => {
expect(isUntouchedBuiltInDefaultProject(untouchedProject)).toBe(true)
@@ -174,14 +198,14 @@ describe('project execution space contracts', () => {
})
describe('conversation activity contracts', () => {
it('accepts long activity runs within the retained display bounds', () => {
const tools = Array.from({ length: 101 }, (_, index) => ({
it('accepts complete long activity runs without a fixed item limit', () => {
const tools = Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
summary: `Tool ${index + 1}`
}))
const subagents = Array.from({ length: 101 }, (_, index) => ({
const subagents = Array.from({ length: 501 }, (_, index) => ({
childTaskId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
routingMode: 'native' as const,
actor: {
@@ -203,23 +227,22 @@ describe('conversation activity contracts', () => {
})),
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true,
tools,
subagents
})
).toMatchObject({
tools: expect.arrayContaining([
expect.objectContaining({ callId: 'call-101' })
expect.objectContaining({ callId: 'call-501' })
]),
subagents: expect.arrayContaining([
expect.objectContaining({
childTaskId: '00000000-0000-4000-8000-000000000101'
childTaskId: '00000000-0000-4000-8000-000000000501'
})
])
})
})
it('rejects conversation activity and block arrays beyond display bounds', () => {
it('accepts message block arrays beyond the former display bound', () => {
const baseMessage = {
id: '00000000-0000-4000-8000-000000000999',
role: 'assistant' as const,
@@ -227,17 +250,6 @@ describe('conversation activity contracts', () => {
createdAt: 1,
state: 'complete' as const
}
expect(
conversationMessageSchema.safeParse({
...baseMessage,
tools: Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
summary: `Tool ${index + 1}`
}))
}).success
).toBe(false)
expect(
conversationMessageSchema.safeParse({
...baseMessage,
@@ -247,7 +259,7 @@ describe('conversation activity contracts', () => {
content: 'x'
}))
}).success
).toBe(false)
).toBe(true)
})
})
+59 -14
View File
@@ -31,6 +31,57 @@ export type ProjectChannel = z.infer<typeof projectChannelSchema>
const projectExecutionSpacePathSchema = z.string().max(4_096)
export const activityRecordSchema = z
.object({
id: z.string().min(1).max(256),
conversationId: z.string().min(1).max(256),
requestId: z.string().min(1).max(256),
callId: z.string().min(1).max(256).optional(),
scope: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('global') }).strict(),
z
.object({
kind: z.literal('project'),
projectId: z.string().min(1).max(256),
projectName: z.string().min(1).max(120)
})
.strict(),
z.object({ kind: z.literal('unavailable') }).strict()
]),
kind: z.enum([
'request',
'tool',
'approval',
'subagent',
'result'
]),
title: z.string().min(1).max(240),
detail: z.string(),
status: z.enum([
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled',
'interrupted'
]),
createdAt: z.number().finite().nonnegative()
})
.strict()
export const activityHistorySnapshotSchema = z
.object({
records: z.array(activityRecordSchema),
legacyHistoryMayBeIncomplete: z.boolean()
})
.strict()
export type ActivityRecord = z.infer<typeof activityRecordSchema>
export type ActivityHistorySnapshot = z.infer<
typeof activityHistorySnapshotSchema
>
export const projectExecutionSpaceSchema = z.discriminatedUnion(
'kind',
[
@@ -182,12 +233,9 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
.strict()
])
export const maximumConversationRetainedActivities = 500
export const maximumConversationMessageBlocks = 2_000
export const conversationMessageBlocksSchema = z
.array(conversationMessageBlockSchema)
.max(maximumConversationMessageBlocks)
export const conversationMessageBlocksSchema = z.array(
conversationMessageBlockSchema
)
export type ConversationToolActivity = z.infer<
typeof conversationToolActivitySchema
@@ -278,14 +326,8 @@ export const conversationMessageSchema = z
.array(conversationContextCompressionMarkerSchema)
.max(2)
.optional(),
tools: z
.array(conversationToolActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
subagents: z
.array(conversationSubagentActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z
.array(
@@ -559,6 +601,9 @@ export type WorkspaceFilePreview = {
content: string
mimeType: 'text/markdown' | 'text/plain' | 'application/json'
size: number
offsetBytes: number
nextOffsetBytes: number
truncated: boolean
}
export type AssistantTaskStatus =
+13 -2
View File
@@ -41,6 +41,8 @@ import {
type AssistantHeartbeatRun,
type AssistantExpert,
type AssistantTask,
type ActivityHistorySnapshot,
type ActivityRecord,
type TokenUsageSummary,
type ConversationSnapshot,
type ConversationAttachment,
@@ -288,7 +290,8 @@ export const workspaceDirectoryRequestSchema = z
export const workspaceFileRequestSchema = z
.object({
projectId: assistantIdSchema,
path: workspaceRelativePathSchema
path: workspaceRelativePathSchema,
offsetBytes: z.number().int().nonnegative().safe().optional()
})
.strict()
@@ -1888,7 +1891,8 @@ export type DesktopApi = {
) => Promise<WorkspaceDirectoryListing>
readFile: (
projectId: string,
path: string
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
openPath: (
projectId: string,
@@ -1903,6 +1907,13 @@ export type DesktopApi = {
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
) => Promise<void>
}
activityHistory: {
get: () => Promise<ActivityHistorySnapshot>
replace: (
records: ActivityRecord[],
legacyHistoryMayBeIncomplete: boolean
) => Promise<void>
}
usage: {
getTokenSummary: () => Promise<TokenUsageSummary>
}
@@ -137,6 +137,7 @@ describe('GoodBuddy configuration contracts', () => {
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false,
magicNotesEnabled: true,
magicNotesShowIncompleteTodoCount: true,
+2
View File
@@ -185,6 +185,8 @@ export const ipcChannels = {
workspacePathOpen: 'workspace:path:open',
tasksList: 'tasks:list',
tasksSetStatus: 'tasks:set-status',
activityHistoryGet: 'activity-history:get',
activityHistoryReplace: 'activity-history:replace',
tokenUsageSummary: 'usage:token-summary',
artifactsList: 'artifacts:list',
artifactsGet: 'artifacts:get',