fix: preserve tool details and prevent repeated history growth

Show bounded operation summaries for Continue, DeepSeek Harness and direct-model tools, retaining input details across incremental updates. Use one canonical workspace path for DeepSeek Host startup and ACP sessions so equivalent Windows paths reuse a working Runtime.

Compare persisted subagent tool fields by value, release terminal task caches, and avoid rebuilding caches for duplicate remote events. Upgrade to schema 35 to recompact repeated schema-34 tool blocks while preserving events and provenance. Include regression coverage, validation records and temporary-resource test exclusions.

Validation: 4155 tests passed, 66 skipped; typecheck and lint passed. Full desktop real-model validation covered all three tool-summary paths and both Windows path spellings. Storage validation evidence includes local and remote tool workloads and real database upgrade checks.

Release note: Tool records now show paths, commands and search details across runtimes. Fix DeepSeek Windows workspace failures and repeated subagent history growth; schema 35 reclaims redundant history data while preserving execution records, and older clients cannot reopen the upgraded database.
This commit is contained in:
mesalogo
2026-09-14 00:34:51 +08:00
parent c71da4b6ae
commit 0d7ba55a50
29 changed files with 667 additions and 97 deletions
+2 -1
View File
@@ -116,7 +116,8 @@ otherwise.
The first upgrade automatically converts existing history and reclaims disk The first upgrade automatically converts existing history and reclaims disk
space, with visible progress and quit/retry support. Chats, execution details, space, with visible progress and quit/retry support. Chats, execution details,
results, and remote event deduplication are preserved; older clients cannot results, and remote event deduplication are preserved; older clients cannot
reopen the upgraded database. See reopen the upgraded database. The unreleased schema-35 repair also handles
repeated tool blocks written after upgrading to 0.13.1, without deleting events. See
[storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md). [storage and upgrade behavior](./docs/features/assistant-workbar/execution-history-storage.md).
- [x] **Direct model Runtime**: Supports question answering, knowledge - [x] **Direct model Runtime**: Supports question answering, knowledge
+1 -1
View File
@@ -32,7 +32,7 @@
### Agent Runtime 与模型连接 ### Agent Runtime 与模型连接
- [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。 - [x] **子任务历史去重与空间回收**:本机与托管 SSH 子任务事件只存变化,不再反复保存完整进度。首次升级自动转换旧记录并回收空间,显示进度且支持退出重试;保留聊天、执行详情、结果和远程事件去重。当前未发布的 schema 35 补修还会整理升级到 0.13.1 后重复写入的工具块,不删除事件。旧客户端不能打开升级后的数据库,具体规则见[执行记录存储与升级回收](./docs/features/assistant-workbar/execution-history-storage.md)。
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。 - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行、图像生成,以及通过支持 OpenAI 兼容图片编辑接口的服务商进行参考图编辑。连续请求自动复用最近一次成功生成的图片和文字历史,已保存会话重开后仍可继续修改,本轮显式附图优先;上游明确不支持编辑时继续按文字生成,仅在回复底部提示未使用参考图。编辑使用 multipart 上传图片,结果仍只接受经过校验的内联图片,不下载服务商返回的图片 URL。详见[图片生成与连续修改](./docs/features/image-generation/README.md)。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。 - [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
+1
View File
@@ -631,6 +631,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 普通工具在主会话和子 Agent 过程中使用同一轻量折叠记录:不显示外层“工具执行”标题、计数、背景和边框,保留无障碍区域名称。记录纵向左对齐,头部按内容收宽且不超过消息宽度;展开箭头在左,工具名作为主标题,有额外信息的单行摘要弱化显示。右侧以绿色对勾显示成功完成,以图标显示运行和待执行状态,提供悬停文字和无障碍名称;失败和可恢复状态保留文字。仅重复工具名和执行状态的已知 Runtime 模板摘要在头部与详情中均隐藏;刷新重试、审批等额外提示保留。默认折叠,状态和输出更新不改变用户的展开选择;折叠时露出一行错误,展开详情使用消息可用宽度、浅背景和左侧细线,按错误、结果、独立折叠的输入参数阅读。详情保留原始文本,完整 JSON 输入可缩进显示,复制仍使用收到的原文,并通过应用通知反馈。详情工具栏只保留复制按钮,文本默认自动换行,长内容在固定最大高度内滚动,不提供换行切换和展开全部按钮。 - 普通工具在主会话和子 Agent 过程中使用同一轻量折叠记录:不显示外层“工具执行”标题、计数、背景和边框,保留无障碍区域名称。记录纵向左对齐,头部按内容收宽且不超过消息宽度;展开箭头在左,工具名作为主标题,有额外信息的单行摘要弱化显示。右侧以绿色对勾显示成功完成,以图标显示运行和待执行状态,提供悬停文字和无障碍名称;失败和可恢复状态保留文字。仅重复工具名和执行状态的已知 Runtime 模板摘要在头部与详情中均隐藏;刷新重试、审批等额外提示保留。默认折叠,状态和输出更新不改变用户的展开选择;折叠时露出一行错误,展开详情使用消息可用宽度、浅背景和左侧细线,按错误、结果、独立折叠的输入参数阅读。详情保留原始文本,完整 JSON 输入可缩进显示,复制仍使用收到的原文,并通过应用通知反馈。详情工具栏只保留复制按钮,文本默认自动换行,长内容在固定最大高度内滚动,不提供换行切换和展开全部按钮。
- OpenCode 主会话和子 Agent 的工具记录均保留 Runtime 提供的操作标题作为摘要,显示文件路径、命令或操作描述;本地 SDK 标题去除首尾空白后最多保留 240 字符,没有标题时沿用工具名回退规则。主会话工具失败或中断时保留已收到的标题;远端 ACP 继续在记录名称中显示其提供的标题。 - OpenCode 主会话和子 Agent 的工具记录均保留 Runtime 提供的操作标题作为摘要,显示文件路径、命令或操作描述;本地 SDK 标题去除首尾空白后最多保留 240 字符,没有标题时沿用工具名回退规则。主会话工具失败或中断时保留已收到的标题;远端 ACP 继续在记录名称中显示其提供的标题。
- 工具取消后保留“已取消”文字,避免与失败图标混淆。 - 工具取消后保留“已取消”文字,避免与失败图标混淆。
- Continue、DeepSeek Harness 和直连模型的工具名后显示单行操作摘要,内容提取、长度与完成态保留规则见[工具操作摘要](./docs/features/assistant-workbar/runtime-interactions.md#工具操作摘要)。
- Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。 - Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。
- 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。 - 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。
- 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。 - 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。
+8
View File
@@ -37,6 +37,9 @@
覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题 覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题
必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。 必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。
对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省
的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口;
不能只处理尚未迁移的旧库。
### 真实旧库 ### 真实旧库
@@ -52,6 +55,9 @@
源 schema 与目标 schema。确认原件和备份未被改写。 源 schema 与目标 schema。确认原件和备份未被改写。
6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则 6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则
检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。 检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。
7. 用真实模型模拟正常用户任务,允许主代理与子代理按需调用工具、修改独立测试项目、
运行测试和处理失败;只读纯文字模型调用不能证明工具事件增长正常。记录实际提供商
请求数与统计范围,不把 Runtime 用量上报数当成全部 HTTP 请求数。
## 发布与维护 ## 发布与维护
@@ -65,3 +71,5 @@
表示,并回收空间。具体规则见 表示,并回收空间。具体规则见
[执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md), [执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md),
数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。 数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。
- 当前未发布源码:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按
JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。
@@ -19,7 +19,9 @@
- `messages.metadata_json` 继续保存供会话显示的完整子任务过程。 - `messages.metadata_json` 继续保存供会话显示的完整子任务过程。
- `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。 - `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。
`progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。 `progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。
完全相同的过程使用空更新数组。其他事件内容不改变。 完全相同的过程使用空更新数组。工具字段按值比较,缺省字段与 `undefined` 等价,
不因 JSON 省略空字段而重复写入整个工具结果;空字符串与实际输出/错误变化仍保留。
其他事件内容不改变。
- 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、 - 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、
最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。 最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。
- Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整 - Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整
@@ -27,6 +29,9 @@
- 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验, - 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验,
相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID 相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID
核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。 核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。
- 存储层最多缓存 16 个任务;收到任务完成、错误、取消或中断的终态记录后立即移除该任务
的缓存引用,不影响其他会话。若事务随后回滚或仍收到该任务的后续事件,按已提交记录重建。
释放引用不等于进程 RSS 立即下降,垃圾回收和 Runtime 自身会话缓存另按生命周期回收。
## 旧库升级 ## 旧库升级
@@ -35,19 +40,25 @@
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史 自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。 快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。
转换可以混合读取旧完整记录与已经转换的记录,因此退出、磁盘写入失败后可以重新扫描 转换可以混合读取旧完整记录与已经转换的记录,也会重新编码 schema 34 中重复的工具块
并继续,不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非 更新。因此退出、磁盘写入失败后可以重新扫描并继续;已是相同 JSON 的记录不重复更新,
不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非
在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。 在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。
业务初始化随后将数据库升级为 schema 34,并建立子任务事件的部分索引。 当前源码的业务初始化随后将数据库升级为 schema 35;schema 34 用户也会执行本次整理,
成功后后续启动直接跳过。继续复用 schema 34 的子任务事件部分索引和同一差量表示。
低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用 低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用
升级前备份,不要修改 `user_version` 强行绕过版本检查。 升级前备份,不要修改 `user_version` 强行绕过版本检查。
## 验证要求 ## 验证要求
- 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。 - 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。
- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。
同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。
- 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。 - 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。
- 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、 - 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、
其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。 其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。
- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。具体证据见 - 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理
与子代理按正常任务需要读取、修改文件和执行测试,不能用“子代理不使用工具”的纯文字
场景代替工具存储验收;记录实际请求数、有效变化、重复工具块及还原结果。具体证据见
[实现与验证进度](./progress.md)。 [实现与验证进度](./progress.md)。
+91 -1
View File
@@ -1,5 +1,94 @@
# 工作栏实现与验证进度 # 工作栏实现与验证进度
## 2026-09-14 多 Runtime 工具操作摘要
显示与事件合并规则见[工具操作摘要](./runtime-interactions.md#工具操作摘要)。Continue、
DeepSeek Harness 和直连模型已接入共享摘要函数,完成事件缺少输入时保留已有摘要。
Windows 完整 App 使用当前生产 Main、Preload、Renderer 和隔离配置,模型为
`deepseek-v4-flash`。从真实输入区输入并点击发送读取测试文件的请求,校验码只存于
文件中。三条成功会话都返回正确校验码,工具完成后仍显示路径;原生点击展开可查看
输入与输出,离开后重新打开会话、重新加载 Renderer 后再次打开均保留摘要和详情。
六张对话区截图已实际打开审阅。
| Runtime | 成功模式 | 提交数(含失败重跑) | 模型 HTTP dispatch | 工具执行 |
| --- | --- | ---: | ---: | ---: |
| 直连模型 | Ask | 1 | 3 | 3 |
| Continue | Execute | 2 | 4 | 3 |
| DeepSeek Harness | Execute | 3 | 3 | 2 |
| 合计 | | 6 | 10 | 8 |
计数来自 Main 和真实 Utility Host 的 HTTP dispatch,未注入答案或工具事件。Continue
首轮 Ask 仅提供配置工具,未能读取文件;改用 Execute 成功。DS 前两次因 Windows
工作区路径写法与 Host `realpath` 比较不一致而返回 ACP `-32602`,均未调用模型;
同目录改写路径仍复用缓存 Runtime,改用全新规范路径目录后成功。该路径问题随后由
[DS 工作区路径修复](../deepseek-harness/progress.md#2026-09-14-windows-工作区路径修复)
单独处理,新增验证计数见该记录,不计入上表。驱动的包解析、
DOM 定位及卡片比较器失败已记录,修正比较器后的复验没有追加模型请求。
本机临时 `runtime-summary-live/report.json` 保留逐次结果和产物哈希;源码基线为
`c71da4b6ae1d7228ff968d39c63db205caf61339` 加本次工作树改动,Main SHA-256 为
`2153d72a947ab6f5eab22eab28d7a3f2f86a47740d5223a3e1547f199fa7de84`。
测试进程、隔离配置、数据库、工作区和构建均已清理,正式配置字节未改变;报告和截图
保留在本机临时目录,不作为长期可用的仓库资源。请求使用已配置 HTTPS,沿用生产
证书策略,本轮未验证严格证书校验。未测试 Main 重启或远程 Agent。
专项回归 210 项通过、1 项跳过。完整 `npm test` 首轮 4154 项通过、66 项跳过,另有
6 个 `.agent-resources` 内的本地 `node:test` 文件被 Vitest 误收集导致套件失败。
使用 `npm test -- --exclude "**/.agent-resources/**"` 完整重跑后,353 个文件通过、
9 个跳过,4154 项通过、66 项跳过、0 失败,耗时 646.04 秒。`npm run typecheck`、
`npm run lint` 和 `git diff --check` 通过。
## 2026-09-13–14 schema 35 工具重复写入补修(未发布)
- 0.13.1 的历史库回收成功,但其真实模型验收限制子代理不使用工具,遗漏了生产工具对象
的 `undefined` 可选字段。JSON 重读省略这些键,原深比较误判未变化的工具块。正常开发
任务复现同批 928 条事件的 7,787 次重复工具块写入;仅归一化空字段的对照将负载从
10,467,622 降为 452,391 字节,逐条还原一致。该对照不是产品修复验收。
- 当前修复按共享工具 schema 的字段值比较,不序列化整份工具输出进行比较。空字符串、
输出修订、状态和错误变化仍保留。schema 35 使用同一编码器重整旧完整事件及 schema 34
已存差量,跳过 JSON 未变化的行;不会删除事件、会话、工具结果或来源字段。
- 存储缓存仍最多 16 个任务,终态及时移除缓存引用;回滚后按已提交记录重建,其他任务
不受影响。远程先按来源主键判断是否重放,重复事件不重建已经释放的写入缓存;
`INSERT OR IGNORE`、冲突校验及消息投影事务仍保留。
- Windows 当前源码加便携版内置 OpenCode 完成正常 CSV 项目开发,主/子代理实际读取、
改代码、补测试和执行命令;独立 59 项测试及 CLI 通过。698 条子代理事件共
368,575 字节,重复工具块为 0,与归一化对照字节数完全相同,每条事件还原一致。
- 多项目实测使用两个独立 OpenCode 实例、每项目两个会话,两轮共 8 次 Execute。
第一轮四个开发任务全部完成;第二轮复用会话,主动取消一个正在使用工具的子代理任务,
其余三个完成并通过测试。2,841 条子代理事件共 1,305,340 字节,重复工具块为 0,
全部逐条还原一致。每轮结束后缓存任务数、打开的 SSE 响应流均为 0。
隔离 Main 测试进程在显式 GC 后的堆占用为 12.94 → 12.91 MiB;
这证明本轮未持续积累,不是整个 Electron UI/Runtime 的 RSS 上限或无限负载保证。
- 共享 Linux x64 Host 使用当前桌面源码经真实 SSH、Agent、ACP 和模型 gateway 完成
Ask 与正常工具 Execute。912 条子代理事件共 405,360 字节,重复工具块为 0,
逐条还原及重复重放通过,远程 Python 项目测试通过。修复位于共享桌面层,不修改 Agent
或公开事件协议。两次早期测试驱动错误中断操作,修正瞬态事件处理后才完成上述验收;
不能将早期失败写成产品通过。
- 本轮实际提供商请求为 121 次:本地开发 26,多会话 79,远程账本 16(成功验收 14,
早期驱动中断 2 次结果未知、未交付)。本地计数器只记录 HTTP 状态,远程按测试
request/operation 的 Agent ledger 统计,不记录凭据、正文或请求头。另有先前诊断的
24 次请求,不计入这 121 次。已核对本轮本机和远程测试工作区无残留 Runtime 进程。
- 活动 schema 34 库通过 SQLite online backup 取得一致性备份,并校验独立测试副本。
正常启动升级成功,2,898,984,960 → 607,055,872 字节(2.70 GiB → 578.93 MiB,
回收 79.06%)。完整比较 424,148 条原事件的内容、ID、时间及来源一致。
隔离启动未复制模型配置,既有启动修复调整一条项目/会话 Runtime 选择及一个在途任务、
消息和活动状态,另追加一个状态事件;不是迁移删除或改写历史内容。
- 同一真实备份的另一个副本直接运行实际构建的升级 Worker,在处理 64 条后取消,
重启后完成 316,664 条子代理记录;全量事件再次还原一致。全部非事件表逐表内容一致,
包括 150 个会话、1,915 条消息、914 个任务和 136 个成果;`integrity_check=ok`。
原运行库和校验备份没有被测试改写。schema 35 副本再次进入实际 Worker 时直接完成,
进度事件数为 0,不重复整理。
- 最终专项 99 项、typecheck、lint 及生产开发构建通过。首轮全量产品用例 4,154 通过、
66 跳过,但 6 个临时 Node 测试项目被 Vitest 错误发现;配置已排除 `.agent-resources/`,
独立项目仍单独运行自己的 Node 测试。随后显式双 worker 全量为 4,148 通过、66 跳过、
`App.test.tsx` 6 项时序失败;恢复仓库默认单 worker 后该文件 220 项全部通过,
未修改 UI 代码。最终默认配置全量为 4,151 通过、66 跳过、3 个问答 UI 测试失败,
失败项与双 worker 不同;本轮全量回归尚未全绿。随后仅调整测试等待和 mock 隔离的
尝试仍有不稳定失败,已完整撤回这些额外 UI 测试改动,不把单次重跑通过当作稳定修复。
存储专项、真实工具任务、并发内存和真实数据迁移证据均通过;UI 测试问题仍需单独处理,
本次未提交、未发布,也未替换用户正在运行的 0.13.1。
## 2026-09-13 Desktop 0.13.1 执行记录去重与大库升级 ## 2026-09-13 Desktop 0.13.1 执行记录去重与大库升级
- 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发 - 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发
@@ -31,7 +120,8 @@
实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求 实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求
不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。 不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。
- 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。 - 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。
截至本记录尚未提交或打标签,准确候选提交、main CI 和原生发布结果待发布流程确认。 已发布提交 `4937720` 和不可变标签 `v0.13.1`,main CI 与六平台发布通过;
后续真实工具场景的遗漏及补修见本页 schema 35 记录。
## 2026-09-13 原生浏览器与应用浮层修复 ## 2026-09-13 原生浏览器与应用浮层修复
@@ -57,6 +57,21 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答
- 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义, - 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义,
模型桥生成最终配置时同样保留上述快照策略。 模型桥生成最终配置时同样保留上述快照策略。
## 工具操作摘要
工具记录沿用 [UI 设计系统](../../../UI-DESIGN.md)的折叠布局。Continue、DeepSeek Harness
和直连模型共用 `toolOperationSummary`:优先使用有效原生标题,否则从参数提取命令、
文件路径、搜索条件或 URL,合并空白后最多显示 240 字符。无法识别时保留原有工具名
回退规则;文件正文、整份参数 JSON 不进入标题,参数详情仍可展开查看。
Continue 在宿主截断输入前提取摘要,流式完成与失败事件使用合并后的输入和摘要。
DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省略输入时仍能显示操作内容。
直连模型从原始参数生成一次摘要,在待执行、执行中和完成时复用;审批失败、拒绝和
执行失败、刷新重试仍保留已有提示。
本机 OpenCode 沿用原生操作标题。远端 ACP 独立使用原生标题作为记录名称,不经过上述
本地转换,本次没有修改 Agent、远程桥或协议。
## 消息底部请求状态 ## 消息底部请求状态
- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示 - 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示
+1
View File
@@ -8,5 +8,6 @@
| 文档 | 权威职责 | | 文档 | 权威职责 |
| --- | --- | | --- | --- |
| [技术设计](./technical-design.md) | Runtime 产品与技术方案、协议、插件、打包、测试和验收 | | [技术设计](./technical-design.md) | Runtime 产品与技术方案、协议、插件、打包、测试和验收 |
| [实现进度](./progress.md) | Windows 工作区路径修复及验证记录 |
现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。 现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。
@@ -0,0 +1,45 @@
# DeepSeek Harness 实现进度
## 2026-09-14 Windows 工作区路径修复
本机 Runtime 的 Host 启动与 `newSession.cwd` 已共用初始化时解析的 `realpath`,
规则见[技术设计](./technical-design.md#52-受控组合不启动用户-profile)。修复此前
`C:/...\workspace` 写法触发 ACP `-32602`,以及随后换成同目录规范写法仍复用
错误 Runtime 的问题。Host 严格比较与 execution-space 缓存规则保持原样。
新增 Windows 真目录回归,连接真实 ACP、Host 和控制面,分别用混合分隔符及规范
路径创建会话,断言共用同一 Runtime、只启动一次 Host、两次均完成。该自动化回归
使用内存模型;外部真实模型验证单独由完整 App 完成。
Windows 完整 App 使用当前源码构建的 Main、Preload、Renderer 和新建隔离配置。
正式配置只读,模型凭据由隔离配置中的 safeStorage 解密;真实 composer 输入请求,
由 `deepseek-v4-flash` 在 Ask 模式读取 `summary-probe.txt`,校验码未放入 prompt。
| 目录写法 | Composer 提交 | 真实模型 HTTP dispatch | 文件读取 | 结果 |
| --- | ---: | ---: | ---: | --- |
| Windows 混合分隔符 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
| 同目录规范路径 | 1 | 2 | 1 | 仅返回文件中校验码,任务完成 |
| 合计 | 2 | 4 | 2 | 两条会话均通过 |
两轮 dispatch 来自同一 DS Utility Host 进程,切换目录写法未新增 Host;ACP 会话
ID 不同。两条工具卡片均显示路径摘要,原生点击展开后可查看输入与输出;重载
Renderer 并重新选择项目后,已保存会话的摘要、详情和完成状态仍通过检查。四张
局部截图已逐张打开审阅。驱动曾返回不可克隆的监听器函数,以及在重载后默认项目
等待工具卡片超时;修正定位后通过,这两次驱动错误没有增加模型请求。
本机临时 `ds-windows-path-live/report.json`、`dispatch.jsonl` 与 `source.json`
记录结果、逐请求计数和构建哈希,原 `runtime-summary-live` 报告未覆盖。
本次 App 正常退出,退出前记录的 5 个所属进程均已停止;隔离 profile、数据库、
工作区、临时构建和私有日志已清理。正式模型配置与启动前逐字节一致,报告和截图
保留在本机临时目录,不作为长期可用的仓库资源。
测试沿用生产 HTTPS 与证书处理,未单独验证严格证书校验或 Main 重启。
专项 7 个测试文件通过,91 项通过、3 项跳过。`npm run typecheck` 与
`npm run lint` 通过。最初两个假时钟测试因异步目录解析尚未结束就推进启动时钟而
超时;调整为等待 launcher 启动后,DS Runtime 的 34 项测试全部通过。
最终全量 `vitest run`(`npm test` 的脚本入口)为 353 个文件通过、9 个跳过,
4155 项通过、66 项跳过、0 失败,耗时 602.00 秒。`git diff --check` 通过。
远端核查:Main 的 SSH 分支创建托管远程 Runtime,最终使用 `AcpRemoteRuntime`;
`create-runtime.ts` 的本机 DS 入口拒绝 SSH execution space。本次未改远端代码,
未调用远端模型,也未进行 Linux Host 实测。
@@ -121,6 +121,12 @@ GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不
模型名称、服务地址、工作区、Skills、MCP schema 和已启用插件的规范化入口通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。插件配置只来自 GoodBuddy 受管状态,不合并用户 profile 或全局补丁。 模型名称、服务地址、工作区、Skills、MCP schema 和已启用插件的规范化入口通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。插件配置只来自 GoodBuddy 受管状态,不合并用户 profile 或全局补丁。
本机 Runtime 初始化时对工作区调用 `realpath`,将结果保存在该 Host 的运行状态中;
Host 启动与 ACP `newSession.cwd` 共用这一路径。Host 仍校验目录并严格比较会话路径。
Windows 混合分隔符与同目录规范写法可继续通过现有 execution-space identity 复用
Runtime,无需更改缓存键或增加重试。远端 SSH 由 `ManagedRemoteAcpRuntime` /
`AcpRemoteRuntime` 单独处理,不经过本机 DS Runtime 的目录解析。
### 5.3 双层内部控制面 ### 5.3 双层内部控制面
Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层: Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层:
@@ -307,6 +313,7 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
- `text`:已提交的助手文本分片。 - `text`:已提交的助手文本分片。
- `reasoning`:可选的有界推理摘要分片。 - `reasoning`:可选的有界推理摘要分片。
- `tool`:工具 ID、名称、状态和有界输入/输出摘要。 - `tool`:工具 ID、名称、状态和有界输入/输出摘要。
- 操作标题与增量完成事件的参数保留遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),本机适配器按调用 ID 合并,详情上限保持不变。
- `model-usage`:模型、提供方、输入、输出和缓存 Token。 - `model-usage`:模型、提供方、输入、输出和缓存 Token。
- `done`:停止原因和 Session ID。 - `done`:停止原因和 Session ID。
@@ -53,6 +53,8 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由
### 3.1 标题与摘要 ### 3.1 标题与摘要
正常执行状态的操作摘要遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),从原始调用参数提取并在完成后保留;异常状态继续显示原有提示。
```text ```text
进程执行 进程执行
PowerShell · packages/app · npm test PowerShell · packages/app · npm test
@@ -44,7 +44,8 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭
桌面保存托管 SSH 子任务事件时,共用 桌面保存托管 SSH 子任务事件时,共用
[执行记录差量存储](../assistant-workbar/execution-history-storage.md)。 [执行记录差量存储](../assistant-workbar/execution-history-storage.md)。
Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号 Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号
继续保留。该存储修复不要求单独升级 Agent。 继续保留。工具字段比较与 schema 35 对 schema 34 重复块的整理也在该共享桌面层完成;
不把工具过程改为仅存内存,不要求单独升级 Agent。
```text ```text
Renderer Renderer
+6 -1
View File
@@ -84,10 +84,15 @@ Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
转换后回收空间,不靠删聊天或单独修本机数据库解决。 转换后回收空间,不靠删聊天或单独修本机数据库解决。
**防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、 **防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、
消息事务回滚及旧库中断续跑。真实旧库需验证逐条等价和物理文件缩小。 消息事务回滚及旧库中断续跑。工具对象须包含生产路径中的 `undefined` 可选字段,
经过真实 JSON 往返后仍识别为无变化,不能按对象键存在性比较。真实模型验证须包含
子代理正常使用工具、修改文件及运行测试,不能只测禁用工具的文本流。
真实旧库需验证逐条等价和物理文件缩小,已升级 schema 的重复数据同样要覆盖。
**历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB; **历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB;
本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。 本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。
0.13.1 发布后的真实工具任务再次复现:同批 928 条事件有 7,787 次重复工具块写入;
只统一 JSON 空字段后,事件负载从 10,467,622 降为 452,391 字节,逐条还原一致。
## 生产路径复用“测试”函数 ## 生产路径复用“测试”函数
+11 -7
View File
@@ -1603,7 +1603,7 @@ describe('ContinueHostAdapter', () => {
expect(existsSync(globalDirectory)).toBe(false) expect(existsSync(globalDirectory)).toBe(false)
}) })
it('uses auto mode and returns audit metadata for agent tools', async () => { it.each(['completed', 'failed'] as const)('uses auto mode and retains streamed audit metadata on %s', async (toolState) => {
const distribution = await createDistribution() const distribution = await createDistribution()
let launchArgs: string[] = [] let launchArgs: string[] = []
const permissionBodies: unknown[] = [] const permissionBodies: unknown[] = []
@@ -1681,7 +1681,7 @@ describe('ContinueHostAdapter', () => {
toolCall: { toolCall: {
function: { name: 'Bash' } function: { name: 'Bash' }
}, },
status: 'done' status: toolState
} }
] ]
} }
@@ -1695,7 +1695,7 @@ describe('ContinueHostAdapter', () => {
type: 'tool', type: 'tool',
callId: 'call-1', callId: 'call-1',
name: 'Bash', name: 'Bash',
state: 'completed', state: toolState,
output: output:
'Tests passed\nAuthorization: Bearer secret-token' 'Tests passed\nAuthorization: Bearer secret-token'
}, },
@@ -1737,9 +1737,10 @@ describe('ContinueHostAdapter', () => {
{ {
callId: 'call-1', callId: 'call-1',
name: 'Bash', name: 'Bash',
state: 'completed', state: toolState,
input: input:
'{"command":"npm test","token":"secret-token"}', '{"command":"npm test","token":"secret-token"}',
summary: 'npm test',
output: output:
'Tests passed\nAuthorization: Bearer secret-token' 'Tests passed\nAuthorization: Bearer secret-token'
} }
@@ -1754,7 +1755,8 @@ describe('ContinueHostAdapter', () => {
name: 'Bash', name: 'Bash',
state: 'running', state: 'running',
input: input:
'{"command":"npm test","token":"secret-token"}' '{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
} }
}, },
{ {
@@ -1762,9 +1764,11 @@ describe('ContinueHostAdapter', () => {
tool: { tool: {
callId: 'call-1', callId: 'call-1',
name: 'Bash', name: 'Bash',
state: 'completed', state: toolState,
output: output:
'Tests passed\nAuthorization: Bearer secret-token' 'Tests passed\nAuthorization: Bearer secret-token',
input: '{"command":"npm test","token":"secret-token"}',
summary: 'npm test'
} }
}, },
{ type: 'text', delta: 'TOOLS_OK' } { type: 'text', delta: 'TOOLS_OK' }
+18 -1
View File
@@ -1,4 +1,5 @@
import spawn from 'cross-spawn' import spawn from 'cross-spawn'
import { toolOperationSummary } from './tool-operation-summary'
import { createHash, randomBytes } from 'node:crypto' import { createHash, randomBytes } from 'node:crypto'
import { import {
copyFile, copyFile,
@@ -112,6 +113,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
state: z.enum(['running', 'completed', 'failed']), state: z.enum(['running', 'completed', 'failed']),
input: z.string().max(4_000).optional(), input: z.string().max(4_000).optional(),
summary: z.string().max(240).optional(),
output: z.string().max(16_000).optional(), output: z.string().max(16_000).optional(),
error: z.string().max(1_000).optional() error: z.string().max(1_000).optional()
}) })
@@ -181,6 +183,7 @@ export type ContinueHostTool = {
name: string name: string
state: 'pending' | 'running' | 'completed' | 'failed' state: 'pending' | 'running' | 'completed' | 'failed'
input?: string input?: string
summary?: string
output?: string output?: string
error?: string error?: string
} }
@@ -729,6 +732,10 @@ function extractContinueTools(
callId, callId,
name: name.trim().slice(0, 200), name: name.trim().slice(0, 200),
state: normalizedState, state: normalizedState,
summary: toolOperationSummary(
(toolFunction as Record<string, unknown>).arguments,
state.title
),
...(input ? { input } : {}), ...(input ? { input } : {}),
...(output ? { output } : {}), ...(output ? { output } : {}),
...(error ? { error } : {}) ...(error ? { error } : {})
@@ -749,6 +756,7 @@ function mergeContinueTools(
...previous, ...previous,
...tool, ...tool,
input: tool.input ?? previous?.input, input: tool.input ?? previous?.input,
summary: tool.summary ?? previous?.summary,
output: tool.output ?? previous?.output, output: tool.output ?? previous?.output,
error: tool.error ?? previous?.error error: tool.error ?? previous?.error
}) })
@@ -976,6 +984,11 @@ export class ContinueHostAdapter {
serverStateMarker, serverStateMarker,
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B=' 'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
) )
patched = replaceExactly(
patched,
'state:"running",input:(()=>',
`state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>`
)
patched = replaceExactly( patched = replaceExactly(
patched, patched,
serverStateEndpointMarker, serverStateEndpointMarker,
@@ -1682,6 +1695,7 @@ export class ContinueHostAdapter {
callId: event.callId, callId: event.callId,
name: event.name, name: event.name,
state: event.state, state: event.state,
summary: toolOperationSummary(event.input, event.summary),
...(event.input ...(event.input
? { input: boundedToolDetail(event.input, 4_000) } ? { input: boundedToolDetail(event.input, 4_000) }
: {}), : {}),
@@ -1693,7 +1707,10 @@ export class ContinueHostAdapter {
: {}) : {})
} }
observedTools = mergeContinueTools(observedTools, [tool]) observedTools = mergeContinueTools(observedTools, [tool])
await runOptions.onEvent?.({ type: 'tool', tool }) await runOptions.onEvent?.({
type: 'tool',
tool: observedTools.find((item) => item.callId === tool.callId)!
})
} }
const pendingQuestion = state.goodbuddyQuestion const pendingQuestion = state.goodbuddyQuestion
if ( if (
+1 -1
View File
@@ -1005,7 +1005,7 @@ describe('ContinueAgentRuntime', () => {
type: 'tool', type: 'tool',
name: 'Bash', name: 'Bash',
state: 'completed', state: 'completed',
summary: 'Continue 工具:Bash', summary: 'npm test',
input: '{"command":"npm test"}', input: '{"command":"npm test"}',
output: 'Tests passed' output: 'Tests passed'
}), }),
+2 -1
View File
@@ -1,4 +1,5 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import type { import type {
AgentQuestionAnswer, AgentQuestionAnswer,
AgentEvent, AgentEvent,
@@ -88,7 +89,7 @@ function toContinueToolEvent(
: terminalize && tool.state !== 'completed' : terminalize && tool.state !== 'completed'
? 'failed' ? 'failed'
: tool.state, : tool.state,
summary: `Continue 工具:${tool.name}`, summary: toolOperationSummary(tool.input, tool.summary) ?? `Continue 工具:${tool.name}`,
...(tool.input ? { input: tool.input } : {}), ...(tool.input ? { input: tool.input } : {}),
...(tool.output ? { output: tool.output } : {}), ...(tool.output ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {}) ...(tool.error ? { error: tool.error } : {})
@@ -35,6 +35,8 @@ import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol' import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
import { createOpenAIChatCompletionsUrl } from './openai-endpoint' import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
import { createModelRequestProbe } from '../../../tests/support/model-request-probe' import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
import { SelectedRuntimeManager } from './selected-runtime-manager'
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY' const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
const SKILL_CALL_ID = 'e2e-skill-call' const SKILL_CALL_ID = 'e2e-skill-call'
@@ -383,6 +385,49 @@ function createInProcessLaunch(
} }
describe('DeepSeek Harness real ACP control-plane E2E', () => { describe('DeepSeek Harness real ACP control-plane E2E', () => {
it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-')))
const workspace = join(root, 'workspace')
const dshHome = join(root, 'home')
await Promise.all([mkdir(workspace), mkdir(dshHome)])
const canonical = await realpath(workspace)
const mixed = `${root.replaceAll('\\', '/')}\\workspace`
const resolver = new ExecutionSpaceResolver()
const inProcess = createInProcessLaunch(dshHome, {
stream: () => textResponse('PATH_OK')
})
const launch = vi.fn(inProcess.launch)
const manager = new SelectedRuntimeManager(async (_selection, space) => {
if (space?.kind !== 'local') throw new Error('local workspace required')
return new DeepSeekHarnessRuntime({
defaultWorkspace: space.rootPath, baseUrl: 'https://api.deepseek.com',
model: 'path-test', launch,
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
})
})
try {
expect(mixed).not.toBe(canonical)
expect(await realpath(mixed)).toBe(canonical)
const first = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(mixed))
for (const [index, spelling] of [mixed, canonical].entries()) {
const runtime = await manager.getRuntime({ provider: 'deepseek-harness' }, resolver.resolveLocal(spelling))
expect(runtime).toBe(first)
const events = await collect(runtime.run({
requestId: `path-${index}`, conversationId: `path-${index}`,
workMode: 'ask', prompt: 'Return PATH_OK.'
}, AbortSignal.timeout(20_000)))
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(events.filter(event => event.type === 'text').map(event => event.delta).join('')).toBe('PATH_OK')
}
expect(launch).toHaveBeenCalledOnce()
expect(launch.mock.calls[0]?.[0].cwd).toBe(canonical)
} finally {
await manager.dispose()
await rm(root, { recursive: true, force: true })
}
})
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => { it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-'))) const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
const workspace = join(root, 'workspace') const workspace = join(root, 'workspace')
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { realpath } from 'node:fs/promises'
import type { RuntimeEvent } from './runtime' import type { RuntimeEvent } from './runtime'
import { import {
ModelToolProvider, ModelToolProvider,
@@ -243,7 +244,7 @@ function setup(
}) })
) )
const runtime = new DeepSeekHarnessRuntime({ const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: 'C:\\workspace', defaultWorkspace: process.cwd(),
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test', model: 'deepseek-test',
supportsImageInput: options.supportsImageInput, supportsImageInput: options.supportsImageInput,
@@ -474,6 +475,7 @@ describe('DeepSeekHarnessRuntime', () => {
}) })
const status = harness.runtime.getStatus() const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(10_001) await vi.advanceTimersByTimeAsync(10_001)
await expect(status).resolves.toMatchObject({ await expect(status).resolves.toMatchObject({
@@ -503,6 +505,7 @@ describe('DeepSeekHarnessRuntime', () => {
}) })
const status = harness.runtime.getStatus() const status = harness.runtime.getStatus()
await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(12_000) await vi.advanceTimersByTimeAsync(12_000)
await expect(status).resolves.toMatchObject({ await expect(status).resolves.toMatchObject({
@@ -674,7 +677,7 @@ describe('DeepSeekHarnessRuntime', () => {
harness.child.stdout harness.child.stdout
) )
expect(harness.launch).toHaveBeenCalledWith({ expect(harness.launch).toHaveBeenCalledWith({
cwd: 'C:\\workspace', cwd: await realpath(process.cwd()),
signal: expect.any(AbortSignal), signal: expect.any(AbortSignal),
baseUrl: 'https://api.deepseek.com', baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test', model: 'deepseek-test',
@@ -710,7 +713,12 @@ describe('DeepSeekHarnessRuntime', () => {
await harness.runtime.dispose() await harness.runtime.dispose()
}) })
it('keeps the original tool name on generic completion updates', async () => { it.each([
['completed', undefined, 'GoodBuddy'],
['failed', undefined, 'GoodBuddy'],
['completed', 'Search the release notes', 'Search the release notes'],
['failed', 'Search the release notes', 'Search the release notes']
] as const)('keeps tool input and summary on incremental %s updates with title %s', async (status, title, summary) => {
const harness = setup() const harness = setup()
const running = collect( const running = collect(
harness.runtime.run( harness.runtime.run(
@@ -725,6 +733,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call', sessionUpdate: 'tool_call',
toolCallId: 'call-web-search', toolCallId: 'call-web-search',
name: 'web_search', name: 'web_search',
title,
status: 'pending', status: 'pending',
rawInput: { query: 'GoodBuddy' } rawInput: { query: 'GoodBuddy' }
}) })
@@ -732,7 +741,7 @@ describe('DeepSeekHarnessRuntime', () => {
sessionUpdate: 'tool_call_update', sessionUpdate: 'tool_call_update',
toolCallId: 'call-web-search', toolCallId: 'call-web-search',
name: 'tool', name: 'tool',
status: 'completed', status,
rawOutput: 'search result' rawOutput: 'search result'
}) })
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
@@ -743,13 +752,16 @@ describe('DeepSeekHarnessRuntime', () => {
type: 'tool', type: 'tool',
callId: 'call-web-search', callId: 'call-web-search',
name: 'web_search', name: 'web_search',
state: 'pending' state: 'pending',
summary
}), }),
expect.objectContaining({ expect.objectContaining({
type: 'tool', type: 'tool',
callId: 'call-web-search', callId: 'call-web-search',
name: 'web_search', name: 'web_search',
state: 'completed' state: status,
summary,
input: expect.stringContaining('GoodBuddy')
}) })
]) ])
) )
+24 -9
View File
@@ -1,3 +1,5 @@
import { toolOperationSummary } from './tool-operation-summary'
import { realpath } from 'node:fs/promises'
import type { import type {
AgentRuntimeStatus, AgentRuntimeStatus,
ModelRequestHeaders, ModelRequestHeaders,
@@ -210,12 +212,16 @@ type ActiveRun = {
toolController: AbortController toolController: AbortController
authorize?: RuntimeAuthorizer authorize?: RuntimeAuthorizer
updates: AcpSessionNotification['update'][] updates: AcpSessionNotification['update'][]
toolNames: Map<string, string> toolNames: Map<
string,
{ name: string; input?: string; summary?: string; title?: string }
>
wake?: () => void wake?: () => void
closed: boolean closed: boolean
} }
type HarnessState = { type HarnessState = {
workspace: string
child: DeepSeekHarnessChild child: DeepSeekHarnessChild
connection: AcpConnection connection: AcpConnection
agent: AcpAgent agent: AcpAgent
@@ -778,9 +784,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
this.launchController = launchController this.launchController = launchController
let child: DeepSeekHarnessChild | undefined let child: DeepSeekHarnessChild | undefined
try { try {
const workspace = await realpath(this.options.defaultWorkspace)
child = await withTimeout( child = await withTimeout(
this.options.launch({ this.options.launch({
cwd: this.options.defaultWorkspace, cwd: workspace,
signal: launchController.signal, signal: launchController.signal,
baseUrl: this.options.baseUrl, baseUrl: this.options.baseUrl,
model: this.options.model, model: this.options.model,
@@ -977,6 +984,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
throw new Error('DeepSeek Harness ACP 客户端初始化失败') throw new Error('DeepSeek Harness ACP 客户端初始化失败')
} }
const stateWithoutCapabilities = { const stateWithoutCapabilities = {
workspace,
child, child,
connection, connection,
agent agent
@@ -1306,7 +1314,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
} }
const creation = state.agent const creation = state.agent
.newSession({ .newSession({
cwd: this.options.defaultWorkspace, cwd: state.workspace,
mcpServers: [] mcpServers: []
}) })
.then((response) => { .then((response) => {
@@ -1336,7 +1344,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
private toRuntimeEvent( private toRuntimeEvent(
requestId: string, requestId: string,
update: AcpSessionNotification['update'], update: AcpSessionNotification['update'],
toolNames: Map<string, string> toolNames: ActiveRun['toolNames']
): RuntimeEvent | undefined { ): RuntimeEvent | undefined {
if (update.goodBuddyEvent) { if (update.goodBuddyEvent) {
return this.toUsageEvent( return this.toUsageEvent(
@@ -1378,16 +1386,23 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
'DeepSeek Harness 工具' 'DeepSeek Harness 工具'
).slice(0, 200) ).slice(0, 200)
const callId = update.toolCallId.slice(0, 256) const callId = update.toolCallId.slice(0, 256)
const previous = toolNames.get(callId)
const name = const name =
reportedName === 'tool' reportedName === 'tool' || (!update.name && !update.title)
? toolNames.get(callId) ?? reportedName ? previous?.name ?? reportedName
: reportedName : reportedName
const title = toolOperationSummary(
undefined,
update.title === 'tool' || update.title === update.name || update.title === previous?.name
? undefined : update.title
) ?? previous?.title
const input = safeStringify(update.rawInput) ?? previous?.input
const summary = toolOperationSummary(update.rawInput, title) ?? previous?.summary
if (state === 'pending' || state === 'running') { if (state === 'pending' || state === 'running') {
toolNames.set(callId, name) toolNames.set(callId, { name, input, summary, title })
} else { } else {
toolNames.delete(callId) toolNames.delete(callId)
} }
const input = safeStringify(update.rawInput)
const output = safeStringify(update.rawOutput) const output = safeStringify(update.rawOutput)
return { return {
requestId, requestId,
@@ -1395,7 +1410,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
callId, callId,
name, name,
state, state,
summary: `DeepSeek Harness 工具:${name}`, summary: summary ?? `DeepSeek Harness 工具:${name}`,
...(input ? { input } : {}), ...(input ? { input } : {}),
...(output ? { output } : {}) ...(output ? { output } : {})
} }
+10 -5
View File
@@ -2473,7 +2473,10 @@ describe('ModelAgentRuntime', () => {
).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u) ).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u)
}) })
it('runs approved direct-model tools and returns their results to OpenAI', async () => { it.each([false, true])('runs approved tools with summaries from raw arguments (long input: %s)', async (longInput) => {
const args = longInput
? { content: 'x'.repeat(5_000), path: 'README.md' }
: { path: 'README.md' }
const responses = [ const responses = [
{ {
model: 'qwen3', model: 'qwen3',
@@ -2488,7 +2491,7 @@ describe('ModelAgentRuntime', () => {
type: 'function', type: 'function',
function: { function: {
name: 'workspace_read_text', name: 'workspace_read_text',
arguments: '{"path":"README.md"}' arguments: JSON.stringify(args)
} }
} }
] ]
@@ -2596,7 +2599,7 @@ describe('ModelAgentRuntime', () => {
) )
expect(toolProvider.getApproval).toHaveBeenCalledWith( expect(toolProvider.getApproval).toHaveBeenCalledWith(
expect.objectContaining({ name: 'workspace_read_text' }), expect.objectContaining({ name: 'workspace_read_text' }),
{ path: 'README.md' }, args,
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
conversationId: 'conversation-tools', conversationId: 'conversation-tools',
@@ -2605,7 +2608,7 @@ describe('ModelAgentRuntime', () => {
) )
expect(toolProvider.callTool).toHaveBeenCalledWith( expect(toolProvider.callTool).toHaveBeenCalledWith(
'workspace_read_text', 'workspace_read_text',
{ path: 'README.md' }, args,
expect.any(AbortSignal), expect.any(AbortSignal),
expect.objectContaining({ expect.objectContaining({
conversationId: 'conversation-tools', conversationId: 'conversation-tools',
@@ -2617,11 +2620,13 @@ describe('ModelAgentRuntime', () => {
.filter((event) => event.type === 'tool') .filter((event) => event.type === 'tool')
.map((event) => event.state) .map((event) => event.state)
).toEqual(['pending', 'running', 'completed']) ).toEqual(['pending', 'running', 'completed'])
expect(events.filter((event) => event.type === 'tool').map((event) => event.summary))
.toEqual(['README.md', 'README.md', 'README.md'])
expect(events).toContainEqual( expect(events).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'tool', type: 'tool',
state: 'completed', state: 'completed',
input: '{\n "path": "README.md"\n}', input: longInput ? expect.any(String) : '{\n "path": "README.md"\n}',
output: output:
'tool result\n\n[图片结果 1:image/png]' 'tool result\n\n[图片结果 1:image/png]'
}) })
+5 -3
View File
@@ -1,4 +1,5 @@
import { createHash, randomBytes } from 'node:crypto' import { createHash, randomBytes } from 'node:crypto'
import { toolOperationSummary } from './tool-operation-summary'
import { dirname } from 'node:path' import { dirname } from 'node:path'
import type { import type {
ApprovalDecision, ApprovalDecision,
@@ -3752,6 +3753,7 @@ export class ModelAgentRuntime implements AgentRuntime {
tool?.name !== 'subagent_delegate' tool?.name !== 'subagent_delegate'
const displayName = tool?.displayName ?? call.name.slice(0, 128) const displayName = tool?.displayName ?? call.name.slice(0, 128)
const input = boundedToolDetail(call.arguments, 4_000) const input = boundedToolDetail(call.arguments, 4_000)
const summary = toolOperationSummary(call.arguments) ?? `直连模型工具:${displayName}`
roundSummary.push( roundSummary.push(
[ [
`TOOL_CALL: ${displayName}`, `TOOL_CALL: ${displayName}`,
@@ -3767,7 +3769,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id, callId: call.id,
name: displayName, name: displayName,
state: 'pending', state: 'pending',
summary: `直连模型工具:${displayName}`, summary,
input input
} }
} }
@@ -3844,7 +3846,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id, callId: call.id,
name: displayName, name: displayName,
state: 'running', state: 'running',
summary: `正在执行直连模型工具:${displayName}`, summary,
input input
} }
} }
@@ -3925,7 +3927,7 @@ export class ModelAgentRuntime implements AgentRuntime {
callId: call.id, callId: call.id,
name: displayName, name: displayName,
state: 'completed', state: 'completed',
summary: `直连模型工具已完成:${displayName}`, summary,
input, input,
output: getToolResultPreview(result.parts) output: getToolResultPreview(result.parts)
} }
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { toolOperationSummary } from './tool-operation-summary'
describe('toolOperationSummary', () => {
it.each(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])(
'extracts %s without displaying file contents', (key) => {
expect(toolOperationSummary({ content: 'x'.repeat(10_000), [key]: 'src/main.ts' }))
.toBe('src/main.ts')
}
)
it.each([
[{ command: 'npm test\r\n-- --run', path: '/repo' }, 'npm test -- --run'],
[{ pattern: 'TODO', path: 'src', filePattern: '*.ts' }, 'TODO | src | *.ts'],
[{ query: 'latest release' }, 'latest release'],
[{ glob: '**/*.ts' }, '**/*.ts'],
[{ url: 'https://example.com' }, 'https://example.com']
])('extracts command, search and URL parameters', (args, expected) => {
expect(toolOperationSummary(JSON.stringify(args))).toBe(expected)
})
it.each([undefined, null, [], 12, 'broken JSON', '{"path":', '"text"', { content: 'body' }, { path: {}, command: 42 }])(
'falls back for invalid or irrelevant input %j', (input) => {
expect(toolOperationSummary(input)).toBeUndefined()
}
)
it('prefers a nonempty native title and bounds a single line to 240 characters', () => {
expect(toolOperationSummary({ path: 'file' }, 'Read\n native\tfile')).toBe('Read native file')
expect(toolOperationSummary({ path: 'file' }, ' \n ')).toBe('file')
expect(toolOperationSummary({ command: 'x'.repeat(500) })).toHaveLength(240)
expect(toolOperationSummary({}, 'x'.repeat(500))).toHaveLength(240)
})
it('can run in the Continue host bundle before input preview truncation', () => {
const summarize = new Function(`return (${toolOperationSummary.toString()})`)() as typeof toolOperationSummary
expect(summarize({ content: 'x'.repeat(10_000), filepath: 'report.md' })).toBe('report.md')
})
})
+34
View File
@@ -0,0 +1,34 @@
// Kept self-contained so the Continue host can use it before truncating input.
export function toolOperationSummary(
input: unknown,
title?: unknown
): string | undefined {
const singleLine = (value: unknown): string =>
typeof value === 'string'
? [...value].map((character) => {
const code = character.charCodeAt(0)
return code <= 31 || code === 127 ? ' ' : character
}).join('').replace(/\s+/gu, ' ').trim()
: ''
const nativeTitle = singleLine(title)
if (nativeTitle) return nativeTitle.slice(0, 240)
if (typeof input === 'string') {
try {
input = JSON.parse(input)
} catch {
return undefined
}
}
if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined
const args = input as Record<string, unknown>
const first = (keys: string[]): string =>
keys.map((key) => singleLine(args[key])).find(Boolean) ?? ''
const command = first(['command'])
const path = first(['path', 'filePath', 'file_path', 'filepath', 'dirpath'])
const search = first(['query', 'pattern', 'glob'])
const filter = first(['filePattern', 'include'])
const summary = command || (search
? [search, path, filter].filter(Boolean).join(' | ')
: path || first(['url']))
return summary ? summary.slice(0, 240) : undefined
}
+37 -23
View File
@@ -95,9 +95,12 @@ import {
setMagicNoteChecklistCompletion setMagicNoteChecklistCompletion
} from '../magic-notes/rich-content' } from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence' import { computeNextHeartbeatRun } from './heartbeat-recurrence'
import { SubagentProgressStorage } from './subagent-progress-storage' import {
SubagentProgressStorage,
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34 export const ASSISTANT_DATABASE_SCHEMA_VERSION = SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
export type RemoteTaskEventInput = { export type RemoteTaskEventInput = {
taskId: string taskId: string
@@ -5344,6 +5347,33 @@ export class AssistantDatabase {
database: DatabaseSync, database: DatabaseSync,
event: ValidatedRemoteTaskEvent event: ValidatedRemoteTaskEvent
): boolean { ): boolean {
const findExisting = (): {
id: number; task_id: string; kind: string; payload_json: string
} | undefined => database
.prepare(
`SELECT id, task_id, kind, payload_json
FROM task_events
WHERE remote_binding_id = ?
AND remote_operation_id = ?
AND remote_semantic_sequence = ?
AND remote_event_index = ?`
)
.get(
event.bindingId,
event.operationId,
event.semanticSequence,
event.eventIndex
) as
| {
id: number
task_id: string
kind: string
payload_json: string
}
| undefined
// Duplicate replay must not recreate a completed task's released write cache.
let existing = findExisting()
if (!existing) {
const payloadJson = this.subagentProgress!.serialize( const payloadJson = this.subagentProgress!.serialize(
event.taskId, event.kind, JSON.parse(event.payloadJson) event.taskId, event.kind, JSON.parse(event.payloadJson)
) )
@@ -5371,28 +5401,8 @@ export class AssistantDatabase {
) )
return true return true
} }
const existing = database existing = findExisting()
.prepare(
`SELECT id, task_id, kind, payload_json
FROM task_events
WHERE remote_binding_id = ?
AND remote_operation_id = ?
AND remote_semantic_sequence = ?
AND remote_event_index = ?`
)
.get(
event.bindingId,
event.operationId,
event.semanticSequence,
event.eventIndex
) as
| {
id: number
task_id: string
kind: string
payload_json: string
} }
| undefined
if ( if (
existing?.task_id === event.taskId && existing?.task_id === event.taskId &&
existing.kind === event.kind && existing.kind === event.kind &&
@@ -9681,6 +9691,10 @@ export class AssistantDatabase {
COMMIT; COMMIT;
`) `)
} }
if (version.user_version < SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION) {
// Startup's worker has already compacted full and schema-34 delta events.
database.exec(`PRAGMA user_version = ${SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION}`)
}
} }
private requireDatabase(): DatabaseSync { private requireDatabase(): DatabaseSync {
@@ -7,11 +7,12 @@ import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts' import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { SubagentEvent } from '../../shared/contracts' import type { SubagentEvent } from '../../shared/contracts'
import { AssistantDatabase } from './assistant-database' import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
import { upgradeAssistantStorage } from './assistant-storage-upgrade' import { upgradeAssistantStorage } from './assistant-storage-upgrade'
import { import {
compactSubagentPayload, compactSubagentPayload,
restoreSubagentPayload restoreSubagentPayload,
SubagentProgressStorage
} from './subagent-progress-storage' } from './subagent-progress-storage'
const directories: string[] = [] const directories: string[] = []
@@ -78,7 +79,10 @@ describe('subagent progress storage', () => {
const event = subagent(fixtureData.taskId) const event = subagent(fixtureData.taskId)
const tool: ConversationMessageBlock = { const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool', id: randomUUID(), type: 'tool',
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) } tool: {
name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'x'.repeat(200_000), error: undefined
}
} }
const textId = randomUUID() const textId = randomUUID()
try { try {
@@ -102,23 +106,116 @@ describe('subagent progress storage', () => {
const state = new Map<string, ConversationMessageBlock[]>() const state = new Map<string, ConversationMessageBlock[]>()
let restored: unknown let restored: unknown
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state) for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
expect(restored).toEqual(event) expect(restored).toStrictEqual(JSON.parse(JSON.stringify(event)))
} finally { inspection.close() } } finally { inspection.close() }
} finally { database.close() } } finally { database.close() }
}) })
it('ignores omitted optional tool fields but preserves every actual field change', () => {
const event = subagent(randomUUID())
const block: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
callId: 'read-1', name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'unchanged', error: undefined
}
}
const state = new Map<string, ConversationMessageBlock[]>([
[event.childTaskId, JSON.parse(JSON.stringify([block]))]
])
expect(compactSubagentPayload({ ...event, progress: [block] }, state))
.toMatchObject({ progressUpdates: [] })
for (const change of [
{ callId: 'read-2' }, { name: 'write' }, { summary: 'Updated' },
{ state: 'failed' as const }, { input: '' }, { output: 'changed' },
{ error: 'failed' }, { output: undefined }
]) {
const updated = { ...block, tool: { ...block.tool, ...change } }
const compact = compactSubagentPayload({ ...event, progress: [updated] }, state)
expect(compact).toMatchObject({
progressUpdates: [{ type: 'upsert', block: updated }]
})
}
})
it('releases terminal task caches without clearing peers, and rebuilds after rollback', () => {
const database = new DatabaseSync(':memory:')
database.exec(`CREATE TABLE task_events(
id INTEGER PRIMARY KEY, task_id TEXT, kind TEXT, payload_json TEXT
)`)
const storage = new SubagentProgressStorage(database)
const caches = (storage as unknown as { tasks: Map<string, unknown> }).tasks
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
name: 'read', state: 'completed', summary: 'Read',
output: 'x'.repeat(200_000), error: undefined
}
}
let id = 0
const append = (taskId: string, kind: string, event: unknown) => {
const payload = storage.serialize(taskId, kind, event)
database.prepare('INSERT INTO task_events VALUES (?, ?, ?, ?)')
.run(++id, taskId, kind, payload)
storage.inserted(taskId, kind, id, payload)
return JSON.parse(payload) as { progressUpdates: unknown[] }
}
try {
const peer = subagent(randomUUID())
append(peer.requestId, 'subagent', { ...peer, progress: [tool] })
for (const [kind, payload] of [
['done', {}], ['error', {}],
...['completed', 'failed', 'cancelled', 'interrupted']
.map((status) => ['status', { status }])
] as Array<[string, unknown]>) {
const event = { ...subagent(randomUUID()), progress: [tool] }
append(event.requestId, 'subagent', event)
expect(caches.size).toBe(2)
database.exec('BEGIN')
append(event.requestId, kind, payload)
expect(caches.has(event.requestId)).toBe(false)
expect(caches.has(peer.requestId)).toBe(true)
database.exec('ROLLBACK')
expect(append(event.requestId, 'subagent', event).progressUpdates).toEqual([])
append(event.requestId, kind, payload)
expect(caches.size).toBe(1)
}
for (let index = 0; index < 40; index++) {
const event = subagent(randomUUID())
append(event.requestId, 'subagent', { ...event, progress: [tool] })
expect(caches.size).toBeLessThanOrEqual(16)
}
} finally { database.close() }
})
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => { it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
const { database, taskId, path } = await fixture() const { database, taskId, path } = await fixture()
const event = subagent(taskId) const event = subagent(taskId)
const textId = randomUUID() const textId = randomUUID()
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: {
name: 'read', summary: 'Read', state: 'completed',
input: undefined, output: 'x'.repeat(20_000), error: undefined
}
}
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' } const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] } const first = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed' }] }
const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] } const second = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed tail' }] }
try { try {
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true) expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true) expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false) expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts') expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
database.appendTaskEvent(taskId, 'done', {})
const caches = (database as unknown as {
subagentProgress: { tasks: Map<string, unknown> }
}).subagentProgress.tasks
expect(caches.size).toBe(0)
expect(database.appendRemoteTaskEventOnce({
...provenance, semanticSequence: '2', payload: second
})).toBe(false)
expect(caches.size).toBe(0)
} finally { database.close() } } finally { database.close() }
const reopened = new AssistantDatabase(path) const reopened = new AssistantDatabase(path)
reopened.initialize(tmpdir()) reopened.initialize(tmpdir())
@@ -208,7 +305,7 @@ describe('subagent progress storage', () => {
} finally { inspection.close(); database.close() } } finally { inspection.close(); database.close() }
}) })
it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => { it.each([33, 34])('converts schema %i rows, resumes and shrinks without losing provenance', async (sourceVersion) => {
const { database, taskId, path } = await fixture() const { database, taskId, path } = await fixture()
database.close() database.close()
const event = subagent(taskId) const event = subagent(taskId)
@@ -220,10 +317,12 @@ describe('subagent progress storage', () => {
const legacy = new DatabaseSync(path) const legacy = new DatabaseSync(path)
const expected: SubagentEvent[] = [] const expected: SubagentEvent[] = []
try { try {
legacy.exec('PRAGMA user_version = 33; BEGIN') legacy.exec(`PRAGMA user_version = ${sourceVersion}; BEGIN`)
const insert = legacy.prepare( const insert = legacy.prepare(
`INSERT INTO task_events(task_id, kind, payload_json, created_at) `INSERT INTO task_events(
VALUES (?, 'subagent', ?, ?)` task_id, kind, payload_json, created_at, remote_binding_id,
remote_operation_id, remote_semantic_sequence, remote_event_index
) VALUES (?, 'subagent', ?, ?, 'binding', 'operation', ?, 0)`
) )
for (let index = 0; index < 150; index++) { for (let index = 0; index < 150; index++) {
const full = { const full = {
@@ -231,9 +330,17 @@ describe('subagent progress storage', () => {
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }] progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
} }
expected.push(full) expected.push(full)
insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z') const { progress, ...metadata } = full
const stored = sourceVersion === 34 ? {
...metadata,
progressUpdates: progress.map((block) => ({ type: 'upsert', block }))
} : full
insert.run(taskId, JSON.stringify(stored), '2026-09-13T00:00:00Z', String(index + 1))
} }
legacy.exec('COMMIT') legacy.exec(`COMMIT;
CREATE TRIGGER reject_unchanged_payload BEFORE UPDATE OF payload_json ON task_events
WHEN OLD.payload_json = NEW.payload_json
BEGIN SELECT RAISE(ABORT, 'unchanged payload rewritten'); END;`)
} finally { legacy.close() } } finally { legacy.close() }
const before = (await stat(path)).size const before = (await stat(path)).size
expect(before).toBeGreaterThan(40_000_000) expect(before).toBeGreaterThan(40_000_000)
@@ -248,13 +355,25 @@ describe('subagent progress storage', () => {
try { try {
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
const rows = inspected.prepare( const rows = inspected.prepare(
`SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id` `SELECT id, task_id, payload_json, created_at, remote_binding_id,
).all() as Array<{ payload_json: string; created_at: string }> remote_operation_id, remote_semantic_sequence, remote_event_index
FROM task_events WHERE kind = 'subagent' ORDER BY id`
).all() as Array<{
id: number; task_id: string; payload_json: string; created_at: string
remote_binding_id: string; remote_operation_id: string
remote_semantic_sequence: string; remote_event_index: number
}>
expect(rows).toHaveLength(expected.length) expect(rows).toHaveLength(expected.length)
const blocks = new Map<string, ConversationMessageBlock[]>() const blocks = new Map<string, ConversationMessageBlock[]>()
rows.forEach((row, index) => { rows.forEach((row, index) => {
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index]) expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
expect(row.created_at).toBe('2026-09-13T00:00:00Z') expect(row.created_at).toBe('2026-09-13T00:00:00Z')
expect(row.task_id).toBe(taskId)
expect(row.remote_binding_id).toBe('binding')
expect(row.remote_operation_id).toBe('operation')
expect(row.remote_semantic_sequence).toBe(String(index + 1))
expect(row.remote_event_index).toBe(0)
if (index > 0) expect(row.id).toBe(rows[index - 1]!.id + 1)
}) })
} finally { inspected.close() } } finally { inspected.close() }
const reopened = new AssistantDatabase(path) const reopened = new AssistantDatabase(path)
@@ -263,5 +382,47 @@ describe('subagent progress storage', () => {
const messages: unknown[] = [] const messages: unknown[] = []
upgradeAssistantStorage(path, (value) => messages.push(value)) upgradeAssistantStorage(path, (value) => messages.push(value))
expect(messages).toEqual([]) expect(messages).toEqual([])
const latest = new DatabaseSync(path)
try {
expect(latest.prepare('PRAGMA user_version').get()).toEqual({
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
})
} finally { latest.close() }
}, 30_000) }, 30_000)
it('recompacts mixed full and delta events without changing corrections, resets or child state', () => {
const first = subagent(randomUUID())
const second = { ...subagent(first.requestId), state: 'completed' as const, output: 'Done' }
const textId = randomUUID()
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: { name: 'read', state: 'completed', summary: 'Read', output: 'payload' }
}
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
const stored = [
{ ...first, progress: [tool, text] },
{ ...second, progressUpdates: [{ type: 'upsert', block: text }] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'upsert', block: tool },
{ type: 'append', id: textId, blockType: 'text', delta: ' tail' }
] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'upsert', block: { ...text, content: 'corrected' } }
] },
{ ...first, progress: undefined, progressUpdates: [
{ type: 'reset', blocks: [text, tool] }
] },
{ ...second, progressUpdates: [] },
{ ...first, progress: [] },
{ ...first, progress: [text] }
]
const oldState = new Map<string, ConversationMessageBlock[]>()
const newState = new Map<string, ConversationMessageBlock[]>()
for (const input of stored) {
const event = JSON.parse(JSON.stringify(input))
const compact = compactSubagentPayload(event, oldState)
const expected = restoreSubagentPayload(event, oldState)
expect(restoreSubagentPayload(compact, newState)).toStrictEqual(expected)
}
})
}) })
@@ -4,7 +4,8 @@ import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts' import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
import { import {
compactSubagentPayload, compactSubagentPayload,
restoreSubagentPayload restoreSubagentPayload,
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage' } from './subagent-progress-storage'
export function upgradeAssistantStorage( export function upgradeAssistantStorage(
@@ -21,7 +22,10 @@ export function upgradeAssistantStorage(
const version = database.prepare('PRAGMA user_version').get() as { const version = database.prepare('PRAGMA user_version').get() as {
user_version: number user_version: number
} }
if (version.user_version < 1 || version.user_version >= 34) return if (
version.user_version < 1 ||
version.user_version >= SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
) return
checkCancelled() checkCancelled()
const progress: AssistantStorageProgress = { const progress: AssistantStorageProgress = {
stage: 'scanning', processed: 0, total: 0, bytesBefore stage: 'scanning', processed: 0, total: 0, bytesBefore
@@ -65,8 +69,9 @@ export function upgradeAssistantStorage(
const compact = compactSubagentPayload(payload, blocks) const compact = compactSubagentPayload(payload, blocks)
// Replay mixed old/new rows on retry. Never delete events or provenance. // Replay mixed old/new rows on retry. Never delete events or provenance.
restoreSubagentPayload(payload, blocks) restoreSubagentPayload(payload, blocks)
if (compact !== payload) { const compactJson = JSON.stringify(compact)
update.run(JSON.stringify(compact), row.id) if (compactJson !== row.payload_json) {
update.run(compactJson, row.id)
} }
afterId = row.id afterId = row.id
} }
@@ -4,9 +4,28 @@ import type { DatabaseSync } from 'node:sqlite'
import { import {
assistantIdSchema, assistantIdSchema,
conversationMessageBlockSchema, conversationMessageBlockSchema,
type ConversationMessageBlock conversationToolActivitySchema,
type ConversationMessageBlock,
type ConversationToolActivity
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
export const SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION = 35
const toolFields = Object.keys(conversationToolActivitySchema.shape) as
Array<keyof ConversationToolActivity>
function sameProgressBlock(
previous: ConversationMessageBlock,
next: ConversationMessageBlock
): boolean {
if (previous.type === 'tool' && next.type === 'tool') {
// JSON omits undefined optional fields. Compare their values, not key presence.
return previous.id === next.id &&
toolFields.every((field) => previous.tool[field] === next.tool[field])
}
return isDeepStrictEqual(previous, next)
}
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [ export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
z.object({ z.object({
type: z.literal('append'), type: z.literal('append'),
@@ -89,7 +108,7 @@ export function diffSubagentProgress(
type: 'append', id: block.id, blockType: block.type, delta type: 'append', id: block.id, blockType: block.type, delta
}) })
} }
} else if (!prior || !isDeepStrictEqual(prior, block)) { } else if (!prior || !sameProgressBlock(prior, block)) {
updates.push({ type: 'upsert', block }) updates.push({ type: 'upsert', block })
} }
} }
@@ -114,12 +133,15 @@ export function compactSubagentPayload(
progress: ReadonlyMap<string, ConversationMessageBlock[]> progress: ReadonlyMap<string, ConversationMessageBlock[]>
): unknown { ): unknown {
const event = asSubagent(payload) const event = asSubagent(payload)
if (!event || !Array.isArray(event.progress)) return payload if (!event || (
!Array.isArray(event.progress) && !Array.isArray(event.progressUpdates)
)) return payload
const previous = progress.get(event.childTaskId) ?? []
const compact = { ...event } const compact = { ...event }
delete compact.progress delete compact.progress
compact.progressUpdates = diffSubagentProgress( compact.progressUpdates = diffSubagentProgress(
progress.get(event.childTaskId) ?? [], previous,
event.progress applySubagentProgress(previous, event) ?? []
) )
return compact return compact
} }
@@ -158,7 +180,17 @@ export class SubagentProgressStorage {
} }
inserted(taskId: string, kind: string, id: number, payloadJson: string): void { inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
if (kind !== 'subagent') return if (kind !== 'subagent') {
if (
kind === 'done' || kind === 'error' ||
(kind === 'status' && [
'completed', 'failed', 'cancelled', 'interrupted'
].includes((JSON.parse(payloadJson) as { status?: string }).status ?? ''))
) {
this.tasks.delete(taskId)
}
return
}
const task = this.tasks.get(taskId) const task = this.tasks.get(taskId)
if (!task) return if (!task) return
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks) restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/renderer/src/test-setup.ts'], setupFiles: ['./src/renderer/src/test-setup.ts'],
exclude: [ exclude: [
'.agent-resources/**',
'shareserver/**', 'shareserver/**',
'**/node_modules/**', '**/node_modules/**',
'**/dist/**', '**/dist/**',