From 5dd6cff103e91e04598517cba3cb59bdc4e79c43 Mon Sep 17 00:00:00 2001 From: hy Date: Fri, 9 Oct 2026 10:30:56 +0800 Subject: [PATCH] feat: update subagent task view and related logic for streaming support - Changed `requesting` to `streaming` in `SubagentTaskView` type. - Updated logic in `reduceAssistantProcessGroups` to handle streaming state and message timing. - Modified `AiRunEventPayload` to reflect the new streaming property. - Enhanced tests for human subagent to validate streaming behavior and prefix cache retention. - Added new test case for subagent prefix cache retention during live execution. - Introduced assertions for prefix cache reuse in the continuation logic. - Added a new DOCX fixture for testing document selection scenarios. --- doc/ai-harness/human-harness.md | 2 +- doc/deploy/release.md | 2 +- doc/prd/workbench.md | 8 +- doc/test-plan/ai-test.md | 9 + doc/test-plan/workbench-test.md | 21 +- scripts/release/prepare.mjs | 17 +- src/main/services/ai/run/subagents.ts | 18 +- src/renderer/src/assets/main.css | 6 +- .../fileRenders/DocxSelectionLayer.tsx | 62 +- .../fileRenders/docxSelectionText.ts | 123 ++-- .../workbench/AssistantTurnCard.tsx | 537 +++++++----------- .../workbench/SubagentGroupView.tsx | 172 ++++++ .../src/components/workbench/types.ts | 3 +- .../src/components/workbench/utils.ts | 6 +- src/shared/types.ts | 2 +- .../ai/human/mock-llm/human-subagent.test.ts | 97 +++- .../docx-selection-reference.e2e.test.ts | 157 ++++- ...ent-delegation-browser-sidecar.e2e.test.ts | 124 +++- ...agent-prefix-cache-retain-live.e2e.test.ts | 206 +++++++ .../workspace/docx-selection-layout.docx | Bin 0 -> 45184 bytes .../harness/core/continuation-prefix-cache.ts | 13 +- 21 files changed, 1142 insertions(+), 443 deletions(-) create mode 100644 src/renderer/src/components/workbench/SubagentGroupView.tsx create mode 100644 tests/case/workbench/run/subagent/subagent-prefix-cache-retain-live.e2e.test.ts create mode 100644 tests/fixtures/workspace/docx-selection-layout.docx diff --git a/doc/ai-harness/human-harness.md b/doc/ai-harness/human-harness.md index e679af10..a7d99b5b 100644 --- a/doc/ai-harness/human-harness.md +++ b/doc/ai-harness/human-harness.md @@ -96,7 +96,7 @@ Conversation 行(`user` / `assistant` / `tool`)进 `cache.messages` 只经 ` ### 1.7 一次性子运行 - **执行**:共用 `AiRunService.run`、human graph 和工具链;继承父配置,独立上下文。任务数不限,`SUBAGENT_EXECUTION_CONCURRENCY` 默认 2,其余 FIFO 排队,轮到才创建实例。工具目录、绑定与入口均禁止再次派发、提问及定时任务管理。 -- **转录**:`AiTranscript` 的 SQLite / 内存适配共用投影、预算和压缩。`runId` 标识实例,`ownerRunId` 归属主运行,`parentToolCallId` 关联派发;不建子运行或伙伴行。子完整转录结束释放,父 annotation 保留状态、公开行动、完整公开消息和有界结果,不进主模型;全部收口后返回一条 ToolMessage。流式事件仅更新展示态,子过程和终态不改变父状态;I/O 保留父子关联。 +- **转录**:`AiTranscript` 的 SQLite / 内存适配共用投影、预算和压缩。`runId` 标识实例,`ownerRunId` 归属主运行,`parentToolCallId` 关联派发;不建子运行或伙伴行。子完整转录结束释放,父 annotation 保留状态、公开行动、完整公开消息和有界结果,不进主模型;全部收口后返回一条 ToolMessage。流式事件仅更新展示态,明确公开文字流的开始、替换和结束,不把工具或审核请求当作文字流;子过程和终态不改变父状态,I/O 保留父子关联。 - **审批**:共用 hand 的规则检查和 AI 审核,各自传入本次任务、命令及关联 ID 和执行环境,命令说明仅留人工审批卡及日志。主任务内 FIFO、一次一个批次,已有问题优先;有待审批时父状态为 `waiting_approval`。先登记等待器再发布;决定、关联日志及下一批快照同事务提交后恢复原 hand。等待保留实例并占名额,不调用模型;拒绝/指导跳过原操作。当前批次存主 `ai_run`,队列和等待器留 Main;取消不伪造用户决定。 - **文件**:派发前释放父已提交文档会话,返回后重读;跨执行租约互斥,冲突交回父运行。修改和产物从产生时归主运行。 - **结束与恢复**:finally 释放上下文及执行资源,保留落盘产物。Renderer 刷新恢复当前审批;应用重启不恢复子实例或旧审批,未完成状态投影为中断。悬空派发只在模型投影补中断反馈,保留真实结果,不写虚构 tool 行、不自动重派。 diff --git a/doc/deploy/release.md b/doc/deploy/release.md index c54c9e72..70664462 100644 --- a/doc/deploy/release.md +++ b/doc/deploy/release.md @@ -26,7 +26,7 @@ 1. 校验 `released-migrations.json` 中已锁定 migration 的版本、名称、文件名和 SHA-256。 2. 并行执行 `typecheck:node`、`typecheck:web`,并准备网页抓取 sidecar。 3. 使用 6 个 worker 执行 Unit、update backend 和非 live AI backend(其中由 `requiresBackendPptBrowser` 识别的 PPT 浏览器用例 `pptBrowserFiles` 固定 1 个 worker 串行)。 -4. 必跑 Workbench e2e `prefix-cache-retain-live`,验证同一伙伴经真实 UI 短续接、文本读工具历史、跨 run 前缀缓存和 `toolsHash`;没有 live key 必须失败,不能跳过。 +4. 必跑 Workbench e2e `prefix-cache-retain-live` 和 `subagent-prefix-cache-retain-live`,分别验证主伙伴跨任务短续接、同一子实例连续调用及主派发收口的前缀稳定和真实缓存;具体门槛按 Workbench 测试计划执行,没有 live key 必须失败,不能跳过。 5. 全部通过后,把当前连续 migration 纳入发布锁,与 `package.json` 版本一并提交,创建 annotated Tag,并原子推送 `main` 和 Tag。 第一个平台执行 `release:start` 时锁定本次新增 migration;另一平台使用同一份已锁源码。已锁 migration 不能合并、重排或修改,只能新增更高版本。 diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index 22627976..12dd8485 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -509,12 +509,13 @@ PPT 普通预览和审核需完整显示中文及全角标点,换行测量与 - 系统行为:右侧真实 DOCX 预览选中文字后保留淡紫色背景高亮,先展示星光图标+「AI 编辑」入口,点击后展开紧凑输入条,placeholder 为「描述编辑内容」。输入条使用 13px 字号、20px 行高,单行高度约 38px,随内容增高至三行后滚动;右侧约 28px 的深色圆形按钮使用对勾图标,新增时提示「添加到输入框」,编辑已有引用时提示「确认修改」。浮层默认在选区下方,以文档预览可见区域做碰撞检测,下方不足时翻到上方,左右超出时调整;滚动、缩放及输入条增高时重新定位。输入获得焦点后原文高亮仍保留,不添加下划线。点击对勾或 Enter 将内容同步到左侧输入区,尚不发送 AI;Shift+Enter 换行,IME 确认不提交;空原文或空想法不提交,Esc/点击外部放弃未确认改动。 - 选区片段由不可编辑的文档图标+文件名+编号胶囊以及可编辑的想法组成;胶囊为浅紫底,与正文基线对齐、保留间距,不展示方括号;长文件名省略,悬浮可看完整名称和原文。想法可含 `@`,两侧编辑同一份内容。选区既可用于修改也可用于提问。 -- 仅支持正文连续文字、跨段落与单个表格单元格;跨单元格、页眉页脚和文本框不开放。引用保留全部原文及前后各最多 150 字上下文,保持段落换行;不包含文字格式或图片内容。 +- 支持原生连续文字选择,包括跨段落、跨页、跨单元格、整表及正文与表格混选;页眉页脚和文本框中的真实 HTML 文字同样支持。引用只保留实际选中的文字及前后各最多 150 字上下文,保持段落和单元格内换行;表格单元格以 Tab 分隔、行以空行分隔,空单元格及合并单元格的占位保留,合并文字不重复。图片与文字混选时只提取文字,不包含图片、图形像素文字或文字格式。提取、高亮及定位使用同一份预览文字索引。 +- 选区没有可读取文字或原文无法可靠定位时,使用系统 toast 自动消失,同一提示合并;文档内不显示提示条。重复/重叠选区继续静默保留当前选中范围。 - 确认后收起输入条,正文末尾换行追加片段,右侧原文只保留紫色背景,不显示编号、不添加下划线、不改变 DOCX 排版。悬停已保存选区时背景加深并显示手形光标,单击直接打开输入条重新编辑,已有引用提供删除图标;拖动或 Shift 选取文字仍按普通文本选择处理。相同或重叠选区只保留当前选中范围,不展示 AI 编辑或提示,不新增引用。点击左侧引用展开对应文件并定位;删除片段或浮层删除同步移除引用与右侧高亮。左侧当前草稿按伙伴和文件分别从 1 连续编号,按正文中选区片段的顺序排列;删除后重新连续编号,全部删除或发送成功后,新建选区从 1 开始。引用以独立 ID 关联,重新编号不改变原文和想法;已发送消息保留发送时的编号。 - 未发送选区及关联想法只存内存,伙伴/文件切换与关闭 Tab 不丢失,重启丢失;其他正文和附件继续保存。提交期间锁定正文和标注编辑,消息被接受或排队成功后清除本批标注,提交失败保留。已发送消息可回显和再次编辑;原文变化导致无法可靠定位时提示,保留引用内容。 - 文档选区不自动收窄知识库范围,未索引选区仍可发送。 - 验收: - - `WB-DOCUMENT-REFERENCE-001-AC1` 正文、跨段落及单个表格单元格的原文和上下文准确;不支持的区域无法保存引用。 + - `WB-DOCUMENT-REFERENCE-001-AC1` 跨段落、跨页、跨单元格、整表、正文/表格混选及可读取的页眉页脚/文本框原文和上下文准确;表格行列、空单元格、合并占位与单元格内换行保留;混选图片不阻断文字引用;无可读取文字不新增引用,提示使用系统 toast 且不重复。 - `WB-DOCUMENT-REFERENCE-001-AC2` 选中先展示 AI 编辑,点击后展开小字号紧凑输入条;对勾图标及新增/编辑提示正确,确认后同步到输入区;紫色选区在输入时保留,浮层在预览区域内随滚动/缩放定位并支持上下翻转,缩放不丢失未确认输入;确认、换行、取消、空内容与 IME 行为正确。 - `WB-DOCUMENT-REFERENCE-001-AC3` 图标、文件名和编号胶囊与正文清晰分隔,输入区与消息回显样式一致;引用与想法按输入顺序保留,支持 @,两侧编辑同步。 - `WB-DOCUMENT-REFERENCE-001-AC4` 右侧紫色高亮无编号和附加下划线,悬停反馈可点击,单击打开对应输入条,文本选择不受影响;重复/重叠选区保留选中范围且无入口或提示;定位和删除正确;左侧编号连续,删除全部后从 1 开始,重新编号不串原文和想法,历史消息编号不变。 @@ -527,7 +528,7 @@ PPT 普通预览和审核需完整显示中文及全角标点,换行测量与 - 验收标准: - `WB-SUBAGENT-001-AC1`:主伙伴可通过内置派发工具创建任意数量的临时子任务,深度一层。执行并发默认 2(由 `SUBAGENT_EXECUTION_CONCURRENCY` 控制),其余 FIFO 排队,轮到后才创建完整运行实例。继承模型、偏好、权限和工作区快照,独立上下文,不复制主聊天。Brain 对可独立完成的任务给出按需协作建议。 - - `WB-SUBAGENT-001-AC2`:当前主回答过程区竖排显示子任务,每行只有稳定的随机内置组合头像和名称。排队头像和名称置灰且静止;运行头像动画,等待用户审批及结束后停止;支持减少动态效果偏好。鼠标悬浮或键盘聚焦打开小云,保留云轮廓及尾巴,最大宽 220px、云面最大高 96px。云内始终只有最新一条公开消息:排队显示「我在排队」,等待审批显示「等待你审核操作」且无请求黑球;执行中随实际模型文本流式追加,新消息替换旧消息;工具期间保留附带公开说明,无说明使用现有工具动作文案;完成显示最终交付,失败、停止、交回主伙伴或中断显示对应原因。不显示原始内部推理。请求期间黑色呼吸圆球跟随文字尾部,无文字时只显示圆球;请求结束或单纯工具执行期间不显示圆球。内容换行、不省略;隐藏滚动条但可滚动,打开及新内容到达时自动滚到底部。鼠标可进入云内,完成后沿用整个过程区折叠规则。无原位展开、额外分组标题、完成计数或正常状态文字。 + - `WB-SUBAGENT-001-AC2`:当前主回答过程区竖排显示子任务,每行只有稳定的随机内置组合头像和名称。排队头像和名称置灰且静止;运行头像动画,等待用户审批及结束后停止;支持减少动态效果偏好。鼠标悬浮或键盘聚焦打开小云,保留云轮廓及尾巴,最大宽 220px、云面最大高 96px;文字区域避开轮廓凹陷,左右至少 24px 留白,长路径换行且文字和黑球均限制在云面内部。云内始终只有最新一条公开消息:排队显示「我在排队」,等待审批显示「等待你审核操作」且无请求黑球;执行中随实际模型文本流式追加,新消息替换旧消息;工具期间保留附带公开说明,无说明使用现有工具动作文案;完成显示最终交付,失败、停止、交回主伙伴或中断显示对应原因。不显示原始内部推理。最新公开消息第一个字到达即自动显示云,实际文字流期间黑色呼吸圆球跟随文字尾部;文字流结束后黑球消失,停留 2 秒自动隐藏,后续工具执行或审核不延长黑球。一次只自动展示最新开始输出的子任务云;鼠标悬浮、进入云内或键盘聚焦优先且保持显示。新消息替换并重新计时,换行不另起周期;切换伙伴、刷新或展开历史不重播旧自动云。排队、审批和历史交付仍可悬浮查看。内容换行、不省略;隐藏滚动条但可滚动,打开及新内容到达时自动滚到底部。鼠标可进入云内,完成后沿用整个过程区折叠规则。无原位展开、额外分组标题、完成计数或正常状态文字。 - `WB-SUBAGENT-001-AC3`:子任务不创建伙伴、侧栏项、独立气泡或通知;不得再次派发、提问或管理定时任务。需用户补充信息时交回主伙伴;人工审批使用主对话统一审批卡,子实例等待决定后继续。 - `WB-SUBAGENT-001-AC4`:受控文件修改、快照、产物和回退统一归主伙伴和主 run。同路径保留首次基线与最后状态;跨执行文件工作态互斥,冲突交回主伙伴串行处理。子卡片无独立文件修改列表。终端写入沿用不自动记账规则。 - `WB-SUBAGENT-001-AC5`:子任务自行创建和操作独立浏览器标签,列表和 CDP 操作限制在自身执行范围,不同标签可并行。同一页面调用依次执行。后台网页视图隐藏显示且保留有效页面尺寸,不遮挡主界面;后台操作不切换用户伙伴、活动资源或展开面板;控制结束后标签保留为主伙伴资源,登录状态共享。 @@ -535,6 +536,7 @@ PPT 普通预览和审核需完整显示中文及全角标点,换行测量与 - `WB-SUBAGENT-001-AC7`:子任务完成、失败、取消后释放完整上下文和运行资源,保留已落盘文件及主日志中的精简结果。切伙伴不停止,主任务停止取消子任务。真实应用退出后不恢复子实例;重启保留已完成结果、未完成显示中断;用户续聊修复悬空派发反馈并可创建新子任务。 - `WB-SUBAGENT-001-AC8`:主子运行共用审核和审批执行链,以当前执行者的任务作为审核输入;人工审批保留原子实例,允许后执行原操作,拒绝或指导反馈给原子模型。等待占用执行名额,不产生模型请求。主任务内审批 FIFO,一次展示一个批次,不合并不同执行者的批次;有待审批批次时主状态保持 waiting_approval,已有提问仍优先展示,处理后推进审批。来源头像/名称及指导 placeholder 对应执行者;当前批次提交后自动切换并清空草稿。 - `WB-SUBAGENT-001-AC9`:审批来源、请求、决定和取消归主日志,决定和下一批快照同事务保存后才唤醒执行者;重复/过期决定无效。切伙伴及 Renderer 刷新恢复当前审批;停止清除全部等待并保留已有文件;应用退出不恢复子实例或旧审批,续聊修复等待审批任务为中断。 + - `WB-SUBAGENT-001-AC10`:同一子实例运行期间,内存消息经公共投影保持可复用前缀;无技能切换或压缩时工具定义及已有消息不改写。派发过程 annotation 不进入主模型,主伙伴接收子结果时保留已有请求前缀。新子实例不继承旧子历史;缓存从各执行者连续调用的服务端实际 usage 取证。 #### WB-BLOCKER-001 人机交互阻塞 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 2ff83561..1a09b5fe 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -54,6 +54,15 @@ Word、PPT、Excel、普通文本统一由主 run 的文件会话管理。工具 - How:生产 `AiRunService.run`,只脚本化 LLM 决策,其他依赖使用真实服务与存储。 - Oracle:一次提交超过 2 项仍被接受,峰值并发为默认 2,排队任务仅在前序释放后启动,全部结果进入主工具消息;公开文本真实流式投递,实际请求状态成对开关且原始推理不转发;子完整日志不入 SQLite;状态与精简行动 annotation 持久化;受限工具不可绑定和执行。 +#### `human-subagent-prefix-stability` + +- 测试类型:backend / mock-llm;扩展现有 human-subagent 文件。 +- What:子内存投影、连续工具结果追加、新实例的固定 System 和工具前缀,以及公开文字流的开始与结束。 +- Why:确定性区分请求前缀改写与供应商缓存低命中,守住临时实例隔离和文字流生命周期。 +- How:生产 AiRunService、人类 graph、真实文本文件及工具;只脚本化 LLM 派发、学习及读取决策。保存真实 requestWire,比较稳定工具集合下的相邻主子 brain;两次派发创建新实例。 +- Oracle:已有消息逐项保留,工具定义和顺序不变;新子 System/初始工具相同且无旧子历史;主上下文只含派发汇总,没有子过程 annotation。公开文字 delta 开始流、completed/reset/取消结束流,审核请求不能冒充文字流。服务端缓存由 Workbench `subagent-prefix-cache-retain-live` 验证,mock 不伪造命中。 +- 最新验证:2026-10-09 生产 AiRunService 通过;raw logs:`tests/logs/ai/human/mock-llm/human-subagent-prefix-stability/20261009-102111-987-e9228cfe`。两轮共四个新子实例,主子连续请求前缀保持、新实例无旧子历史、真实文本读取及公开流开始/结束通过;不把不同子实例的服务端缓存命中作为断言。 + #### `human-subagent-file-cancellation` - 测试类型:backend / mock-llm。 diff --git a/doc/test-plan/workbench-test.md b/doc/test-plan/workbench-test.md index fd2b3004..393b12a5 100644 --- a/doc/test-plan/workbench-test.md +++ b/doc/test-plan/workbench-test.md @@ -316,11 +316,11 @@ pnpm test:update:e2e #### `docx-selection-reference` - 测试类型:`e2e / live`;suite `conversation`,group `composer`。 -- What:真实 DOCX 选区、AI 编辑入口与对勾确认输入条、可点击的紫色高亮、左侧连续编号、混合正文、双向同步、定位与草稿生命周期。 +- What:真实 DOCX 任意连续文字选区、表格行列与合并占位、系统 toast、AI 编辑入口与对勾确认输入条、可点击的紫色高亮、左侧连续编号、混合正文、双向同步、定位与草稿生命周期。 - Why:确保原文准确,引用与想法关联,两侧使用同一内容。 -- How:通过鼠标点击起点、Shift 点击终点选取跨段落、部分单词及单元格文字,确认先展示 AI 编辑再点击打开输入条;核对对勾提示、实际字号和高度;悬停原文检查手形光标与加深的紫色背景,点击高亮直接打开对应输入条,未选中文字和段落空白无点击反馈;重复/重叠选择保留原生选中范围且无提示或 AI 编辑;滚动到预览底部检查浮层翻转,缩放后核对浮层边界和未确认输入;确认、双向编辑、跳转和删除;分别从浮层和输入区删除中间及全部选区,核对左侧编号、内容关联和右侧高亮清理;切换伙伴和文件,实际发送后新增选区检查从 1 开始,再编辑历史消息并刷新 Renderer 检查持久化草稿。 -- Oracle:覆盖 `WB-DOCUMENT-REFERENCE-001-AC1` 的跨段落/单元格原文与跨单元格拒绝,`WB-DOCUMENT-REFERENCE-001-AC2` 的两阶段入口、对勾及新增/编辑提示、小字号紧凑高度、输入后保留紫色高亮、浮层边界/翻转/滚动/缩放、空内容禁用、Enter 确认、Shift+Enter 换行和 Esc/外部点击关闭,`WB-DOCUMENT-REFERENCE-001-AC3` 的图标编号胶囊和两侧编辑同步,`WB-DOCUMENT-REFERENCE-001-AC4` 的右侧无编号、仅高亮文字可悬停和点击、定位、重复/重叠选择静默保留、删除后左侧连续编号与右侧高亮清理、删空及发送后从 1 开始和内容不串,`WB-DOCUMENT-REFERENCE-001-AC5` 的伙伴隔离及内存/持久化边界,`WB-DOCUMENT-REFERENCE-001-AC6` 的接受后清理、历史编号保留和历史编辑。刷新后选区消失,普通文字与 @ 引用保留。 -- 待覆盖:页眉页脚/文本框拒绝、IME、想法内 @ 混排、关闭资源 Tab、应用进程重启及附件恢复、外部原文变化后的定位提示、提交期间锁定、提交失败保留、排队成功清理及历史复制。 +- How:通过鼠标起点与 Shift 终点选择部分单词、跨段落、跨单元格、整表及正文/表格混选;复用 workspace DOCX,并用 `docx-selection-layout.docx` 覆盖单元格内换行、空单元格、横向/纵向合并、跨页、页眉页脚、图片混选及反向选择。只读 DOM 取证和引用原文核对;确认、高亮、点击重新编辑和定位均走真实 UI。纯图片选区检查系统 toast 合并与自动消失,文档内无提示条;重复/重叠选区保持静默。核对两阶段入口、对勾提示、字号、高度、浮层翻转/缩放和未确认输入;双向编辑、跳转和两侧删除,核对左侧编号、原文与想法关联及右侧高亮清理;切换伙伴和文件,实际发送后新增选区检查从 1 开始,再编辑历史消息并刷新 Renderer 检查持久化草稿。 +- Oracle:覆盖 `WB-DOCUMENT-REFERENCE-001-AC1` 的跨段落/跨页/跨单元格/整表/正文混选、页眉页脚、反向选择、表格行列分隔与空/合并占位、单元格内换行、图片混选和无文字 toast,`WB-DOCUMENT-REFERENCE-001-AC2` 的两阶段入口、对勾及新增/编辑提示、小字号紧凑高度、输入后保留紫色高亮、浮层边界/翻转/滚动/缩放、空内容禁用、Enter 确认、Shift+Enter 换行和 Esc/外部点击关闭,`WB-DOCUMENT-REFERENCE-001-AC3` 的图标编号胶囊和两侧编辑同步,`WB-DOCUMENT-REFERENCE-001-AC4` 的右侧无编号、仅高亮文字可悬停和点击、定位、重复/重叠选择静默保留、删除后左侧连续编号与右侧高亮清理、删空及发送后从 1 开始和内容不串,`WB-DOCUMENT-REFERENCE-001-AC5` 的伙伴隔离及内存/持久化边界,`WB-DOCUMENT-REFERENCE-001-AC6` 的接受后清理、历史编号保留和历史编辑。刷新后选区消失,普通文字与 @ 引用保留。 +- 待覆盖:文本框文字、IME、想法内 @ 混排、关闭资源 Tab、应用进程重启及附件恢复、外部原文变化后的定位 toast、提交期间锁定、提交失败保留、排队成功清理及历史复制。 ### 5.2 `run` @@ -330,8 +330,17 @@ pnpm test:update:e2e - What:并行浏览器与 sidecar、子任务头像和悬浮云、主文件回退,以及运行停止与崩溃续聊。 - Why:覆盖独立标签、共享 sidecar、背景 UI 和临时实例资源之间的真实交互风险。 - How:真实 UI 配置模型和完全访问权限、发送、切伙伴、悬浮及回退。三项任务分别提交独立表单、读取 JS 资料和真实搜索;另以独立伙伴分别运行停止、崩溃阶段,保存文件的子任务完成时中断另一个待加载网页,再续聊创建新实例。真实网站为输入,禁用 localhost 抓取快路;observer、只读 IPC 和原生视图检查只取证;harness 仅终止自身应用进程并以同 userData 重启。 -- Oracle:对齐 `WB-SUBAGENT-001-AC1`、`WB-SUBAGENT-001-AC2`、`WB-SUBAGENT-001-AC3`、`WB-SUBAGENT-001-AC4`、`WB-SUBAGENT-001-AC5`、`WB-SUBAGENT-001-AC6`、`WB-SUBAGENT-001-AC7`。标签和页面结果不串;后台视图隐藏、viewport 有效、真实截图有效且不遮挡界面;sidecar 完成 JS 抓取与搜索;切换和折叠不抢选择。竖排头像/名称、排队灰态、运行及完成头像状态、最新消息流式云与黑球、隐藏滚动条和自动滚底符合 PRD。修改归主回答且真实 UI 回退恢复磁盘。停止释放控制并保留文件;重启保留完成记录、未完成投影中断;续聊创建新实例并使用新的网页输入。 -- 最新验证:2026-10-08 真实 qwen3.8-flash 三阶段全部通过,耗时 1168s;raw logs:`tests/logs/workbench/run/subagent/subagent-delegation-browser-sidecar/20261008-163711-106`。独立表单提交、JS 资料与真实搜索、后台视图/截图、悬浮云、主修改回退、加载中停止、崩溃重启及新实例续聊均通过。人工完成度 90/100:事实和磁盘交付准确;反复核对与报告篇幅使耗时偏高。主 31 次调用,输入/输出 391,531 / 16,943 token;子 67 次调用,输入/输出 528,108 / 24,154 token;缓存读 754,944(82.1%)。 +- Oracle:对齐 `WB-SUBAGENT-001-AC1`、`WB-SUBAGENT-001-AC2`、`WB-SUBAGENT-001-AC3`、`WB-SUBAGENT-001-AC4`、`WB-SUBAGENT-001-AC5`、`WB-SUBAGENT-001-AC6`、`WB-SUBAGENT-001-AC7`。标签和页面结果不串;后台视图隐藏、viewport 有效、真实截图有效且不遮挡界面;sidecar 完成 JS 抓取与搜索;切换和折叠不抢选择。竖排头像/名称、排队灰态、运行及完成头像状态、最新消息流式云与黑球、隐藏滚动条和自动滚底符合 PRD。第一字自动开云、文字流结束后黑球消失及 2 秒隐藏;新消息重置周期、同组只有一个自动云、hover/focus 优先且历史不自动重播。长文字与黑球不越过云轮廓安全区域。修改归主回答且真实 UI 回退恢复磁盘。停止释放控制并保留文件;重启保留完成记录、未完成投影中断;续聊创建新实例并使用新的网页输入。 +- 最新验证:2026-10-08 真实 qwen3.8-flash 三阶段全部通过,1325s;raw logs:`tests/logs/workbench/run/subagent/subagent-delegation-browser-sidecar/20261008-185724-429`。第一字自动开云,真实文字流结束后 2175ms 隐藏;文字区域四角位于 SVG 云面内且无横向溢出,长交付、黑球、排队灰态、hover/focus 优先、隐藏滚动条和自动滚底通过。独立表单、JS 资料、真实搜索、后台视图/截图、主修改回退、加载中停止、手动悬浮超过两秒、崩溃恢复无旧自动云及新实例续聊均通过。人工完成度 88/100:事实及磁盘交付准确;主伙伴重复子任务核验、额外检查旧慢网页和字节细节,报告偏长。主 32 次调用,输入/输出 388,109 / 18,433;子 65 次,589,748 / 27,217;缓存读 810,496(82.88%)。保留四张云朵截图及重启前后 trace。 + +#### `subagent-prefix-cache-retain-live` + +- 测试类型:e2e / live;suite `run`,group `subagent`;serviceMode `real`;发布准备必跑。 +- What:同一个子实例执行过程中连续模型调用的前缀稳定和真实缓存,以及主伙伴派发后接收结果时的缓存。 +- Why:子内存消息投影、工具结果追加及派发收口不能改写已发送的请求前缀。 +- How:真实 UI 配置模型并发送一次任务,派发两位临时帮手。复用已有 `工作/本周排期更新.txt`、`工作/对外口径确认.txt`、`工作/临时备注.txt`,分别复制到两个临时资料目录,核对排期负责人及对外风险口径,由主伙伴汇总。按各自 runId/callId 收集连续 brain 请求及实际 usage;工具、存储和投影均为生产实现,observer 仅取证。不注入模型输出、不延时或填充提示制造命中。 +- Oracle:对齐 `WB-SUBAGENT-001-AC10`。两项实际完成、各自真实读取至少两份资料、关键事实有工具结果依据、输入不变。每个子实例至少三次 brain 调用,稳定工具集合下的相邻请求完整保留已有消息前缀;主派发收口同样保留前缀。每个执行者都有有效 usage 和实际缓存复用,缺少可比较请求或全程未命中明确失败。逐段记录输入规模、缓存 token 和复用率,以既有 85% 为评估参考,不把每段短请求均达 85% 作为硬门槛;前缀改写与服务端低命中分别判断。不比较不同子实例的缓存,不要求新实例首次命中。新实例上下文隔离由 AI 确定性回归覆盖。 +- 最新验证:2026-10-09 真实 qwen3.8-flash 通过,75s;raw logs:`tests/logs/workbench/run/subagent/subagent-prefix-cache-retain-live/20261009-101928-843`。一次主任务、两个子实例分别有 3 / 4 次 brain 调用;5 段子连续请求的消息及工具前缀完整保持,复用率 87.16%–99.85%,主派发收口 88.67%。本次未出现低于 85% 的相邻段,不能据此要求所有短请求均达 85%;首次请求不作为复用比较,逐段 usage 继续留证评估。四份临时资料不变,负责人、周四排期、未签署前保持跟进的口径及来源正确。人工完成度 90/100:交付完整,子任务额外目录检查及报告篇幅偏长扣分。主 2 次调用,输入/输出 14,815 / 1,375;子 7 次,40,608 / 2,914;缓存读 39,680。 #### `subagent-approval-flow` diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs index af4031de..579d8259 100644 --- a/scripts/release/prepare.mjs +++ b/scripts/release/prepare.mjs @@ -21,7 +21,7 @@ const LIVE_KEY_ENV_NAMES = [ 'AI_LIVE_KEY_MIMO', 'AI_LIVE_KEY_ZHIPU' ] -const PREFIX_CACHE_LIVE_CASE = 'prefix-cache-retain-live' +const PREFIX_CACHE_LIVE_CASES = ['prefix-cache-retain-live', 'subagent-prefix-cache-retain-live'] const args = process.argv.slice(2) const valueAfter = (name) => { const index = args.indexOf(name) @@ -93,11 +93,11 @@ const assertReleaseLiveKeyConfigured = () => { const anyKey = LIVE_KEY_ENV_NAMES.find((name) => String(process.env[name] || '').trim()) if (anyKey) { throw new Error( - `发布准备必跑 ${PREFIX_CACHE_LIVE_CASE}:已配置 ${anyKey},但 AI_LIVE_PROVIDER=${provider} 缺少 ${preferredKey}` + `发布准备必跑 ${PREFIX_CACHE_LIVE_CASES.join(', ')}:已配置 ${anyKey},但 AI_LIVE_PROVIDER=${provider} 缺少 ${preferredKey}` ) } throw new Error( - `发布准备必跑 ${PREFIX_CACHE_LIVE_CASE}:请在 .env 配置 ${preferredKey}(或设置 AI_LIVE_PROVIDER 并提供对应 AI_LIVE_KEY_*)` + `发布准备必跑 ${PREFIX_CACHE_LIVE_CASES.join(', ')}:请在 .env 配置 ${preferredKey}(或设置 AI_LIVE_PROVIDER 并提供对应 AI_LIVE_KEY_*)` ) } @@ -200,19 +200,16 @@ await run(process.execPath, [ process.env.NCURATOR_RELEASE_PREPARE = '1' assertReleaseLiveKeyConfigured() -process.stdout.write(`[release] running required live gate: ${PREFIX_CACHE_LIVE_CASE}\n`) +process.stdout.write( + `[release] running required live gate: ${PREFIX_CACHE_LIVE_CASES.join(', ')}\n` +) await run(process.execPath, [ path.join(ROOT, 'scripts', 'test', 'workflow.mjs'), '--type', 'e2e', '--domain', 'workbench', - '--suite', - 'conversation', - '--group', - 'prefix-cache', - '--case', - PREFIX_CACHE_LIVE_CASE, + ...PREFIX_CACHE_LIVE_CASES.flatMap((caseId) => ['--case', caseId]), '--target', 'source' ]) diff --git a/src/main/services/ai/run/subagents.ts b/src/main/services/ai/run/subagents.ts index 66ab99dc..925fccf0 100644 --- a/src/main/services/ai/run/subagents.ts +++ b/src/main/services/ai/run/subagents.ts @@ -137,12 +137,11 @@ export const createSubagentDispatcher = ( } deps.abortSignal?.addEventListener('abort', cancel, { once: true }) if (deps.abortSignal?.aborted || deps.isCancelled()) cancel() - const requests = new Set() let replaceStream = true const stream = (update: { delta?: string reset?: boolean - requesting?: boolean + streaming?: boolean }): void => { // Keep live fragments ordered with durable snapshots without persisting each fragment. const next = updates.then(() => { @@ -182,7 +181,7 @@ export const createSubagentDispatcher = ( isCancelled: () => controller.signal.aborted || deps.isCancelled(), requestApprovalBatch: async (request) => { base = { ...base, status: 'waiting_approval' } - stream({ requesting: false }) + stream({ streaming: false }) await publish(base) const result = await deps.requestApprovalBatch({ ...request, @@ -199,19 +198,16 @@ export const createSubagentDispatcher = ( sendEvent: (event) => { // The thought event is public assistant text accompanying this round of tool calls. // Raw reasoning deltas, skill state and lifecycle remain local. - if (event.type === 'llm_request_state') { - if (event.requesting) requests.add(event.callId) - else requests.delete(event.callId) - stream({ requesting: requests.size > 0 }) - } else if (event.type === 'assistant_brain_stream_started') { + if (event.type === 'assistant_brain_stream_started') { replaceStream = true } else if (event.type === 'assistant_brain_stream_reset') { replaceStream = true - stream({ reset: true }) + stream({ reset: true, streaming: false }) } else if (event.type === 'assistant_brain_stream_delta') { - stream({ delta: event.text, reset: replaceStream }) + stream({ delta: event.text, reset: replaceStream, streaming: true }) replaceStream = false } else if (event.type === 'assistant_brain_stream_completed') { + stream({ streaming: false }) if (event.text.trim()) void publish({ ...base, message: event.text.slice(0, 12000) }) } else if ( @@ -306,7 +302,7 @@ export const createSubagentDispatcher = ( await publish(update) return update } finally { - stream({ requesting: false }) + stream({ streaming: false }) try { await updates } finally { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index e40412f7..6f641008 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -17,7 +17,7 @@ position: relative; width: min(220px, calc(100vw - 24px)); max-height: 96px; - padding: 12px 16px; + padding: 26px 30px 22px; isolation: isolate; color: var(--notion-text-soft, #787774); font-size: 12px; @@ -49,8 +49,10 @@ } .nc-subagent-thought-lines { - max-height: 72px; + max-height: 48px; min-height: 18px; + min-width: 0; + overflow-x: hidden; white-space: pre-wrap; overflow-wrap: anywhere; scrollbar-width: none; diff --git a/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx b/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx index 0d1e1228..c9cd7865 100644 --- a/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx +++ b/src/renderer/src/components/fileRenders/DocxSelectionLayer.tsx @@ -1,4 +1,5 @@ -import { useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { App as AntdApp } from 'antd' import { createPortal } from 'react-dom' import { HiOutlineCheck, HiOutlineSparkles, HiOutlineTrash } from 'react-icons/hi2' import type { ChatInlineContentNode, DocumentSelectionNode } from '../../../../shared/types' @@ -43,10 +44,10 @@ export default function DocxSelectionLayer({ revision: number }) { const state = useContext(DocumentSelectionContext) + const { message } = AntdApp.useApp() const [popup, setPopup] = useState(null) const [floating, setFloating] = useState(null) const [hoveredId, setHoveredId] = useState(null) - const [notice, setNotice] = useState('') const popupRef = useRef(null) const latestRef = useRef(state) const targetsRef = useRef([]) @@ -56,17 +57,16 @@ export default function DocxSelectionLayer({ useEffect(() => { latestRef.current = state }, [state]) - const open = ( - node: DocumentSelectionNode, - location: SelectionLocation, - mode: Popup['mode'] - ): void => { - setNotice('') - setHoveredId(null) - setFloating(null) - setPopup({ node, location, mode }) - window.getSelection()?.removeAllRanges() - } + const open = useCallback( + (node: DocumentSelectionNode, location: SelectionLocation, mode: Popup['mode']): void => { + message.destroy('docx-selection') + setHoveredId(null) + setFloating(null) + setPopup({ node, location, mode }) + window.getSelection()?.removeAllRanges() + }, + [message] + ) useEffect(() => { if (!container || !state?.enabled || !revision) return @@ -98,7 +98,8 @@ export default function DocxSelectionLayer({ const preview = readPreviewText(container) const location = captureSelection(container, preview, range) if (!location) { - setNotice('请选择正文中的连续文本;表格仅支持单个单元格。') + setPopup(null) + void message.warning({ key: 'docx-selection', content: '当前选区没有可读取的文字。' }) return true } const nodes = latestRef.current?.selections.filter((node) => node.filePath === filePath) || [] @@ -106,7 +107,7 @@ export default function DocxSelectionLayer({ const existing = locateSelection(preview, node, latestRef.current?.locations.get(node.id)) if (!existing) continue if (existing.start < location.end && location.start < existing.end) { - setNotice('') + message.destroy('docx-selection') setPopup(null) return true } @@ -185,7 +186,16 @@ export default function DocxSelectionLayer({ container.style.cursor = cursor setHoveredId(null) } - }, [container, filePath, revision, state?.enabled, state?.locked, state?.selections]) + }, [ + container, + filePath, + revision, + state?.enabled, + state?.locked, + state?.selections, + message, + open + ]) useLayoutEffect(() => { if (!container || !revision || !state?.enabled) return @@ -291,14 +301,17 @@ export default function DocxSelectionLayer({ const location = locateSelection(preview, node, state.locations.get(node.id)) const range = location && rangeForLocation(preview, location) if (!range || !location) { - setNotice('原文已变化或位置不唯一,无法定位该选区。') + void message.warning({ + key: 'docx-selection', + content: '原文已变化或位置不唯一,无法定位该选区。' + }) return } range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'instant' }) const selected = window.getSelection() selected?.removeAllRanges() selected?.addRange(range) - }, [container, revision, filePath, state?.navigation, state?.locations]) + }, [container, revision, filePath, state?.navigation, state?.locations, message]) useEffect(() => { if (!popupId) return @@ -320,9 +333,9 @@ export default function DocxSelectionLayer({ useEffect(() => { setPopup(null) - setNotice('') + message.destroy('docx-selection') setHoveredId(null) - }, [filePath, revision, state?.enabled]) + }, [filePath, revision, state?.enabled, message]) if (!state?.enabled) return null const save = (): void => { @@ -357,15 +370,6 @@ export default function DocxSelectionLayer({ 编辑所选内容 ))} - {notice && ( - - )} {popup && createPortal(
{ + const style = getComputedStyle(element) + return style.display === 'none' || style.visibility === 'hidden' +} + +function tableGrid(table: HTMLTableElement): (HTMLTableCellElement | null)[][] { + const rows = [...table.rows] + const grid: (HTMLTableCellElement | null)[][] = rows.map(() => []) + for (const [rowIndex, row] of rows.entries()) { + if (isHidden(row)) continue + let column = 0 + for (const cell of row.cells) { + // DOCX vertical merges render a hidden continuation cell beside the spanning cell. + if (isHidden(cell)) continue + while (grid[rowIndex][column] !== undefined) column += 1 + grid[rowIndex][column] = cell + const endRow = Math.min(rows.length, rowIndex + (cell.rowSpan || rows.length - rowIndex)) + for (let r = rowIndex; r < endRow; r += 1) { + for (let c = column; c < column + cell.colSpan; c += 1) { + if (r !== rowIndex || c !== column) grid[r][c] = null + } + } + column += cell.colSpan + } + } + const columns = Math.max(0, ...grid.map((row) => row.length)) + return grid.map((row) => Array.from({ length: columns }, (_, column) => row[column] || null)) +} export function readPreviewText(container: HTMLElement): PreviewText { const runs: TextRun[] = [] let text = '' - const paragraphs = container.querySelectorAll('article p') - paragraphs.forEach((paragraph) => { - if (paragraph.closest(unsupported)) return - if (runs.length) text += '\n' - const walker = document.createTreeWalker( - paragraph, - NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT - ) - let node = walker.nextNode() - while (node) { - if (node instanceof HTMLBRElement && !node.closest(unsupported)) text += '\n' - if (node instanceof Text && node.data && !node.parentElement?.closest(unsupported)) { + const visit = (node: Node): void => { + if (node instanceof Text) { + if (node.data && node.parentElement instanceof HTMLElement) { runs.push({ node, start: text.length, end: text.length + node.data.length }) text += node.data } - node = walker.nextNode() + return } - }) + if (!(node instanceof HTMLElement || node instanceof SVGElement)) return + if (node.matches('style, script, template') || isHidden(node)) return + if (node instanceof HTMLBRElement) { + text += '\n' + return + } + if (node instanceof HTMLTableElement) { + for (const [rowIndex, row] of tableGrid(node).entries()) { + if (rowIndex) text += '\n\n' + for (const [column, cell] of row.entries()) { + if (column) text += '\t' + if (cell) visit(cell) + } + } + return + } + let hasContent = false + for (const child of node.childNodes) { + if (child instanceof Element && isHidden(child)) continue + const block = child instanceof HTMLElement && blocks.has(child.tagName) + if (block && hasContent) text += '\n' + const start = text.length + visit(child) + if (block || text.length > start) hasContent = true + } + } + visit(container.querySelector('.docx-wrapper') || container) return { text, runs } } @@ -35,32 +91,19 @@ export function captureSelection( preview: PreviewText, range: Range ): SelectionLocation | null { - const startElement = - range.startContainer instanceof Element - ? range.startContainer - : range.startContainer.parentElement - const endElement = - range.endContainer instanceof Element ? range.endContainer : range.endContainer.parentElement - if ( - !startElement || - !endElement || - !container.contains(startElement) || - !container.contains(endElement) || - !startElement.closest('article') || - !endElement.closest('article') || - startElement.closest(unsupported) || - endElement.closest(unsupported) - ) + if (!container.contains(range.startContainer) || !container.contains(range.endContainer)) return null - const startCell = startElement.closest('td, th') - if (startCell !== endElement.closest('td, th')) return null - if (range.cloneContents().querySelector('img, svg, table, header, footer')) return null - const intersected = preview.runs.filter((run) => range.intersectsNode(run.node)) + const intersected = preview.runs.flatMap((run) => { + if (!range.intersectsNode(run.node)) return [] + const start = range.startContainer === run.node ? range.startOffset : 0 + const end = range.endContainer === run.node ? range.endOffset : run.node.length + return end > start ? [{ start: run.start + start, end: run.start + end }] : [] + }) const first = intersected[0] const last = intersected.at(-1) if (!first || !last) return null - const start = first.start + (range.startContainer === first.node ? range.startOffset : 0) - const end = last.start + (range.endContainer === last.node ? range.endOffset : last.node.length) + const { start } = first + const { end } = last if (!preview.text.slice(start, end).trim()) return null return { start, end, previewText: preview.text } } @@ -96,11 +139,11 @@ export function locateSelection( export function rangeForLocation(preview: PreviewText, location: SelectionLocation): Range | null { const first = preview.runs.find((run) => run.end > location.start) - const last = preview.runs.find((run) => run.end >= location.end && run.start < location.end) + const last = preview.runs.findLast((run) => run.start < location.end && run.end > location.start) if (!first || !last) return null const range = document.createRange() range.setStart(first.node, Math.max(0, location.start - first.start)) - range.setEnd(last.node, location.end - last.start) + range.setEnd(last.node, Math.min(last.node.length, location.end - last.start)) return range } diff --git a/src/renderer/src/components/workbench/AssistantTurnCard.tsx b/src/renderer/src/components/workbench/AssistantTurnCard.tsx index dce10a05..72f2d7b3 100644 --- a/src/renderer/src/components/workbench/AssistantTurnCard.tsx +++ b/src/renderer/src/components/workbench/AssistantTurnCard.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { Tooltip } from 'antd' -import PartnerAvatar from './PartnerAvatar' +import SubagentGroupView, { SubagentCloudProvider } from './SubagentGroupView' import { FaSpider } from 'react-icons/fa' import { PiBrowsers, PiEyes } from 'react-icons/pi' import { TbMoodConfuzed } from 'react-icons/tb' @@ -29,8 +28,7 @@ import type { AssistantProcessIconKind, AssistantProcessThought, AssistantRichTextSegment, - AssistantTurnViewModel, - SubagentTaskView + AssistantTurnViewModel } from './types' import type { WorkspaceSource } from '../../../../shared/types' import type { OpenableMarkdownReference } from '../markdownReference' @@ -41,7 +39,6 @@ import { import { workbenchSelectors } from '../../testing/selectors' import { buildAssistantExecutionSummaryText, - getSubagentAvatar, resolveAssistantExecutionIcon, resolveQuestionInteraction, shouldShowAssistantApplyTargets, @@ -294,106 +291,6 @@ const AssistantProcessThoughtView: React.FC<{ ) } -const SubagentThought: React.FC<{ child: SubagentTaskView; open: boolean }> = ({ child, open }) => { - const contentRef = useRef(null) - const active = child.status === 'running' - const text = - child.status === 'queued' - ? '我在排队' - : active - ? child.message || '' - : child.result || - { - waiting_approval: '等待你审核操作', - completed: '任务已完成', - failed: '执行失败', - cancelled: '已停止', - blocked: '需要主伙伴继续处理', - interrupted: '执行被中断,未收到完整结果' - }[child.status] - const requesting = active && child.requesting - useLayoutEffect(() => { - if (!open || !contentRef.current) return - const content = contentRef.current - const scrollToLatest = (): void => { - content.scrollTop = content.scrollHeight - } - scrollToLatest() - const frame = requestAnimationFrame(scrollToLatest) - const observer = new ResizeObserver(scrollToLatest) - observer.observe(content) - return () => { - cancelAnimationFrame(frame) - observer.disconnect() - } - }, [open, text, requesting]) - return ( -
- -
- {requesting ? text.trimEnd() : text} - {requesting ? ( - - ) : null} -
-
- ) -} - -const SubagentTaskRow: React.FC<{ child: SubagentTaskView }> = ({ child }) => { - const [open, setOpen] = useState(false) - return ( - } - > - - - ) -} - -const SubagentGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => ( -
- {(group.subagents || []).map((child) => ( - - ))} -
-) - const AssistantProcessGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => { if (group.subagents?.length) return const isRunning = group.status === 'running' @@ -834,227 +731,229 @@ const AssistantTurnCard: React.FC = ({ } return ( -
-
- {timeline.length > 0 || hasStreamingThought || reserveThinkingSlot || turn.startedAt ? ( -
- {turn.startedAt ? ( - turn.runFinished ? ( - - ) : ( -
- - 已处理 {formatRunDuration(turn.startedAt, currentTime)} - -
- ) - ) : null} -
- {timeline.map((entry) => { - if (entry.kind === 'decision') { - return ( - + 用时 {formatRunDuration(turn.startedAt, turn.finishedAt ?? currentTime)} + + {processExpanded ? ( + + ) : ( + + )} + + ) : ( +
+ + 已处理 {formatRunDuration(turn.startedAt, currentTime)} + +
+ ) + ) : null} +
+ {timeline.map((entry) => { + if (entry.kind === 'decision') { + return ( + + ) + } + if (entry.kind === 'activity') { + return ( + + ) + } + return + })} + {streamingRole === 'thought' && streamingMarkdown.trim() ? ( +
+ - ) - } - if (entry.kind === 'activity') { - return ( - - ) - } - return - })} - {streamingRole === 'thought' && streamingMarkdown.trim() ? ( -
- -
- ) : null} - {reserveThinkingSlot ? ( -
- {showThinkingIndicator ? ( - - 让俺想想 - - ) : null} -
- ) : null} -
-
- ) : null} - - {hasFinalAnswer ? ( -
- -
- ) : null} - - {turn.status === 'failed' && turn.error ? ( -
-
失败:{turn.error}
- {turn.errorDetail ? ( -
- - 查看详情 - -
-                  {turn.errorDetail}
-                
-
- ) : null} -
- ) : null} - - {shouldShowAssistantApplyTargets({ - runFinished: turn.runFinished, - applyTargetCount: turn.applyTargets.length - }) ? ( -
-
-
- -
-
-
修改的文件
-
点击文件查看本次修改
-
-
- {turn.applyTargets.length} 个 -
-
-
- {visibleApplyTargets.map((apply) => { - const statusLabel = - apply.status === 'reverted' - ? apply.operation === 'created' - ? '已删除' - : '已回退' - : apply.status === 'failed' - ? '失败' - : null - return ( -
+ ) : null} + {reserveThinkingSlot ? ( +
-
-
- -
- - - {statusLabel ? ( - - {statusLabel} - - ) : null} + {showThinkingIndicator ? ( + + 让俺想想 -
- - ) - })} + ) : null} +
+ ) : null} +
- {turn.applyTargets.length > MAX_VISIBLE_APPLY_TARGETS ? ( - - ) : null} -
- ) : null} + ) : null} + + {hasFinalAnswer ? ( +
+ +
+ ) : null} + + {turn.status === 'failed' && turn.error ? ( +
+
失败:{turn.error}
+ {turn.errorDetail ? ( +
+ + 查看详情 + +
+                    {turn.errorDetail}
+                  
+
+ ) : null} +
+ ) : null} + + {shouldShowAssistantApplyTargets({ + runFinished: turn.runFinished, + applyTargetCount: turn.applyTargets.length + }) ? ( +
+
+
+ +
+
+
修改的文件
+
点击文件查看本次修改
+
+
+ {turn.applyTargets.length} 个 +
+
+
+ {visibleApplyTargets.map((apply) => { + const statusLabel = + apply.status === 'reverted' + ? apply.operation === 'created' + ? '已删除' + : '已回退' + : apply.status === 'failed' + ? '失败' + : null + return ( + + ) + })} +
+ {turn.applyTargets.length > MAX_VISIBLE_APPLY_TARGETS ? ( + + ) : null} +
+ ) : null} +
-
+ ) } diff --git a/src/renderer/src/components/workbench/SubagentGroupView.tsx b/src/renderer/src/components/workbench/SubagentGroupView.tsx new file mode 100644 index 00000000..78b85244 --- /dev/null +++ b/src/renderer/src/components/workbench/SubagentGroupView.tsx @@ -0,0 +1,172 @@ +import React, { + createContext, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react' +import { Tooltip } from 'antd' +import PartnerAvatar from './PartnerAvatar' +import type { AssistantProcessGroup, SubagentTaskView } from './types' +import { getSubagentAvatar } from './utils' + +const SubagentThought: React.FC<{ child: SubagentTaskView; open: boolean }> = ({ child, open }) => { + const contentRef = useRef(null) + const active = child.status === 'running' + const text = + child.status === 'queued' + ? '我在排队' + : active + ? child.message || '' + : child.result || + { + waiting_approval: '等待你审核操作', + completed: '任务已完成', + failed: '执行失败', + cancelled: '已停止', + blocked: '需要主伙伴继续处理', + interrupted: '执行被中断,未收到完整结果' + }[child.status] + const streaming = active && child.streaming + useLayoutEffect(() => { + if (!open || !contentRef.current) return + const content = contentRef.current + const scrollToLatest = (): void => { + content.scrollTop = content.scrollHeight + } + scrollToLatest() + const frame = requestAnimationFrame(scrollToLatest) + const observer = new ResizeObserver(scrollToLatest) + observer.observe(content) + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + } + }, [open, text, streaming]) + return ( +
+ +
+ {streaming ? text.trimEnd() : text} + {streaming ? ( + + ) : null} +
+
+ ) +} + +const AUTO_CLOUD_HOLD_MS = 2000 +const SubagentCloudContext = createContext< + | { + openChildId?: string + setManualChildId: React.Dispatch> + } + | undefined +>(undefined) + +export const SubagentCloudProvider: React.FC<{ + groups: AssistantProcessGroup[] + children: React.ReactNode +}> = ({ groups, children }) => { + const tasks = useMemo(() => groups.flatMap((group) => group.subagents || []), [groups]) + const [manualChildId, setManualChildId] = useState() + const [autoChildId, setAutoChildId] = useState() + // Existing view state on mount is history, including when returning to a running partner. + const seen = useRef(new Map(tasks.map((child) => [child.childRunId, child.messageStartedAt]))) + useEffect(() => { + let latest: SubagentTaskView | undefined + for (const child of tasks) { + if (child.messageStartedAt && seen.current.get(child.childRunId) !== child.messageStartedAt) { + if (!latest || child.messageStartedAt >= (latest.messageStartedAt || 0)) latest = child + } + seen.current.set(child.childRunId, child.messageStartedAt) + } + if (latest) setAutoChildId(latest.childRunId) + }, [tasks]) + const automatic = tasks.find((child) => child.childRunId === autoChildId) + const streaming = automatic?.status === 'running' && automatic.streaming + useEffect(() => { + if (!autoChildId || streaming) return + const timer = window.setTimeout(() => setAutoChildId(undefined), AUTO_CLOUD_HOLD_MS) + return () => window.clearTimeout(timer) + }, [autoChildId, streaming, automatic?.messageStartedAt]) + + return ( + + {children} + + ) +} + +const SubagentGroupView: React.FC<{ group: AssistantProcessGroup }> = ({ group }) => { + const cloud = useContext(SubagentCloudContext) + if (!cloud) throw new Error('SubagentGroupView requires SubagentCloudProvider') + const { openChildId, setManualChildId } = cloud + return ( +
+ {(group.subagents || []).map((child) => { + const open = openChildId === child.childRunId + return ( + + setManualChildId((current) => + next ? child.childRunId : current === child.childRunId ? undefined : current + ) + } + title={} + > + + + ) + })} +
+ ) +} + +export default SubagentGroupView diff --git a/src/renderer/src/components/workbench/types.ts b/src/renderer/src/components/workbench/types.ts index a0d5cf67..3204b878 100644 --- a/src/renderer/src/components/workbench/types.ts +++ b/src/renderer/src/components/workbench/types.ts @@ -148,7 +148,8 @@ export type AssistantProcessGroupItem = { } export type SubagentTaskView = import('../../../../shared/types').SubagentUpdate & { - requesting?: boolean + streaming?: boolean + messageStartedAt?: number actions: import('../../../../shared/types').SubagentAction[] } diff --git a/src/renderer/src/components/workbench/utils.ts b/src/renderer/src/components/workbench/utils.ts index cfdc3774..f74cbd41 100644 --- a/src/renderer/src/components/workbench/utils.ts +++ b/src/renderer/src/components/workbench/utils.ts @@ -676,7 +676,9 @@ export const reduceAssistantProcessGroups = ( return { ...child, message: (event.reset ? '' : child.message || '') + (event.delta || ''), - requesting: event.requesting ?? child.requesting + streaming: event.streaming ?? child.streaming, + messageStartedAt: + event.reset && event.delta ? event.createdAt : child.messageStartedAt } }) } @@ -712,7 +714,7 @@ export const reduceAssistantProcessGroups = ( message: active ? (event.update.message ?? event.update.action?.text ?? previous?.message) : event.update.result, - requesting: event.update.status === 'running' ? previous?.requesting : false + streaming: event.update.status === 'running' ? previous?.streaming : false } const subagents = group.subagents?.some((item) => item.childRunId === child.childRunId) ? group.subagents.map((item) => (item.childRunId === child.childRunId ? child : item)) diff --git a/src/shared/types.ts b/src/shared/types.ts index ad6c0260..8c28136f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1660,7 +1660,7 @@ type AiRunEventPayload = childRunId: string delta?: string reset?: boolean - requesting?: boolean + streaming?: boolean createdAt: number } | { type: 'subagent_updated'; runId: string; update: SubagentUpdate; createdAt: number } diff --git a/tests/case/ai/human/mock-llm/human-subagent.test.ts b/tests/case/ai/human/mock-llm/human-subagent.test.ts index 1abbf077..edb4067b 100644 --- a/tests/case/ai/human/mock-llm/human-subagent.test.ts +++ b/tests/case/ai/human/mock-llm/human-subagent.test.ts @@ -25,6 +25,7 @@ import { } from '../../../../../src/main/services/ai/human/memory/transcript' import { messagesFromLogEntries } from '../../../../../src/main/services/ai/human/memory/conversationLogMessages' import { createMemoryTranscript } from '../../../../../src/main/services/ai/run/subagents' +import { collectBrainPrefixCacheReuse } from '../../../../harness/core/continuation-prefix-cache' import { AiFileChangeService } from '../../../../../src/main/services/ai/fileChanges/AiFileChangeService' import { terminalSessionManager } from '../../../../../src/main/services/ai/human/tools/terminal/TerminalSessionManager' import type { ChatMessageLogEntry, SubagentUpdate } from '../../../../../src/shared/types' @@ -505,8 +506,8 @@ describe('temporary subagents through production AiRunService', () => { .map((event) => event.delta) .join('') ).toBe(ended.entry.payload.result) - expect(own.some((event) => event.requesting === true)).toBe(true) - expect(own.at(-1)?.requesting).toBe(false) + expect(own.some((event) => event.streaming === true)).toBe(true) + expect(own.at(-1)?.streaming).toBe(false) } expect( result.records.filter( @@ -535,6 +536,98 @@ describe('temporary subagents through production AiRunService', () => { }) }) + it('human-subagent-prefix-stability', async () => { + await runBackendCase({ + caseId: 'human-subagent-prefix-stability', + domain: 'ai', + suite: 'human', + groups: ['mock-llm'], + harnessMode: 'mock-llm', + fixtures: [], + runMode: 'single', + description: '真实子内存投影连续追加与新实例固定前缀', + execute: async (runtime) => { + const files = ['A', 'B'].map((suffix) => + path.join(runtime.workspaceRoot, `cache-note-${suffix}.txt`) + ) + for (const file of files) await fs.writeFile(file, '固定资料:苍鹭;负责人:林可。') + let parentCalls = 0 + const calls = new Map() + const runner = await createAiServiceTestRunner(runtime, { + userInput: 'ROOT_CACHE 派发两轮独立核验并汇总', + streamingModeOverride: 'force_stream', + llmScript: { + 'human.brain': steps((call) => { + if (!isChild(call)) { + return ++parentCalls <= 2 + ? delegate([ + { name: '海豹', task: `CHILD_CACHE_A 核验 ${files[0]}` }, + { name: '水獭', task: `CHILD_CACHE_B 核验 ${files[1]}` } + ]) + : complete('主伙伴只整合独立结果') + } + const task = call.user.match(/CHILD_CACHE_[AB]/)?.[0] || '' + const count = (calls.get(task) || 0) + 1 + calls.set(task, count) + return count % 3 === 0 + ? complete(`PUBLIC_RESULT_${task}`) + : buildToolDecision({ + toolName: 'text_file_read', + args: { sourcePath: files[task.endsWith('A') ? 0 : 1] }, + summary: `PUBLIC_ACTION_${task}_${count}` + }) + }) + } + }) + const output = await runner.run() + return { output, io: runner.io, events: runner.events } + }, + assert: async (result, runtime) => { + expect(result.output.status).toBe('completed') + const brain = result.io.filter((record) => record.caller === 'human.brain') + const root = brain.filter((record) => !record.ownerRunId) + const children = [ + ...new Set(brain.filter((record) => record.ownerRunId).map((record) => record.runId)) + ] + expect(root).toHaveLength(3) + expect(children).toHaveLength(4) + const firstCalls = children.map((id) => brain.find((record) => record.runId === id)!) + for (const first of firstCalls) { + expect(first.system).toBe(firstCalls[0].system) + expect(first.requestWire?.toolsHash).toBe(firstCalls[0].requestWire?.toolsHash) + expect(first.requestWire?.messages).toHaveLength(2) + expect(first.user).not.toContain('PUBLIC_RESULT_') + expect(first.user).not.toContain('PUBLIC_ACTION_') + } + for (const runId of [runtime.runId, ...children]) { + const records = brain.filter((record) => record.runId === runId) + expect(records).toHaveLength(3) + for (let index = 1; index < records.length; index += 1) { + const reuse = collectBrainPrefixCacheReuse({ + previousLastBrain: records[index - 1], + nextFirstBrain: records[index] + }) + expect(reuse.prefixPreserved, JSON.stringify(reuse.requestWireDiff)).toBe(true) + } + } + expect(root.some((record) => record.user.includes('PUBLIC_ACTION_'))).toBe(false) + for (const childRunId of children) { + const own = result.events.filter( + (event) => event.type === 'subagent_stream' && event.childRunId === childRunId + ) + expect(own.filter((event) => event.delta && event.reset && event.streaming)).toHaveLength( + 3 + ) + expect(own.at(-1)?.streaming).toBe(false) + } + runtime.assertThat(true, { + name: 'subagent-prefix-stable', + message: '主子真实消息前缀保留,新实例不继承旧子历史;mock 不伪造服务端缓存' + }) + } + }) + }) + it('human-subagent-file-cancellation', async () => { await runBackendCase({ caseId: 'human-subagent-file-cancellation', diff --git a/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts b/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts index 24d6c40d..714e53b1 100644 --- a/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts +++ b/tests/case/workbench/conversation/composer/docx-selection-reference.e2e.test.ts @@ -115,17 +115,20 @@ const selectText = async ( .locator(options.selector || 'article p') .nth(startParagraph) .scrollIntoViewIfNeeded() - const bounds = { - start: await textPoint(page, startParagraph, start, options.selector), - end: await textPoint(page, endParagraph, end, options.selector) - } + const startPoint = await textPoint(page, startParagraph, start, options.selector) // Hold Shift before releasing the first click so saved text remains a selection anchor. - await page.mouse.move(bounds.start.x, bounds.start.y) + await page.mouse.move(startPoint.x, startPoint.y) await page.mouse.down() await page.keyboard.down('Shift') try { await page.mouse.up() - await page.mouse.click(bounds.end.x, bounds.end.y) + await page + .locator('.docx-content') + .locator(options.selector || 'article p') + .nth(endParagraph) + .scrollIntoViewIfNeeded() + const endPoint = await textPoint(page, endParagraph, end, options.selector) + await page.mouse.click(endPoint.x, endPoint.y) } finally { await page.keyboard.up('Shift') } @@ -538,11 +541,12 @@ describe('workbench conversation e2e', () => { await page.keyboard.press('Backspace') const atomicDeleted = (await composer.locator('[data-role="document-selection-token"]').count()) === 1 - await selectText(page, 0, 0, 1, 4, { selector: 'article td p', popup: false }) - await page - .getByText('请选择正文中的连续文本;表格仅支持单个单元格。', { exact: true }) - .waitFor() - const crossCellRejected = (await page.getByTestId('docx-selection-popup').count()) === 0 + await selectText(page, 0, 0, 1, 4, { selector: 'article td p' }) + await page.getByTestId('docx-selection-thought').fill('解释这两个表头。') + await page.getByTestId('docx-selection-save').click() + const crossCellQuote = (await readDraftSelections()).at(-1)?.quote + await clickSavedText(page, 1, 1, 'article td p') + await page.getByRole('button', { name: '删除引用', exact: true }).click() await selectText(page, 0, 0, 0, 2, { selector: 'article td p' }) await page.getByTestId('docx-selection-thought').fill('临时引用。') await page.getByTestId('docx-selection-save').click() @@ -552,6 +556,133 @@ describe('workbench conversation e2e', () => { .getAttribute('aria-label') await clickSavedText(page, 0, 0, 'article td p') await page.getByRole('button', { name: '删除引用', exact: true }).click() + + await openWorkbenchWorkspaceFile(runtime, 'docx-selection-layout.docx') + await page.locator('.docx-content article table').waitFor({ state: 'visible' }) + const cellSelector = 'article table td p' + const cellTexts = await page + .locator('.docx-content') + .locator(cellSelector) + .allTextContents() + const lastCell = cellTexts.length - 1 + const tableText = + 'Area\tOwner\tNotes\tStatus\n\nDesign\tAda\tFirst line\nSecond line\nThird paragraph\tReady\n\nBuild\tShared work\t\tPending\n\n\tBea\t\tDone' + const confirmExcerpt = async (thought: string) => { + await page.getByTestId('docx-selection-thought').fill(thought) + await page.getByTestId('docx-selection-save').click() + return (await readDraftSelections()).at(-1)?.quote + } + const removeExcerpt = async ( + paragraph: number, + offset: number, + selector = 'article p' + ) => { + await clickSavedText(page, paragraph, offset, selector) + await page.getByRole('button', { name: '删除引用', exact: true }).click() + } + await selectText(page, 0, 0, lastCell, cellTexts[lastCell].length, { + selector: cellSelector + }) + const wholeTableQuote = await confirmExcerpt('解释所选整张表格。') + await composer.locator('[data-role="document-selection-token"]').last().click() + const locatedTable = await page.evaluate(() => window.getSelection()?.toString() || '') + await clickSavedText(page, 2, 1, cellSelector) + const tableThought = await page.getByTestId('docx-selection-thought').textContent() + await runtime.captureScreenshot({ + name: 'selection-whole-table', + message: '整表引用保留多段落单元格、空格及合并占位,点击高亮重新编辑' + }) + await page.getByRole('button', { name: '删除引用', exact: true }).click() + await selectText(page, lastCell, cellTexts[lastCell].length, 0, 0, { + selector: cellSelector + }) + const reversedTableQuote = await confirmExcerpt('反向选择整表。') + await removeExcerpt(0, 1, cellSelector) + + const paragraphIndex = (text: string) => + page + .locator('.docx-content article p') + .evaluateAll( + (paragraphs, text) => + paragraphs.findIndex((paragraph) => paragraph.textContent === text), + text + ) + const beforeTable = await paragraphIndex('Text before table.') + const afterTable = await paragraphIndex('Text after table.') + await selectText(page, beforeTable, 0, afterTable, 'Text after table.'.length) + const mixedTableQuote = await confirmExcerpt('解释表格及前后正文。') + await removeExcerpt(beforeTable, 2) + await selectText(page, 0, 0, 0, 'Selection header'.length, { selector: 'header p' }) + const headerQuote = await confirmExcerpt('解释页眉。') + await removeExcerpt(0, 1, 'header p') + await selectText(page, 0, 0, 0, 'Selection footer'.length, { selector: 'footer p' }) + const footerQuote = await confirmExcerpt('解释页脚。') + await removeExcerpt(0, 1, 'footer p') + const firstPage = await paragraphIndex('Page one end.') + const secondPage = await paragraphIndex('Page two start.') + await selectText(page, firstPage, 0, secondPage, 'Page two start.'.length) + const crossPageQuote = await confirmExcerpt('解释跨页选区。') + const pageCount = await page.locator('.docx-content section.docx').count() + await removeExcerpt(secondPage, 1) + + const imageBefore = await paragraphIndex('Image before.') + const imageAfter = await paragraphIndex('Image after.') + await selectText(page, imageBefore, 0, imageAfter, 'Image after.'.length) + const imageMixedQuote = await confirmExcerpt('解释图片前后的文字。') + await removeExcerpt(imageBefore, 1) + await selectText(page, imageBefore, 'Image before.'.length, imageAfter, 0, { + popup: false + }) + const toast = page + .locator('.ant-message-notice') + .filter({ hasText: '当前选区没有可读取的文字。' }) + await toast.waitFor({ state: 'visible' }) + const toastCount = await toast.count() + const noInlineNotice = + !(await page.locator('.docx-content').textContent())?.includes( + '当前选区没有可读取的文字。' + ) && (await page.getByTestId('docx-selection-popup').count()) === 0 + await runtime.captureScreenshot({ + name: 'selection-system-toast', + message: '纯图片选区使用统一系统 toast,同一提示合并,文档内无提示条' + }) + await toast.waitFor({ state: 'detached', timeout: 10_000 }) + await runtime.assertThat( + wholeTableQuote === tableText && + reversedTableQuote === tableText && + tableThought === '解释所选整张表格。' && + locatedTable.includes('First line') && + locatedTable.includes('Done') && + mixedTableQuote === 'Text before table.\n' + tableText + '\nText after table.' && + headerQuote === 'Selection header' && + footerQuote === 'Selection footer' && + pageCount === 2 && + crossPageQuote?.startsWith('Page one end.') === true && + crossPageQuote.endsWith('Page two start.') && + imageMixedQuote?.includes('Image before.') === true && + imageMixedQuote.includes('Image after.') && + toastCount === 1 && + noInlineNotice, + { + name: 'docx-selection-table-pages-and-toast', + message: + '真实整表、反向选择、正文混选、合并与空单元格、单元格内换行、页眉页脚、跨页及图片混选正确;无文字使用单个自动消失的系统 toast。', + data: { + wholeTableQuote, + reversedTableQuote, + mixedTableQuote, + headerQuote, + footerQuote, + crossPageQuote, + pageCount, + imageMixedQuote, + tableThought, + locatedTable, + toastCount, + noInlineNotice + } + } + ) await composer.locator('[data-role="document-selection-token"]').click() await page .locator('.docx-content article p') @@ -679,7 +810,7 @@ describe('workbench conversation e2e', () => { memoryRestored, cellQuote, atomicDeleted, - crossCellRejected, + crossCellQuote, resetLabel, nextDraftLabel, frozenHistoryLabel, @@ -712,7 +843,7 @@ describe('workbench conversation e2e', () => { result.memoryRestored && result.cellQuote === '模式' && result.atomicDeleted && - result.crossCellRejected && + result.crossCellQuote === '模式\t核心驱动' && result.resetLabel === '人工智能探究课堂PRD.docx · 1' && result.nextDraftLabel === '客户周会纪要.docx · 1' && result.frozenHistoryLabel === result.historyLabel && diff --git a/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts b/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts index 6af59417..a5f66fe0 100644 --- a/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts +++ b/tests/case/workbench/run/subagent/subagent-delegation-browser-sidecar.e2e.test.ts @@ -1,3 +1,4 @@ +import type { Locator } from 'playwright' import http from 'node:http' import fs from 'node:fs/promises' import path from 'node:path' @@ -39,6 +40,30 @@ const updates = async (runtime: E2ETestRuntime, started: number) => { ) } +const assertCloudTextInside = async (cloud: Locator): Promise => { + expect( + await cloud.evaluate((element) => { + const lines = element.querySelector('.nc-subagent-thought-lines')! + const outline = element.querySelector('.nc-subagent-thought-outline path')! + const inverse = outline.getScreenCTM()!.inverse() + const bounds = lines.getBoundingClientRect() + const corners = [ + [bounds.left, bounds.top], + [bounds.right, bounds.top], + [bounds.left, bounds.bottom], + [bounds.right, bounds.bottom] + ] + return ( + corners.every(([x, y]) => + outline.isPointInFill(new DOMPoint(x, y).matrixTransform(inverse)) + ) && + lines.scrollWidth <= lines.clientWidth && + getComputedStyle(lines).overflowX === 'hidden' + ) + }) + ).toBe(true) +} + // Real website input: forms send server receipts, source facts are rendered by JavaScript. const createSite = async () => { const receipts: string[] = [] @@ -191,11 +216,75 @@ describe('subagent browser and sidecar real UI', () => { )?.includes('选择模型'), 10_000 ) + await runtime.page.mouse.move(0, 0) + // Read-only DOM observer measures one real automatic text cycle, including + // visibility after text ends. A newer message may legitimately supersede it. + const autoCycle = runtime.page.evaluate( + () => + new Promise<{ elapsedMs: number }>((resolve, reject) => { + let cycle: { key: string; endedAt?: number } | undefined + const started = performance.now() + const timer = window.setInterval(() => { + if (performance.now() - started > 120_000) { + window.clearInterval(timer) + reject(new Error('未观察到真实自动云的完整显示周期')) + return + } + const cloud = [ + ...document.querySelectorAll('[data-testid="subagent-thought"]') + ].find( + (element) => + element.getClientRects().length > 0 && !element.closest('.ant-tooltip-hidden') + ) + const key = cloud + ? `${cloud.dataset.childRunId}:${cloud.dataset.messageStartedAt}` + : undefined + const text = cloud + ?.querySelector('[data-testid="subagent-message"]') + ?.textContent?.trim() + const streaming = Boolean( + cloud?.querySelector('[data-testid="subagent-stream-dot"]') + ) + if (cloud && key && text && streaming) { + cycle = { key } + } else if (cycle && key === cycle.key && !streaming) { + cycle.endedAt ??= performance.now() + } else if (cycle?.endedAt && !cloud) { + window.clearInterval(timer) + resolve({ elapsedMs: performance.now() - cycle.endedAt }) + } else if (key !== cycle?.key) { + cycle = undefined + } + }, 25) + }) + ) const started = Date.now() await sendWorkbenchMessage( runtime.page, `请一次派发三位临时帮手分头处理,并由你最后整合:第一位在内置浏览器打开 ${site.url},填写 ALPHA_731 并提交,提交后用内置浏览器截图保存到 ${screenshot} 并保留表单页面,另外用网页资料抓取读取 ${site.url}/facts?marker=31415 并核对该数字,最后将 ${config} 中 timeoutMs 改为 3000,其他字段不动;第二位独立在内置浏览器打开同一个 ${site.url},填写 BETA_892 并提交,提交后保留表单页面,另外用网页资料抓取读取 ${site.url}/facts?marker=27182 核对该数字;第三位搜索 Python 官方 csv reader 说明返回官方来源链接。网页帮手各自使用自己的标签。你把两个提交结果、资料数字和官方来源写到 ${report},检查后交付。` ) + // No hover: the real public text stream opens the cloud and shows its trailing dot. + const automaticCloud = runtime.page.locator('[data-testid="subagent-thought"]:visible') + await waitUntil( + async () => + (await automaticCloud.count()) === 1 && + (await automaticCloud.getByTestId('subagent-stream-dot').count()) === 1 && + Boolean((await automaticCloud.getByTestId('subagent-message').textContent())?.trim()), + 120_000 + ) + await assertCloudTextInside(automaticCloud) + await runtime.captureScreenshot({ + name: 'subagent-cloud-auto', + message: '未悬浮时最新公开消息自动显示,文字及黑球位于云内' + }) + const cycle = await autoCycle + expect(cycle.elapsedMs).toBeGreaterThanOrEqual(1800) + expect(cycle.elapsedMs).toBeLessThan(3500) + runtime.signal({ + name: 'subagent-auto-cloud-hold', + message: '真实文字流结束后云停留约两秒自动隐藏', + data: cycle + }) await waitUntil(async () => (await updates(runtime, started)).some((event) => event.update.status === 'running') ) @@ -208,7 +297,11 @@ describe('subagent browser and sidecar real UI', () => { await queuedRow.hover() const queuedCloud = runtime.page.locator('[data-testid="subagent-thought"]:visible') await queuedCloud.waitFor({ state: 'visible' }) + expect(await queuedCloud.getAttribute('data-child-run-id')).toBe( + await queuedRow.getAttribute('data-child-run-id') + ) expect(await queuedCloud.getByTestId('subagent-message').textContent()).toBe('我在排队') + await assertCloudTextInside(queuedCloud) expect(await queuedCloud.getByTestId('subagent-stream-dot').count()).toBe(0) await runtime.captureScreenshot({ name: 'subagent-cloud-queued', @@ -236,7 +329,11 @@ describe('subagent browser and sidecar real UI', () => { .evaluate((group) => getComputedStyle(group).flexDirection) ).toBe('column') await waitUntil(async () => { - if (await runtime.page.locator('[data-testid="subagent-thought"]:visible').isVisible()) + const visible = runtime.page.locator('[data-testid="subagent-thought"]:visible') + if ( + (await visible.count()) === 1 && + (await visible.getAttribute('data-child-run-id')) === previewChildRunId + ) return true await runtime.page.mouse.move(0, 0) await childRow.hover() @@ -261,6 +358,7 @@ describe('subagent browser and sidecar real UI', () => { .evaluate((dot) => getComputedStyle(dot).animationName) ).toBe('nc-subagent-dot-breathe') expect(await liveCloud.getByTestId('subagent-message').count()).toBe(1) + await assertCloudTextInside(liveCloud) await waitUntil( async () => liveCloud @@ -489,6 +587,7 @@ describe('subagent browser and sidecar real UI', () => { ) expect(await completedCloud.getByTestId('subagent-message').count()).toBe(1) expect(await completedCloud.getByTestId('subagent-stream-dot').count()).toBe(0) + await assertCloudTextInside(completedCloud) expect( await completedCloud.evaluate((cloud) => { const lines = cloud.querySelector('.nc-subagent-thought-lines')! @@ -566,6 +665,13 @@ describe('subagent browser and sidecar real UI', () => { site.getSlowRequests() > slowBefore && (await updates(runtime, start)).some((event) => event.update.status === 'completed') ) + await runtime.page.mouse.move(0, 0) + await getWorkbenchLocator(runtime.page, workbenchSelectors.input).focus() + // The genuine pending navigation leaves a gap after text output; no test delay + // is added to the model or tool. Auto clouds must expire while the tool still runs. + await runtime.page + .locator('[data-testid="subagent-thought"]:visible') + .waitFor({ state: 'hidden', timeout: 10_000 }) const before = await updates(runtime, start) const completed = before.find((event) => event.update.status === 'completed')! expect(await fs.readFile(saved, 'utf8')).toContain(crash ? 'SAVED_CRASH' : 'SAVED_STOP') @@ -584,6 +690,9 @@ describe('subagent browser and sidecar real UI', () => { .locator('[data-testid="subagent-task"][data-status="interrupted"]') .last() .waitFor({ state: 'visible' }) + expect( + await runtime.page.locator('[data-testid="subagent-thought"]:visible').count() + ).toBe(0) expect( await runtime.page .locator(`[data-child-run-id="${completed.update.childRunId}"]`) @@ -600,6 +709,19 @@ describe('subagent browser and sidecar real UI', () => { .map((contents) => contents.id) ) expect(loadingPages.length).toBeGreaterThan(0) + const pendingChild = runtime.page + .locator('[data-testid="subagent-task"][data-status="running"]') + .last() + await pendingChild.hover() + const pendingCloud = runtime.page.locator('[data-testid="subagent-thought"]:visible') + await pendingCloud.waitFor({ state: 'visible' }) + expect(await pendingCloud.getByTestId('subagent-stream-dot').count()).toBe(0) + await assertCloudTextInside(pendingCloud) + // Manual reading survives longer than the automatic two-second hold. + await runtime.page.waitForTimeout(2200) + expect(await pendingCloud.isVisible()).toBe(true) + await runtime.page.mouse.move(0, 0) + await pendingCloud.waitFor({ state: 'hidden' }) await runtime.page.getByRole('button', { name: '停止本次运行', exact: true }).click() await waitUntil(async () => { const latest = new Map( diff --git a/tests/case/workbench/run/subagent/subagent-prefix-cache-retain-live.e2e.test.ts b/tests/case/workbench/run/subagent/subagent-prefix-cache-retain-live.e2e.test.ts new file mode 100644 index 00000000..448d9054 --- /dev/null +++ b/tests/case/workbench/run/subagent/subagent-prefix-cache-retain-live.e2e.test.ts @@ -0,0 +1,206 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AiLlmIoRecord, AiRunEvent } from '../../../../../src/shared/types' +import { + collectBrainPrefixCacheReuse, + continuationPrefixCacheAssertionData +} from '../../../../harness/core/continuation-prefix-cache' +import { runE2ECase } from '../../../../harness/e2e/run-e2e-case' +import { + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, + assertNoWorkbenchFakeSignals, + clampWorkbenchPartnerName, + collectWorkbenchLlmIoRecords, + configureLiveModelThroughSettingsUi, + createPartnerThroughWorkbenchUi, + createTemporaryWorkbenchWorkspace, + loadWorkbenchLiveModelEnv, + prepareWorkbenchWorkspaceThroughUi, + recordWorkbenchAiEvidence, + selectWorkbenchModel, + sendWorkbenchMessage, + waitForWorkbenchAiRunCompletion +} from '../../../../harness/e2e/workbench' + +const CASE_ID = 'subagent-prefix-cache-retain-live' +const SOURCES = ['本周排期更新.txt', '对外口径确认.txt', '临时备注.txt'] +const TASKS = [ + { directory: '排期核对', files: [SOURCES[0], SOURCES[1]] }, + { directory: '口径核对', files: [SOURCES[2], SOURCES[1]] } +] +const brainCalls = (records: readonly AiLlmIoRecord[], runId: string): AiLlmIoRecord[] => + records.filter( + (record) => + record.runId === runId && record.caller === 'human.brain' && record.status === 'completed' + ) +const childUpdates = (events: readonly AiRunEvent[]) => + events.filter( + (event): event is Extract => + event.type === 'subagent_updated' + ) + +describe('workbench subagent prefix cache e2e', () => { + it( + CASE_ID, + async () => { + await runE2ECase({ + caseId: CASE_ID, + domain: 'workbench', + suite: 'run', + groups: ['subagent'], + description: '真实 UI 一次派发,验证同一子实例连续调用及主派发收口的前缀和缓存', + fixtures: SOURCES.map((name) => `tests/fixtures/workspace/工作/${name}`), + serviceMode: 'real', + execution: 'live', + windowMode: 'visible', + runMode: 'single', + execute: async (runtime) => { + const env = loadWorkbenchLiveModelEnv() + const workspace = await createTemporaryWorkbenchWorkspace(CASE_ID, 'empty') + const originals = new Map( + await Promise.all( + SOURCES.map( + async (name) => + [ + name, + await fs.readFile(path.resolve('tests/fixtures/workspace/工作', name), 'utf8') + ] as const + ) + ) + ) + const inputs: Array<{ sourcePath: string; original: string }> = [] + const taskFiles: string[][] = [] + for (const task of TASKS) { + const directory = path.join(workspace.rootPath, task.directory) + await fs.mkdir(directory) + const files = task.files.map((name) => path.join(directory, name)) + for (const [index, name] of task.files.entries()) { + const original = originals.get(name)! + await fs.writeFile(files[index], original, 'utf8') + inputs.push({ sourcePath: files[index], original }) + } + taskFiles.push(files) + } + await configureLiveModelThroughSettingsUi(runtime.page, env) + const workspaceName = clampWorkbenchPartnerName(`Workbench ${CASE_ID}`) + await prepareWorkbenchWorkspaceThroughUi(runtime.page, { + workspaceName, + rootPath: workspace.rootPath + }) + await createPartnerThroughWorkbenchUi(runtime.page, workspaceName) + await selectWorkbenchModel(runtime.page, env.modelName) + const tasks = [ + `先阅读 ${taskFiles[0][0]},找出最终复核的时间安排;得到排期信息后,再阅读 ${taskFiles[0][1]} 核对正式负责人称呼。交付时间、负责人及两份来源路径。`, + `先阅读 ${taskFiles[1][0]},识别这份内部随手记对风险状态的判断;得到判断后,再阅读 ${taskFiles[1][1]} 核对能否用于对外同步。交付可用的风险口径、原因及两份来源路径。` + ] + const startedAt = Date.now() + await sendWorkbenchMessage( + runtime.page, + `请通过 delegate_to_subagent 一次派发两位临时帮手,分别独立执行任务A:${tasks[0]} 任务B:${tasks[1]} 两位帮手只读自己的资料目录,不修改文件。你只简短汇总他们交付的负责人、排期和对外风险口径,不接管核对。` + ) + const evidence = await waitForWorkbenchAiRunCompletion( + runtime, + startedAt, + env.modelName, + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS, + { page: runtime.page } + ) + recordWorkbenchAiEvidence(runtime, evidence, 0.8) + const runId = evidence.events.find((event) => event.type === 'run_started')?.runId + if (!runId) throw new Error('缺少主运行 ID') + const records = await collectWorkbenchLlmIoRecords(runtime) + const unchanged = await Promise.all( + inputs.map( + async (input) => (await fs.readFile(input.sourcePath, 'utf8')) === input.original + ) + ) + await runtime.captureScreenshot({ + name: 'subagent-cache-completed', + message: '一次派发的两项资料核对和主伙伴汇总' + }) + return { runId, events: evidence.events, records, taskFiles, unchanged } + }, + assert: async (result, runtime) => { + expect(result.unchanged).toEqual([true, true, true, true]) + const assertCache = async (calls: AiLlmIoRecord[]): Promise => { + const pairs = calls.slice(1).map((next, index) => + collectBrainPrefixCacheReuse({ + previousLastBrain: calls[index], + nextFirstBrain: next + }) + ) + expect(pairs.length).toBeGreaterThan(0) + for (const [index, reuse] of pairs.entries()) { + await runtime.assertThat(reuse.prefixPreserved, { + name: `request-prefix-${calls[0].runId}-${index + 1}`, + message: '同一执行者的连续请求保留已有消息及工具前缀', + data: continuationPrefixCacheAssertionData(reuse) + }) + expect(Number.isFinite(reuse.reuseRatio)).toBe(true) + expect(reuse.nextFirstCachedInputTokens).toBeGreaterThanOrEqual(0) + expect(reuse.nextFirstCachedInputTokens!).toBeLessThanOrEqual( + reuse.nextFirstInputTokens! + ) + } + await runtime.assertThat( + pairs.some((reuse) => (reuse.nextFirstCachedInputTokens || 0) > 0), + { + name: `run-cache-reuse-${calls[0].runId}`, + message: '同一执行者的连续请求实际复用缓存;逐段比例记录供评估', + data: { + pairs: pairs.map(continuationPrefixCacheAssertionData) + } + } + ) + } + const updates = childUpdates(result.events) + const completed = updates.filter((event) => event.update.status === 'completed') + expect(completed).toHaveLength(2) + const results: string[] = [] + for (const event of completed) { + const childRunId = event.update.childRunId + const calls = brainCalls(result.records, childRunId) + expect(calls.length).toBeGreaterThanOrEqual(3) + expect( + calls.every( + (call) => + call.ownerRunId === result.runId && + call.parentToolCallId === event.update.toolCallId + ) + ).toBe(true) + const reads = updates.filter( + (item) => + item.update.childRunId === childRunId && + item.update.action?.toolName === 'text_file_read' && + item.update.action.status === 'completed' + ) + expect(reads.length).toBeGreaterThanOrEqual(2) + const toolMessages = JSON.stringify( + calls.flatMap( + (call) => + call.requestWire?.messages.filter((message) => message.role === 'tool') || [] + ) + ) + const files = result.taskFiles.find((sources) => toolMessages.includes(sources[0])) + expect(files).toBeDefined() + for (const file of files!) expect(toolMessages).toContain(file) + expect(toolMessages).toContain('Alice') + expect(toolMessages).toContain('保持跟进') + results.push(event.update.result || '') + await assertCache(calls) + } + expect(results.join('\n')).toContain('Alice') + expect(results.join('\n')).toMatch(/跟进|签署/) + const root = brainCalls(result.records, result.runId) + expect(root.length).toBeGreaterThanOrEqual(2) + await assertCache(root) + expect(root.at(-1)?.rawText).toContain('Alice') + expect(root.at(-1)?.rawText).toMatch(/跟进|签署/) + await assertNoWorkbenchFakeSignals(runtime) + } + }) + }, + WORKBENCH_LIVE_AI_E2E_TIMEOUT_MS + ) +}) diff --git a/tests/fixtures/workspace/docx-selection-layout.docx b/tests/fixtures/workspace/docx-selection-layout.docx new file mode 100644 index 0000000000000000000000000000000000000000..5a5bdb0ee7885f00e62ca15dad04854a46a6191b GIT binary patch literal 45184 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 ZxK)sb1^=i22wY-9kb)bCk0pP6`VVH_>aqX; literal 0 HcmV?d00001 diff --git a/tests/harness/core/continuation-prefix-cache.ts b/tests/harness/core/continuation-prefix-cache.ts index a448bdb7..666ad9fc 100644 --- a/tests/harness/core/continuation-prefix-cache.ts +++ b/tests/harness/core/continuation-prefix-cache.ts @@ -4,7 +4,7 @@ import { diffChatToolsRequestWire } from '../../../src/main/services/ai/utils/ll /** * 跨 run 续接时,下一 run 首次 brain 的 cachedInputTokens * 相对上一 run 末次 brain inputTokens 的最低比例。 - * 全 live 跨 run case 共用;调低/调高只改这一处,避免各 case 阈值漂移。 + * 连续 brain 与跨 run live 共用;调低/调高只改这一处,避免各 case 阈值漂移。 */ export const CROSS_RUN_PREFIX_CACHE_MIN_RATIO = 0.85 @@ -60,6 +60,17 @@ export const collectContinuationPrefixCacheReuse = (input: { const previousLastBrain = lastBrainRecordWithUsage(previousRecords) || lastBrainRecord(previousRecords) const nextFirstBrain = firstBrainRecord(nextRecords) + return collectBrainPrefixCacheReuse({ previousLastBrain, nextFirstBrain, minRatio }) +} + +/** Same projection invariant for adjacent brain calls within a run or across runs. */ +export const collectBrainPrefixCacheReuse = (input: { + previousLastBrain: AiLlmIoRecord | undefined + nextFirstBrain: AiLlmIoRecord | undefined + minRatio?: number +}): ContinuationPrefixCacheReuse => { + const { previousLastBrain, nextFirstBrain } = input + const minRatio = input.minRatio ?? CROSS_RUN_PREFIX_CACHE_MIN_RATIO const previousWire = previousLastBrain?.requestWire const nextWire = nextFirstBrain?.requestWire const prefixPreserved = Boolean(