diff --git a/docs/chat-and-interface.md b/docs/chat-and-interface.md index f62dd8c9..ecc83909 100644 --- a/docs/chat-and-interface.md +++ b/docs/chat-and-interface.md @@ -212,7 +212,7 @@ TTFT 认首个任意增量,含 thinking 和工具增量;工具条取本轮 `tool:start` 提供工具名、args preview、phase;审批/范围门前 `waiting`,通过后 `running`,拒绝直接 error。只读调用只发 running;批层排除、未知工具等硬拒绝不需要先造 start 卡。`tool_progress` 只携带真实输出分片,不能再承担空分片建卡。 -主会话的 start、result 和 progress 三条路径都先跨 assistant 项查已有 callKey,找不到才建;notice 插队不能制造第二张卡。已完成卡不能被迟到 start 翻回运行中;run 结束后的 start 不建卡。run 终态把遗留 waiting/running 置 `E_INTERRUPTED`,子代理终态只收其子流。**主会话历史无结果或 repair 的 `[interrupted]` 与子流均显示中性「已中断」**;不能写成「已使用」。`E_ASK_CANCELLED`、`E_ASK_NOT_ANSWERED` 同属中性用户中止;真实失败保留错误信息。 +主会话的 start、result 和 progress 三条路径都先跨 assistant 项查已有 callKey,找不到才建;notice 插队不能制造第二张卡。已完成卡不能被迟到 start 翻回运行中;run 结束后的 start 不建卡。run 终态把遗留 waiting/running 置 `E_INTERRUPTED`,子代理终态只收其子流。**主会话历史无结果或 repair 的 `[interrupted]` 与子流均显示中性「已中断」**;不能写成「已使用」。`E_ASK_CANCELLED`、`E_ASK_NOT_ANSWERED` 同属中性用户中止;`E_ASK_SKIPPED` 显示中性「已跳过」并停止当前任务;真实失败保留错误信息。 waiting 仅表示审批/范围确认,工具信号量和文件写锁排队仍包含在 running 内。当前 start 事件没有 `run_id`,改事件身份时应整体审查生命周期,不能只在渲染侧补一个未可靠传递的字段。 @@ -295,7 +295,7 @@ iframe 使用 `sandbox="allow-scripts"`、不授予 `allow-same-origin`;资源 ask 是活跃会话唯一底部输入区域;主 Composer 和队列暂时不显示。选项标题和推荐 chip 同行,说明独占下一行、与标题左对齐、完整折行;整行包括说明都可点击,键盘/ARIA 状态不能因排版容器改变而丢失。长说明不限高,会占更多垂直空间。 -补充说明内 `Enter` 走下一题/提交,IME 不提交;`Shift+Enter` 不提交,但当前补充控件是单行 Input,不能承诺它提供可见多行。批准、预览、多选与忽略的决定逻辑以[计划与批准](./plan-and-approval.md)为准;计划卡已展示全文时,单题批准形的题干可精简为确认问句,多题/普通询问不能丢原题信息。 +补充说明内 `Enter` 走下一题/提交,IME 不提交;`Shift+Enter` 不提交,但当前补充控件是单行 Input,不能承诺它提供可见多行。批准、预览、多选与跳过的决定逻辑以[计划与批准](./plan-and-approval.md)为准;「跳过」在任意页明确停止当前任务,不提交答案或自动批准。计划卡已展示全文时,单题批准形的题干可精简为确认问句,多题/普通询问不能丢原题信息。 ### 7.2 自绘标题栏 diff --git a/docs/plan-and-approval.md b/docs/plan-and-approval.md index d61c0af0..a91d725f 100644 --- a/docs/plan-and-approval.md +++ b/docs/plan-and-approval.md @@ -64,6 +64,8 @@ git 仓库方案须提出 `/`(type 使用 Conventional Commits,s [Option2](../src-tauri/src/tools/ask/tool.rs) 的 `mode: Option` 用 serde default 与 `skip_serializing_if`:缺省时 wire 不出现该键,不能输出 `mode:null`。结构化批准类判定为 `mode.is_some()`;目标取用户选中项的 mode,旧批准形态才回落 AutoEdit。旧 `id="approve"`、`switchToAutoEdit` 与宽松 id/label 匹配保留兼容,不要随意删掉。 +例外:ask 缺失关联 id 的确定性补齐见[工具参数容错](./tools-and-safety.md#1-工具接线与结果通道)。宿主对补齐过 id 的询问标记 `explicit_mode_only=true`(前端 `explicitModeOnly`),该询问只接受选中项明确声明的 mode;关闭旧 id/label 批准匹配和有效应答轻量切档,不因补齐 id 推断审批意图,也不生成 `approve` 锚点。普通回答、修订与预览保持原档;原本完整的旧载荷兼容语义不变。 + 必须区分: - `approval_shape` 是宽松的单题批准形**渲染标记,无安全语义**。 @@ -129,9 +131,11 @@ git 仓库方案须提出 `/`(type 使用 Conventional Commits,s ## 5. 普通询问、计划卡与预览 -ask schema 为 1–5 问,每题最多 6 选项,支持开放题。ask/wait 必须独占批次。全空 selections 且 note trim 后为空时,在 `ask:closed` 后返回 `E_ASK_NOT_ANSWERED`,明确要求停下等待,不把忽略当成功/默许,也不原样重问;run 取消/通道关闭为 `E_ASK_CANCELLED`。部分回答保留未回答题尾注,不把整组当忽略。 +ask schema 为 1–5 问,每题最多 6 选项,支持开放题。ask/wait 必须独占批次。询问卡的「跳过」发送独立 `{action:"skip"}`,表示用户明确停止当前任务:关闭整组询问、返回中性 `E_ASK_SKIPPED`;主循环先保存工具结果配对,再直接走取消终态,不再请求模型或自动运行下一条队列项。无论单题、多题哪一页,都不提交已选项、推荐默认值或补充说明;不批准、不切档、不冻结基线。在子代理询问中跳过还会停止其所属根 run,按出生时的 root run 身份校验,迟到子代理不得取消新 run。已有进度与历史保留,等待用户新指示。 + +跳过动作与 answers/权限字段混合时失败关闭,不回落批准通道。旧全空 selections 且 note trim 后为空的载荷仍返回 `E_ASK_NOT_ANSWERED`(未作答,不是明确跳过);run 取消/通道关闭为 `E_ASK_CANCELLED`。部分回答保留未回答题尾注。 -- AskPanel 覆盖输入区;回答或忽略后恢复草稿。多题按页回答,非末页忽略只清当前题并翻页,末页清当前题后提交整组。 +- AskPanel 覆盖输入区;回答或跳过后恢复草稿。多题按页回答;「跳过」在任意页都停止整轮,不再清当前题后翻页。 - 批准形渲染优先;其余由[同一 `inferSingle`](../ui/src/features/tools/askShape.ts) 控制单选展示与 toggle,不能“显示 radio、实际多选”。显式 true/false 优先于启发式。当前 Rust `Question.single` 是 default bool,标准事件会把缺省序列化为 false,因此不要承诺模型省略该字段就一定触发前端启发式;强互斥题应显式 true。 - 键盘环包含选项与补充说明位;输入位 Enter/Space 只聚焦,不提交。完全访问风险提示由界面追加,不得被模型 description 抵消。 @@ -153,7 +157,7 @@ preview 技能只用于已有成型对象,界面示意须标“示意稿 · 后端重点入口:[ask/tests.rs](../src-tauri/src/tools/ask/tests.rs)、[batch.rs 测试](../src-tauri/src/tools/batch.rs)、[plan.rs](../src-tauri/src/tools/plan.rs)、[approval.rs](../src-tauri/src/safety/approval.rs)。前端入口:[askpanel.test.tsx](../ui/src/__tests__/askpanel.test.tsx)、[runWidgetAutoOpen.test.ts](../ui/src/__tests__/runWidgetAutoOpen.test.ts)、[events.contract.test.ts](../ui/src/__tests__/events.contract.test.ts)。载荷只增可选字段时不改事件键名。 -必须覆盖:无模型预览选项仍有固定入口、旧选项去重、计划保存失败仍可预览、重复/键盘点击与跨会话竞态、第四次手动预览;四档起点按所选 mode 同步;preview/preview+备注/自拟 approve 子串 id 均不切;revise 不冻结基线;G2 豁免仍拒空 todos;同批 plan 失败仍豁免的既有取舍;并发范围门只弹一次且拒绝持续拦;忽略不继续;自动预览只开一次且恢复不重开。 +必须覆盖:无模型预览选项仍有固定入口、旧选项去重、计划保存失败仍可预览、重复/键盘点击与跨会话竞态、第四次手动预览;四档起点按所选 mode 同步;preview/preview+备注/自拟 approve 子串 id 均不切;revise 不冻结基线;G2 豁免仍拒空 todos;同批 plan 失败仍豁免的既有取舍;并发范围门只弹一次且拒绝持续拦;任意页跳过直接停止且最后一步也不报成功;自动预览只开一次且恢复不重开。 还需补齐文本 ask 的双向契约测试:`text_ask.rs` 的 `MAX_QUESTIONS/MAX_OPTIONS` 与 AskTool schema 的 `maxItems` 目前双写 5/6;当前四档 `ApprovalMode` 的 serde 名与 schema enum 也须成套比对。现有单一 `full_access` 解析用例不能替代这两类等价断言。 diff --git a/docs/providers-and-network.md b/docs/providers-and-network.md index a1450b16..7284ed32 100644 --- a/docs/providers-and-network.md +++ b/docs/providers-and-network.md @@ -86,6 +86,12 @@ KeyPool 以供应商 id 分组,同供应商模型共享健康状态:优先 Auth、Billing、Cancelled 不进入通用重试;网络、限流、服务端和协议错误最多重试 6 次,退避封顶 10 秒。BadRequest 另有每 run 一次 sanitize/repair 修复重试,不占通用重试计数。流内错误的分类由各适配器解析器决定,不应把 HTTP 状态分类套到 HTTP 200 的 error 帧上。 +`run:retry` 增加可选 `reason`(事件键不变):`empty_response` / `stream_stalled` / provider 错误分类 / `request_repaired`。界面按原因提示;缺失或未知原因显示通用重试提示,不再一律声称“请求失败、丢弃半截回复”。清理旧代际候选帧的行为不变。 + +三协议每次**空响应**在常开会话日志写入“空响应诊断” JSON,不要求打开 verbose:包含 run/step/尝试序号(首次为 1)、模型、协议、当次耗时与 usage、HTTP 状态、有限长度 request ID、收到的 chunk/字节数、SSE 事件和内容块类型计数、内容字节数、工具块数、结束原因、正常完成标记及装配结果。摘要仅保留固定类型词表和计数,不保留正文、思考、参数原文或任意未知字段值;未知类型归为 `other`。诊断元数据不进入模型历史、界面帧、首 token 计时或流活跃度。 + +取证标签:`no_content_observed` 表示未观察到已知内容;`content_not_assembled` 表示原始 JSON 中有内容或工具块但最终装配为空;`unrecognized_shape` 表示存在未知结构/被忽略的非 JSON 事件;`diagnostics_missing` 表示摘要缺失。这些是排查线索,不把未知格式断言为上游空回复。内容字节数及工具结构出现次数包含事件中的重复快照,不能当作去重输出长度、独立调用数或计费依据。响应收集任务异常单独报告协议错误,不再静默降成空响应。 + 源码:[dto.rs](../src-tauri/src/provider/dto.rs)、[retry.rs](../src-tauri/src/provider/retry.rs)、[drive.rs](../src-tauri/src/core/agent/drive.rs)(`run_llm_turn`)、[runHandlers.ts](../ui/src/stores/runHandlers.ts)、[ChatMessages.tsx](../ui/src/features/chat/ChatMessages.tsx)。 ### 输出上限与截断 diff --git a/docs/runtime-and-scheduling.md b/docs/runtime-and-scheduling.md index 28774ab8..f41fad37 100644 --- a/docs/runtime-and-scheduling.md +++ b/docs/runtime-and-scheduling.md @@ -14,6 +14,8 @@ 前端 [runHandlers.ts](../ui/src/stores/runHandlers.ts) 的共享 `settleRun` 清理运行标志、流式正文、运行/等待工具卡、残留子代理及 AskPanel。只有清工具卡而不清 ask,会留下输入区被询问永久覆盖的假卡死。 +明确跳过后仍可在同一会话重新发消息:历史、已写文件与 todos 保留,新 run 重新建立取消令牌和运行态;旧询问 responder 不复用。带 `run_id` 的实时 `ask:opened` 必须排除已收尾轮次及与当前轮不符的事件,避免跳过后的迟到询问重新覆盖输入区。start_chat 回执在途时允许新身份的询问先到达;缺 run_id 的旧载荷保持兼容。取消事件与询问关闭事件丢失时仍走下述快照对账收敛,取消本身不自动续队列。 + ### 丢事件后的自愈 [run.ts](../ui/src/stores/run.ts) 的 `reconcileRun` 通过 `session_run_snapshot` 双向对账: diff --git a/docs/tools-and-safety.md b/docs/tools-and-safety.md index 0ce16406..a3256d86 100644 --- a/docs/tools-and-safety.md +++ b/docs/tools-and-safety.md @@ -20,6 +20,10 @@ `ToolOutcome = ok + data + error + warnings + extra_model_content` 有两条消费通道:前端收完整结构化结果,模型经 [compact_for_model](../src-tauri/src/tools/compact.rs) 收文本。 +参数只对确定的结构做容错,模型侧 schema 仍要求标准形态:read / batch_read 缺失 `files`、顶层只有非空字符串 `path` 与可选 `startLine/endLine` 时自动包装单元素 files,行区间与路径原样保留;主循环在保存 ToolUse 与执行前共用同一归一化,工具直调也复用它。已有 files(包括空、null、类型错误)、混合/未知字段或无有效 path 时不猜测、不合并,按原错误路径处理,空 files 的错误带正确示例。归一化后仍走读取根、二进制/文档、图片与行数预算等检查。 + +ask 仅为**缺失**的问题/选项 `id` 按位置生成确定的中性关联 id,避开已有 id 与选项 label,保留已有 id、原始选项和 mode;显式 null、空白、类型错误、同级重复 id 不覆盖。问题最多 5 题、每题最多 6 项。发生补齐时宿主下发 `explicit_mode_only=true`:前后端均关闭 id/label 批准兼容匹配及“有效回答→自动编辑”轻量通道,只有用户选中原输入明确带 mode 的选项才可切到该档,预览仍不批准、不切档。此标记不能由模型覆写;关联 id 补齐不生成 approve/revise/preview 语义,不推断、补写或默认批准选项与权限档位。完整合法输入保留既有兼容路径;计划门与编辑匹配检查继续生效。 + - command 在工具内部裁剪,因此不再二次头尾截断;非零退出仍把 error 与 data.output/exit_code 一起交给模型。 - read/batch_read 保持文本行号与完整所选窗口;图片条目剥去 data_url,禁止把 base64 作为普通文本送模型烧 token。图片经出网副本的 `route_tool_images` 放到紧随的 User 消息,受模型 vision 设置控制,历史不改写。 - 其他成功工具只序列化 data,保留头 4 KiB + 尾 8 KiB。截断后不再是可靠完整 JSON;失败通常只剩错误行。 diff --git a/src-tauri/src/core/agent/drive.rs b/src-tauri/src/core/agent/drive.rs index 499a1318..90faa1e7 100644 --- a/src-tauri/src/core/agent/drive.rs +++ b/src-tauri/src/core/agent/drive.rs @@ -1242,7 +1242,7 @@ pub async fn drive_agent( rt.id ); if params.emit_events { - emit_retry(&sink, rt, run_id, attempt); + emit_retry(&sink, rt, run_id, attempt, "empty_response"); } if !sleep_backoff(attempt, &run_token).await { outcome = Err(ProviderError::Cancelled); @@ -1424,6 +1424,21 @@ pub async fn drive_agent( claims.clone(), ) .await; + // A user skip is terminal. The batch result is already in history, preserving tool pairing. + if call_sigs + .iter() + .any(|sig| sig.name == "ask" && sig.error.as_deref() == Some("E_ASK_SKIPPED")) + { + session_log::info(rt, "用户跳过询问,停止当前运行并等待新指示"); + run_token.cancel(); + if let (Some(root_id), Some(root_run_id)) = (&rt.root_session_id, &rt.root_run_id) + && let Some(root) = core.session(root_id) + { + root.cancel_run_if_matches(root_run_id); + } + outcome = Err(ProviderError::Cancelled); + break 'steps; + } if let Some(items) = batch_suggest { suggest_out = Some(items); } @@ -1664,7 +1679,7 @@ async fn run_llm_turn( ), ); if params.emit_events { - emit_retry(sink, rt, run_id, *attempt); + emit_retry(sink, rt, run_id, *attempt, "stream_stalled"); } if !sleep_backoff(*attempt, run_token).await { return Err(ProviderError::Cancelled); @@ -1682,7 +1697,10 @@ async fn run_llm_turn( .store(usage.prompt_total(&model.api_format), Ordering::SeqCst); // 收干流(= 本次生成结束)后才计耗时:duration 是「请求发出 → 流失结束」的 // **成功尝试**窗口(退避与失败尝试不进此窗口) - let (asm, ttft_ms) = collector.await.unwrap_or_default(); + let (asm, ttft_ms) = collector.await.map_err(|error| { + session_log::error(rt, &format!("step {step} 响应收集任务异常:{error}")); + ProviderError::Protocol(format!("响应收集任务异常:{error}")) + })?; let duration_ms = attempt_started.elapsed().as_millis() as u64; rt.run_timing .lock() @@ -1702,6 +1720,17 @@ async fn run_llm_turn( ); } // 空响应按可重试处理 + if asm.is_empty() { + let diagnostic = serde_json::json!({ + "run_id": run_id, "step": step, "attempt": *attempt + 1, + "model": model.name, "api_format": model.api_format, + "duration_ms": duration_ms, "usage": usage, + "evidence": asm.diagnostics.as_ref().map(|d| d.empty_evidence()).unwrap_or("diagnostics_missing"), + "stream": asm.diagnostics, + "assembled": {"blocks": asm.blocks.len(), "tool_calls": asm.tool_calls.len()}, + }); + session_log::warn(rt, &format!("空响应诊断 {diagnostic}")); + } if asm.is_empty() && retry::should_retry(&ProviderError::Server("空响应".into()), *attempt) { @@ -1710,7 +1739,7 @@ async fn run_llm_turn( session_log::warn(rt, &format!("step {step} 空响应,第 {} 次重试", *attempt)); tracing::warn!("session {} step {step} 空响应,重试 #{}", rt.id, *attempt); if params.emit_events { - emit_retry(sink, rt, run_id, *attempt); + emit_retry(sink, rt, run_id, *attempt, "empty_response"); } if !sleep_backoff(*attempt, run_token).await { return Err(ProviderError::Cancelled); @@ -1813,6 +1842,7 @@ async fn run_llm_turn( serde_json::json!({ "session": rt.id, "run_id": run_id, "attempt": attempt.saturating_add(1), + "reason": "request_repaired", "gen": rt.stream.generation(), "delay_ms": 0, }), ); @@ -1849,7 +1879,7 @@ async fn run_llm_turn( *attempt ); if params.emit_events { - emit_retry(sink, rt, run_id, *attempt); + emit_retry(sink, rt, run_id, *attempt, e.kind_tag()); } if !sleep_backoff(*attempt, run_token).await { return Err(ProviderError::Cancelled); @@ -2822,12 +2852,14 @@ pub(super) fn emit_retry( rt: &SessionRuntime, run_id: &str, attempt: u32, + reason: &str, ) { sink.emit( &rt.id, "run:retry", serde_json::json!({ "session": rt.id, "run_id": run_id, "attempt": attempt, + "reason": reason, "gen": rt.stream.generation(), "delay_ms": retry::delay_for_attempt(attempt.saturating_sub(1)).as_millis() as u64, }), diff --git a/src-tauri/src/core/agent/runtime.rs b/src-tauri/src/core/agent/runtime.rs index 6d11d0d5..c287b5b5 100644 --- a/src-tauri/src/core/agent/runtime.rs +++ b/src-tauri/src/core/agent/runtime.rs @@ -533,6 +533,14 @@ impl SessionRuntime { } } + /// A stale child decision must never stop a newer run on the owning session. + pub(super) fn cancel_run_if_matches(&self, run_id: &str) { + let snapshot = super::guards::lock_ok(&self.run_snapshot); + if snapshot.running && snapshot.run_id.as_deref() == Some(run_id) { + self.cancel_active(); + } + } + /// 整体写 prefs(仅内存态;持久化由 `AgentCore::transition_prefs` 负责)。 pub fn transition_prefs(&self, new: crate::core::prefs::SessionPrefs) -> bool { *self.prefs.lock().unwrap() = new; @@ -909,6 +917,18 @@ mod tests { assert!(h.rt.begin_run("next")); } + #[test] + fn skip_cancellation_matches_the_owning_run_identity() { + let h = harness(); + assert!(h.rt.begin_run("new-run")); + let cancel = CancellationToken::new(); + *h.rt.active_cancel.lock().unwrap() = Some(cancel.clone()); + h.rt.cancel_run_if_matches("old-run"); + assert!(!cancel.is_cancelled()); + h.rt.cancel_run_if_matches("new-run"); + assert!(cancel.is_cancelled()); + } + /// 记录事件的测试 sink(不关心具体事件名,仅作 runtime 装配)。 #[derive(Default)] struct RecSink { diff --git a/src-tauri/src/core/agent/stream.rs b/src-tauri/src/core/agent/stream.rs index 1d731140..4984e05a 100644 --- a/src-tauri/src/core/agent/stream.rs +++ b/src-tauri/src/core/agent/stream.rs @@ -339,6 +339,10 @@ pub(super) async fn collect_deltas( let mut asm = Assembled::default(); let mut ttft_ms: Option = None; while let Some(d) = rx.recv().await { + if let crate::provider::StreamDelta::Diagnostics { summary } = d { + asm.diagnostics = Some(summary); + continue; // Diagnostic metadata is neither a first token nor stream activity. + } // 首帧(不论类型,含 ToolCall 增量)即 TTFT:放在 match 之前 if ttft_ms.is_none() { ttft_ms = Some(anchor.elapsed().as_millis() as u64); @@ -370,6 +374,7 @@ pub(super) async fn collect_deltas( } } crate::provider::StreamDelta::ToolCallEnd { .. } => {} + crate::provider::StreamDelta::Diagnostics { .. } => unreachable!(), } } (asm, ttft_ms) @@ -388,11 +393,14 @@ pub(super) fn build_assistant_message( for (ord, c) in asm.tool_calls.iter().enumerate() { match crate::core::sessions::repair::parse_or_salvage(&c.args_raw) { Some(v) => { - let args = if v.is_object() { + let mut args = if v.is_object() { v } else { serde_json::json!({ "value": v }) }; + if matches!(c.name.as_str(), "read" | "batch_read") { + args = crate::tools::read::normalize_args(args); + } ord_of.insert(ord, calls.len()); calls.push(NormalizedCall { id: c.id.clone(), diff --git a/src-tauri/src/core/agent/tests.rs b/src-tauri/src/core/agent/tests.rs index dd9d05eb..67947539 100644 --- a/src-tauri/src/core/agent/tests.rs +++ b/src-tauri/src/core/agent/tests.rs @@ -8,6 +8,27 @@ use crate::provider::dto::{AsmBlock, Assembled, AssembledToolCall}; use std::sync::Arc; use std::sync::atomic::Ordering; +#[test] +fn read_shorthand_is_canonical_in_history_and_execution_including_alias() { + for name in ["read", "batch_read"] { + let mut assembled = Assembled::default(); + assembled.tool_calls.push(AssembledToolCall { + index: 0, + id: "read-call".into(), + name: name.into(), + args_raw: r#"{"path":"src/main.rs","startLine":3,"endLine":7}"#.into(), + }); + assembled.blocks.push(AsmBlock::Tool(0)); + let (message, calls, rejected) = build_assistant_message(&assembled); + assert!(rejected.is_empty()); + let args = serde_json::json!({"files":[{"path":"src/main.rs","startLine":3,"endLine":7}]}); + assert_eq!(calls[0].args, args); + assert!( + matches!(&message.content[0], Content::ToolUse { args: historical, .. } if *historical == args) + ); + } +} + /// [docs/subagents](../../../../docs/subagents.md):子代理帧信封的序列化形态(前端 types.ts Frame 联合类型的 serde 锚点)。 #[test] fn frame_sub_envelope_serialization_shape() { @@ -2135,6 +2156,301 @@ fn text_only_sse_turn(text: &str) -> Vec { sse_body(&[chunk.as_str(), SSE_STOP]) } +#[tokio::test] +async fn explicit_skip_stops_before_any_next_llm_request_even_on_the_last_step() { + for max_steps in [1, 5] { + let tool = serde_json::json!({"choices":[{"delta":{"tool_calls":[{ + "index":0,"id":"skip-ask","type":"function","function":{ + "name":"ask","arguments":serde_json::json!({"questions":[{"id":"q","question":"Continue?"}]}).to_string() + } + }]}}]}).to_string(); + let mut server = step_start_server(vec![sse_body(&[&tool, SSE_STOP])]).await; + let (core, rt, sink, _ws, _dd) = scripted_core_recording(server.port(), "explicit-skip"); + let run_core = core.clone(); + let run_rt = rt.clone(); + let running = tokio::spawn(async move { + super::drive::drive_agent( + &run_core, + &run_rt, + DriveParams { + main_session: true, + max_steps, + ..DriveParams::default() + }, + "skip-run", + ) + .await + }); + let ask_id = wait_for_ask(&rt).await; + assert!(rt.resolve_ask(&ask_id, serde_json::json!({"action":"skip"}))); + let (result, _, _) = tokio::time::timeout(std::time::Duration::from_secs(5), running) + .await + .unwrap() + .unwrap(); + assert!( + matches!(result, Err(crate::provider::ProviderError::Cancelled)), + "{result:?}" + ); + server.finish().await.unwrap(); + assert_eq!(rt.step_count.load(Ordering::SeqCst), 1); + assert!(rt.history.lock().unwrap().iter().any(|m| m.content.iter().any(|c| matches!(c, + Content::ToolResult { tool_use_id, content, .. } if tool_use_id == "skip-ask" && content.contains("E_ASK_SKIPPED") + )))); + assert!(sink.events("ask:closed").len() == 1); + } +} + +#[tokio::test] +async fn skipped_chat_can_restart_with_saved_progress_and_no_stale_waiters() { + use crate::tools::plan::TodoStatus; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + async fn wait_terminal(sink: &EventRecorder, event: &str, run_id: &str) { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while !sink.events(event).iter().any(|p| p["run_id"] == run_id) { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + } + let tool_turn = |id: &str, name: &str, args: serde_json::Value| { + let chunk = serde_json::json!({"choices":[{"delta":{"tool_calls":[{ + "index":0,"id":id,"type":"function","function":{"name":name,"arguments":args.to_string()} + }]}}]}).to_string(); + sse_body(&[&chunk, SSE_STOP]) + }; + let script = vec![ + tool_turn( + "progress-initial-plan", + "plan", + serde_json::json!({"todos":[ + {"title":"first","status":"completed"},{"title":"second","status":"in_progress"} + ]}), + ), + tool_turn( + "progress-create", + "create", + serde_json::json!({"path":"progress.txt","content":"step one\n"}), + ), + tool_turn( + "progress-ask", + "ask", + serde_json::json!({"questions":[{"id":"q","question":"Continue?"}]}), + ), + tool_turn( + "progress-resume", + "create", + serde_json::json!({"path":"progress.txt","content":"step one\nstep two\n","overwrite":true}), + ), + tool_turn( + "progress-plan", + "plan", + serde_json::json!({"todos":[ + {"title":"first","status":"completed"},{"title":"second","status":"completed"} + ]}), + ), + text_only_sse_turn("Continued from existing progress"), + ]; + let listener = Arc::new(tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap()); + let requests = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = requests.clone(); + let owned = listener.clone(); + let task = tokio::spawn(async move { + for response in script { + let (mut socket, _) = owned.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 8192]; + let header_end = loop { + let n = socket.read(&mut buffer).await.unwrap(); + assert!(n > 0); + request.extend_from_slice(&buffer[..n]); + assert!(request.len() < 2 * 1024 * 1024); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + break end + 4; + } + }; + let length: usize = String::from_utf8_lossy(&request[..header_end]) + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap(); + assert!(length < 2 * 1024 * 1024); + while request.len() < header_end + length { + let n = socket.read(&mut buffer).await.unwrap(); + assert!(n > 0); + request.extend_from_slice(&buffer[..n]); + } + captured + .lock() + .unwrap() + .push(serde_json::from_slice(&request[header_end..header_end + length]).unwrap()); + socket.write_all(&response).await.unwrap(); + socket.shutdown().await.unwrap(); + } + }); + let mut server = TestSseServer { listener, task }; + let (mut core, rt, sink, ws, _dd) = scripted_core_recording(server.port(), "skip-then-resume"); + // Match host assembly too: terminal events must clear the persisted running marker. + let watched_sink = Arc::new(crate::core::sessions::interrupt::InterruptWatchSink::new( + core.sink.clone(), + core.store.clone(), + )); + Arc::get_mut(&mut core).unwrap().sink = watched_sink; + struct CancelOnDrop(Arc); + impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.cancel_active(); + } + } + let _cleanup = CancelOnDrop(rt.clone()); + // Seed an earlier message to avoid a concurrent title-generation request in this fixture. + rt.history + .lock() + .unwrap() + .push(Message::user_text("Existing task").stamped()); + rt.set_prefs(crate::core::prefs::SessionPrefs { + approval_mode: crate::core::prefs::ApprovalMode::FullAccess, + ..Default::default() + }); + let first = core + .start_chat(rt.clone(), "准备继续任务".into(), vec![]) + .unwrap(); + core.store.mark_running(&rt.id, true).unwrap(); + let ask_id = wait_for_ask(&rt).await; + assert_eq!( + std::fs::read_to_string(ws.path().join("progress.txt")).unwrap(), + "step one\n" + ); + assert!(rt.resolve_ask(&ask_id, serde_json::json!({"action":"skip"}))); + wait_terminal(&sink, "run:cancelled", &first).await; + assert!(!rt.running.load(Ordering::SeqCst)); + assert!(rt.active_cancel.lock().unwrap().is_none()); + assert!(rt.asks.lock().unwrap().is_empty()); + assert!( + !core + .store + .list() + .iter() + .find(|meta| meta.id == rt.id) + .unwrap() + .running + ); + assert!(!rt.resolve_ask(&ask_id, serde_json::json!({"answers":{}}))); + assert!( + core.activity + .snapshot() + .sessions + .iter() + .all(|s| s.session_id != rt.id) + ); + assert_eq!(requests.lock().unwrap().len(), 3); + assert_eq!(core.store.load_todos(&rt.id), *rt.todos.lock().unwrap()); + let saved = core.store.load_history(&rt.id).unwrap(); + assert!(saved.iter().any(|m| m.content.iter().any(|c| matches!(c, + Content::ToolResult { content, .. } if content.contains("E_ASK_SKIPPED") + )))); + let second = core + .start_chat(rt.clone(), "从已有进度继续".into(), vec![]) + .unwrap(); + core.store.mark_running(&rt.id, true).unwrap(); + assert_ne!(first, second); + wait_terminal(&sink, "run:done", &second).await; + server.finish().await.unwrap(); + assert_eq!( + std::fs::read_to_string(ws.path().join("progress.txt")).unwrap(), + "step one\nstep two\n" + ); + assert!( + rt.todos + .lock() + .unwrap() + .iter() + .all(|todo| todo.status == TodoStatus::Completed) + ); + assert!(!rt.running.load(Ordering::SeqCst)); + assert!(rt.active_cancel.lock().unwrap().is_none()); + assert!(rt.asks.lock().unwrap().is_empty()); + assert!( + !core + .store + .list() + .iter() + .find(|meta| meta.id == rt.id) + .unwrap() + .running + ); + assert!( + core.activity + .snapshot() + .sessions + .iter() + .all(|s| s.session_id != rt.id) + ); + assert!(matches!( + rt.run_snapshot().terminal.unwrap().kind, + super::runtime::RunTerminalKind::Done + )); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 6); + let resumed_history = requests[3]["messages"].to_string(); + for expected in ["step one", "E_ASK_SKIPPED", "从已有进度继续"] { + assert!( + resumed_history.contains(expected), + "Missing continuation context: {expected}" + ); + } +} + +#[tokio::test] +async fn empty_response_logs_attempt_evidence_and_retry_reason_then_recovers() { + let mut server = + step_start_server(vec![sse_body(&[SSE_STOP]), text_only_sse_turn("Recovered")]).await; + let (core, rt, sink, _ws, _dd) = scripted_core_recording(server.port(), "empty-diagnostic"); + let (result, _, _) = tokio::time::timeout( + std::time::Duration::from_secs(10), + super::drive::drive_agent( + &core, + &rt, + DriveParams { + main_session: true, + max_steps: 2, + ..DriveParams::default() + }, + "empty-diagnostic-run", + ), + ) + .await + .unwrap(); + assert_eq!(result.unwrap(), "Recovered"); + server.finish().await.unwrap(); + let retries = sink.events("run:retry"); + assert_eq!(retries.len(), 1); + assert_eq!(retries[0]["reason"], "empty_response"); + let log = std::fs::read_to_string(crate::core::session_log::file_for(&rt).unwrap()).unwrap(); + let line = log + .lines() + .find(|line| line.contains("空响应诊断 ")) + .unwrap(); + let value: serde_json::Value = + serde_json::from_str(line.split_once("空响应诊断 ").unwrap().1).unwrap(); + assert_eq!(value["run_id"], "empty-diagnostic-run"); + assert_eq!(value["attempt"], 1); + assert_eq!(value["evidence"], "no_content_observed"); + assert_eq!(value["stream"]["http_status"], 200); + assert_eq!(value["assembled"]["tool_calls"], 0); + assert!(value["usage"].is_object()); + assert!( + !rt.history + .lock() + .unwrap() + .iter() + .any(|message| message.text_joined().contains("空响应诊断")) + ); +} + /// 轮询等待 ask 挂起(工具已 emit `ask:opened`、正阻塞在应答通道上),返回 ask_id。 async fn wait_for_ask(rt: &Arc) -> String { for _ in 0..500 { @@ -2765,6 +3081,29 @@ async fn collect_deltas_without_deltas_reports_no_ttft() { assert_eq!(ttft_ms, None, "无增量 = 无 TTFT 样本"); } +#[tokio::test] +async fn diagnostic_metadata_is_not_content_or_a_first_token() { + let (tx, rx) = tokio::sync::mpsc::channel(4); + tx.send(crate::provider::StreamDelta::Diagnostics { + summary: Box::new(crate::provider::diagnostics::StreamDiagnostics { + http_status: 200, + completed: true, + ..Default::default() + }), + }) + .await + .unwrap(); + drop(tx); + let stream = Arc::new(crate::util::throttle::ThrottledStream::new()); + let (asm, ttft_ms) = super::stream::collect_deltas(rx, stream, std::time::Instant::now()).await; + assert!(asm.is_empty()); + assert_eq!(ttft_ms, None); + assert_eq!(asm.diagnostics.as_ref().unwrap().http_status, 200); + let (message, calls, _) = build_assistant_message(&asm); + assert!(message.content.is_empty()); + assert!(calls.is_empty()); +} + /// 同 `scripted_core`,但 core.sink 换成可捕获帧的 sink(usage 帧字段断言用)。 #[allow(clippy::type_complexity)] fn scripted_core_capturing( diff --git a/src-tauri/src/provider/anthropic.rs b/src-tauri/src/provider/anthropic.rs index a47c230a..81fd1d0c 100644 --- a/src-tauri/src/provider/anthropic.rs +++ b/src-tauri/src/provider/anthropic.rs @@ -201,6 +201,7 @@ fn convert_message(m: &Message, out: &mut Vec) { /// 从 `message_start` / `message_delta` 提取 token 用量。 #[derive(Debug, Default)] pub struct AnAccum { + pub diagnostics: super::diagnostics::StreamDiagnostics, /// block index → 块类型("text" / "tool_use" / "thinking" 等),用于在块结束时判断是否补发 ToolCallEnd block_kinds: std::collections::HashMap, /// 已发出 ToolCallEnd 的 block index 集合,保证每块只收尾一次 @@ -221,6 +222,8 @@ pub fn handle_event( let v: Value = serde_json::from_str(data) .map_err(|e| ProviderError::Protocol(format!("anthropic event json: {e}")))?; + acc.diagnostics.event(v["type"].as_str().unwrap_or(""), &v); + match event.unwrap_or("") { "ping" => return Ok(true), "error" => { @@ -376,9 +379,14 @@ pub async fn stream( return Err(ProviderError::from_status(status, &body_text)); } + let mut diagnostics = super::diagnostics::StreamDiagnostics::default(); + diagnostics.set_response(&resp); let mut stream = resp.bytes_stream(); let mut parser = SseParser::new(); - let mut acc = AnAccum::default(); + let mut acc = AnAccum { + diagnostics, + ..Default::default() + }; loop { let chunk = tokio::select! { @@ -390,6 +398,7 @@ pub async fn stream( }, }; // C2:只在 chunk 边界喂入、绝不调用 finish——半行 / 未完结事件必须缓冲到 EOF + acc.diagnostics.chunk(chunk.len()); let mut events: Vec = Vec::new(); parser.feed(&chunk, &mut events); for ev in events { @@ -430,6 +439,12 @@ pub async fn stream( )); } + acc.diagnostics.completed = acc.finished; + tx.send(StreamDelta::Diagnostics { + summary: Box::new(acc.diagnostics), + }) + .await + .map_err(|_| ProviderError::Cancelled)?; Ok(acc.usage) } diff --git a/src-tauri/src/provider/diagnostics.rs b/src-tauri/src/provider/diagnostics.rs new file mode 100644 index 00000000..6bf03648 --- /dev/null +++ b/src-tauri/src/provider/diagnostics.rs @@ -0,0 +1,216 @@ +//! Bounded, content-free evidence for responses that assemble to no content. + +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Default, PartialEq, Serialize)] +pub struct StreamDiagnostics { + pub http_status: u16, + pub request_id: Option, + pub chunks: u64, + pub bytes: u64, + pub events: BTreeMap, + pub block_types: BTreeMap, + pub content_bytes: u64, + pub tool_blocks: u64, + pub unrecognized_values: u64, + pub non_json_events: u64, + pub stop_reason: Option, + pub completed: bool, +} + +impl StreamDiagnostics { + pub fn set_response(&mut self, response: &reqwest::Response) { + self.http_status = response.status().as_u16(); + self.request_id = ["request-id", "x-request-id", "x-minimax-request-id"] + .iter() + .find_map(|name| response.headers().get(*name)?.to_str().ok()) + .map(|id| { + id.chars() + .filter(|c| c.is_ascii_alphanumeric() || "-_.:".contains(*c)) + .take(128) + .collect() + }); + } + + pub fn chunk(&mut self, bytes: usize) { + self.chunks += 1; + self.bytes += bytes as u64; + } + + pub fn event(&mut self, name: &str, value: &Value) { + // Only fixed vocabulary enters logs; unknown names and arbitrary values are counted. + let name = match name { + "message_start" + | "message_delta" + | "message_stop" + | "ping" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + | "chat.chunk" + | "[DONE]" + | "response.created" + | "response.in_progress" + | "response.completed" + | "response.incomplete" + | "response.output_item.added" + | "response.output_item.done" + | "response.content_part.added" + | "response.content_part.done" + | "response.output_text.delta" + | "response.output_text.done" + | "response.reasoning_summary_text.delta" + | "response.reasoning_text.delta" + | "response.function_call_arguments.delta" + | "response.function_call_arguments.done" => name, + _ => "other", + }; + *self.events.entry(name.into()).or_default() += 1; + self.observe(value, 0); + } + + fn observe(&mut self, value: &Value, depth: usize) { + if depth > 16 { + self.unrecognized_values += 1; + return; + } + match value { + Value::Object(fields) => { + for (key, value) in fields { + match key.as_str() { + "tool_calls" => { + if let Some(calls) = value.as_array() { + self.tool_blocks += calls.len() as u64; + } + } + "stop_reason" | "finish_reason" | "reason" => { + if let Some(reason) = value.as_str() { + self.stop_reason = Some( + match reason { + "end_turn" | "tool_use" | "stop_sequence" + | "max_tokens" | "stop" | "tool_calls" | "length" + | "content_filter" | "max_output_tokens" | "pause_turn" + | "refusal" => reason, + _ => "other", + } + .into(), + ); + } + } + "type" => { + if let Some(kind) = value.as_str() { + let kind = match kind { + "text" | "thinking" | "tool_use" | "text_delta" + | "thinking_delta" | "input_json_delta" | "signature_delta" + | "function_call" | "function" | "output_text" + | "reasoning" | "message" | "refusal" => kind, + // Event types are already counted separately. + kind if kind.starts_with("response.") + || matches!( + kind, + "message_start" + | "message_delta" + | "message_stop" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + ) => + { + "event" + } + _ => "other", + }; + *self.block_types.entry(kind.into()).or_default() += 1; + if matches!(kind, "tool_use" | "function_call") { + self.tool_blocks += 1; + } + } + } + "text" | "thinking" | "content" | "reasoning_content" | "delta" + | "partial_json" | "arguments" => { + if let Some(text) = value.as_str() { + self.content_bytes += text.len() as u64; + } + } + // Known metadata must not look like unparsed content. + "id" | "call_id" | "name" | "model" | "role" | "object" | "signature" + | "status" | "service_tier" | "system_fingerprint" => {} + _ => { + if value.as_str().is_some_and(|s| !s.is_empty()) { + self.unrecognized_values += 1; + } + } + } + if value.is_object() || value.is_array() { + self.observe(value, depth + 1); + } + } + } + Value::Array(items) => { + for item in items { + self.observe(item, depth + 1); + } + } + Value::String(text) if !text.is_empty() => self.unrecognized_values += 1, + _ => {} + } + } + + /// Evidence labels, not a claim that the upstream or parser is definitively at fault. + pub fn empty_evidence(&self) -> &'static str { + if self.content_bytes > 0 || self.tool_blocks > 0 { + "content_not_assembled" + } else if self.non_json_events > 0 + || self.unrecognized_values > 0 + || self.events.contains_key("other") + || self.block_types.contains_key("other") + { + "unrecognized_shape" + } else { + "no_content_observed" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn distinguishes_empty_from_unassembled_and_unknown_without_logging_content() { + let mut d = StreamDiagnostics::default(); + d.event("message_stop", &json!({"type":"message_stop"})); + assert_eq!(d.empty_evidence(), "no_content_observed"); + d.event( + "content_block_start", + &json!({"content_block":{"type":"text","text":"secret body"}}), + ); + assert_eq!(d.empty_evidence(), "content_not_assembled"); + assert!(!serde_json::to_string(&d).unwrap().contains("secret")); + let mut d = StreamDiagnostics::default(); + d.event( + "secret event", + &json!({"type":"secret type","payload":"secret value"}), + ); + assert_eq!(d.empty_evidence(), "unrecognized_shape"); + assert!(!serde_json::to_string(&d).unwrap().contains("secret")); + assert_eq!(d.events.len(), 1); + } + + #[test] + fn chat_tool_structure_with_missing_identity_is_not_an_empty_upstream_reply() { + let mut d = StreamDiagnostics::default(); + d.event( + "chat.chunk", + &json!({"choices":[{"delta":{"tool_calls":[{ + "index":0,"function":{"name":"read"} + }]},"finish_reason":"stop"}]}), + ); + assert_eq!(d.tool_blocks, 1); + assert_eq!(d.content_bytes, 0); + assert_eq!(d.empty_evidence(), "content_not_assembled"); + } +} diff --git a/src-tauri/src/provider/dto.rs b/src-tauri/src/provider/dto.rs index 91c6c3b4..e555918d 100644 --- a/src-tauri/src/provider/dto.rs +++ b/src-tauri/src/provider/dto.rs @@ -84,6 +84,10 @@ impl StreamRequest { #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum StreamDelta { + /// Attempt-local diagnostic metadata; never forwarded to UI or model history. + Diagnostics { + summary: Box, + }, /// 正文文本增量 Text { /// 本段增量文本 @@ -283,6 +287,7 @@ pub enum AsmBlock { /// 把 SSE 流式 delta 重新装配为一条 assistant Message(主循环消费)。 #[derive(Debug, Default)] pub struct Assembled { + pub diagnostics: Option>, /// 按到达顺序排列的装配块(text/thinking 穿插 + 工具调用占位) pub blocks: Vec, /// 本回合的工具调用(含拼接完成的原始入参 JSON) diff --git a/src-tauri/src/provider/mod.rs b/src-tauri/src/provider/mod.rs index c84a55d7..dff3ab3c 100644 --- a/src-tauri/src/provider/mod.rs +++ b/src-tauri/src/provider/mod.rs @@ -1,4 +1,5 @@ pub mod anthropic; +pub mod diagnostics; pub mod dto; pub mod headers; pub mod keys; diff --git a/src-tauri/src/provider/openai_chat.rs b/src-tauri/src/provider/openai_chat.rs index 4e4d33f6..84c0e737 100644 --- a/src-tauri/src/provider/openai_chat.rs +++ b/src-tauri/src/provider/openai_chat.rs @@ -164,6 +164,7 @@ fn convert_message(m: &Message, out: &mut Vec) { /// Chat Completions SSE 流的累积器:记录工具调用身份与收尾态,从 usage 帧提取 token 用量。 #[derive(Debug, Default)] pub struct OcAccum { + pub diagnostics: super::diagnostics::StreamDiagnostics, /// 规范 index → (调用 id, 工具名),finish_reason=tool_calls 时按此补发 ToolCallEnd pub tool_ids: std::collections::HashMap, /// 已补发 ToolCallEnd 的规范 index 集合,保证只收尾一次 @@ -185,12 +186,15 @@ pub fn handle_chunk( deltas: &mut Vec, ) -> Result { if data.trim() == "[DONE]" { + acc.diagnostics.event("[DONE]", &Value::Null); acc.finished = true; return Ok(false); } let v: Value = serde_json::from_str(data) .map_err(|e| ProviderError::Protocol(format!("openai chunk json: {e}")))?; + acc.diagnostics.event("chat.chunk", &v); + if let Some(err) = v.get("error") { return Err(ProviderError::Server(format!( "stream error: {}", @@ -327,9 +331,14 @@ pub async fn stream( return Err(ProviderError::from_status(status, &body_text)); } + let mut diagnostics = super::diagnostics::StreamDiagnostics::default(); + diagnostics.set_response(&resp); let mut stream = resp.bytes_stream(); let mut parser = SseParser::new(); - let mut acc = OcAccum::default(); + let mut acc = OcAccum { + diagnostics, + ..Default::default() + }; loop { let chunk = tokio::select! { @@ -340,6 +349,7 @@ pub async fn stream( None => break, }, }; + acc.diagnostics.chunk(chunk.len()); let mut events: Vec = Vec::new(); parser.feed(&chunk, &mut events); for ev in events { @@ -366,6 +376,12 @@ pub async fn stream( )); } + acc.diagnostics.completed = acc.finished; + tx.send(StreamDelta::Diagnostics { + summary: Box::new(acc.diagnostics), + }) + .await + .map_err(|_| ProviderError::Cancelled)?; Ok(acc.usage) } diff --git a/src-tauri/src/provider/openai_responses.rs b/src-tauri/src/provider/openai_responses.rs index 73867dab..77d9599e 100644 --- a/src-tauri/src/provider/openai_responses.rs +++ b/src-tauri/src/provider/openai_responses.rs @@ -118,6 +118,7 @@ fn convert_message(m: &Message, out: &mut Vec) { /// Responses SSE 流的累积器:以 call_id 关联 function_call 的 index 与收尾态。 #[derive(Debug, Default)] pub struct OrAccum { + pub diagnostics: super::diagnostics::StreamDiagnostics, /// function_call call_id → 输出序号(按到达顺序分配) call_index: std::collections::HashMap, /// 下一个可分配的工具调用序号 @@ -140,10 +141,15 @@ pub fn handle_event( ) -> Result { let v: Value = match serde_json::from_str(data) { Ok(v) => v, - Err(_) => return Ok(true), // 非 JSON 数据(如空行)直接忽略 + Err(_) => { + acc.diagnostics.non_json_events += 1; + return Ok(true); + } // Record ignored data without retaining it. }; let name = event.unwrap_or_else(|| v["type"].as_str().unwrap_or("")); + acc.diagnostics.event(name, &v); + match name { "response.output_text.delta" => { if let Some(t) = v["delta"].as_str() @@ -284,9 +290,14 @@ pub async fn stream( return Err(ProviderError::from_status(status, &body_text)); } + let mut diagnostics = super::diagnostics::StreamDiagnostics::default(); + diagnostics.set_response(&resp); let mut stream = resp.bytes_stream(); let mut parser = SseParser::new(); - let mut acc = OrAccum::default(); + let mut acc = OrAccum { + diagnostics, + ..Default::default() + }; loop { let chunk = tokio::select! { @@ -297,6 +308,7 @@ pub async fn stream( None => break, }, }; + acc.diagnostics.chunk(chunk.len()); let mut events: Vec = Vec::new(); parser.feed(&chunk, &mut events); for ev in events { @@ -321,6 +333,12 @@ pub async fn stream( "SSE 流被截断(未收到 response.completed)".into(), )); } + acc.diagnostics.completed = acc.finished; + tx.send(StreamDelta::Diagnostics { + summary: Box::new(acc.diagnostics), + }) + .await + .map_err(|_| ProviderError::Cancelled)?; Ok(acc.usage) } diff --git a/src-tauri/src/provider/tests_integration.rs b/src-tauri/src/provider/tests_integration.rs index b22ca756..3b785a7d 100644 --- a/src-tauri/src/provider/tests_integration.rs +++ b/src-tauri/src/provider/tests_integration.rs @@ -12,6 +12,113 @@ use tokio::net::TcpListener; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; +#[tokio::test] +async fn empty_stream_diagnostics_distinguish_upstream_content_for_all_protocols() { + use crate::core::config::ApiFormat; + let cases = [ + ( + ApiFormat::AnthropicMessages, + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + "no_content_observed", + ), + // Some compatible endpoints send text in the initial block, which our delta parser ignores. + ( + ApiFormat::AnthropicMessages, + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"private body\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + "content_not_assembled", + ), + ( + ApiFormat::OpenAiChat, + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "no_content_observed", + ), + ( + ApiFormat::OpenAiChat, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"read\"}}]},\"finish_reason\":\"stop\"}]}\n\n", + "content_not_assembled", + ), + ( + ApiFormat::OpenAiResponses, + "event: response.completed\ndata: {\"response\":{\"usage\":{}}}\n\n", + "no_content_observed", + ), + ( + ApiFormat::OpenAiResponses, + "event: response.unknown\ndata: {\"payload\":\"private body\"}\n\nevent: response.completed\ndata: {\"response\":{\"usage\":{}}}\n\n", + "unrecognized_shape", + ), + ]; + for (format, body, evidence) in cases { + let listener = Arc::new(TcpListener::bind("127.0.0.1:0").await.unwrap()); + let port = listener.local_addr().unwrap().port(); + let server_listener = listener.clone(); + let server = tokio::spawn(async move { + let (mut socket, _) = server_listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + while !request.windows(4).any(|s| s == b"\r\n\r\n") { + let n = socket.read(&mut buffer).await.unwrap(); + assert!(n > 0); + request.extend_from_slice(&buffer[..n]); + assert!(request.len() < 65536); + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nX-Request-Id: trace-123\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.shutdown().await.unwrap(); + socket // Keep the connection alive through the client read. + }); + let mut model = test_model(port); + model.api_format = format; + let (tx, mut rx) = mpsc::channel(32); + let request = StreamRequest { + model: model.clone(), + system_core: "sys".into(), + system_extra: String::new(), + messages: vec![Message::user_text("hi")], + tools: vec![], + cache_key: None, + cache_gen_index: None, + reasoning_effort: None, + session_id: None, + }; + super::stream_model( + &reqwest::Client::new(), + &model, + None, + request, + tx, + CancellationToken::new(), + ) + .await + .unwrap(); + let mut diagnostic = None; + while let Some(delta) = rx.recv().await { + match delta { + StreamDelta::Diagnostics { summary } => diagnostic = Some(summary), + other => panic!("Unexpected content for empty fixture: {other:?}"), + } + } + let diagnostic = diagnostic.unwrap(); + assert_eq!(diagnostic.http_status, 200); + assert_eq!(diagnostic.request_id.as_deref(), Some("trace-123")); + assert!(diagnostic.completed); + assert!(diagnostic.chunks > 0); + assert_eq!(diagnostic.bytes, body.len() as u64); + assert_eq!(diagnostic.empty_evidence(), evidence); + assert!( + !serde_json::to_string(&diagnostic) + .unwrap() + .contains("private body") + ); + drop(server.await.unwrap()); + drop(listener); + } +} + /// 捕获首个请求的原始头部(含请求行 + 头部,直至 body 起始前)并回一个既定响应。 async fn spawn_capturing_sse_server(captured: Arc>, resp: Vec) -> u16 { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/src-tauri/src/tools/ask/tests.rs b/src-tauri/src/tools/ask/tests.rs index 93702fd3..7cbcd870 100644 --- a/src-tauri/src/tools/ask/tests.rs +++ b/src-tauri/src/tools/ask/tests.rs @@ -2,6 +2,139 @@ use super::*; use crate::tools::{Tool, ToolCtx, ToolOutcome}; use serde_json::{Value, json}; +fn missing_id_gate_args() -> Value { + json!({"switchToAutoEdit":true,"questions":[{"question":"Choose", "options":[ + {"label":"Use auto edit","mode":"auto_edit"}, + {"label":"Use full access","mode":"full_access"}, + {"label":"补充意见"}, + {"label":"先看预览"} + ]}]}) +} + +#[tokio::test] +async fn explicit_skip_never_approves_switches_or_freezes_a_plan() { + use crate::core::prefs::ApprovalMode; + for mode in [ + ApprovalMode::Plan, + ApprovalMode::ConfirmEach, + ApprovalMode::AutoEdit, + ApprovalMode::FullAccess, + ] { + let ctx = ask_ctx_mode(mode); + let out = + drive_ask_with_answer(&ctx, approval_gate_args(true), json!({"action":"skip"})).await; + assert_eq!(out.error.unwrap().code, "E_ASK_SKIPPED"); + assert_eq!(ctx.rt.prefs().approval_mode, mode); + assert!(ctx.rt.approved_plan.lock().unwrap().is_none()); + assert!(ctx.rt.history.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn skip_mixed_with_answers_or_permissions_is_rejected() { + let ctx = plan_ctx(); + for answer in [ + json!({"action":"skip","answers":{}}), + json!({"action":"skip","mode":"full_access"}), + json!({"action":"skip","note":"continue"}), + ] { + let out = drive_ask_with_answer(&ctx, approval_gate_args(true), answer).await; + assert_eq!(out.error.unwrap().code, "E_ASK_INVALID_ACTION"); + assert_eq!( + ctx.rt.prefs().approval_mode, + crate::core::prefs::ApprovalMode::Plan + ); + } +} + +fn repaired_answer(option: &str) -> Value { + json!({"answers":{"__cw_q_1":{"selections":[option],"note":"additional context"}}}) +} + +#[tokio::test] +async fn repaired_ids_never_infer_approval_or_mode_from_labels_ids_or_valid_answers() { + use crate::core::prefs::ApprovalMode; + for mode in [ + ApprovalMode::Plan, + ApprovalMode::ConfirmEach, + ApprovalMode::AutoEdit, + ApprovalMode::FullAccess, + ] { + for args in [ + json!({"switchToAutoEdit":true,"questions":[{"question":"?","options":[{"id":"approve","label":"批准方案"}]}]}), + json!({"switchToAutoEdit":true,"questions":[{"question":"?","options":[{"label":"Approve plan"}]}]}), + ] { + let ctx = ask_ctx_mode(mode); + let selection = if args["questions"][0]["options"][0].get("id").is_some() { + "approve" + } else { + "__cw_q1_option_1" + }; + let out = drive_ask_with_answer(&ctx, args, repaired_answer(selection)).await; + assert!(out.ok, "{out:?}"); + assert_ne!(out.data["plan_approved"], json!(true)); + assert_eq!(ctx.rt.prefs().approval_mode, mode); + assert!(ctx.rt.approved_plan.lock().unwrap().is_none()); + } + for option in ["__cw_q1_option_3", "__cw_q1_option_4"] { + let ctx = ask_ctx_mode(mode); + let out = + drive_ask_with_answer(&ctx, missing_id_gate_args(), repaired_answer(option)).await; + assert!(out.ok, "{out:?}"); + assert_eq!(ctx.rt.prefs().approval_mode, mode); + assert_ne!(out.data["plan_approved"], json!(true)); + } + } +} + +#[tokio::test] +async fn repaired_ids_follow_only_explicit_selected_mode_and_plan_gate_still_applies() { + use crate::core::prefs::ApprovalMode; + let denied = plan_ctx(); + let out = drive_ask_with_answer( + &denied, + missing_id_gate_args(), + repaired_answer("__cw_q1_option_2"), + ) + .await; + assert_eq!(out.error.unwrap().code, "E_PLAN_TODOS_REQUIRED"); + assert_eq!(denied.rt.prefs().approval_mode, ApprovalMode::Plan); + for mode in [ + ApprovalMode::Plan, + ApprovalMode::ConfirmEach, + ApprovalMode::AutoEdit, + ApprovalMode::FullAccess, + ] { + let ctx = ask_ctx_mode(mode); + register_todos(&ctx, &["Work"]); + ctx.rt + .analysis_done + .store(true, std::sync::atomic::Ordering::SeqCst); + let out = drive_ask_with_answer( + &ctx, + missing_id_gate_args(), + repaired_answer("__cw_q1_option_2"), + ) + .await; + assert!(out.ok, "{out:?}"); + assert_eq!(ctx.rt.prefs().approval_mode, ApprovalMode::FullAccess); + } +} + +#[tokio::test] +async fn repaired_ids_never_resolve_a_selected_id_through_another_options_label() { + use crate::core::prefs::ApprovalMode; + let ctx = ask_ctx_mode(ApprovalMode::ConfirmEach); + let args = json!({"questions":[{"question":"Choose", "options":[ + {"id":"full", "label":"revise", "mode":"full_access"}, + {"id":"revise", "label":"补充意见"} + ]}]}); + let out = drive_ask_with_answer(&ctx, args, repaired_answer("revise")).await; + assert!(out.ok); + assert_eq!(ctx.rt.prefs().approval_mode, ApprovalMode::ConfirmEach); + assert_ne!(out.data["plan_approved"], json!(true)); +} + fn ask_ctx_mode(mode: crate::core::prefs::ApprovalMode) -> ToolCtx { let ws = tempfile::tempdir().unwrap(); let dd = tempfile::tempdir().unwrap(); @@ -688,7 +821,7 @@ async fn ignored_single_question_returns_err() { assert!(!outcome.ok); let err = outcome.error.expect("忽略应返回错误"); assert_eq!(err.code, "E_ASK_NOT_ANSWERED"); - assert!(err.message.contains("忽略"), "{}", err.message); + assert!(err.message.contains("未回答"), "{}", err.message); assert!(err.message.contains("不要继续执行"), "{}", err.message); } diff --git a/src-tauri/src/tools/ask/tool.rs b/src-tauri/src/tools/ask/tool.rs index 4491c546..c580b671 100644 --- a/src-tauri/src/tools/ask/tool.rs +++ b/src-tauri/src/tools/ask/tool.rs @@ -6,6 +6,9 @@ use serde_json::{Value, json}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct Args { + /// Host-only provenance: repairing IDs never authorizes legacy implicit mode changes. + #[serde(skip)] + ids_repaired: bool, /// 问题列表(1–5 题)。 questions: Vec, /// G2 豁免:轻量路径声明(跳过分析产物校验;todos 非空硬要求不变)。schema key 是 camelCase,rename_all 必须保持对齐 @@ -73,11 +76,168 @@ pub struct Option2 { /// 计划文本自动落盘为计划文件供前端卡片查看。 pub struct AskTool; +/// Fill absent correlation IDs only; explicit invalid IDs and ambiguous duplicates fail closed. +fn parse_args(mut value: Value) -> Result { + let mut repaired = false; + if let Some(questions) = value.get_mut("questions").and_then(Value::as_array_mut) { + if questions.len() > 5 { + return Err("questions 最多 5 题".into()); + } + repaired |= fill_ids(questions, "__cw_q", "questions", &Default::default())?; + let reserved_options = questions + .iter() + .filter_map(|q| q.get("options").and_then(Value::as_array)) + .flatten() + .flat_map(|o| { + [ + o.get("id").and_then(Value::as_str), + o.get("label").and_then(Value::as_str), + ] + }) + .flatten() + .map(str::to_string) + .collect(); + for (qi, question) in questions.iter_mut().enumerate() { + if let Some(options) = question.get_mut("options").and_then(Value::as_array_mut) { + if options.len() > 6 { + return Err(format!("questions[{qi}].options 最多 6 项")); + } + repaired |= fill_ids( + options, + &format!("__cw_q{}_option", qi + 1), + &format!("questions[{qi}].options"), + &reserved_options, + )?; + } + } + } + let mut args: Args = serde_json::from_value(value).map_err(|e| format!("参数解析失败:{e}"))?; + args.ids_repaired = repaired; + Ok(args) +} + +fn fill_ids( + items: &mut [Value], + prefix: &str, + path: &str, + reserved: &std::collections::HashSet, +) -> Result { + let mut used = std::collections::HashSet::new(); + for (index, item) in items.iter().enumerate() { + if let Some(id) = item.get("id") { + let id = id + .as_str() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + format!("{path}[{index}].id 必须是非空字符串;只自动补齐缺失字段") + })?; + if !used.insert(id.to_string()) { + return Err(format!("{path}[{index}].id 重复:请为每项提供唯一 id")); + } + } + } + // Selection parsing has a label fallback; generated IDs must not collide with any label. + used.extend(reserved.iter().cloned()); + let mut repaired = false; + for (index, item) in items.iter_mut().enumerate() { + if let Some(object) = item.as_object_mut() + && !object.contains_key("id") + { + let base = format!("{prefix}_{}", index + 1); + let mut id = base.clone(); + let mut suffix = 1; + while used.contains(&id) { + id = format!("{base}_{suffix}"); + suffix += 1; + } + used.insert(id.clone()); + object.insert("id".into(), Value::String(id)); + repaired = true; + } + } + Ok(repaired) +} + +#[cfg(test)] +mod argument_diagnostics_tests { + use super::*; + + #[test] + fn missing_ids_are_neutral_deterministic_and_preserve_explicit_modes() { + let value = json!({"questions":[{"question":"Proceed?", "options":[ + {"label":"Approve"}, {"label":"Full access", "mode":"full_access"} + ]}]}); + let args = parse_args(value.clone()).unwrap(); + let again = parse_args(value).unwrap(); + assert!(args.ids_repaired); + let q = &args.questions[0]; + assert_eq!(q.id, "__cw_q_1"); + assert_eq!(q.options[0].id, "__cw_q1_option_1"); + assert_eq!(q.options[0].mode, None); + assert_eq!( + q.options[1].mode, + Some(crate::core::prefs::ApprovalMode::FullAccess) + ); + assert_eq!( + serde_json::to_value(&args.questions).unwrap(), + serde_json::to_value(&again.questions).unwrap() + ); + } + + #[test] + fn generated_ids_avoid_existing_ids_and_labels_including_other_questions() { + let args = parse_args(json!({"questions":[ + {"question":"First", "options":[{"label":"A"},{"id":"custom","label":"__cw_q1_option_1"}]}, + {"id":"__cw_q_1","question":"Second", "options":[{"label":"B"},{"id":"__cw_q1_option_1_1","label":"C"}]} + ]})).unwrap(); + assert_eq!(args.questions[0].id, "__cw_q_1_1"); + assert_eq!(args.questions[0].options[0].id, "__cw_q1_option_1_2"); + assert_eq!(args.questions[0].options[1].id, "custom"); + assert_ne!( + args.questions[0].options[0].id, + args.questions[1].options[0].id + ); + } + + #[test] + fn explicit_invalid_or_duplicate_ids_and_unknown_modes_are_not_repaired() { + for id in [json!(null), json!(""), json!(" "), json!(42)] { + assert!(parse_args(json!({"questions":[{"id":id,"question":"?"}]})).is_err()); + } + assert!( + parse_args( + json!({"questions":[{"id":"same","question":"?"},{"id":"same","question":"?"}]}) + ) + .is_err() + ); + assert!( + parse_args( + json!({"questions":[{"question":"?","options":[{"label":"A","mode":"guess"}]}]}) + ) + .is_err() + ); + assert!(parse_args(json!({"questions":[{"question":"?","options":[{"id":"same","label":"A"},{"id":"same","label":"B"}]}]})).is_err()); + } + + #[test] + fn model_cannot_override_repair_provenance() { + let args = parse_args( + json!({"ids_repaired":false,"explicit_mode_only":false,"questions":[{"question":"?"}]}), + ) + .unwrap(); + assert!(args.ids_repaired); + let args = parse_args(json!({"questions":[{"id":"original","question":"?"}]})).unwrap(); + assert!(!args.ids_repaired); + } +} + /// UI 动作使用独立载荷,不能与 questions 的 answers 混用。 /// 未知动作、额外字段以及非字符串备注都必须失败关闭,不能回落到批准/轻量切档路径。 #[derive(Deserialize)] #[serde(tag = "action", deny_unknown_fields)] enum AskAction { + #[serde(rename = "skip")] + Skip {}, #[serde(rename = "preview_plan")] PreviewPlan { #[serde(default)] @@ -179,9 +339,9 @@ impl Tool for AskTool { async fn run(&self, ctx: &ToolCtx, args: Value) -> ToolOutcome { let run_id = ctx.rt.run_snapshot().run_id; - let args: Args = match serde_json::from_value(args) { + let args: Args = match parse_args(args) { Ok(a) => a, - Err(e) => return ToolOutcome::err("E_ARGS", format!("参数解析失败:{e}")), + Err(e) => return ToolOutcome::err("E_ARGS", e), }; if args.questions.is_empty() { return ToolOutcome::err("E_ARGS", "至少一个问题"); @@ -208,20 +368,26 @@ impl Tool for AskTool { ); // 固定 UI 入口只取决于本次询问与真实正文,不依赖模型是否声明 preview 选项、 // 当前权限档位或计划文件是否成功落盘;应答时沿用此快照,不能重读运行态推断能力。 - let can_preview_plan = approval_shape(&args.questions) && usable.is_some(); + let approval = if args.ids_repaired { + args.questions.len() == 1 && args.questions[0].options.iter().any(is_approve_class) + } else { + approval_shape(&args.questions) + }; + let can_preview_plan = approval && usable.is_some(); let plan_file = usable.as_deref().and_then(|body| save_plan_file(ctx, body)); let mut ask_opened = json!({ "session": ctx.rt.id, "run_id": run_id, "ask_id": ask_id, "kind": "ask", + "explicit_mode_only": args.ids_repaired, "questions": args.questions, // arch 批准闸标记(附加字段,不新增事件键):前端用它批准后同步权限胶囊 - "switch_to_auto_edit": arch_gate_shape(&args.questions) && args.switch_to_autoedit.unwrap_or(false), + "switch_to_auto_edit": !args.ids_repaired && arch_gate_shape(&args.questions) && args.switch_to_autoedit.unwrap_or(false), // [docs/plan-and-approval](../../../../docs/plan-and-approval.md):计划文件路径(计划卡「查看完整计划」按钮打开;落盘失败或无可用方案时为 None) "plan_file": plan_file, // [docs/plan-and-approval](../../../../docs/plan-and-approval.md):批准形标记(后端宽松检测 = 单一事实源); // 前端据此渲染单选 + 批准项直提,不再猜测 id === "approve" //(此前 id 自拟会使面板回退为多选切换) - "approval": approval_shape(&args.questions), - "approve_id": approve_option_id(&args.questions), + "approval": approval, + "approve_id": if args.ids_repaired { None } else { approve_option_id(&args.questions) }, "can_preview_plan": can_preview_plan, }); // [docs/plan-and-approval](../../../../docs/plan-and-approval.md): @@ -255,7 +421,7 @@ impl Tool for AskTool { return ToolOutcome::err("E_ASK_CANCELLED", "用户取消或未回答"); }; - // UI 预览动作必须先于忽略、批准匹配、G2/G3 与所有切档路径处理。 + // Explicit UI actions precede unanswered checks, approval matching and every mode switch. // 只要携带 action,就不允许任何错误载荷退回普通 answers 路径。 if answer.get("action").is_some() { let action: AskAction = match serde_json::from_value(answer.clone()) { @@ -263,17 +429,29 @@ impl Tool for AskTool { Err(_) => { return ToolOutcome::err( "E_ASK_INVALID_ACTION", - "询问动作无效:只接受 {action: 'preview_plan', note?: string},不得混入 answers 或批准选项。方案未获批准,请停下等待用户指示。", + "询问动作无效:只接受 {action: 'skip'} 或 {action: 'preview_plan', note?: string},不得混入 answers 或批准选项。方案未获批准,请停下等待用户指示。", ); } }; + if matches!(action, AskAction::Skip {}) { + crate::core::session_log::info( + ctx.rt.as_ref(), + &format!("ask [{ask_id}] 用户明确跳过,停止当前运行(不批准、不切档)"), + ); + return ToolOutcome::err( + "E_ASK_SKIPPED", + "用户明确跳过本次询问,当前运行已停止。保留已有进度,等待用户新指示;不要继续执行、重试或重新询问。", + ); + } if !can_preview_plan { return ToolOutcome::err( "E_ASK_PREVIEW_UNAVAILABLE", "本次询问没有可预览的批准方案,预览请求未生效。方案未获批准,请停下等待用户指示。", ); } - let AskAction::PreviewPlan { note } = action; + let AskAction::PreviewPlan { note } = action else { + unreachable!() + }; crate::core::session_log::info( ctx.rt.as_ref(), &format!("ask [{ask_id}] 用户请求查看预览(不批准、不切档、不改变计划基线)"), @@ -290,8 +468,8 @@ impl Tool for AskTool { })); } - // 忽略检测([docs/plan-and-approval](../../../../docs/plan-and-approval.md)):前端「忽略」= 清空末页直提空载荷 - //(所有 selections 空 + 所有 note 空);产品语义是「未回答 / 不作答」。 + // Legacy empty-answer compatibility: explicit skip now uses its own terminal action. + // All-empty selections/notes still mean unanswered, never approval. // 此前空载荷仍返回 ToolOutcome::ok(仅尾注「(未回答)」),模型收到成功形态的结果 // 会误读为「用户已应答 / 默许」而继续推进(真实案例:批准形询问被忽略后模型直接开工)。 // 必须在工具层显式失败,让「停下等待用户指示」作为硬信号到达模型, @@ -300,12 +478,12 @@ impl Tool for AskTool { crate::core::session_log::warn( ctx.rt.as_ref(), &format!( - "ask [{ask_id}] 用户忽略(全空应答:所有题 selections/note 均空)→ 按 E_ASK_NOT_ANSWERED 返回" + "ask [{ask_id}] 未作答(所有题 selections/note 均空)→ 按 E_ASK_NOT_ANSWERED 返回" ), ); return ToolOutcome::err( "E_ASK_NOT_ANSWERED", - "用户忽略了本次询问(未作答)。这不是批准或确认:不要继续执行、不要重发同一询问;请简要说明当前状态或调整方案,然后停下等待用户的进一步指示。", + "用户未回答本次询问。这不是批准或确认:不要继续执行、不要重发同一询问;请简要说明当前状态或调整方案,然后停下等待用户的进一步指示。", ); } @@ -346,7 +524,7 @@ impl Tool for AskTool { || s.contains("Approve") || approve_ids.contains(s.as_str())) }); - if approved_hit { + if approved_hit && !args.ids_repaired { approved = true; } let note = ans["note"].as_str().unwrap_or(""); @@ -380,7 +558,11 @@ impl Tool for AskTool { // 批准门选档确认(C1-C3):选中批准类选项(mode 非空)= 批准,且目标档位由该选项声明; // mode 是结构化单一事实源,宽松子串匹配(approved_hit)只作旧形态兜底。 // 预览项不带 mode → selected_mode 天然为 None,故选 preview 不会走到这里。 - let selected_mode = selected_target_mode(&args.questions, &answer); + let selected_mode = if args.ids_repaired { + selected_option_mode(&args.questions, &answer, false) + } else { + selected_target_mode(&args.questions, &answer) + }; if selected_mode.is_some() { approved = true; } @@ -389,6 +571,7 @@ impl Tool for AskTool { // 切档判定:叠加 `!preview_only_answer` **同时封掉两条通道**(结构化的 selected_mode 路径天然被排除; // 这里封的是 valid_answer 轻量通道与 arch_flag —— 否则用户点「先看预览」会被静默批准并按方案开工)。 let switch = !preview_only_answer + && (!args.ids_repaired || selected_mode.is_some()) && (!gate_shape || approved) && wants_mode_switch( mode_at_open, @@ -604,13 +787,21 @@ pub(super) fn preview_only(questions: &[Question], answer: &Value) -> bool { pub(super) fn selected_target_mode( questions: &[Question], answer: &Value, +) -> Option { + selected_option_mode(questions, answer, true) +} + +fn selected_option_mode( + questions: &[Question], + answer: &Value, + allow_label: bool, ) -> Option { questions.iter().find_map(|q| { let sel = answer["answers"][&q.id]["selections"].as_array()?; sel.iter().filter_map(|s| s.as_str()).find_map(|s| { q.options .iter() - .find(|o| o.id == s || o.label == s) + .find(|o| o.id == s || (allow_label && o.label == s)) .and_then(|o| o.mode) }) }) diff --git a/src-tauri/src/tools/read.rs b/src-tauri/src/tools/read.rs index 5292812a..6d4d2a87 100644 --- a/src-tauri/src/tools/read.rs +++ b/src-tauri/src/tools/read.rs @@ -58,6 +58,24 @@ pub struct FileReq { /// 图片文件不走文本通道,而是以 DataURL + 多模态内容双路交付。 pub struct ReadTool; +/// Repair only the unambiguous single-file shorthand. Never merge competing shapes. +pub(crate) fn normalize_args(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() + && !object.contains_key("files") + && object + .get("path") + .and_then(Value::as_str) + .is_some_and(|p| !p.trim().is_empty()) + && object + .keys() + .all(|k| matches!(k.as_str(), "path" | "startLine" | "endLine")) + { + let file = std::mem::take(object); + object.insert("files".into(), Value::Array(vec![Value::Object(file)])); + } + value +} + /// 按扩展名推断图片 MIME 类型;非图片返回 None。 pub fn media_type_of(path: &Path) -> Option<&'static str> { let ext = path.extension()?.to_string_lossy().to_lowercase(); @@ -219,12 +237,15 @@ impl Tool for ReadTool { } async fn run(&self, ctx: &ToolCtx, args: Value) -> ToolOutcome { - let args: Args = match serde_json::from_value(args) { + let args: Args = match serde_json::from_value(normalize_args(args)) { Ok(a) => a, Err(e) => return ToolOutcome::err("E_ARGS", format!("参数解析失败:{e}")), }; if args.files.is_empty() || args.files.len() > 20 { - return ToolOutcome::err("E_ARGS", "files 必须包含 1–20 个文件"); + return ToolOutcome::err( + "E_ARGS", + "files 必须包含 1–20 个文件。使用 files 数组包裹文件请求,例如 {\"files\":[{\"path\":\"src/main.rs\",\"startLine\":1,\"endLine\":100}]},不要把 path 直接放在顶层。", + ); } let roots = ctx.write_roots(); let mut out_files = Vec::new(); @@ -458,6 +479,66 @@ mod tests { assert_eq!(media_type_of(Path::new("a/b.rs")), None); } + #[tokio::test] + async fn ambiguous_missing_files_returns_a_model_visible_fix_example() { + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let (core, rt) = make_ctx(&ws, &dd); + let result = ReadTool + .run( + &ctx_for(core, rt), + json!({"path":"src/main.rs", "unknown":true}), + ) + .await; + assert!(!result.ok); + assert_eq!(result.error.as_ref().unwrap().code, "E_ARGS"); + let text = + crate::tools::compact::compact_for_model(ToolKind::ReadOnly, "read", &result, false); + assert!(text.contains("\"files\":[{\"path\""), "{text}"); + } + + #[test] + fn normalize_only_unambiguous_read_shorthand_and_is_idempotent() { + let raw = json!({"path":"src/main.rs","startLine":2,"endLine":8}); + let normalized = normalize_args(raw.clone()); + assert_eq!(normalized, json!({"files":[raw]})); + assert_eq!(normalize_args(normalized.clone()), normalized); + for value in [ + json!({"files":null,"path":"a"}), + json!({"files":[],"path":"a"}), + json!({"path":"a","other":true}), + json!({"path":42}), + json!({"path":""}), + ] { + assert_eq!(normalize_args(value.clone()), value); + } + } + + #[tokio::test] + async fn shorthand_reads_same_range_and_keeps_read_fence() { + let ws = tempfile::tempdir().unwrap(); + let dd = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(ws.path().join("sample.txt"), "first\nsecond\nthird\n").unwrap(); + std::fs::write(outside.path().join("outside.txt"), "private").unwrap(); + let (core, rt) = make_ctx(&ws, &dd); + let ctx = ctx_for(core, rt); + let result = ReadTool + .run(&ctx, json!({"path":"sample.txt","startLine":2,"endLine":2})) + .await; + assert!(result.ok, "{result:?}"); + assert_eq!(result.data["files"][0]["start_line"], 2); + let content = result.data["files"][0]["content"].as_str().unwrap(); + assert!( + content.contains("second") && !content.contains("first") && !content.contains("third") + ); + let denied = ReadTool + .run(&ctx, json!({"path":outside.path().join("outside.txt")})) + .await; + assert!(!denied.ok); + assert_eq!(denied.error.unwrap().code, "E_PATH_OUTSIDE"); + } + /// 构造(core, 主会话 runtime):走 get_or_create_session(is_main_session = true)。 fn make_ctx( ws: &tempfile::TempDir, diff --git a/ui/src/__tests__/askpanel.test.tsx b/ui/src/__tests__/askpanel.test.tsx index 5669f87f..57a9acc5 100644 --- a/ui/src/__tests__/askpanel.test.tsx +++ b/ui/src/__tests__/askpanel.test.tsx @@ -24,6 +24,73 @@ vi.mock("@tauri-apps/api/core", () => ({ const prefs = { approval_mode: "plan" as const, model_id: null, reasoning_effort: null }; +describe("host repaired ask IDs", () => { + it("a reused option ID in another question cannot supply its mode", async () => { + seedAsk({ + askId: "repaired-multi", kind: "ask", explicitModeOnly: true, + questions: [ + { id: "q1", question: "First", options: [{ id: "shared", label: "Ordinary choice" }] }, + { id: "q2", question: "Second", options: [{ id: "shared", label: "Full access", mode: "full_access" }] }, + ], + }, "confirm_each"); + render(); + fireEvent.click(screen.getByText("Ordinary choice")); + expect(calls.some((call) => call.cmd === "resolve_ask")).toBe(false); + fireEvent.click(btnByText("下一题")); + fireEvent.click(btnByText("提交回答")); + await waitFor(() => expect(calls.some((call) => call.cmd === "resolve_ask")).toBe(true)); + expect(calls.some((call) => call.cmd === "set_session_prefs")).toBe(false); + expect(useSessions.getState().tabs[0].prefs.approval_mode).toBe("confirm_each"); + }); + + it.each(["plan", "confirm_each", "auto_edit", "full_access"] as ApprovalMode[])( + "legacy-looking label/id cannot switch %s after ID repair", async (mode) => { + seedAsk({ + askId: "repaired-plain", kind: "ask", explicitModeOnly: true, + switchToAutoEdit: true, approval: true, approveId: "approve", + questions: [{ id: "__cw_q_1", question: "Choose", options: [{ id: "approve", label: "Approve plan" }] }], + }, mode); + render(); + expect(screen.queryByText(i18n.t("ask.switchHint"), { exact: false })).toBeNull(); + fireEvent.click(screen.getByText("Approve plan")); + expect(calls.some((call) => call.cmd === "resolve_ask")).toBe(false); + fireEvent.click(btnByText("提交回答")); + await waitFor(() => expect(calls.some((call) => call.cmd === "resolve_ask")).toBe(true)); + expect(calls.some((call) => call.cmd === "set_session_prefs")).toBe(false); + expect(useSessions.getState().tabs[0].prefs.approval_mode).toBe(mode); + }, + ); + + it.each(["补充意见", "先看预览"])("%s cannot switch after ID repair", async (label) => { + seedAsk({ + askId: "repaired-gate", kind: "ask", explicitModeOnly: true, + questions: [{ id: "__cw_q_1", question: "Choose", options: [ + { id: "__cw_q1_option_1", label: "Full access", mode: "full_access" }, + { id: "__cw_q1_option_2", label: "补充意见" }, + { id: "__cw_q1_option_3", label: "先看预览" }, + ] }], + }, "confirm_each"); + render(); + fireEvent.click(screen.getByText(label)); + if (label === "补充意见") fireEvent.click(btnByText("提交回答")); + await waitFor(() => expect(calls.some((call) => call.cmd === "resolve_ask")).toBe(true)); + expect(calls.some((call) => call.cmd === "set_session_prefs")).toBe(false); + }); + + it("explicitly declared mode remains selectable after ID repair", async () => { + seedAsk({ + askId: "repaired-mode", kind: "ask", explicitModeOnly: true, + questions: [{ id: "__cw_q_1", question: "Choose", options: [ + { id: "__cw_q1_option_1", label: "Full access", mode: "full_access" }, + ] }], + }, "confirm_each"); + render(); + fireEvent.click(screen.getByText("Full access")); + await waitFor(() => expect(calls.some((call) => call.cmd === "set_session_prefs")).toBe(true)); + expect(useSessions.getState().tabs[0].prefs.approval_mode).toBe("full_access"); + }); +}); + function seed(mode: ApprovalMode = "plan") { useSessions.setState({ tabs: [{ key: "s1", sessionId: "s1", workspace: "/tmp/ws", title: "s1", projectId: null, createdAt: "2026-09-01T00:00:00Z", prefs: { ...prefs, approval_mode: mode } }], @@ -309,7 +376,7 @@ describe("AskPanel(docs/plan-and-approval)", () => { const payload = calls.find((c) => c.cmd === "resolve_ask")?.args?.value; expect(payload.answers.q1.selections).toContain("revise"); }); - it("忽略缺陷回归(单题/末页):点击「忽略」清空后直提,载荷 selections 为空", async () => { + it("单题跳过发送独立动作,不提交选项", async () => { seedAsk({ askId: "a6", kind: "ask", questions: [{ @@ -318,13 +385,13 @@ describe("AskPanel(docs/plan-and-approval)", () => { }], }); render(); - fireEvent.click(btnByText("忽略")); + fireEvent.click(btnByText("跳过")); await waitFor(() => expect(calls.some((c) => c.cmd === "resolve_ask")).toBe(true)); const payload = calls.find((c) => c.cmd === "resolve_ask")?.args?.value; - expect(payload.answers.q1.selections).toEqual([]); + expect(payload).toEqual({ action: "skip" }); }); - it("忽略多题·非末页:清空当前题并翻页不提交,提交载荷验证已清空", async () => { + it("多题非末页跳过立即终止整组询问,不翻页或提交已有答案", async () => { seedAsk({ askId: "a7", kind: "ask", questions: [ @@ -334,16 +401,15 @@ describe("AskPanel(docs/plan-and-approval)", () => { }); render(); fireEvent.click(screen.getByText(/^甲/)); // select option A on question 1 (text carries a ✓ suffix, hence prefix match) - fireEvent.click(btnByText("忽略")); // not the last page: clears q1 and moves to page 2, no resolve - expect(calls.some((c) => c.cmd === "resolve_ask")).toBe(false); - expect(screen.getByText("第二题")).toBeTruthy(); // page turned - fireEvent.click(btnByText("提交回答")); + fireEvent.click(btnByText("跳过")); await waitFor(() => expect(calls.some((c) => c.cmd === "resolve_ask")).toBe(true)); const payload = calls.find((c) => c.cmd === "resolve_ask")?.args?.value; - expect(payload.answers.q1.selections).toEqual([]); // ignore cleared question 1's answer + expect(payload).toEqual({ action: "skip" }); + expect(screen.getByText("第一题")).toBeTruthy(); + expect(calls.some((c) => c.cmd === "set_session_prefs")).toBe(false); }); - it("忽略多题·末页:直提且其余题作答保留、当前题清空", async () => { + it("多题末页跳过也不提交前面已选答案", async () => { seedAsk({ askId: "a8", kind: "ask", questions: [ @@ -357,10 +423,10 @@ describe("AskPanel(docs/plan-and-approval)", () => { const pagerBtns = document.querySelectorAll(".ask-pager .pager-btn"); fireEvent.click(pagerBtns[pagerBtns.length - 1]); expect(screen.getByText("第二题")).toBeTruthy(); - fireEvent.click(btnByText("忽略")); // last page: clears q2 then submits directly + fireEvent.click(btnByText("跳过")); // last page: clears q2 then submits directly await waitFor(() => expect(calls.some((c) => c.cmd === "resolve_ask")).toBe(true)); const payload = calls.find((c) => c.cmd === "resolve_ask")?.args?.value; - expect(payload.answers.q1.selections).toContain("a"); // other question's answer kept + expect(payload).toEqual({ action: "skip" }); }); it("docs/chat-and-interface:多题 ask(planFile 落盘)渲染计划卡片(planBody 驱动) + page 1 题干仍可见", async () => { @@ -472,7 +538,7 @@ describe("AskPanel(docs/plan-and-approval)", () => { await waitFor(() => expect(calls.some((c) => c.cmd === "set_session_prefs")).toBe(true)); }); - it("空载荷缺陷回归:忽略路径不受 recommended 兑底影响(保持未回答语义)", async () => { + it("跳过不提交推荐批准项,也不切档", async () => { seedAsk({ askId: "a16", kind: "ask", questions: [{ @@ -484,10 +550,10 @@ describe("AskPanel(docs/plan-and-approval)", () => { }], }); render(); - fireEvent.click(btnByText("忽略")); // last-page direct submit: does not clear recommended, sends an empty payload + fireEvent.click(btnByText("跳过")); // last-page direct submit: does not clear recommended, sends an empty payload await waitFor(() => expect(calls.some((c) => c.cmd === "resolve_ask")).toBe(true)); const payload = calls.find((c) => c.cmd === "resolve_ask")?.args?.value; - expect(payload.answers.q1.selections).toEqual([]); + expect(payload).toEqual({ action: "skip" }); expect(calls.some((c) => c.cmd === "set_session_prefs")).toBe(false); // empty payload does not switch mode }); }); @@ -1868,7 +1934,7 @@ describe("AskPanel fixed plan preview", () => { fireEvent.click(button); fireEvent.click(button); fireEvent.click(screen.getByText("执行方案")); - fireEvent.click(btnByText("忽略")); + fireEvent.click(btnByText("跳过")); fireEvent.click(btnByText("提交回答")); fireEvent.keyDown(document.querySelector(".ask-card")!, { key: "Enter" }); fireEvent.keyDown(document.querySelector(".ask-card")!, { key: "1" }); @@ -1883,14 +1949,14 @@ describe("AskPanel fixed plan preview", () => { expect(calls.some((c) => c.cmd.includes("prefs"))).toBe(false); }); - it.each(["执行方案", "提交回答", "忽略"])("blocks preview during an already pending %s answer", async (action) => { + it.each(["执行方案", "提交回答", "跳过"])("blocks preview during an already pending %s answer", async (action) => { seedAsk(previewAsk()); const pending = deferredResolve(); render(); fireEvent.click(action === "执行方案" ? screen.getByText(action) : btnByText(action)); fireEvent.click(screen.getByRole("button", { name: "查看预览" })); expect(calls.filter((c) => c.cmd === "resolve_ask")).toHaveLength(1); - expect(calls.find((c) => c.cmd === "resolve_ask")?.args.value.action).toBeUndefined(); + expect(calls.find((c) => c.cmd === "resolve_ask")?.args.value.action).toBe(action === "跳过" ? "skip" : undefined); await act(async () => { pending.resolve(); await pending.promise; }); }); diff --git a/ui/src/__tests__/run.ask-mode.test.ts b/ui/src/__tests__/run.ask-mode.test.ts index 9a5701d0..82d149b7 100644 --- a/ui/src/__tests__/run.ask-mode.test.ts +++ b/ui/src/__tests__/run.ask-mode.test.ts @@ -30,6 +30,7 @@ describe("ask:opened 批准门选档", () => { const h = useRun.getState().bindGlobalHandlers(); h["ask:opened"]({ session, ask_id: "a1", kind: "ask", approval: true, + explicit_mode_only: true, plan_file: "/ws/.codewave/tasks/plan-x.md", plan_body: "## 完整方案\n1. 改 A\n2. 验证 B", allow_always: true, @@ -44,6 +45,7 @@ describe("ask:opened 批准门选档", () => { }], }); const ask = useRun.getState().tabs[session]!.ask!; + expect(ask.explicitModeOnly).toBe(true); expect(ask.planFile).toBe("/ws/.codewave/tasks/plan-x.md"); expect(ask.planBody).toBe("## 完整方案\n1. 改 A\n2. 验证 B"); expect(ask.allowAlways).toBe(true); diff --git a/ui/src/__tests__/run.interleave.test.ts b/ui/src/__tests__/run.interleave.test.ts index 40749f3e..14ecb30c 100644 --- a/ui/src/__tests__/run.interleave.test.ts +++ b/ui/src/__tests__/run.interleave.test.ts @@ -42,6 +42,24 @@ describe("思考与工具穿插顺序", () => { }); }); + it.each([ + ["empty_response", "未收到有效回复"], + ["network", "连接或回复流中断"], + ["protocol", "回复格式解析异常"], + ["stream_stalled", "回复流长时间无进展"], + ["rate_limited", "服务暂时限流或超时"], + ["server", "服务端返回错误"], + ["request_repaired", "已调整请求内容"], + [undefined, "正在进行第 2 次重试"], + ["future_reason", "正在进行第 2 次重试"], + ["constructor", "正在进行第 2 次重试"], + ])("retry reason %s displays an accurate notice", (reason, expected) => { + useRun.getState().bindGlobalHandlers()["run:retry"]({ session, reason, attempt: 2 }); + const notice = tabOf(session).items.at(-1); + expect(notice).toMatchObject({ kind: "notice", text: expect.stringContaining(expected) }); + expect((notice as { text: string }).text).not.toContain("半截"); + }); + it("delta_thinking/delta_text 帧按到达序穿插进 timeline", () => { useRun.setState((s) => { const t = s.tabs[session]!; diff --git a/ui/src/__tests__/run.queue.test.ts b/ui/src/__tests__/run.queue.test.ts index be12c6d8..32583f95 100644 --- a/ui/src/__tests__/run.queue.test.ts +++ b/ui/src/__tests__/run.queue.test.ts @@ -58,6 +58,46 @@ beforeEach(() => { afterEach(cleanup); describe("运行队列", () => { + it.each([true, false])("跳过后可手动续跑,旧询问不会复活(收到 close=%s)", async (deliverClose) => { + seed(); + useRun.setState((s) => { + s.tabs.s1.runId = "skipped-run"; + s.tabs.s1.todos = [{ title: "已有步骤", status: "completed" }, { title: "继续步骤", status: "in_progress" }]; + }); + const h = handlers(); + const oldAsk = { session: "s1", run_id: "skipped-run", ask_id: "skipped-ask", kind: "ask", + questions: [{ id: "q", question: "继续?" }] }; + h["ask:opened"](oldAsk); + h["tool:start"]({ session: "s1", run_id: "skipped-run", call_key: "skip:0", tool: "ask", phase: "waiting" }); + await useRun.getState().send("仍暂停的队列项"); + await useRun.getState().resolveAsk("skipped-ask", { action: "skip" }, "s1"); + h["tool:result"]({ session: "s1", run_id: "skipped-run", call_key: "skip:0", tool: "ask", + outcome: { ok: false, error: { code: "E_ASK_SKIPPED", message: "用户明确跳过" } } }); + if (deliverClose) h["ask:closed"]({ session: "s1", ask_id: "skipped-ask" }); + h["run:cancelled"]({ session: "s1", run_id: "skipped-run" }); + const stopped = useRun.getState().tabs.s1; + expect(stopped.running).toBe(false); + expect(stopped.ask).toBeNull(); + expect(stopped.startPending).toBe(false); + expect(stopped.todos.map((todo) => todo.status)).toEqual(["completed", "in_progress"]); + expect(stopped.items.filter((item) => item.kind === "assistant").every((item) => !item.streaming)).toBe(true); + expect(calls.filter((call) => call.cmd === "start_chat")).toHaveLength(0); + h["ask:opened"](oldAsk); // A delayed old event must not cover the input again. + expect(useRun.getState().tabs.s1.ask).toBeNull(); + startReply = async () => "resumed-run"; + expect(await useRun.getState().send("从已有进度继续", [], "s1")).toBe(true); + expect(calls.filter((call) => call.cmd === "start_chat")).toHaveLength(1); + expect(useRun.getState().tabs.s1.runId).toBe("resumed-run"); + expect(useRun.getState().tabs.s1.queue.map((item) => item.text)).toEqual(["仍暂停的队列项"]); + h["ask:opened"](oldAsk); + h["run:cancelled"]({ session: "s1", run_id: "skipped-run" }); + expect(useRun.getState().tabs.s1.running).toBe(true); + expect(useRun.getState().tabs.s1.ask).toBeNull(); + h["ask:opened"]({ ...oldAsk, run_id: "resumed-run", ask_id: "new-ask" }); + h["ask:closed"]({ session: "s1", ask_id: "skipped-ask" }); + expect(useRun.getState().tabs.s1.ask?.askId).toBe("new-ask"); + }); + it("新 run 启动失败后,旧 done 的迟到确认不得恢复其队列", async () => { seed(); let resolve!: () => void; diff --git a/ui/src/__tests__/run.terminal-fallback.test.ts b/ui/src/__tests__/run.terminal-fallback.test.ts index 7f2a1edf..07c9e9d2 100644 --- a/ui/src/__tests__/run.terminal-fallback.test.ts +++ b/ui/src/__tests__/run.terminal-fallback.test.ts @@ -184,6 +184,24 @@ describe("run 终态兜底链(docs/runtime-and-scheduling)", () => { }); describe("③ reconcileRun 双向对账", () => { + it("跳过后的关闭和终态事件都丢失时,对账仍恢复输入并允许续跑", async () => { + seedStuck(); + useRun.setState((s) => { s.tabs[SESSION].runId = "skipped-run"; }); + sessionRunSnapshot.mockResolvedValue({ + running: false, run_id: "skipped-run", + terminal: { run_id: "skipped-run", kind: "cancelled" }, + }); + await useRun.getState().reconcileRun(SESSION); + expect(hasUnsettledRunState(tabOf())).toBe(false); + expect(tabOf().ask).toBeNull(); + startChat.mockResolvedValueOnce("resumed-run"); + expect(await useRun.getState().send("从已有进度继续", [], SESSION)).toBe(true); + expect(tabOf().running).toBe(true); + expect(tabOf().runId).toBe("resumed-run"); + handlers()["ask:opened"]({ session: SESSION, run_id: "skipped-run", ask_id: "ask-1", kind: "ask" }); + expect(tabOf().ask).toBeNull(); + }); + it("A/B收尾后idle迟到Astart不得改身份,Bidle快照保持已收敛", async () => { seedStuck(); useRun.setState((s) => { s.tabs[SESSION].runId = "A"; }); diff --git a/ui/src/__tests__/toolcallcard.test.tsx b/ui/src/__tests__/toolcallcard.test.tsx index 3a710727..61e56030 100644 --- a/ui/src/__tests__/toolcallcard.test.tsx +++ b/ui/src/__tests__/toolcallcard.test.tsx @@ -136,6 +136,15 @@ describe("ToolCallCard summary", () => { }); describe("ToolCallCard ask 未作答中性渲染", () => { + it("explicit skip is displayed as neutral skipped, not a failure", () => { + render(); + expect(verbText()).toContain("已跳过"); + expect(verbText()).not.toContain("失败"); + expect(document.querySelector(".tool-card.st-neutral")).not.toBeNull(); + }); // ask 忽略/取消在后端是刻意 err(防模型误读为默许),但用户视角只是「没回答」—— // 展示层按错误码降为中性灰(st-neutral),其余错误仍红色失败。 it("忽略(E_ASK_NOT_ANSWERED):显示「未回答」且卡片为中性灰,不出现「失败」", () => { diff --git a/ui/src/features/tools/AskPanel.tsx b/ui/src/features/tools/AskPanel.tsx index 7e0eb20e..9d52448e 100644 --- a/ui/src/features/tools/AskPanel.tsx +++ b/ui/src/features/tools/AskPanel.tsx @@ -5,7 +5,7 @@ // **别再新建任何叫 cursor 的类**:它与 CSS 的 cursor 属性同名,是历史误伤高发点。 // [docs/plan-and-approval](../../../../docs/plan-and-approval.md):询问窗口重构——卡片布局 + 编号选项 + 键盘导航(Tab/方向键移动、数字快选、Enter 确认)。 // 审批形态:需要权限 + 命令块 + 允许 / 始终允许本项目 / 拒绝 三选项; -// 询问形态:分页多题作答 + 补充说明 + 忽略/提交;计划批准形态额外渲染「方案」卡 +// 询问形态:分页多题作答 + 补充说明 + 跳过/提交;计划批准形态额外渲染「方案」卡 // (完整方案 markdown + 复制 + 查看方案 -> FileViewerModal 打开后端落盘的计划文件)。 // [docs/plan-and-approval](../../../../docs/plan-and-approval.md):批准形渲染(后端判定的单选 + 直提)、复选/单选指示物、 // 补充说明输入纳入键盘导航环。 @@ -61,7 +61,11 @@ function isLegacyPreviewOption(option: AskOptionPayload): boolean { return id === "preview" || label.includes("先看预览") || label.includes("preview first"); } -/** 询问面板:审批(三选项单选直提)/ 询问(分页多题 + 编号选项 + 键盘导航环含补充输入 + 忽略/提交) +function optionKey(questionId: string, optionId: string): string { + return JSON.stringify([questionId, optionId]); +} + +/** 询问面板:审批(三选项单选直提)/ 询问(分页多题 + 编号选项 + 键盘导航环含补充输入 + 跳过/提交) * 两种形态;计划卡对所有 ask 展示(后端一律落盘计划文件);批准/ConfirmEach 有效应答后本地同步切自动编辑档。 */ export default function AskPanel() { const { t } = useTranslation(); @@ -93,7 +97,7 @@ export default function AskPanel() { // 批准类选项 → 目标档位(后端批准门下发的「以 X 档执行」选项);缺 mode = 非批准类选项 const modeById = useMemo(() => { const m = new Map(); - for (const q of questions) for (const o of q.options ?? []) if (o.mode) m.set(o.id, o.mode); + for (const q of questions) for (const o of q.options ?? []) if (o.mode) m.set(optionKey(q.id, o.id), o.mode); return m; }, [questions]); @@ -199,9 +203,9 @@ export default function AskPanel() { // (防契约漏字段导致点选不直提)。 const approvalShape = canPreviewPlan || - ask.approval === true || - modeById.size > 0 || - (questions.length === 1 && (questions[0]?.options ?? []).some((o) => o.id === "approve")); + (!ask.explicitModeOnly && (ask.approval === true || + (questions.length === 1 && (questions[0]?.options ?? []).some((o) => o.id === "approve")))) || + modeById.size > 0; // 批准闸形状(与后端 arch_gate_shape 同口径:单题 + 含 id="approve"):该形状上只有选中批准项才动档位—— // 「补充意见」是「别开工,我要改方案」,不切档(免得前端把胶囊抬到自动编辑档而后端没动,两侧分叉) const gateShape = @@ -210,7 +214,7 @@ export default function AskPanel() { const planApprovalSingle = isPlan && questions.length === 1 && approvalShape; const approveId = ask.approveId ?? null; const approveOptionId = - approveId ?? ((questions[0]?.options ?? []).find((o) => o.id === "approve")?.id ?? null); + ask.explicitModeOnly ? null : approveId ?? ((questions[0]?.options ?? []).find((o) => o.id === "approve")?.id ?? null); // 预览选项([docs/plan-and-approval](../../../../docs/plan-and-approval.md)):批准门的第四选项,语义是「先看预览」—— // 既不是批准、也不算有效应答(后端 preview_only 同样排除),故既不触发切档,也不能被当成批准项。 // 只在批准形询问里认(与后端 preview_option_ids 同口径;普通澄清询问里的同名选项不受影响) @@ -265,7 +269,7 @@ export default function AskPanel() { // 预览项提交后不切档(后端 preview_only 排除),只是把「要看预览」这件事交给模型 const direct = approvalShape && - (modeById.has(optId) || + (modeById.has(optionKey(qid, optId)) || (approveOptionId != null && optId === approveOptionId) || (previewOptionId != null && optId === previewOptionId)); if (direct) { @@ -279,7 +283,7 @@ export default function AskPanel() { async function submit() { // 空载荷缺陷修复的双保险:提交按钮路径上仍未作答的题回退到推荐选项; - // 「忽略」路径(ignoreCurrent 清空后直提)绕开 submit(),保持「未作答」语义不被污染 + // Explicit skip has its own action and never submits recommended defaults. const fallback: Record = { ...selected }; for (const q of questions) { if ((fallback[q.id] ?? []).length === 0) { @@ -325,10 +329,10 @@ export default function AskPanel() { if (sel.some((s) => s !== previewOptionId)) allPreview = false; // 结构化判定([docs/plan-and-approval]):选中带 mode 的选项即批准,并记下要切到的档位 for (const s of sel) { - const mode = modeById.get(s); + const mode = modeById.get(optionKey(q.id, s)); if (mode) { approved = true; - approvedMode = mode; + approvedMode ??= mode; // Same first selected explicit mode as the host. } } // Plan 档协议:选中批准选项即在本地把胶囊同步为批准档位(后端在 ask 工具内切档) @@ -342,6 +346,7 @@ export default function AskPanel() { const approveCandidates = previewOptionId == null ? sel : sel.filter((s) => s !== previewOptionId); if ( !approved && + !ask!.explicitModeOnly && (approveCandidates.includes("approve") || approveCandidates.some((s) => /approve|执行方案/i.test(s)) || (approveOptionId != null && approveCandidates.includes(approveOptionId))) @@ -382,9 +387,9 @@ export default function AskPanel() { // 「模型违约给预览项挂 mode」——该载荷下 approved=true,planPath(plan 档或 switchToAutoEdit 标志) // 与 modePath(approvedMode 非空)都会单边把胶囊切档,而后端 preview_only_answer 为真、根本不切 → // 用户可见结果就是**单边静默提权**。lightPath 已由 answerEffective(含 !previewOnly)封住,无需再乘。 - const planPath = approved && !previewOnly && (tab?.prefs.approval_mode === "plan" || ask!.switchToAutoEdit); + const planPath = !ask!.explicitModeOnly && approved && !previewOnly && (tab?.prefs.approval_mode === "plan" || ask!.switchToAutoEdit); const lightPath = - tab?.prefs.approval_mode === "confirm_each" && answerEffective && (!gateShape || approved); + !ask!.explicitModeOnly && tab?.prefs.approval_mode === "confirm_each" && answerEffective && (!gateShape || approved); // [docs/plan-and-approval]:结构化路径(选中的选项带 mode)优先且不看当前档位; // 旧形态(无 mode)走既有回落:仅 plan / confirm_each 两条路径同步,档位回落 auto_edit const modePath = approvedMode != null && !previewOnly; @@ -396,25 +401,11 @@ export default function AskPanel() { }); } - function ignoreCurrent() { + function skipAsk() { if (isSubmitting || submissionLocks.current.has(submissionKey)) return; - if (!cur) return; - // setSelected/setNotes 是异步的;直提必须显式传清空后的值,不能依赖 state 时序 - const clearedSel = { ...selected, [cur.id]: [] }; - const clearedNotes = { ...notes, [cur.id]: "" }; - if (page < questions.length - 1) { - // 非末页([docs/plan-and-approval](../../../../docs/plan-and-approval.md) 语义):清空当前题应答并翻页;不 resolve - setSelected(clearedSel); - setNotes(clearedNotes); - setPage(page + 1); - setCursor(0); - } else { - // 末页(含单题):忽略 = 清空当前题提交整个 ask(未作答)。 - // 缺陷修复:此前末页点击既不清空、不翻页、不 resolve 也不反馈——按钮像坏的。 - setSelected(clearedSel); - setNotes(clearedNotes); - void submitWith(clearedSel, clearedNotes); - } + void runSubmission(async () => { + await useRun.getState().resolveAsk(ask!.askId, { action: "skip" }, sessionId!); + }); } // ---------- 键盘(焦点不在输入框内时生效;卡片有焦点即可用;鼠标始终可用) ---------- @@ -684,13 +675,13 @@ export default function AskPanel() {
{/* docs/chat-and-interface:ConfirmEach 在提交时切档;明示提示防止静默提权 */} - {tabMode === "confirm_each" && ⚠ {t("ask.switchHint")}} + {tabMode === "confirm_each" && !ask.explicitModeOnly && ⚠ {t("ask.switchHint")}} {/* docs/plan-and-approval:hint 按形态切换(批准「Enter 确认」/ 多选「Enter 或空格切换」); 单题(互斥)形态有独立提示——选择即替换、重选即清空 */} ⓘ {approvalShape ? t("ask.keyHint") : cur?.single ? t("ask.keyHintSingle") : t("ask.keyHintMulti")} - + {/* 多题非末页 = 「下一题」(仅翻页不提交);末页与单题 = 「提交回答」 */}