diff --git a/FEATURES.md b/FEATURES.md index 036de2c8..251da032 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -116,7 +116,8 @@ otherwise. The first upgrade automatically converts existing history and reclaims disk space, with visible progress and quit/retry support. Chats, execution details, 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). - [x] **Direct model Runtime**: Supports question answering, knowledge diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 9ac1413e..da5ae53d 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -32,7 +32,7 @@ ### 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] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 768aa1b9..885edaf7 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -631,6 +631,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 普通工具在主会话和子 Agent 过程中使用同一轻量折叠记录:不显示外层“工具执行”标题、计数、背景和边框,保留无障碍区域名称。记录纵向左对齐,头部按内容收宽且不超过消息宽度;展开箭头在左,工具名作为主标题,有额外信息的单行摘要弱化显示。右侧以绿色对勾显示成功完成,以图标显示运行和待执行状态,提供悬停文字和无障碍名称;失败和可恢复状态保留文字。仅重复工具名和执行状态的已知 Runtime 模板摘要在头部与详情中均隐藏;刷新重试、审批等额外提示保留。默认折叠,状态和输出更新不改变用户的展开选择;折叠时露出一行错误,展开详情使用消息可用宽度、浅背景和左侧细线,按错误、结果、独立折叠的输入参数阅读。详情保留原始文本,完整 JSON 输入可缩进显示,复制仍使用收到的原文,并通过应用通知反馈。详情工具栏只保留复制按钮,文本默认自动换行,长内容在固定最大高度内滚动,不提供换行切换和展开全部按钮。 - OpenCode 主会话和子 Agent 的工具记录均保留 Runtime 提供的操作标题作为摘要,显示文件路径、命令或操作描述;本地 SDK 标题去除首尾空白后最多保留 240 字符,没有标题时沿用工具名回退规则。主会话工具失败或中断时保留已收到的标题;远端 ACP 继续在记录名称中显示其提供的标题。 - 工具取消后保留“已取消”文字,避免与失败图标混淆。 +- Continue、DeepSeek Harness 和直连模型的工具名后显示单行操作摘要,内容提取、长度与完成态保留规则见[工具操作摘要](./docs/features/assistant-workbar/runtime-interactions.md#工具操作摘要)。 - Agent 回复进行中不能禁用普通消息发送。新消息与到期 Scheduled Task 共用 Conversation 级待发送队列。队列位于 Composer 容器之外、与输入框等宽并保持独立间距;有待发送项时直接显示无标题栏的极简列表,空队列不保留占位。每项固定为一行,按进入顺序显示轻量来源图标和截断摘要;普通消息使用消息图标,Scheduled Task 统一使用淡出时钟图标。 - 待发送项默认在当前回复结束后顺序执行。每项提供明确的“立即中断并插入”(空闲时为“立即运行”)和删除操作;前者先取消当前 Conversation 的活动执行,再将所选项提升为下一项,不允许同一 Conversation 并发写入时间线。 - 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。 diff --git a/docs/development/database-migrations.md b/docs/development/database-migrations.md index e85bc3b3..c36d351f 100644 --- a/docs/development/database-migrations.md +++ b/docs/development/database-migrations.md @@ -37,6 +37,9 @@ 覆盖正常转换、无变化记录、重复打开、内容修订、失败事务和退出续跑。数据库大小问题 必须同时验证新增写入的增长量、旧记录可还原性以及文件实际缩小,而不只检查 schema。 +对象比较必须覆盖真实生产形状和 JSON 往返,尤其是可选字段值为 `undefined` 时与字段缺省 +的差异。再次修复已发布迁移产生的数据时,提高目标 schema,并覆盖已升级用户的入口; +不能只处理尚未迁移的旧库。 ### 真实旧库 @@ -52,6 +55,9 @@ 源 schema 与目标 schema。确认原件和备份未被改写。 6. 运行 `npm test`、`npm run typecheck`、`npm run lint`;生产构建变化按开发验证规则 检查,发布准备则遵循[发布手册](./release-runbook.md),不重复本地打包。 +7. 用真实模型模拟正常用户任务,允许主代理与子代理按需调用工具、修改独立测试项目、 + 运行测试和处理失败;只读纯文字模型调用不能证明工具事件增长正常。记录实际提供商 + 请求数与统计范围,不把 Runtime 用量上报数当成全部 HTTP 请求数。 ## 发布与维护 @@ -65,3 +71,5 @@ 表示,并回收空间。具体规则见 [执行记录存储与升级回收](../features/assistant-workbar/execution-history-storage.md), 数据保留与真实旧库验证见[工作栏进度](../features/assistant-workbar/progress.md)。 +- 当前未发布源码:schema 35 同时整理旧快照和 schema 34 的重复工具块,工具字段按 + JSON 可保存的值比较,不因 `undefined` 被省略而误判变化。 diff --git a/docs/features/assistant-workbar/execution-history-storage.md b/docs/features/assistant-workbar/execution-history-storage.md index a072226a..d430ba7a 100644 --- a/docs/features/assistant-workbar/execution-history-storage.md +++ b/docs/features/assistant-workbar/execution-history-storage.md @@ -19,7 +19,9 @@ - `messages.metadata_json` 继续保存供会话显示的完整子任务过程。 - `task_events` 的 `subagent` 内容由共享 `SubagentProgressStorage` 编码。 `progressUpdates` 只保存文字追加、变更的块,或确有删除/重排时的完整重置。 - 完全相同的过程使用空更新数组。其他事件内容不改变。 + 完全相同的过程使用空更新数组。工具字段按值比较,缺省字段与 `undefined` 等价, + 不因 JSON 省略空字段而重复写入整个工具结果;空字符串与实际输出/错误变化仍保留。 + 其他事件内容不改变。 - 每个任务内按事件 ID 顺序、按 `childTaskId` 独立还原过程。事件 ID、顺序、状态、 最终输出、错误、时间及远程来源字段均保留,不按天数裁剪或删除旧事件。 - Runtime、Agent transcript、Preload 和 Renderer 的公共 `SubagentEvent` 仍传完整 @@ -27,6 +29,9 @@ - 远程消息投影与事件插入仍共用 SQLite 事务。重放时还原已保存内容进行等值校验, 相同来源重复事件不重复投影,不同内容继续报冲突。内存过程缓存通过已提交事件 ID 核对;事务回滚或重新打开后从数据库重建,不保存独立快照或回收日志。 +- 存储层最多缓存 16 个任务;收到任务完成、错误、取消或中断的终态记录后立即移除该任务 + 的缓存引用,不影响其他会话。若事务随后回滚或仍收到该任务的后续事件,按已提交记录重建。 + 释放引用不等于进程 RSS 立即下降,垃圾回收和 Runtime 自身会话缓存另按生命周期回收。 ## 旧库升级 @@ -35,19 +40,25 @@ 自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史 快照在过小缓存中反复读取;不以关闭事务或同步落盘换取速度。 -转换可以混合读取旧完整记录与已经转换的记录,因此退出、磁盘写入失败后可以重新扫描 -并继续,不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非 +转换可以混合读取旧完整记录与已经转换的记录,也会重新编码 schema 34 中重复的工具块 +更新。因此退出、磁盘写入失败后可以重新扫描并继续;已是相同 JSON 的记录不重复更新, +不建立额外的恢复状态表。最后运行 SQLite `VACUUM` 回收已释放的空间,而非 在转换前复制整份膨胀内容。回收仍需要额外可用磁盘空间,失败保留已提交转换并允许重试。 -业务初始化随后将数据库升级为 schema 34,并建立子任务事件的部分索引。 +当前源码的业务初始化随后将数据库升级为 schema 35;schema 34 用户也会执行本次整理, +成功后后续启动直接跳过。继续复用 schema 34 的子任务事件部分索引和同一差量表示。 低于该 schema 的旧版客户端不能重新打开已经升级的数据库。需要回退客户端时,必须使用 升级前备份,不要修改 `user_version` 强行绕过版本检查。 ## 验证要求 - 使用可还原的合成过程验证文字追加、内容修订、块重排、清空和多次打开。 +- 工具 fixture 必须含生产对象会携带的 `undefined` 可选字段,并经过实际 JSON 写入/重读。 + 同时验证旧完整事件和 schema 34 差量事件的整理、混合重试及无变化记录不被重写。 - 验证远程重复事件、冲突事件,以及消息更新失败时事件和过程缓存的事务回滚。 - 在真实旧库的已核验备份副本上运行 portable 的正常启动入口,核对事件逐条等价、 其他用户表内容、数据库完整性和实际文件大小,不能只统计空闲页或只测试内存 fixture。 -- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。具体证据见 +- 同时验证真实本地 Runtime 和共享 Linux Host 的桌面持久化路径。真实场景必须让主代理 + 与子代理按正常任务需要读取、修改文件和执行测试,不能用“子代理不使用工具”的纯文字 + 场景代替工具存储验收;记录实际请求数、有效变化、重复工具块及还原结果。具体证据见 [实现与验证进度](./progress.md)。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index dafa9ea7..11b2f440 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -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 执行记录去重与大库升级 - 实现与兼容边界见[执行记录存储与升级回收](./execution-history-storage.md),通用开发 @@ -31,7 +120,8 @@ 实包开发验证通过。0.13.1 版本与发布说明变更后的本地最终验证被用户主动取消,按要求 不再追加本地测试,候选的最终全量测试、类型、lint 与生产构建由 main CI 判定。 - 用户批准 0.13.1 中英文说明及仅标准六平台发布;Agent 保持 0.11.24。 - 截至本记录尚未提交或打标签,准确候选提交、main CI 和原生发布结果待发布流程确认。 + 已发布提交 `4937720` 和不可变标签 `v0.13.1`,main CI 与六平台发布通过; + 后续真实工具场景的遗漏及补修见本页 schema 35 记录。 ## 2026-09-13 原生浏览器与应用浮层修复 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index 613ca77b..4b94a049 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -57,6 +57,21 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答 - 远端最终启动配置由[远程主机技术设计](../remote-host/technical-design.md)定义, 模型桥生成最终配置时同样保留上述快照策略。 +## 工具操作摘要 + +工具记录沿用 [UI 设计系统](../../../UI-DESIGN.md)的折叠布局。Continue、DeepSeek Harness +和直连模型共用 `toolOperationSummary`:优先使用有效原生标题,否则从参数提取命令、 +文件路径、搜索条件或 URL,合并空白后最多显示 240 字符。无法识别时保留原有工具名 +回退规则;文件正文、整份参数 JSON 不进入标题,参数详情仍可展开查看。 + +Continue 在宿主截断输入前提取摘要,流式完成与失败事件使用合并后的输入和摘要。 +DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省略输入时仍能显示操作内容。 +直连模型从原始参数生成一次摘要,在待执行、执行中和完成时复用;审批失败、拒绝和 +执行失败、刷新重试仍保留已有提示。 + +本机 OpenCode 沿用原生操作标题。远端 ACP 独立使用原生标题作为记录名称,不经过上述 +本地转换,本次没有修改 Agent、远程桥或协议。 + ## 消息底部请求状态 - 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示 diff --git a/docs/features/deepseek-harness/README.md b/docs/features/deepseek-harness/README.md index 17545fb1..ae3101ba 100644 --- a/docs/features/deepseek-harness/README.md +++ b/docs/features/deepseek-harness/README.md @@ -8,5 +8,6 @@ | 文档 | 权威职责 | | --- | --- | | [技术设计](./technical-design.md) | Runtime 产品与技术方案、协议、插件、打包、测试和验收 | +| [实现进度](./progress.md) | Windows 工作区路径修复及验证记录 | 现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。 diff --git a/docs/features/deepseek-harness/progress.md b/docs/features/deepseek-harness/progress.md new file mode 100644 index 00000000..602070dd --- /dev/null +++ b/docs/features/deepseek-harness/progress.md @@ -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 实测。 diff --git a/docs/features/deepseek-harness/technical-design.md b/docs/features/deepseek-harness/technical-design.md index ff90a5b6..bd0f632f 100644 --- a/docs/features/deepseek-harness/technical-design.md +++ b/docs/features/deepseek-harness/technical-design.md @@ -121,6 +121,12 @@ GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不 模型名称、服务地址、工作区、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 双层内部控制面 Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不能代替进程内的 Session/Tool 适配层: @@ -307,6 +313,7 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段: - `text`:已提交的助手文本分片。 - `reasoning`:可选的有界推理摘要分片。 - `tool`:工具 ID、名称、状态和有界输入/输出摘要。 +- 操作标题与增量完成事件的参数保留遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),本机适配器按调用 ID 合并,详情上限保持不变。 - `model-usage`:模型、提供方、输入、输出和缓存 Token。 - `done`:停止原因和 Session ID。 diff --git a/docs/features/direct-model-agent/ui-design.md b/docs/features/direct-model-agent/ui-design.md index 65034cf8..b3371847 100644 --- a/docs/features/direct-model-agent/ui-design.md +++ b/docs/features/direct-model-agent/ui-design.md @@ -53,6 +53,8 @@ Job、Subjob 和 Run 继续是内部对象,不创建树形 UI、独立路由 ### 3.1 标题与摘要 +正常执行状态的操作摘要遵循[工具操作摘要规则](../assistant-workbar/runtime-interactions.md#工具操作摘要),从原始调用参数提取并在完成后保留;异常状态继续显示原有提示。 + ```text 进程执行 PowerShell · packages/app · npm test diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index de008c31..02c036a1 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -44,7 +44,8 @@ profile 和凭据作为有界、逐 Prompt 的控制消息交给 Agent;该凭 桌面保存托管 SSH 子任务事件时,共用 [执行记录差量存储](../assistant-workbar/execution-history-storage.md)。 Agent transcript 与公共 Runtime 事件不改变;桌面的来源去重、消息投影事务和恢复序号 -继续保留。该存储修复不要求单独升级 Agent。 +继续保留。工具字段比较与 schema 35 对 schema 34 重复块的整理也在该共享桌面层完成; +不把工具过程改为仅存内存,不要求单独升级 Agent。 ```text Renderer diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md index 756eb6d2..68e73933 100644 --- a/docs/quality/recurring-defects.md +++ b/docs/quality/recurring-defects.md @@ -84,10 +84,15 @@ Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。 转换后回收空间,不靠删聊天或单独修本机数据库解决。 **防复发**:使用含大工具输出的多次更新检查总存储量,并验证重启、重复远程事件、 -消息事务回滚及旧库中断续跑。真实旧库需验证逐条等价和物理文件缩小。 +消息事务回滚及旧库中断续跑。工具对象须包含生产路径中的 `undefined` 可选字段, +经过真实 JSON 往返后仍识别为无变化,不能按对象键存在性比较。真实模型验证须包含 +子代理正常使用工具、修改文件及运行测试,不能只测禁用工具的文本流。 +真实旧库需验证逐条等价和物理文件缩小,已升级 schema 的重复数据同样要覆盖。 **历史**:2026-09-13 检查到 305,551 条子任务事件占用 90.11 GiB; 本机 `appendTaskEvent` 与远程 `insertRemoteTaskEventOnce` 同时存在重复保存路径。 +0.13.1 发布后的真实工具任务再次复现:同批 928 条事件有 7,787 次重复工具块写入; +只统一 JSON 空字段后,事件负载从 10,467,622 降为 452,391 字节,逐条还原一致。 ## 生产路径复用“测试”函数 diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index 7a6dfd44..ca60f390 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -1603,7 +1603,7 @@ describe('ContinueHostAdapter', () => { 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() let launchArgs: string[] = [] const permissionBodies: unknown[] = [] @@ -1681,7 +1681,7 @@ describe('ContinueHostAdapter', () => { toolCall: { function: { name: 'Bash' } }, - status: 'done' + status: toolState } ] } @@ -1695,7 +1695,7 @@ describe('ContinueHostAdapter', () => { type: 'tool', callId: 'call-1', name: 'Bash', - state: 'completed', + state: toolState, output: 'Tests passed\nAuthorization: Bearer secret-token' }, @@ -1737,9 +1737,10 @@ describe('ContinueHostAdapter', () => { { callId: 'call-1', name: 'Bash', - state: 'completed', + state: toolState, input: '{"command":"npm test","token":"secret-token"}', + summary: 'npm test', output: 'Tests passed\nAuthorization: Bearer secret-token' } @@ -1754,7 +1755,8 @@ describe('ContinueHostAdapter', () => { name: 'Bash', state: 'running', input: - '{"command":"npm test","token":"secret-token"}' + '{"command":"npm test","token":"secret-token"}', + summary: 'npm test' } }, { @@ -1762,9 +1764,11 @@ describe('ContinueHostAdapter', () => { tool: { callId: 'call-1', name: 'Bash', - state: 'completed', + state: toolState, 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' } diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index 45293f02..7ccb5c42 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -1,4 +1,5 @@ import spawn from 'cross-spawn' +import { toolOperationSummary } from './tool-operation-summary' import { createHash, randomBytes } from 'node:crypto' import { copyFile, @@ -112,6 +113,7 @@ const continueHostStreamEventSchema = z.discriminatedUnion('type', [ name: z.string().min(1).max(200), state: z.enum(['running', 'completed', 'failed']), input: z.string().max(4_000).optional(), + summary: z.string().max(240).optional(), output: z.string().max(16_000).optional(), error: z.string().max(1_000).optional() }) @@ -181,6 +183,7 @@ export type ContinueHostTool = { name: string state: 'pending' | 'running' | 'completed' | 'failed' input?: string + summary?: string output?: string error?: string } @@ -729,6 +732,10 @@ function extractContinueTools( callId, name: name.trim().slice(0, 200), state: normalizedState, + summary: toolOperationSummary( + (toolFunction as Record).arguments, + state.title + ), ...(input ? { input } : {}), ...(output ? { output } : {}), ...(error ? { error } : {}) @@ -749,6 +756,7 @@ function mergeContinueTools( ...previous, ...tool, input: tool.input ?? previous?.input, + summary: tool.summary ?? previous?.summary, output: tool.output ?? previous?.output, error: tool.error ?? previous?.error }) @@ -976,6 +984,11 @@ export class ContinueHostAdapter { serverStateMarker, 'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B=' ) + patched = replaceExactly( + patched, + 'state:"running",input:(()=>', + `state:"running",summary:(${toolOperationSummary.toString()})(l),input:(()=>` + ) patched = replaceExactly( patched, serverStateEndpointMarker, @@ -1682,6 +1695,7 @@ export class ContinueHostAdapter { callId: event.callId, name: event.name, state: event.state, + summary: toolOperationSummary(event.input, event.summary), ...(event.input ? { input: boundedToolDetail(event.input, 4_000) } : {}), @@ -1693,7 +1707,10 @@ export class ContinueHostAdapter { : {}) } 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 if ( diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index 2aa69550..097dd3fe 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -1005,7 +1005,7 @@ describe('ContinueAgentRuntime', () => { type: 'tool', name: 'Bash', state: 'completed', - summary: 'Continue 工具:Bash', + summary: 'npm test', input: '{"command":"npm test"}', output: 'Tests passed' }), diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index d2d7c832..50f7d757 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { toolOperationSummary } from './tool-operation-summary' import type { AgentQuestionAnswer, AgentEvent, @@ -88,7 +89,7 @@ function toContinueToolEvent( : terminalize && tool.state !== 'completed' ? 'failed' : tool.state, - summary: `Continue 工具:${tool.name}`, + summary: toolOperationSummary(tool.input, tool.summary) ?? `Continue 工具:${tool.name}`, ...(tool.input ? { input: tool.input } : {}), ...(tool.output ? { output: tool.output } : {}), ...(tool.error ? { error: tool.error } : {}) diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts index 4ef2960b..4e7f1909 100644 --- a/src/main/agent/deepseek-harness-acp-e2e.test.ts +++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts @@ -35,6 +35,8 @@ import { DshNpmExtensionInstaller } from './dsh-extension-marketplace' import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol' import { createOpenAIChatCompletionsUrl } from './openai-endpoint' 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 SKILL_CALL_ID = 'e2e-skill-call' @@ -383,6 +385,49 @@ function createInProcessLaunch( } 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 () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-'))) const workspace = join(root, 'workspace') diff --git a/src/main/agent/deepseek-harness-runtime.test.ts b/src/main/agent/deepseek-harness-runtime.test.ts index 78156a68..a9a23bba 100644 --- a/src/main/agent/deepseek-harness-runtime.test.ts +++ b/src/main/agent/deepseek-harness-runtime.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' +import { realpath } from 'node:fs/promises' import type { RuntimeEvent } from './runtime' import { ModelToolProvider, @@ -243,7 +244,7 @@ function setup( }) ) const runtime = new DeepSeekHarnessRuntime({ - defaultWorkspace: 'C:\\workspace', + defaultWorkspace: process.cwd(), baseUrl: 'https://api.deepseek.com', model: 'deepseek-test', supportsImageInput: options.supportsImageInput, @@ -474,6 +475,7 @@ describe('DeepSeekHarnessRuntime', () => { }) const status = harness.runtime.getStatus() + await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(10_001) await expect(status).resolves.toMatchObject({ @@ -503,6 +505,7 @@ describe('DeepSeekHarnessRuntime', () => { }) const status = harness.runtime.getStatus() + await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(12_000) await expect(status).resolves.toMatchObject({ @@ -674,7 +677,7 @@ describe('DeepSeekHarnessRuntime', () => { harness.child.stdout ) expect(harness.launch).toHaveBeenCalledWith({ - cwd: 'C:\\workspace', + cwd: await realpath(process.cwd()), signal: expect.any(AbortSignal), baseUrl: 'https://api.deepseek.com', model: 'deepseek-test', @@ -710,7 +713,12 @@ describe('DeepSeekHarnessRuntime', () => { 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 running = collect( harness.runtime.run( @@ -725,6 +733,7 @@ describe('DeepSeekHarnessRuntime', () => { sessionUpdate: 'tool_call', toolCallId: 'call-web-search', name: 'web_search', + title, status: 'pending', rawInput: { query: 'GoodBuddy' } }) @@ -732,7 +741,7 @@ describe('DeepSeekHarnessRuntime', () => { sessionUpdate: 'tool_call_update', toolCallId: 'call-web-search', name: 'tool', - status: 'completed', + status, rawOutput: 'search result' }) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) @@ -743,13 +752,16 @@ describe('DeepSeekHarnessRuntime', () => { type: 'tool', callId: 'call-web-search', name: 'web_search', - state: 'pending' + state: 'pending', + summary }), expect.objectContaining({ type: 'tool', callId: 'call-web-search', name: 'web_search', - state: 'completed' + state: status, + summary, + input: expect.stringContaining('GoodBuddy') }) ]) ) diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index 098a3a39..6f6756d4 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -1,3 +1,5 @@ +import { toolOperationSummary } from './tool-operation-summary' +import { realpath } from 'node:fs/promises' import type { AgentRuntimeStatus, ModelRequestHeaders, @@ -210,12 +212,16 @@ type ActiveRun = { toolController: AbortController authorize?: RuntimeAuthorizer updates: AcpSessionNotification['update'][] - toolNames: Map + toolNames: Map< + string, + { name: string; input?: string; summary?: string; title?: string } + > wake?: () => void closed: boolean } type HarnessState = { + workspace: string child: DeepSeekHarnessChild connection: AcpConnection agent: AcpAgent @@ -778,9 +784,10 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { this.launchController = launchController let child: DeepSeekHarnessChild | undefined try { + const workspace = await realpath(this.options.defaultWorkspace) child = await withTimeout( this.options.launch({ - cwd: this.options.defaultWorkspace, + cwd: workspace, signal: launchController.signal, baseUrl: this.options.baseUrl, model: this.options.model, @@ -977,6 +984,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { throw new Error('DeepSeek Harness ACP 客户端初始化失败') } const stateWithoutCapabilities = { + workspace, child, connection, agent @@ -1306,7 +1314,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } const creation = state.agent .newSession({ - cwd: this.options.defaultWorkspace, + cwd: state.workspace, mcpServers: [] }) .then((response) => { @@ -1336,7 +1344,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { private toRuntimeEvent( requestId: string, update: AcpSessionNotification['update'], - toolNames: Map + toolNames: ActiveRun['toolNames'] ): RuntimeEvent | undefined { if (update.goodBuddyEvent) { return this.toUsageEvent( @@ -1378,16 +1386,23 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { 'DeepSeek Harness 工具' ).slice(0, 200) const callId = update.toolCallId.slice(0, 256) + const previous = toolNames.get(callId) const name = - reportedName === 'tool' - ? toolNames.get(callId) ?? reportedName + reportedName === 'tool' || (!update.name && !update.title) + ? previous?.name ?? 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') { - toolNames.set(callId, name) + toolNames.set(callId, { name, input, summary, title }) } else { toolNames.delete(callId) } - const input = safeStringify(update.rawInput) const output = safeStringify(update.rawOutput) return { requestId, @@ -1395,7 +1410,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { callId, name, state, - summary: `DeepSeek Harness 工具:${name}`, + summary: summary ?? `DeepSeek Harness 工具:${name}`, ...(input ? { input } : {}), ...(output ? { output } : {}) } diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index d0e280c0..bdd74475 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -2473,7 +2473,10 @@ describe('ModelAgentRuntime', () => { ).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 = [ { model: 'qwen3', @@ -2488,7 +2491,7 @@ describe('ModelAgentRuntime', () => { type: 'function', function: { name: 'workspace_read_text', - arguments: '{"path":"README.md"}' + arguments: JSON.stringify(args) } } ] @@ -2596,7 +2599,7 @@ describe('ModelAgentRuntime', () => { ) expect(toolProvider.getApproval).toHaveBeenCalledWith( expect.objectContaining({ name: 'workspace_read_text' }), - { path: 'README.md' }, + args, expect.any(String), expect.objectContaining({ conversationId: 'conversation-tools', @@ -2605,7 +2608,7 @@ describe('ModelAgentRuntime', () => { ) expect(toolProvider.callTool).toHaveBeenCalledWith( 'workspace_read_text', - { path: 'README.md' }, + args, expect.any(AbortSignal), expect.objectContaining({ conversationId: 'conversation-tools', @@ -2617,11 +2620,13 @@ describe('ModelAgentRuntime', () => { .filter((event) => event.type === 'tool') .map((event) => event.state) ).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.objectContaining({ type: 'tool', state: 'completed', - input: '{\n "path": "README.md"\n}', + input: longInput ? expect.any(String) : '{\n "path": "README.md"\n}', output: 'tool result\n\n[图片结果 1:image/png]' }) diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index b7928873..63240458 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from 'node:crypto' +import { toolOperationSummary } from './tool-operation-summary' import { dirname } from 'node:path' import type { ApprovalDecision, @@ -3752,6 +3753,7 @@ export class ModelAgentRuntime implements AgentRuntime { tool?.name !== 'subagent_delegate' const displayName = tool?.displayName ?? call.name.slice(0, 128) const input = boundedToolDetail(call.arguments, 4_000) + const summary = toolOperationSummary(call.arguments) ?? `直连模型工具:${displayName}` roundSummary.push( [ `TOOL_CALL: ${displayName}`, @@ -3767,7 +3769,7 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'pending', - summary: `直连模型工具:${displayName}`, + summary, input } } @@ -3844,7 +3846,7 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'running', - summary: `正在执行直连模型工具:${displayName}`, + summary, input } } @@ -3925,7 +3927,7 @@ export class ModelAgentRuntime implements AgentRuntime { callId: call.id, name: displayName, state: 'completed', - summary: `直连模型工具已完成:${displayName}`, + summary, input, output: getToolResultPreview(result.parts) } diff --git a/src/main/agent/tool-operation-summary.test.ts b/src/main/agent/tool-operation-summary.test.ts new file mode 100644 index 00000000..ea967201 --- /dev/null +++ b/src/main/agent/tool-operation-summary.test.ts @@ -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') + }) +}) diff --git a/src/main/agent/tool-operation-summary.ts b/src/main/agent/tool-operation-summary.ts new file mode 100644 index 00000000..bc02c407 --- /dev/null +++ b/src/main/agent/tool-operation-summary.ts @@ -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 + 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 +} diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index d648075b..8c1df6d1 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -95,9 +95,12 @@ import { setMagicNoteChecklistCompletion } from '../magic-notes/rich-content' 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 = { taskId: string @@ -5344,34 +5347,9 @@ export class AssistantDatabase { database: DatabaseSync, event: ValidatedRemoteTaskEvent ): boolean { - const payloadJson = this.subagentProgress!.serialize( - event.taskId, event.kind, JSON.parse(event.payloadJson) - ) - const result = database - .prepare( - `INSERT OR IGNORE INTO task_events - (task_id, run_id, kind, payload_json, created_at, - remote_binding_id, remote_operation_id, - remote_semantic_sequence, remote_event_index) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)` - ) - .run( - event.taskId, - event.kind, - payloadJson, - new Date().toISOString(), - event.bindingId, - event.operationId, - event.semanticSequence, - event.eventIndex - ) - if (result.changes === 1) { - this.subagentProgress!.inserted( - event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson - ) - return true - } - const existing = database + 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 @@ -5393,6 +5371,38 @@ export class AssistantDatabase { 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( + event.taskId, event.kind, JSON.parse(event.payloadJson) + ) + const result = database + .prepare( + `INSERT OR IGNORE INTO task_events + (task_id, run_id, kind, payload_json, created_at, + remote_binding_id, remote_operation_id, + remote_semantic_sequence, remote_event_index) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)` + ) + .run( + event.taskId, + event.kind, + payloadJson, + new Date().toISOString(), + event.bindingId, + event.operationId, + event.semanticSequence, + event.eventIndex + ) + if (result.changes === 1) { + this.subagentProgress!.inserted( + event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson + ) + return true + } + existing = findExisting() + } if ( existing?.task_id === event.taskId && existing.kind === event.kind && @@ -9681,6 +9691,10 @@ export class AssistantDatabase { 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 { diff --git a/src/main/assistant/assistant-storage-upgrade.test.ts b/src/main/assistant/assistant-storage-upgrade.test.ts index c8547321..a620a48f 100644 --- a/src/main/assistant/assistant-storage-upgrade.test.ts +++ b/src/main/assistant/assistant-storage-upgrade.test.ts @@ -7,11 +7,12 @@ import { DatabaseSync } from 'node:sqlite' import { afterEach, describe, expect, it } from 'vitest' import type { ConversationMessageBlock } from '../../shared/assistant-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 { compactSubagentPayload, - restoreSubagentPayload + restoreSubagentPayload, + SubagentProgressStorage } from './subagent-progress-storage' const directories: string[] = [] @@ -78,7 +79,10 @@ describe('subagent progress storage', () => { const event = subagent(fixtureData.taskId) const tool: ConversationMessageBlock = { 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() try { @@ -102,23 +106,116 @@ describe('subagent progress storage', () => { const state = new Map() let restored: unknown 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 { 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([ + [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 }).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 () => { const { database, taskId, path } = await fixture() const event = subagent(taskId) 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 first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] } - const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] } + const first = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed' }] } + const second = { ...event, progress: [tool, { id: textId, type: 'text' as const, content: 'seed tail' }] } try { 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: '1', payload: first })).toBe(false) expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts') + database.appendTaskEvent(taskId, 'done', {}) + const caches = (database as unknown as { + subagentProgress: { tasks: Map } + }).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() } const reopened = new AssistantDatabase(path) reopened.initialize(tmpdir()) @@ -208,7 +305,7 @@ describe('subagent progress storage', () => { } 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() database.close() const event = subagent(taskId) @@ -220,10 +317,12 @@ describe('subagent progress storage', () => { const legacy = new DatabaseSync(path) const expected: SubagentEvent[] = [] try { - legacy.exec('PRAGMA user_version = 33; BEGIN') + legacy.exec(`PRAGMA user_version = ${sourceVersion}; BEGIN`) const insert = legacy.prepare( - `INSERT INTO task_events(task_id, kind, payload_json, created_at) - VALUES (?, 'subagent', ?, ?)` + `INSERT INTO task_events( + 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++) { const full = { @@ -231,9 +330,17 @@ describe('subagent progress storage', () => { progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }] } 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() } const before = (await stat(path)).size expect(before).toBeGreaterThan(40_000_000) @@ -248,13 +355,25 @@ describe('subagent progress storage', () => { try { expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) const rows = inspected.prepare( - `SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id` - ).all() as Array<{ payload_json: string; created_at: string }> + `SELECT id, task_id, payload_json, created_at, remote_binding_id, + 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) const blocks = new Map() rows.forEach((row, index) => { expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index]) 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() } const reopened = new AssistantDatabase(path) @@ -263,5 +382,47 @@ describe('subagent progress storage', () => { const messages: unknown[] = [] upgradeAssistantStorage(path, (value) => messages.push(value)) 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) + + 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() + const newState = new Map() + 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) + } + }) }) diff --git a/src/main/assistant/assistant-storage-upgrade.ts b/src/main/assistant/assistant-storage-upgrade.ts index 81df9e32..52ca05f4 100644 --- a/src/main/assistant/assistant-storage-upgrade.ts +++ b/src/main/assistant/assistant-storage-upgrade.ts @@ -4,7 +4,8 @@ import type { ConversationMessageBlock } from '../../shared/assistant-contracts' import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts' import { compactSubagentPayload, - restoreSubagentPayload + restoreSubagentPayload, + SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION } from './subagent-progress-storage' export function upgradeAssistantStorage( @@ -21,7 +22,10 @@ export function upgradeAssistantStorage( const version = database.prepare('PRAGMA user_version').get() as { 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() const progress: AssistantStorageProgress = { stage: 'scanning', processed: 0, total: 0, bytesBefore @@ -65,8 +69,9 @@ export function upgradeAssistantStorage( const compact = compactSubagentPayload(payload, blocks) // Replay mixed old/new rows on retry. Never delete events or provenance. restoreSubagentPayload(payload, blocks) - if (compact !== payload) { - update.run(JSON.stringify(compact), row.id) + const compactJson = JSON.stringify(compact) + if (compactJson !== row.payload_json) { + update.run(compactJson, row.id) } afterId = row.id } diff --git a/src/main/assistant/subagent-progress-storage.ts b/src/main/assistant/subagent-progress-storage.ts index 7c3d6a27..af65b924 100644 --- a/src/main/assistant/subagent-progress-storage.ts +++ b/src/main/assistant/subagent-progress-storage.ts @@ -4,9 +4,28 @@ import type { DatabaseSync } from 'node:sqlite' import { assistantIdSchema, conversationMessageBlockSchema, - type ConversationMessageBlock + conversationToolActivitySchema, + type ConversationMessageBlock, + type ConversationToolActivity } from '../../shared/assistant-contracts' +export const SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION = 35 + +const toolFields = Object.keys(conversationToolActivitySchema.shape) as + Array + +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', [ z.object({ type: z.literal('append'), @@ -89,7 +108,7 @@ export function diffSubagentProgress( 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 }) } } @@ -114,12 +133,15 @@ export function compactSubagentPayload( progress: ReadonlyMap ): unknown { 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 } delete compact.progress compact.progressUpdates = diffSubagentProgress( - progress.get(event.childTaskId) ?? [], - event.progress + previous, + applySubagentProgress(previous, event) ?? [] ) return compact } @@ -158,7 +180,17 @@ export class SubagentProgressStorage { } 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) if (!task) return restoreSubagentPayload(JSON.parse(payloadJson), task.blocks) diff --git a/vitest.config.ts b/vitest.config.ts index 3afeceaf..a25330c5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/renderer/src/test-setup.ts'], exclude: [ + '.agent-resources/**', 'shareserver/**', '**/node_modules/**', '**/dist/**',