diff --git a/src-tauri/src/commands/ai/audit/finalize.rs b/src-tauri/src/commands/ai/audit/finalize.rs new file mode 100644 index 0000000..07b237c --- /dev/null +++ b/src-tauri/src/commands/ai/audit/finalize.rs @@ -0,0 +1,79 @@ +//! 工具调用审计记录写/回填(audit_tool_call 写入 + audit_finalize 审批后状态回填) +//! +//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs +//! `pub use finalize::{audit_tool_call, audit_finalize};` 保持 `commands::ai::audit::*` +//! 路径透明(调用方零变更)。 + +use df_ai::ai_tools::RiskLevel; +use df_storage::crud::AiToolExecutionRepo; +use df_storage::models::AiToolExecutionRecord; + +use df_types::types::new_id; + +use crate::commands::now_millis; +use crate::state::AppState; + +use super::risk_str; + +/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) +/// +/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; +/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 +pub(crate) async fn audit_tool_call( + repo: &AiToolExecutionRepo, + conv_id: &str, + tool_call_id: &str, + tool_name: &str, + arguments: &str, + status: &str, + risk_level: RiskLevel, + result: Option, + decided_by: Option<&str>, +) { + let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; + let _ = repo + .insert(AiToolExecutionRecord { + id: new_id(), + conversation_id: Some(conv_id.to_string()), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + arguments: arguments.to_string(), + result, + status: status.to_string(), + risk_level: risk_str(risk_level).to_string(), + requested_at: now_millis(), + executed_at, + decided_by: decided_by.map(|s| s.to_string()), + }) + .await; +} + +/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result) +/// +/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC, +/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉, +/// 导致审批后审计记录永久卡 pending。 +pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option) { + // 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None, + // DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。 + let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await { + Ok(Some(rec)) => rec, + Ok(None) => { + tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id); + return; + } + Err(e) => { + tracing::error!("audit_finalize: 查询 tool_call_id={} 审计记录失败(DB 故障,状态未回填): {}", tool_call_id, e); + return; + } + }; + rec.status = status.to_string(); + rec.decided_by = Some("human".to_string()); + rec.executed_at = Some(now_millis()); + if let Some(r) = result { + rec.result = Some(r); + } + if let Err(e) = state.ai_tool_executions.update_full(&rec).await { + tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e); + } +} diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index b814f14..6cff631 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -10,13 +10,10 @@ use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; -use df_storage::models::AiToolExecutionRecord; - -use df_types::types::new_id; use crate::state::AppState; -use crate::commands::{err_str, now_millis}; +use crate::commands::err_str; use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft}; use super::tool_registry::generate_diff; @@ -122,143 +119,22 @@ pub async fn list_tool_executions( // reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason) -// 拆至子模块 audit/reason.rs(本批 helper 抽离,行为零变更)。 +// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。 mod reason; // process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。 use reason::build_approval_reason; -/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢) -/// -/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存 -/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。 -/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。 -/// -/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按 -/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv -/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve → -/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。 -/// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务 -/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂), -/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。 -pub async fn restore_pending_approvals(state: &AppState) { - let pending = match state.ai_tool_executions.list_pending().await { - Ok(v) => v, - Err(e) => { - tracing::warn!("启动恢复 pending 审批失败(非阻断): {}", e); - return; - } - }; - if pending.is_empty() { - return; - } - let mut session = state.ai_session.lock().await; - // 多 conv 分配:先建 conv → per_conv 索引(惰性建 PerConvState::new),再 insert pending。 - // conv() 已自处理「已存在则复用」(同 conv_id 多条 pending 复用同一 PerConvState), - // 故对每条 pending 都调一次 conv() 是幂等的(后续命中 entry().or_insert_with 跳过新建)。 - let mut convs_restored: std::collections::HashSet = std::collections::HashSet::new(); - for rec in pending { - let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default(); - // 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删) - if risk_from_str(&rec.risk_level).is_none() { - continue; - } - // 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。 - // 无 conversation_id 的无主审批(R-9)不建 per_conv——无 conv_id 可挂,仍进 pending_approvals - // 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。 - if let Some(cid) = rec.conversation_id.as_deref() { - if !cid.is_empty() && convs_restored.insert(cid.to_string()) { - // 惰性建:无则建 PerConvState::new,有则 or_insert_with 跳过(幂等,无副作用)。 - // 不在此恢复 messages——messages 由 switchConversation 从 DB reload 独立路径恢复, - // 此处仅保证 conv 在 per_conv 中存在(供 conv_read 命中)。 - let _ = session.conv(cid); - } - } - session.pending_approvals.insert( - rec.tool_call_id.clone(), - PendingApproval { - tool_call_id: rec.tool_call_id, - tool_name: rec.tool_name, - arguments: args, - conversation_id: rec.conversation_id, - recovered: true, - // AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启, - // 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态, - // 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。 - // 前端见 diff=None 时回退显新 content。 - diff: None, - }, - ); - } - tracing::info!( - "启动恢复: {} 条 pending 工具审批重建到内存, 分布 {} 个 conv 的 per_conv", - session.pending_approvals.len(), - convs_restored.len() - ); -} +// restore(audit/restore.rs):启动恢复重建 pending_approvals。 +// 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。 +mod restore; +pub use restore::restore_pending_approvals; -/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) -/// -/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; -/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 -pub(crate) async fn audit_tool_call( - repo: &AiToolExecutionRepo, - conv_id: &str, - tool_call_id: &str, - tool_name: &str, - arguments: &str, - status: &str, - risk_level: RiskLevel, - result: Option, - decided_by: Option<&str>, -) { - let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; - let _ = repo - .insert(AiToolExecutionRecord { - id: new_id(), - conversation_id: Some(conv_id.to_string()), - tool_call_id: tool_call_id.to_string(), - tool_name: tool_name.to_string(), - arguments: arguments.to_string(), - result, - status: status.to_string(), - risk_level: risk_str(risk_level).to_string(), - requested_at: now_millis(), - executed_at, - decided_by: decided_by.map(|s| s.to_string()), - }) - .await; -} - -/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result) -/// -/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC, -/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉, -/// 导致审批后审计记录永久卡 pending。 -pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option) { - // 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None, - // DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。 - let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await { - Ok(Some(rec)) => rec, - Ok(None) => { - tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id); - return; - } - Err(e) => { - tracing::error!("audit_finalize: 查询 tool_call_id={} 审计记录失败(DB 故障,状态未回填): {}", tool_call_id, e); - return; - } - }; - rec.status = status.to_string(); - rec.decided_by = Some("human".to_string()); - rec.executed_at = Some(now_millis()); - if let Some(r) = result { - rec.result = Some(r); - } - if let Err(e) = state.ai_tool_executions.update_full(&rec).await { - tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e); - } -} +// finalize(audit/finalize.rs):audit_tool_call 写入 + audit_finalize 审批后状态回填。 +// 第二批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls / +// find_cached_high_risk_result 裸名调用,且保持 commands::ai::audit::* 对 crate 内可见。 +mod finalize; +pub(crate) use finalize::{audit_finalize, audit_tool_call}; /// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。 /// @@ -708,105 +584,3 @@ pub(crate) async fn process_tool_calls( pending_count } - -#[cfg(test)] -mod tests_f09_batch8_restore { - use super::*; - use crate::commands::ai::{AiSession, PendingApproval}; - - /// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。 - /// - /// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑 - /// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖 - /// 的契约是 AiSession::conv 的幂等惰性创建 + conv_read 可达。本测试用同一契约重现分配 - /// 逻辑,验证各 conv 各归各(不串)、无主审批(None)不建 per_conv。 - #[test] - fn test_restore_multi_conv_distribution_invariant() { - let mut session = AiSession::new(); - - // 模拟 DB pending 行:3 条分属 2 个 conv(conv-a 两条 + conv-b 一条)+ 1 条 None 无主。 - let pending_rows: Vec<(String, String, Option)> = vec![ - ("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())), - ("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())), - ("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())), - ("tc-4".to_string(), "create_task".to_string(), None), // 无主(R-9) - ]; - - // 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。 - let mut convs_restored: HashSet = HashSet::new(); - for (tool_call_id, tool_name, conversation_id) in &pending_rows { - if let Some(cid) = conversation_id.as_deref() { - if !cid.is_empty() && convs_restored.insert(cid.to_string()) { - let _ = session.conv(cid); // 惰性建 - } - } - // pending 入单层 HashMap(不进 per_conv,设计 §2.1.2)。 - session.pending_approvals.insert( - tool_call_id.clone(), - PendingApproval { - tool_call_id: tool_call_id.clone(), - tool_name: tool_name.clone(), - arguments: serde_json::Value::Null, - conversation_id: conversation_id.clone(), - recovered: true, - diff: None, - }, - ); - } - - // 不变量 1:per_conv 含 conv-a + conv-b 各一份(惰性建,无主 None 不建)。 - assert_eq!(session.per_conv.len(), 2, "2 个 conv 各惰性建 1 份 per_conv"); - assert!(session.conv_read("conv-a").is_some(), "conv-a 恢复后 conv_read 应可达"); - assert!(session.conv_read("conv-b").is_some(), "conv-b 恢复后 conv_read 应可达"); - assert!( - session.conv_read("never-restored").is_none(), - "未恢复的 conv 不应被惰性建" - ); - - // 不变量 2:pending_approvals 单层 HashMap 4 条(含无主),conversation_id 保留(业务语义)。 - assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入单层 HashMap"); - let conv_a_pending: Vec<_> = session - .pending_approvals - .values() - .filter(|a| a.conversation_id.as_deref() == Some("conv-a")) - .collect(); - assert_eq!(conv_a_pending.len(), 2, "conv-a 2 条 pending 各归各"); - let conv_b_pending: Vec<_> = session - .pending_approvals - .values() - .filter(|a| a.conversation_id.as_deref() == Some("conv-b")) - .collect(); - assert_eq!(conv_b_pending.len(), 1, "conv-b 1 条 pending"); - let ownerless: Vec<_> = session - .pending_approvals - .values() - .filter(|a| a.conversation_id.is_none()) - .collect(); - assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入单层表(R-9)"); - - // 不变量 3:同 conv 多条 pending 复用同一 PerConvState(conv() 幂等,不重复建)。 - let conv_a_ptr = session.conv("conv-a") as *const _; - let _ = session.conv("conv-a"); // 再次访问(模拟同 conv 二次 pending) - let conv_a_ptr_again = session.conv("conv-a") as *const _; - assert_eq!(conv_a_ptr, conv_a_ptr_again, "同 conv 复用同一 PerConvState(幂等)"); - assert_eq!(session.per_conv.len(), 2, "同 conv 二次访问不新增 per_conv 条目"); - } - - /// 验证 restore 仅含无主审批(None)时不建任何 per_conv(R-9 边界)。 - #[test] - fn test_restore_only_ownerless_no_per_conv() { - let mut session = AiSession::new(); - let mut convs_restored: HashSet = HashSet::new(); - // 全部 None 的 pending 行:无主,不应触发任何 conv() 惰性建。 - let pending_rows: Vec> = vec![None, None, None]; - for conversation_id in &pending_rows { - if let Some(cid) = conversation_id.as_deref() { - if !cid.is_empty() && convs_restored.insert(cid.to_string()) { - let _ = session.conv(cid); - } - } - } - assert!(session.per_conv.is_empty(), "仅无主审批不应建任何 per_conv"); - assert!(convs_restored.is_empty(), "无 conv 被记录为已恢复"); - } -} diff --git a/src-tauri/src/commands/ai/audit/restore.rs b/src-tauri/src/commands/ai/audit/restore.rs new file mode 100644 index 0000000..2584e6f --- /dev/null +++ b/src-tauri/src/commands/ai/audit/restore.rs @@ -0,0 +1,183 @@ +//! 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢) +//! +//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs +//! `pub use restore::restore_pending_approvals;` 保持 `commands::ai::audit::*` 路径透明。 + +use std::collections::HashSet; + +use crate::state::AppState; + +use super::super::PendingApproval; +use super::risk_from_str; + +/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢) +/// +/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存 +/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。 +/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。 +/// +/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按 +/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv +/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve → +/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。 +/// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务 +/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂), +/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。 +pub async fn restore_pending_approvals(state: &AppState) { + let pending = match state.ai_tool_executions.list_pending().await { + Ok(v) => v, + Err(e) => { + tracing::warn!("启动恢复 pending 审批失败(非阻断): {}", e); + return; + } + }; + if pending.is_empty() { + return; + } + let mut session = state.ai_session.lock().await; + // 多 conv 分配:先建 conv → per_conv 索引(惰性建 PerConvState::new),再 insert pending。 + // conv() 已自处理「已存在则复用」(同 conv_id 多条 pending 复用同一 PerConvState), + // 故对每条 pending 都调一次 conv() 是幂等的(后续命中 entry().or_insert_with 跳过新建)。 + let mut convs_restored: HashSet = HashSet::new(); + for rec in pending { + let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default(); + // 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删) + if risk_from_str(&rec.risk_level).is_none() { + continue; + } + // 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。 + // 无 conversation_id 的无主审批(R-9)不建 per_conv——无 conv_id 可挂,仍进 pending_approvals + // 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。 + if let Some(cid) = rec.conversation_id.as_deref() { + if !cid.is_empty() && convs_restored.insert(cid.to_string()) { + // 惰性建:无则建 PerConvState::new,有则 or_insert_with 跳过(幂等,无副作用)。 + // 不在此恢复 messages——messages 由 switchConversation 从 DB reload 独立路径恢复, + // 此处仅保证 conv 在 per_conv 中存在(供 conv_read 命中)。 + let _ = session.conv(cid); + } + } + session.pending_approvals.insert( + rec.tool_call_id.clone(), + PendingApproval { + tool_call_id: rec.tool_call_id, + tool_name: rec.tool_name, + arguments: args, + conversation_id: rec.conversation_id, + recovered: true, + // AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启, + // 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态, + // 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。 + // 前端见 diff=None 时回退显新 content。 + diff: None, + }, + ); + } + tracing::info!( + "启动恢复: {} 条 pending 工具审批重建到内存, 分布 {} 个 conv 的 per_conv", + session.pending_approvals.len(), + convs_restored.len() + ); +} + +#[cfg(test)] +mod tests_f09_batch8_restore { + use super::super::PendingApproval; + use super::*; + use crate::commands::ai::AiSession; + + /// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。 + /// + /// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑 + /// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖 + /// 的契约是 AiSession::conv 的幂等惰性创建 + conv_read 可达。本测试用同一契约重现分配 + /// 逻辑,验证各 conv 各归各(不串)、无主审批(None)不建 per_conv。 + #[test] + fn test_restore_multi_conv_distribution_invariant() { + let mut session = AiSession::new(); + + // 模拟 DB pending 行:3 条分属 2 个 conv(conv-a 两条 + conv-b 一条)+ 1 条 None 无主。 + let pending_rows: Vec<(String, String, Option)> = vec![ + ("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())), + ("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())), + ("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())), + ("tc-4".to_string(), "create_task".to_string(), None), // 无主(R-9) + ]; + + // 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。 + let mut convs_restored: HashSet = HashSet::new(); + for (tool_call_id, tool_name, conversation_id) in &pending_rows { + if let Some(cid) = conversation_id.as_deref() { + if !cid.is_empty() && convs_restored.insert(cid.to_string()) { + let _ = session.conv(cid); // 惰性建 + } + } + // pending 入单层 HashMap(不进 per_conv,设计 §2.1.2)。 + session.pending_approvals.insert( + tool_call_id.clone(), + PendingApproval { + tool_call_id: tool_call_id.clone(), + tool_name: tool_name.clone(), + arguments: serde_json::Value::Null, + conversation_id: conversation_id.clone(), + recovered: true, + diff: None, + }, + ); + } + + // 不变量 1:per_conv 含 conv-a + conv-b 各一份(惰性建,无主 None 不建)。 + assert_eq!(session.per_conv.len(), 2, "2 个 conv 各惰性建 1 份 per_conv"); + assert!(session.conv_read("conv-a").is_some(), "conv-a 恢复后 conv_read 应可达"); + assert!(session.conv_read("conv-b").is_some(), "conv-b 恢复后 conv_read 应可达"); + assert!( + session.conv_read("never-restored").is_none(), + "未恢复的 conv 不应被惰性建" + ); + + // 不变量 2:pending_approvals 单层 HashMap 4 条(含无主),conversation_id 保留(业务语义)。 + assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入单层 HashMap"); + let conv_a_pending: Vec<_> = session + .pending_approvals + .values() + .filter(|a| a.conversation_id.as_deref() == Some("conv-a")) + .collect(); + assert_eq!(conv_a_pending.len(), 2, "conv-a 2 条 pending 各归各"); + let conv_b_pending: Vec<_> = session + .pending_approvals + .values() + .filter(|a| a.conversation_id.as_deref() == Some("conv-b")) + .collect(); + assert_eq!(conv_b_pending.len(), 1, "conv-b 1 条 pending"); + let ownerless: Vec<_> = session + .pending_approvals + .values() + .filter(|a| a.conversation_id.is_none()) + .collect(); + assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入单层表(R-9)"); + + // 不变量 3:同 conv 多条 pending 复用同一 PerConvState(conv() 幂等,不重复建)。 + let conv_a_ptr = session.conv("conv-a") as *const _; + let _ = session.conv("conv-a"); // 再次访问(模拟同 conv 二次 pending) + let conv_a_ptr_again = session.conv("conv-a") as *const _; + assert_eq!(conv_a_ptr, conv_a_ptr_again, "同 conv 复用同一 PerConvState(幂等)"); + assert_eq!(session.per_conv.len(), 2, "同 conv 二次访问不新增 per_conv 条目"); + } + + /// 验证 restore 仅含无主审批(None)时不建任何 per_conv(R-9 边界)。 + #[test] + fn test_restore_only_ownerless_no_per_conv() { + let mut session = AiSession::new(); + let mut convs_restored: HashSet = HashSet::new(); + // 全部 None 的 pending 行:无主,不应触发任何 conv() 惰性建。 + let pending_rows: Vec> = vec![None, None, None]; + for conversation_id in &pending_rows { + if let Some(cid) = conversation_id.as_deref() { + if !cid.is_empty() && convs_restored.insert(cid.to_string()) { + let _ = session.conv(cid); + } + } + } + assert!(session.per_conv.is_empty(), "仅无主审批不应建任何 per_conv"); + assert!(convs_restored.is_empty(), "无 conv 被记录为已恢复"); + } +} diff --git a/src/components/ai/TopBar.vue b/src/components/ai/TopBar.vue index 4797ded..3cdeddf 100644 --- a/src/components/ai/TopBar.vue +++ b/src/components/ai/TopBar.vue @@ -285,3 +285,215 @@ onBeforeUnmount(() => { if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null } }) + + +