From 903ba66f17a9530c76ca1fae2a057312247154d9 Mon Sep 17 00:00:00 2001 From: xiaomakuaiz Date: Sun, 30 Aug 2026 20:53:28 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(desktop):=20=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E5=81=9C=E6=AD=A2=E5=85=A5=E5=8F=A3?= =?UTF-8?q?(subagent/cancel=20=E7=9B=B4=E9=80=9A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引擎 733df47 落地 subagentControl(subagent/list + subagent/cancel), spec P3 的 publishEvent 模型中介方案作废,按 §4 预留升级位直通: - driver:session_call 新增 background_stop {agent_id}——cap 守卫、 engine_id 寻址、应答 {state,stopped} 与引擎错误原样透传。终态刻意 不走应答:仍由 task_notification 权威回填,引擎更替走既有孤儿对账, 不新增终态路径;Caps 投影新增 subagent_control - UI:BackgroundBar 任务行常驻「停止」,二段确认(原位变「确认停止?」, 4s 自动回弹)→「停止中…」直至通知收卡;失败回弹并条内外显原因可重试; engine_caps 无能力或卡缺 agentId(旧 journal)不出入口 - reduce:stopped 通知按「已停止」收卡(chat.tool.bgStopped)——用户 刚点过停止,不再与「执行失败」混词 - 测试:driver 4 条(cap 拒绝/agent_id 校验/RPC 形状/错误透传)、 UI 二段确认/停止中/门控/回弹、reduce stopped 收卡;spec P3 与 ARCHITECTURE 已补齐清单同步 --- .../design.md | 38 +++-- .../tasklist.md | 26 +-- desktop/ARCHITECTURE.md | 4 + desktop/src/driver/mod.rs | 5 +- desktop/src/driver/ohmy_tests.rs | 150 ++++++++++++++++++ desktop/src/driver/session.rs | 33 ++++ .../ui-next/src/features/chat/ChatView.tsx | 75 ++++++++- .../features/chat/composer/Composer.test.tsx | 140 ++++++++++++++++ .../src/features/chat/composer/Composer.tsx | 15 +- .../chat/composer/LocalComposerHost.tsx | 11 +- .../features/chat/composer/composerKit.tsx | 60 +++++++ desktop/ui-next/src/lib/i18n/en.ts | 5 + desktop/ui-next/src/lib/i18n/zh.ts | 5 + desktop/ui-next/src/lib/ipc/approvals.ts | 2 + desktop/ui-next/src/lib/ipc/controls.ts | 22 ++- .../ui-next/src/lib/protocol/reduce.test.ts | 23 +++ desktop/ui-next/src/lib/protocol/reduce.ts | 7 +- 17 files changed, 584 insertions(+), 37 deletions(-) diff --git a/.monkeycode/specs/desktop-background-agent-presence/design.md b/.monkeycode/specs/desktop-background-agent-presence/design.md index e270a9bc9..d8ed7e540 100644 --- a/.monkeycode/specs/desktop-background-agent-presence/design.md +++ b/.monkeycode/specs/desktop-background-agent-presence/design.md @@ -40,11 +40,23 @@ sidecar 增加 `background: [{agentId, tcId, summary, startedAt}]`: 派发闭卡帧(driver 生成"已转后台"回执处)附带结构化标记(沿用 `progress {kind:"background_agent"}` 词汇或闭卡 update 上的专用字段),reducer 优先按结构化字段进入"保持运行态"分支;对引擎中文回执的字符串嗅探保留为旧 journal 回放 fallback,注释标注其唯一用途。 -### 2.6 停止(P3) - -- `session_call` 新增 kind `background_stop {agentId}`:映射 shell sid → engine sid 后调 `session/publishEvent`,`event_id = "bgstop:" + agentId + ":" + uuid`(幂等),`type = "host_request"`,`source = "desktop"`,message 固定模板:点名 `TaskStop(task_id=)`、要求仅执行停止并简短确认。`busy`/`not_found` 原样返回给 UI。 -- `normalize.rs` 通知轮开轮分支放宽:`source == "notification" || source == "desktop"` 共用同一幂等开轮/收轮逻辑;desktop 事件轮的正文以轻量系统行呈现。 -- TaskStop 观察:driver 按事件携带的 `event_id` 关联该轮 `tool_call`,登记 `bgstop` 待决项;见到 `TaskStop` 即确认,超时(30s)未见或轮次结束仍未见 → 经 session-event 通知 UI 回弹。停止成功的终态不需要专门处理——引擎 `manager.Complete` 照常入 `NotificationQueue`,现有 stopped 结果卡与派发卡回填链路原样工作。 +### 2.6 停止(P3,已按直通 RPC 落地) + +引擎 733df47 提供 `subagentControl` 能力(`subagent/list` / `subagent/cancel`), +§4 预留的「直通切换位」直接生效;原 publishEvent 模型中介方案从未实现,整体作废 +(连同 desktop 事件轮、TaskStop 观察与 30s 回弹——直通应答本身就是受理确认): + +- `session_call` 新增 kind `background_stop {agent_id}`:映射 shell sid → engine_id + 后直调 `subagent/cancel`,应答 `{status:"ok", state, stopped}` 原样透传。 + `stopped=false, state="stopping"` = 引擎已发出取消但 5s(shutdownGrace)内未收尾, + 子代理稍后退出时通知照常到达;引擎错误(agent/顶层会话不存在)原样上抛给 UI 外显。 +- 命令层守卫 `subagentControl` cap(契约 2 的唯一强制点);`driver/mod.rs::Caps` + 投影新增 `subagent_control` 供 UI 门控。 +- 终态不走应答:引擎 `manager.Complete` 照常入 `NotificationQueue`,现有 + `task_notification` 链路(stopped 结果卡 + 派发卡回填 + 第三态收敛)原样工作, + 不新增终态路径;通知丢失(引擎更替)由既有孤儿对账兜底。stopped 通知的派发卡 + 收卡语义细化为「已停止」(`chat.tool.bgStopped`),不再与「执行失败」混词。 +- `subagent/list` 暂未消费:pending 派生与对账已由壳侧登记覆盖,留作日后对账校验位。 ## 3. UI(ui-next) @@ -59,7 +71,11 @@ sidecar 增加 `background: [{agentId, tcId, summary, startedAt}]`: - 渲染条件:`!presentation.running && presentation.backgroundRunning > 0 && conn.connected`,占 RunBar 的插槽位; - 内容:脉冲状态点 + `chat.bg.running`(count 插值)。单任务摘要与耗时内联(`rawInput` 的 summary/description 优先,退回工具标题);多任务收起为计数,可展开逐任务一行(摘要 + 耗时 + 各自「查看子会话」,对齐工具卡组「×N 展开」形态)。取材为 items 里全部 `run+background` 卡,ChatView 供给并经签名 memo 稳定引用; - 交互:任务行点击打开对应子会话回放浮层(`childSessionId`);无主循环停止按钮; -- P3:任务行 hover 出"停止"→ 二段确认(按钮原位变"确认停止?")→ 调 `background_stop` → 行进入"停止中…";busy/超时回弹并 toast。 +- P3(已落地):任务行常驻「停止」(与「查看子会话」同形态;实现弃用了 hover 出钮—— + 同排的查看入口是常驻的,忽隐忽现反而更抓眼)→ 二段确认(按钮原位变「确认停止?」, + 4s 未点自动回弹)→ 调 `background_stop` → 行进入「停止中…」直至通知收卡;失败回弹、 + 原因在条内外显(`chat.bg.stopFailed`),可重试。停止入口按 `backgroundAgentId` 寻址, + 无 agentId 的旧 journal 卡与 `engine_caps.subagent_control` 为假时不出入口。 ### 3.3 轮末分隔线变体 @@ -76,16 +92,16 @@ sidecar 增加 `background: [{agentId, tcId, summary, startedAt}]`: ### 3.6 i18n -新增键:`chat.bg.running`、`chat.sys.turnEndBg`、`chat.tool.bgInterrupted`、停止入口与确认文案、系统通知模板。zh/en 同步。 +新增键:`chat.bg.running`、`chat.sys.turnEndBg`、`chat.tool.bgInterrupted`、停止入口与确认文案(`chat.bg.stop/stopConfirm/stopping/stopFailed`、`chat.tool.bgStopped`)、系统通知模板。zh/en 同步。 ## 4. 兼容与升级策略 - 旧 journal:无结构化标记的历史帧靠保留的嗅探 fallback 正常回放;无 sidecar `background` 字段的旧会话对账视为空集。 -- 旧引擎:`publishEvent` 已在 caps 声明,缺失时停止入口整体隐藏(探测 `engine_caps`)。 -- 升级位:引擎将来提供 `task/stop`/轮末任务快照 caps 时,`background_stop` 切直通 RPC,publishEvent 降为 fallback;派生逻辑收敛在 driver 一处,UI 无感。 +- 旧引擎:`subagentControl` 缺失时停止入口整体隐藏(探测 `engine_caps.subagent_control`,命令层同守)。 +- 升级位(已兑现):引擎 733df47 落地 `subagentControl` 直通 RPC,`background_stop` 直调 `subagent/cancel`;publishEvent fallback 不再保留(从未实现,直接按直通落地),见 §2.6。 ## 5. 测试 -- Rust(`ohmy_tests.rs`):pending 派生(两条派发路径、多任务并存);turn/stopped complete/interrupted × pending 有无的状态词;通知清理回写;sidecar 持久化写/删;引擎更替对账(合成终态帧、状态归一、正常重开不触发);desktop 事件轮幂等开轮/收轮;TaskStop 观察确认与超时回弹。 -- UI(Vitest):countItem 拆分与 presentation 全等;BackgroundBar 渲染条件、断连门控、点击定位;turn-end 变体 key;结构化标记优先 + 嗅探 fallback;停止二段确认、停止中、回弹(P3);通知触发条件(P2,mock plugin)。 +- Rust(`ohmy_tests.rs`):pending 派生(两条派发路径、多任务并存);turn/stopped complete/interrupted × pending 有无的状态词;通知清理回写;sidecar 持久化写/删;引擎更替对账(合成终态帧、状态归一、正常重开不触发);background_stop cap 守卫/agent_id 校验/RPC 形状与应答透传/引擎错误透传。 +- UI(Vitest):countItem 拆分与 presentation 全等;BackgroundBar 渲染条件、断连门控、点击定位;turn-end 变体 key;结构化标记优先 + 嗅探 fallback;停止二段确认、停止中、能力门控、失败回弹(P3);stopped 通知按「已停止」收卡;通知触发条件(P2,mock plugin)。 - 门禁:相关 Cargo tests、Vitest、TypeScript typecheck、`git diff --check`。 diff --git a/.monkeycode/specs/desktop-background-agent-presence/tasklist.md b/.monkeycode/specs/desktop-background-agent-presence/tasklist.md index 73408e9af..5d825860d 100644 --- a/.monkeycode/specs/desktop-background-agent-presence/tasklist.md +++ b/.monkeycode/specs/desktop-background-agent-presence/tasklist.md @@ -36,18 +36,20 @@ - [ ] 6.2 失焦/非当前会话的结构化通知 → 系统通知,点击聚焦并定位结果卡 - [ ] 6.3 权限缺失静默降级;触发条件测试(mock plugin) -## P3 停止(best-effort) - -- [ ] 7. Driver 停止通道 - - [ ] 7.1 `session_call` kind `background_stop`:publishEvent 组装(幂等 event_id、点名 TaskStop、busy/not_found 透传) - - [ ] 7.2 `normalize.rs` host 事件轮(source=desktop)接入开轮/收轮,轻量系统行呈现 - - [ ] 7.3 按 event_id 观察 TaskStop tool_call:确认/超时回弹(session-event 通知 UI) - - [ ] 7.4 Rust 测试:幂等开轮、观察确认、超时回弹、busy 透传 -- [ ] 8. UI 停止入口 - - [ ] 8.1 BackgroundBar 任务行与派发卡详情弹窗的"停止"入口,二段确认(提示不可恢复) - - [ ] 8.2 停止中态、busy/超时回弹与 toast - - [ ] 8.3 `engine_caps` 探测:无 publishEvent 时隐藏入口;预留 task/stop 直通切换位 - - [ ] 8.4 UI 测试:二段确认、停止中、回弹 +## P3 停止(已按 subagentControl 直通 RPC 落地;publishEvent 中介方案作废,见 design §2.6) + +- [x] 7. Driver 停止通道 + - [x] 7.1 `session_call` kind `background_stop {agent_id}`:直调 `subagent/cancel`(cap 守卫、engine_id 寻址、应答/错误原样透传) + - [x] 7.2 `Caps` 投影新增 `subagent_control`(engine_caps → UI 门控) + - [x] 7.3 Rust 测试:cap 缺失拒绝、agent_id 校验、RPC 形状与应答透传、引擎错误透传 + - ~~publishEvent 组装 / desktop 事件轮 / TaskStop 观察与超时回弹~~(直通 RPC 后不需要:应答即受理确认,终态归 task_notification 既有链路) +- [x] 8. UI 停止入口 + - [x] 8.1 BackgroundBar 任务行常驻「停止」,二段确认(原位变「确认停止?」,4s 自动回弹) + - [x] 8.2 停止中态(等 task_notification 收卡)、失败回弹 + 条内原因外显(chat.bg.stopFailed) + - [x] 8.3 `engine_caps.subagent_control` 门控:无能力/无 agentId(旧 journal 卡)隐藏入口 + - [x] 8.4 stopped 终态状态词:通知按「已停止」收卡(chat.tool.bgStopped),不再混用「执行失败」 + - [x] 8.5 UI 测试:二段确认、停止中、能力门控、失败回弹;reduce stopped 收卡 + - [ ] 8.6 派发卡详情弹窗的停止入口(暂缓:状态条为唯一入口) ## 验证 diff --git a/desktop/ARCHITECTURE.md b/desktop/ARCHITECTURE.md index 871c95eb7..b6cd47a75 100644 --- a/desktop/ARCHITECTURE.md +++ b/desktop/ARCHITECTURE.md @@ -386,6 +386,10 @@ interactive:true,引擎给持久 Task 工具族)、会话思考档 session/setTh 直调自动提升)、子代理事件带 parent_session_id/parent_tool_call_id (dab1b85,壳精确认领)。固定引擎已删除“同步 Agent 超时后自动转后台”, 后台执行必须由 `run_in_background:true` 显式声明。 +子代理控制面(733df47,cap `subagentControl`:`subagent/list` + +`subagent/cancel`):壳经 `session_call background_stop` 直通 cancel +(UI 后台状态条的停止入口,Caps.subagent_control 门控);list 暂未消费, +pending 派生/对账仍以壳侧登记为权威。 ## 开发与构建产物 diff --git a/desktop/src/driver/mod.rs b/desktop/src/driver/mod.rs index 921e1d464..e3c1005f7 100644 --- a/desktop/src/driver/mod.rs +++ b/desktop/src/driver/mod.rs @@ -286,7 +286,7 @@ impl DriverHost { } } -/// 引擎能力表(对表 desktop/ui/src/types.ts 的 EngineCaps)。 +/// 引擎能力表(对表 ui-next/src/lib/ipc/approvals.ts 的 EngineCaps)。 /// 能力仍是渐进的(随上游补齐翻位),由 ready 握手与桌面壳实际能力 /// 共同投影;UI 降级与命令层守卫都读取同一份运行时结果。 #[derive(Clone, Copy, serde::Serialize)] @@ -299,6 +299,8 @@ pub struct Caps { /// 运行中向当前轮追加用户指令。 pub steering: bool, pub attachments: bool, + /// 定向停止后台子代理(subagent/cancel;UI 后台状态条的停止入口)。 + pub subagent_control: bool, } pub fn caps(engine: &OhmyDriver, browser_ext: bool) -> Caps { @@ -315,6 +317,7 @@ pub fn caps(engine: &OhmyDriver, browser_ext: bool) -> Caps { steering: engine.has_capability("session/steer"), // 上传/路径注入由壳实现,不是引擎握手项。 attachments: true, + subagent_control: engine.has_capability("subagentControl"), } } diff --git a/desktop/src/driver/ohmy_tests.rs b/desktop/src/driver/ohmy_tests.rs index 89314edfd..9e29444a5 100644 --- a/desktop/src/driver/ohmy_tests.rs +++ b/desktop/src/driver/ohmy_tests.rs @@ -3788,6 +3788,7 @@ fn public_caps_follow_the_ready_handshake() { let mut engine_caps = inner.transport.engine_caps.lock().unwrap(); engine_caps.insert("turn/stopped".into()); engine_caps.insert("session/steer".into()); + engine_caps.insert("subagentControl".into()); } let driver = OhmyDriver(inner); let caps = crate::driver::caps(&driver, false); @@ -3796,6 +3797,155 @@ fn public_caps_follow_the_ready_handshake() { assert!(!caps.perm_remember); assert!(caps.steering); assert!(caps.attachments); + assert!(caps.subagent_control); +} + +/// background_stop:subagentControl 缺失时命令层拒绝,不下发 RPC。 +#[tokio::test] +async fn background_stop_requires_the_subagent_control_capability() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-no-cap"); + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), bare_session("s1")); + let driver = OhmyDriver(inner.clone()); + + let error = driver + .session_call("s1", "background_stop", json!({ "agent_id": "ag-1" })) + .await + .unwrap_err(); + + assert!(error.contains("不支持"), "应提示引擎能力缺口: {error}"); + assert!(stdin_rx.try_recv().is_err(), "能力缺失不得下发 RPC"); +} + +/// background_stop:agent_id 缺失/空白在本地快速失败。 +#[tokio::test] +async fn background_stop_requires_a_nonempty_agent_id() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-agent-id"); + inner + .transport + .engine_caps + .lock() + .unwrap() + .insert("subagentControl".into()); + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), bare_session("s1")); + let driver = OhmyDriver(inner.clone()); + + let error = driver + .session_call("s1", "background_stop", json!({ "agent_id": " " })) + .await + .unwrap_err(); + + assert!( + error.contains("agent_id"), + "错误应指出缺少 agent_id: {error}" + ); + assert!(stdin_rx.try_recv().is_err(), "非法 agent_id 不得下发 RPC"); +} + +/// background_stop 直通 subagent/cancel:按 engine_id 寻址、应答原样透传 +/// (stopped=false/state=stopping 表示引擎仍在收尾)。终态不在应答里处理 +/// ——收尾归 task_notification 权威路径。 +#[tokio::test] +async fn background_stop_forwards_subagent_cancel_and_passes_the_reply_through() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-forward"); + inner + .transport + .engine_caps + .lock() + .unwrap() + .insert("subagentControl".into()); + let mut session = bare_session("s1"); + session.running = false; // 后台第三态:主循环空闲时也可停止 + session.engine_id = "eng-1".into(); // 换绑过的会话必须按 engine_id 寻址 + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), session); + let driver = OhmyDriver(inner.clone()); + + let call = tokio::spawn(async move { + driver + .session_call("s1", "background_stop", json!({ "agent_id": "ag-7" })) + .await + }); + let request: Value = serde_json::from_str( + &stdin_rx + .recv() + .await + .expect("RPC 出站") + .expect("非 shutdown 消息"), + ) + .unwrap(); + assert_eq!(request["method"], "subagent/cancel"); + assert_eq!( + request["params"], + json!({ "session_id": "eng-1", "agent_id": "ag-7" }) + ); + answer_rpc( + &inner, + &request, + json!({ "jsonrpc": "2.0", "id": request["id"], + "result": { "status": "ok", "state": "stopping", "stopped": false } }), + ); + assert_eq!( + call.await.unwrap().unwrap(), + json!({ "result": { "status": "ok", "state": "stopping", "stopped": false } }) + ); +} + +/// 引擎侧错误(agent 不存在/顶层会话不在)原样上抛给 UI 外显。 +#[tokio::test] +async fn background_stop_surfaces_engine_errors() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-error"); + inner + .transport + .engine_caps + .lock() + .unwrap() + .insert("subagentControl".into()); + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), bare_session("s1")); + let driver = OhmyDriver(inner.clone()); + + let call = tokio::spawn(async move { + driver + .session_call("s1", "background_stop", json!({ "agent_id": "gone" })) + .await + }); + let request: Value = serde_json::from_str( + &stdin_rx + .recv() + .await + .expect("RPC 出站") + .expect("非 shutdown 消息"), + ) + .unwrap(); + answer_rpc( + &inner, + &request, + json!({ "jsonrpc": "2.0", "id": request["id"], + "error": { "code": -32000, "message": "sub-agent not found: gone" } }), + ); + let error = call.await.unwrap().unwrap_err(); + assert!( + error.contains("sub-agent not found"), + "引擎错误应透传: {error}" + ); } fn answer_rpc(inner: &Inner, request: &Value, response: Value) { diff --git a/desktop/src/driver/session.rs b/desktop/src/driver/session.rs index 69fcf7b25..95bc65e75 100644 --- a/desktop/src/driver/session.rs +++ b/desktop/src/driver/session.rs @@ -3025,6 +3025,39 @@ impl OhmyDriver { } Ok(json!({ "result": { "status": "queued" } })) } + // 定向停止一个后台子代理(subagent/cancel 直通)。应答只驱动 UI 的 + // 「停止中」瞬态;终态回填仍由 task_notification 权威路径完成 + // (引擎 manager.Complete 照常入通知队列),不新增终态路径—— + // 通知丢失(引擎更替)也有既有孤儿对账兜底。 + "background_stop" => { + if !self.has_cap("subagentControl") { + return Err("当前引擎版本不支持停止后台子代理,请升级引擎".into()); + } + let agent_id = payload + .get("agent_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|a| !a.is_empty()) + .ok_or_else(|| "停止指令缺少非空 agent_id".to_string())? + .to_string(); + let engine_id = { + let sessions = self.0.sess.sessions.lock_ok(); + let Some(session) = sessions.get(id) else { + return Err("会话未打开".into()); + }; + session.engine_id.clone() + }; + // {status:"ok", state, stopped} 原样透传:stopped=false 且 + // state="stopping" 表示引擎已发出取消但 5s 内未收尾,子代理 + // 稍后退出时通知照常到达 + let response = self + .rpc( + "subagent/cancel", + json!({ "session_id": engine_id, "agent_id": agent_id }), + ) + .await?; + Ok(json!({ "result": response })) + } "session_set_model" => { let name = payload.get("model").and_then(|v| v.as_str()).unwrap_or(""); let model_id = self.model_id_of(name)?; // 前置校验,未知模型不动会话 diff --git a/desktop/ui-next/src/features/chat/ChatView.tsx b/desktop/ui-next/src/features/chat/ChatView.tsx index a2458acae..532e1327c 100644 --- a/desktop/ui-next/src/features/chat/ChatView.tsx +++ b/desktop/ui-next/src/features/chat/ChatView.tsx @@ -26,7 +26,8 @@ import { createPortal } from "react-dom"; import { useApprovalHotkeys } from "@/app/shortcuts"; import { useI18n } from "@/lib/i18n"; import { useSettingsNavigation } from "@/features/settings/SettingsNavigationContext"; -import { sessionOutline, type OutlineItem } from "@/lib/ipc/controls"; +import { sessionBackgroundStop, sessionOutline, type OutlineItem } from "@/lib/ipc/controls"; +import { engineCaps } from "@/lib/ipc/approvals"; import { repoChanges, repoPreviewFiles, repoReveal } from "@/lib/ipc/repo"; import { designTemplatePreviewRead, sessionFrame, sessionPatch, type SessionMeta } from "@/lib/ipc/sessions"; import { onNativeFileDrop, uploadFileURL } from "@/lib/ipc/uploads"; @@ -653,6 +654,46 @@ export function ChatView({ setChildId(null); // 切会话关掉上一个会话的子回放 }, [meta.id]); + // ==== 后台子代理停止入口(background_stop → subagent/cancel 直通) ==== + // 能力门控:无 subagentControl 时入口整体隐藏(契约 2,不按版本猜)。 + // 挂载拉一次即可——引擎自愈会经 epoch 整格重建本组件,快照随之刷新 + const [canStopBg, setCanStopBg] = useState(false); + useEffect(() => { + let alive = true; + void engineCaps().then((caps) => { + if (alive && caps) setCanStopBg(caps.subagent_control); + }); + return () => { + alive = false; + }; + }, []); + // 停止是「已受理」瞬态:成功后行保持「停止中」,真正收卡等 + // task_notification 终态(卡关了行自然消失,集合残留无害);失败回弹 + // 并外显原因,可重试。切会话即作废。 + const [bgStopping, setBgStopping] = useState>(new Set()); + const [bgStopError, setBgStopError] = useState(null); + useEffect(() => { + setBgStopping(new Set()); + setBgStopError(null); + }, [meta.id]); + const onStopBackground = useCallback( + (agentId: string) => { + setBgStopError(null); + setBgStopping((prev) => new Set(prev).add(agentId)); + void sessionBackgroundStop(metaRef.current.id, agentId).catch((e: unknown) => { + setBgStopping((prev) => { + const next = new Set(prev); + next.delete(agentId); + return next; + }); + setBgStopError( + t("chat.bg.stopFailed", { reason: e instanceof Error ? e.message : String(e) }), + ); + }); + }, + [t], + ); + // ==== 空闲态后台状态条取材:全部 run+background 派发卡(时间序) ==== // 摘要优先 SendMessage 的 summary / Agent 的 description(rawInput), // 退回工具标题;入口复用子会话回放浮层。running 时状态条不渲染,跳过 @@ -663,7 +704,14 @@ export function ChatView({ // 断连门控:引擎不在了,"运行中"就是谎言(重启后的孤儿卡另有对账 // 路径补终态);conn 为 null 是"还不知道",按乐观显示 if (conn && !conn.connected) return []; - const out: { key: string; title: string; startedAt?: number; childId?: string }[] = []; + const out: { + key: string; + title: string; + startedAt?: number; + childId?: string; + agentId?: string; + stopping?: boolean; + }[] = []; for (const it of state.items) { if (it.kind === "tool" && it.status === "run" && it.background) { const input = (it.rawInput ?? {}) as { summary?: unknown; description?: unknown }; @@ -676,21 +724,36 @@ export function ChatView({ title, ...(it.startedAt !== undefined ? { startedAt: it.startedAt } : {}), ...(it.childSessionId ? { childId: it.childSessionId } : {}), + ...(it.backgroundAgentId ? { agentId: it.backgroundAgentId } : {}), + ...(it.backgroundAgentId && bgStopping.has(it.backgroundAgentId) + ? { stopping: true } + : {}), }); } } return out; - }, [state, conn]); + }, [state, conn, bgStopping]); const onOpenBackground = useCallback((childId: string) => setChildId(childId), []); const bgSignature = bgCards - .map((c) => `${c.key}|${c.title}|${c.childId ?? ""}|${c.startedAt ?? 0}`) + .map( + (c) => + `${c.key}|${c.title}|${c.childId ?? ""}|${c.startedAt ?? 0}|${c.agentId ?? ""}|${c.stopping ? 1 : 0}`, + ) .join("\n"); const backgroundInfo = useMemo( - () => (bgCards.length === 0 ? undefined : { tasks: bgCards, onOpen: onOpenBackground }), + () => + bgCards.length === 0 + ? undefined + : { + tasks: bgCards, + onOpen: onOpenBackground, + ...(canStopBg ? { onStop: onStopBackground } : {}), + ...(bgStopError !== null ? { stopError: bgStopError } : {}), + }, // bgSignature 覆盖 bgCards 的全部字段值:签名不变则内容必然逐字段相同, // 用它换引用稳定,避免逐批次新数组击穿 memo // eslint-disable-next-line react-hooks/exhaustive-deps - [bgSignature, onOpenBackground], + [bgSignature, onOpenBackground, canStopBg, onStopBackground, bgStopError], ); // pane 连接条的展开态(收/展是会话内瞬态;切会话回到收起) diff --git a/desktop/ui-next/src/features/chat/composer/Composer.test.tsx b/desktop/ui-next/src/features/chat/composer/Composer.test.tsx index e73cd7fd7..729382b67 100644 --- a/desktop/ui-next/src/features/chat/composer/Composer.test.tsx +++ b/desktop/ui-next/src/features/chat/composer/Composer.test.tsx @@ -38,6 +38,7 @@ function stubShell({ directoryPath = null, runtimePath, directoryResult, + caps = null, }: { models?: ModelInfo[]; skills?: SkillInfo[]; @@ -45,6 +46,8 @@ function stubShell({ directoryPath?: string | null; runtimePath?: string; directoryResult?: Promise; + /** engine_caps 应答;缺省 null = 能力未知,按「不支持」降级渲染。 */ + caps?: Record | null; } = {}) { const ops: Op[] = []; const listeners = new Map void>(); @@ -62,6 +65,7 @@ function stubShell({ if (cmd === "models_list") return Promise.resolve(models); if (cmd === "skills_list") return Promise.resolve({ revision: 1, store_id: "test", skills }); if (cmd === "session_call") return sessionCall ? sessionCall(args) : Promise.resolve({ result: {} }); + if (cmd === "engine_caps") return Promise.resolve(caps); if (cmd === "upload_begin") return Promise.resolve({ handle: 9 }); if (cmd === "upload_finish") return Promise.resolve({ path: ".monkeycode/uploads/shot.png" }); if (cmd === "plugin:dialog|open") return directoryResult ?? Promise.resolve(directoryPath); @@ -1329,3 +1333,139 @@ describe("后台状态条·多任务", () => { expect(screen.getAllByText("分析日志").length).toBeGreaterThan(0); }); }); + +describe("后台状态条·停止", () => { + const CAPS = { + browser_ext: false, + usage_update: true, + perm_remember: true, + attachments: true, + steering: false, + subagent_control: true, + }; + // 派发卡带 background_agent 进度(壳在 async_launched 应答时产出,携带 + // 引擎侧寻址 agentId)——停止入口按它寻址,缺席的旧 journal 卡不出入口 + const STOPPABLE_FRAMES = [ + { type: "task-started", timestamp: 3, seq: 3 }, + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "tool_call", + toolCallId: "b1", + title: "SendMessage", + rawInput: { summary: "继续实现" }, + }, + }, + timestamp: 4, + seq: 4, + }, + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "b1", + status: "in_progress", + progress: { kind: "background_agent", agentId: "ag-1", status: "running" }, + }, + }, + timestamp: 5, + seq: 5, + }, + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "b1", + status: "completed", + rawOutput: "receipt", + backgroundLaunch: true, + }, + }, + timestamp: 6, + seq: 6, + }, + { type: "task-ended", timestamp: 7, seq: 7 }, + ]; + + it("二段确认后调 background_stop,行转停止中;通知到达后消退", async () => { + const { ops, emit } = stubShell({ caps: CAPS }); + render(); + await ready(); + emit("frames:s1", STOPPABLE_FRAMES); + + await waitFor(() => expect(screen.getByText("1 个后台代理运行中")).toBeTruthy()); + // 首击只进入确认态,不发命令(停止不可恢复) + await userEvent.click(await screen.findByRole("button", { name: "停止" })); + expect(calls(ops, "background_stop").length).toBe(0); + await userEvent.click(screen.getByRole("button", { name: "确认停止?" })); + + await waitFor(() => expect(calls(ops, "background_stop").length).toBe(1)); + expect(calls(ops, "background_stop")[0]?.args).toMatchObject({ + id: "s1", + kind: "background_stop", + payload: { agent_id: "ag-1" }, + }); + // 应答只表示受理:行保持「停止中」,收卡等 task_notification 终态 + await waitFor(() => expect(screen.getByText("停止中…")).toBeTruthy()); + expect(screen.queryByRole("button", { name: "停止" })).toBeNull(); + + emit("frames:s1", [ + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "task_notification", + agentId: "ag-1", + status: "stopped", + result: "已按请求停止", + text: "📌 后台代理已停止", + }, + }, + timestamp: 8, + seq: 8, + }, + ]); + await waitFor(() => expect(screen.queryByText("1 个后台代理运行中")).toBeNull()); + }); + + it("引擎无 subagentControl 能力时不出停止入口", async () => { + const { emit } = stubShell(); // engine_caps → null,按不支持降级 + render(); + await ready(); + emit("frames:s1", STOPPABLE_FRAMES); + + await waitFor(() => expect(screen.getByText("1 个后台代理运行中")).toBeTruthy()); + expect(screen.queryByRole("button", { name: "停止" })).toBeNull(); + }); + + it("停止失败回弹并外显原因,可重试", async () => { + const { emit } = stubShell({ + caps: CAPS, + sessionCall: (args) => + (args?.kind as string) === "background_stop" + ? Promise.reject(new Error("sub-agent not found: ag-1")) + : Promise.resolve({ result: {} }), + }); + render(); + await ready(); + emit("frames:s1", STOPPABLE_FRAMES); + + await waitFor(() => expect(screen.getByText("1 个后台代理运行中")).toBeTruthy()); + await userEvent.click(await screen.findByRole("button", { name: "停止" })); + await userEvent.click(screen.getByRole("button", { name: "确认停止?" })); + + await waitFor(() => + expect(screen.getByText("停止后台代理失败:sub-agent not found: ag-1")).toBeTruthy(), + ); + // 行回弹,停止入口可重试 + expect(screen.getByRole("button", { name: "停止" })).toBeTruthy(); + expect(screen.queryByText("停止中…")).toBeNull(); + }); +}); diff --git a/desktop/ui-next/src/features/chat/composer/Composer.tsx b/desktop/ui-next/src/features/chat/composer/Composer.tsx index dab986229..e3f3cd4e8 100644 --- a/desktop/ui-next/src/features/chat/composer/Composer.tsx +++ b/desktop/ui-next/src/features/chat/composer/Composer.tsx @@ -164,8 +164,14 @@ interface ComposerProps { focusRequest?: number; onFocusRequestHandled?: (request: number) => void; /** 空闲态后台状态条的取材与入口(ChatView 供给,引用需稳定以保 memo; - * 无后台任务时缺席,渲染由 presentation.backgroundRunning 门控)。 */ - backgroundInfo?: { tasks: BackgroundTask[]; onOpen?: (childId: string) => void }; + * 无后台任务时缺席,渲染由 presentation.backgroundRunning 门控)。 + * onStop 缺席 = 引擎无 subagentControl 能力,停止入口整体不出现。 */ + backgroundInfo?: { + tasks: BackgroundTask[]; + onOpen?: (childId: string) => void; + onStop?: (agentId: string) => void; + stopError?: string; + }; } const ComposerImpl = forwardRef(function Composer({ @@ -714,7 +720,12 @@ const ComposerImpl = forwardRef(function Com openLabel={t("chat.tool.childSession")} expandLabel={t("chat.bg.expand")} collapseLabel={t("chat.bg.collapse")} + stopLabel={t("chat.bg.stop")} + stopConfirmLabel={t("chat.bg.stopConfirm")} + stoppingLabel={t("chat.bg.stopping")} + stopError={backgroundInfo.stopError} onOpen={backgroundInfo.onOpen} + onStop={backgroundInfo.onStop} /> )} diff --git a/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx b/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx index 95725ff57..005121619 100644 --- a/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx +++ b/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx @@ -27,8 +27,17 @@ export const LocalComposerHost = forwardRef< onFocusRequestHandled?: (request: number) => void; /** 空闲态后台状态条取材(ChatView 供给,引用稳定;透传给 Composer) */ backgroundInfo?: { - tasks: { key: string; title: string; startedAt?: number; childId?: string }[]; + tasks: { + key: string; + title: string; + startedAt?: number; + childId?: string; + agentId?: string; + stopping?: boolean; + }[]; onOpen?: (childId: string) => void; + onStop?: (agentId: string) => void; + stopError?: string; }; } >(function LocalComposerHost( diff --git a/desktop/ui-next/src/features/chat/composer/composerKit.tsx b/desktop/ui-next/src/features/chat/composer/composerKit.tsx index 85bf8d965..659a359b3 100644 --- a/desktop/ui-next/src/features/chat/composer/composerKit.tsx +++ b/desktop/ui-next/src/features/chat/composer/composerKit.tsx @@ -73,6 +73,10 @@ export interface BackgroundTask { title: string; startedAt?: number; childId?: string; + /** 引擎侧子代理寻址 id(backgroundAgentId);缺席(旧 journal 卡)无停止入口。 */ + agentId?: string; + /** 停止已受理,等 task_notification 终态收卡(调用方维护的瞬态)。 */ + stopping?: boolean; } /** 后台状态条(RunBar 同槽位的安静版):主循环空闲但后台子代理未回完成 @@ -89,7 +93,12 @@ export function BackgroundBar({ openLabel, expandLabel, collapseLabel, + stopLabel, + stopConfirmLabel, + stoppingLabel, + stopError, onOpen, + onStop, }: { label: string; tasks: BackgroundTask[]; @@ -99,10 +108,25 @@ export function BackgroundBar({ openLabel: string; expandLabel: string; collapseLabel: string; + stopLabel: string; + stopConfirmLabel: string; + stoppingLabel: string; + /** 上一次停止失败的成品文案(调用方求值);行已回弹,可重试。 */ + stopError?: string; onOpen?: (childId: string) => void; + /** 缺席 = 引擎无 subagentControl 能力,停止入口整体不渲染。 */ + onStop?: (agentId: string) => void; }) { const [expanded, setExpanded] = useState(false); const [, setTick] = useState(0); + // 二段确认(按钮原位变「确认停止?」):同一时刻至多一行处于确认态, + // 几秒不点自动回弹——停止不可恢复,不能让确认态永久蹲在那等误触 + const [confirmKey, setConfirmKey] = useState(null); + useEffect(() => { + if (confirmKey === null) return; + const id = setTimeout(() => setConfirmKey(null), 4_000); + return () => clearTimeout(id); + }, [confirmKey]); const anyStarted = tasks.some((t) => t.startedAt !== undefined); useEffect(() => { if (!anyStarted) return; @@ -111,6 +135,35 @@ export function BackgroundBar({ }, [anyStarted]); const minutesOf = (startedAt?: number) => startedAt !== undefined ? Math.floor((Date.now() - startedAt) / 60_000) : 0; + const stopControl = (task: BackgroundTask) => { + const agentId = task.agentId; + if (!onStop || !agentId) return null; + if (task.stopping) { + return ( + + + {stoppingLabel} + + ); + } + const confirming = confirmKey === task.key; + return ( + + ); + }; const single = tasks.length === 1 ? tasks[0] : undefined; const singleMinutes = minutesOf(single?.startedAt); const singleChild = single?.childId; @@ -132,6 +185,7 @@ export function BackgroundBar({ {openLabel} )} + {single && stopControl(single)} {!single && ( {(target.kind === "localhost" || (target.kind === "artifact" && target.artifactKind === "html")) && - } + } {filesOpen &&
setFileQuery(e.target.value)} />
{filesTruncated &&

{t("design.preview.truncated")}

} @@ -1024,18 +1041,22 @@ export function DesignPreviewWorkbench({ const label = t(`design.preview.device.${name}` as MessageKey); return ; })} -
- - -
- {/* 四个动作两两相似(截图/标记都出图,注释/编辑都要选元素),标签本身 - 分不出差别——tooltip 一律说「点完得到什么」,并点明去向:截图只进 - 剪贴板、编辑只改预览,另两个才发进对话。 */} - - - - - { const n = Math.min(500, Math.max(10, Number(e.target.value))); setZoom(n); if (!inlineFallback) void previewSetZoom(n / 100).catch(report); }}> {[10, 25, 50, 75, 100, 125, 150, 200, 300, 400, 500].map((n) => )}
} @@ -1048,10 +1069,25 @@ export function DesignPreviewWorkbench({ ) : tab === "preview" ? (
- {/* 原生 webview 被浮层(菜单/模态)顶避让时,宿主垫冻结帧, - 预览在用户眼里不消失(「预览直接消失了」的报障);截帧 - 失败才退文字说明,浮层一关 previewShow 即还原 */} - {obscured && native && ( + {inlineFallback && native ? ( + /* Linux 内嵌预览:DOM iframe,层级/摆位天然正确,浮层遮挡 + 无需冻结帧;key 带 targetKey 与 reload 计数,切目标/刷新 + 即重载;缩放走 WebKit 的 CSS zoom */ +