//! L2 统一状态机 — `ConvState` enum(单一真相源)+ 合法转换守卫。 //! //! 来源:`generating状态机加固-2026-06-15.md` §3(轻量状态机决策)+ `aichat体验与 //! agent能力系统化重构-2026-06-21.md` §3(L2 统一状态机)。 //! //! # 背景(为什么单独建这个 enum) //! //! 旧实现会话状态由三个标志组合隐式表达(`generating: bool` + `stop_flag: AtomicBool` //! + `pending_approvals` HashMap),判别逻辑散落 `try_continue_agent_loop` / //! `ai_chat_stop` / `ai_conversation_create` 三处(写侧散布 + 读侧散布双根因,见 //! generating状态机加固-2026-06-15.md §2.3)。本模块提供**显式的、单一真相的**状态枚举: //! //! - **写侧收敛**:`ConvState` 仅经 [`ConvState::transition_to`] 守卫方法迁移,非法转换 //! 直接拒绝(`Err(InvalidTransition)`),调用方无法绕过守卫写入非法组合。 //! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等 //! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。 //! //! # 收口后(批3+) //! //! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool //! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点 //! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。 //! //! 散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)已逐个迁到读 //! `ConvState`(`is_active()` / `can_accept_request()`),不再回退旧 bool。 //! //! # 与目标钉扎(G1)的衔接(2026-06-26) //! //! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边); //! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goals`), //! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成 //! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。 //! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goals`, //! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。 use serde::{Deserialize, Serialize}; // ============================================================ // ConvState enum // ============================================================ /// 对话生命周期状态(单一真相源,5 态)。 /// /// 设计取舍(对齐 generating状态机加固-2026-06-15.md §3.2 「轻量状态机,不引入框架」): /// /// - **不引入状态机框架**(codegen / FSM 库):5 态 7 边转换简单,枚举 + 显式 `transition_to` /// 守卫即可表达,过度封装得不偿失。 /// - **派生关系明确**:`Compressed` 是 `Generating`/`Idle` 期间的瞬时派生态(压缩跑完 /// 回到原态),非终端态;`Error` 可经用户「重试」回到 `Idle` 再 `Generating`。 /// - **与读视图 [`SessionState`](../enum.SessionState.html) 区分**:`SessionState` /// (Streaming/AwaitingApproval/Idle)是面向「是否有审批挂起」的只读视图(读侧收敛①), /// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态 /// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。 /// /// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接, /// 预留,避免后续改动序列化兼容性)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConvState { /// 空闲:无 agentic loop 活跃,可接新请求。 /// /// 终态目标:所有正常退出路径(完成 / 停止完成 / 错误恢复)的归处。 Idle, /// 生成中:agentic loop 流式生成 + 工具执行活跃中(`generating=true`)。 /// /// 审批挂起期间 `ConvState` 仍为 `Generating`(审批是 loop 内的暂停点,非独立状态)。 Generating, /// 停止中:用户点了停止(`stop_flag` 已置位),loop 检测中或正在收尾退出。 /// /// 瞬态:loop 检测到 `stop_flag` 后迁移到 `Idle`(完成收尾),或停止过程出错到 `Error`。 Stopping, /// 错误态:生成过程出错(provider 失败 / Fatal / 断路器熔断等),loop 已退出。 /// /// 非终态:用户可「重试」从 `Error` → `Idle` → `Generating` 重新发起,或继续空闲。 Error, /// 压缩派生态:loop 期间触发上下文压缩(LLM 摘要),生成流程的瞬时派生。 /// /// 派生而非独立态:压缩完成(成功 / 失败兜底)后回到原 `Generating`(loop 内压缩)或 /// `Idle`(手动压缩 IPC 触发)。前端可据此展示「压缩中」spinner(本批未接)。 Compressed, } impl Default for ConvState { /// 新会话默认 `Idle`(无 loop 活跃)。 fn default() -> Self { ConvState::Idle } } impl ConvState { /// 尝试迁移到目标态。合法返回 `Ok(新态)`,非法拒绝返回 `Err(InvalidTransition)`。 /// /// 调用方持锁后经本方法迁移状态(写收敛):不允许直接写字段绕过守卫。 /// 非法转换(如 `Idle → Stopping`,无活跃 loop 不可能停止)直接拒绝——调用方应视为 /// 逻辑 bug 并记录 warn 日志,不静默忽略(状态机完整性优先)。 /// /// # 合法转换表(7 边) /// /// | from | to | 触发场景 | /// |------|----|---------| /// | Idle | Generating | `run_agentic_loop` 入口,guard set | /// | Generating | Stopping | 用户点停止,置 `stop_flag` | /// | Generating | Idle | loop 正常收敛退出,guard reset | /// | Generating | Error | provider Fatal / 断路器熔断等 | /// | Generating | Compressed | loop 内自动压缩派生 | /// | Stopping | Idle | stop 收尾完成,guard reset | /// | Stopping | Error | 停止过程出错 | /// | Error | Idle | 用户重试前置位(准备再 `Generating`) | /// | Error | Generating | 直接从错误态重新生成(重试) | /// | Compressed | Generating | 压缩完成,loop 内继续 | /// | Compressed | Idle | 手动压缩 IPC 完成(无 loop 活跃) | /// /// 自环(同态 → 同态)允许(幂等写,如 guard 多次 set 同态),返回 `Ok(self)`。 pub fn transition_to(self, target: ConvState) -> Result { // 自环幂等:同态迁移直接通过(防调用方重复 set 报错)。 if self == target { return Ok(target); } // 合法转换白名单(显式列举,非 `match _ => Ok` 兜底——新加态必须显式补边, // 编译器不会漏)。 let valid = matches!( (self, target), // Idle 起步 (ConvState::Idle, ConvState::Generating) // Generating 分流:停 / 完成 / 错误 / 压缩派生 | (ConvState::Generating, ConvState::Stopping) | (ConvState::Generating, ConvState::Idle) | (ConvState::Generating, ConvState::Error) | (ConvState::Generating, ConvState::Compressed) // Stopping 收尾:完成 / 出错 | (ConvState::Stopping, ConvState::Idle) | (ConvState::Stopping, ConvState::Error) // Error 恢复:重试前置位 / 直接重新生成 | (ConvState::Error, ConvState::Idle) | (ConvState::Error, ConvState::Generating) // Compressed 派生回退:loop 继续 / 手动压缩完成 | (ConvState::Compressed, ConvState::Generating) | (ConvState::Compressed, ConvState::Idle) ); if valid { Ok(target) } else { Err(InvalidTransition { from: self, to: target }) } } /// 是否处于活跃生成态(`Generating` / 压缩派生 `Compressed`)。 /// /// 读侧便利方法:压缩派生期间 loop 仍活跃(只是临时跑压缩 LLM),归「活跃」。 /// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`, /// 故本方法返回 true——审批挂起不算「停止生成」。 /// /// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。 /// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop, /// 不再标 allow(dead_code)(有真实消费方)。 pub fn is_active(self) -> bool { matches!(self, ConvState::Generating | ConvState::Compressed) } /// 是否可接受新请求(非活跃、非停止中、非压缩派生)。 /// /// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断 /// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。 /// /// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send / /// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。 pub fn can_accept_request(self) -> bool { matches!(self, ConvState::Idle | ConvState::Error) } } // ============================================================ // InvalidTransition(非法转换错误) // ============================================================ /// 状态机非法转换错误。 /// /// 携带 `from` / `to` 供调用方记录诊断日志(不 panic:状态机完整性违反应记 warn + /// 兜底走旧 bool 行为,不阻断核心生成流程——对齐「机制优先,失败兜底」原则)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InvalidTransition { pub from: ConvState, pub to: ConvState, } impl std::fmt::Display for InvalidTransition { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ConvState 非法转换:{:?} → {:?}", self.from, self.to) } } impl std::error::Error for InvalidTransition {} // ============================================================ // 单元测试(纯逻辑无 IO) // ============================================================ #[cfg(test)] mod tests { use super::*; // ---- 合法转换通过 ---- #[test] fn test_idle_to_generating() { assert_eq!( ConvState::Idle.transition_to(ConvState::Generating), Ok(ConvState::Generating) ); } #[test] fn test_generating_to_stopping() { assert_eq!( ConvState::Generating.transition_to(ConvState::Stopping), Ok(ConvState::Stopping) ); } #[test] fn test_generating_to_idle_normal_complete() { // loop 正常收敛退出 assert_eq!( ConvState::Generating.transition_to(ConvState::Idle), Ok(ConvState::Idle) ); } #[test] fn test_generating_to_error_provider_fatal() { // provider Fatal / 断路器熔断 assert_eq!( ConvState::Generating.transition_to(ConvState::Error), Ok(ConvState::Error) ); } #[test] fn test_generating_to_compressed_loop_auto_compress() { // loop 内自动压缩派生 assert_eq!( ConvState::Generating.transition_to(ConvState::Compressed), Ok(ConvState::Compressed) ); } #[test] fn test_stopping_to_idle_complete() { // stop 收尾完成 assert_eq!( ConvState::Stopping.transition_to(ConvState::Idle), Ok(ConvState::Idle) ); } #[test] fn test_stopping_to_error() { assert_eq!( ConvState::Stopping.transition_to(ConvState::Error), Ok(ConvState::Error) ); } #[test] fn test_error_to_idle_retry_prep() { // 用户重试前置位 assert_eq!( ConvState::Error.transition_to(ConvState::Idle), Ok(ConvState::Idle) ); } #[test] fn test_error_to_generating_retry() { // 直接从错误态重新生成 assert_eq!( ConvState::Error.transition_to(ConvState::Generating), Ok(ConvState::Generating) ); } #[test] fn test_compressed_to_generating_loop_continue() { // 压缩完成 loop 内继续 assert_eq!( ConvState::Compressed.transition_to(ConvState::Generating), Ok(ConvState::Generating) ); } #[test] fn test_compressed_to_idle_manual_compress_done() { // 手动压缩 IPC 完成(无 loop 活跃) assert_eq!( ConvState::Compressed.transition_to(ConvState::Idle), Ok(ConvState::Idle) ); } // ---- 自环幂等 ---- #[test] fn test_self_transition_idempotent() { // 同态迁移直接通过(防调用方重复 set 报错) for s in [ ConvState::Idle, ConvState::Generating, ConvState::Stopping, ConvState::Error, ConvState::Compressed, ] { assert_eq!(s.transition_to(s), Ok(s), "{:?} 自环应通过", s); } } // ---- 非法转换拒绝 ---- #[test] fn test_idle_to_stopping_rejected() { // 无活跃 loop 不可能停止 let err = ConvState::Idle .transition_to(ConvState::Stopping) .unwrap_err(); assert_eq!(err.from, ConvState::Idle); assert_eq!(err.to, ConvState::Stopping); } #[test] fn test_idle_to_error_rejected() { // Idle 直接到 Error 无意义(错误必经活跃态产生) assert!(ConvState::Idle.transition_to(ConvState::Error).is_err()); } #[test] fn test_idle_to_compressed_rejected() { // 无 loop 活跃不会触发压缩派生(手动压缩 IPC 不经状态机派生) assert!(ConvState::Idle.transition_to(ConvState::Compressed).is_err()); } #[test] fn test_stopping_to_generating_rejected() { // 停止中不能直接回生成(须先回 Idle 再起) assert!(ConvState::Stopping .transition_to(ConvState::Generating) .is_err()); } #[test] fn test_stopping_to_compressed_rejected() { assert!(ConvState::Stopping .transition_to(ConvState::Compressed) .is_err()); } #[test] fn test_error_to_stopping_rejected() { // 错误态已停止,不能再停 assert!(ConvState::Error.transition_to(ConvState::Stopping).is_err()); } #[test] fn test_error_to_compressed_rejected() { assert!(ConvState::Error.transition_to(ConvState::Compressed).is_err()); } #[test] fn test_compressed_to_stopping_rejected() { // 压缩派生期间不直接到停止(压缩完成后由原态分流) assert!(ConvState::Compressed .transition_to(ConvState::Stopping) .is_err()); } #[test] fn test_compressed_to_error_rejected() { assert!(ConvState::Compressed .transition_to(ConvState::Error) .is_err()); } // ---- 便利方法 ---- #[test] fn test_is_active() { assert!(!ConvState::Idle.is_active()); assert!(ConvState::Generating.is_active()); assert!(!ConvState::Stopping.is_active()); assert!(!ConvState::Error.is_active()); // 压缩派生期间 loop 仍活跃(临时跑压缩 LLM) assert!(ConvState::Compressed.is_active()); } #[test] fn test_can_accept_request() { assert!(ConvState::Idle.can_accept_request()); assert!(!ConvState::Generating.can_accept_request()); assert!(!ConvState::Stopping.can_accept_request()); // Error 态可接(用户重试即从 Error 起步) assert!(ConvState::Error.can_accept_request()); assert!(!ConvState::Compressed.can_accept_request()); } #[test] fn test_default_is_idle() { assert_eq!(ConvState::default(), ConvState::Idle); } // ---- 完整生命周期链 ---- #[test] fn test_full_lifecycle_normal_complete() { // 正常生命周期:Idle → Generating → Idle let s = ConvState::Idle; let s = s.transition_to(ConvState::Generating).unwrap(); let s = s.transition_to(ConvState::Idle).unwrap(); assert_eq!(s, ConvState::Idle); } #[test] fn test_full_lifecycle_with_stop() { // 停止生命周期:Idle → Generating → Stopping → Idle let s = ConvState::Idle; let s = s.transition_to(ConvState::Generating).unwrap(); let s = s.transition_to(ConvState::Stopping).unwrap(); let s = s.transition_to(ConvState::Idle).unwrap(); assert_eq!(s, ConvState::Idle); } #[test] fn test_full_lifecycle_with_compress() { // 压缩派生:Idle → Generating → Compressed → Generating → Idle let s = ConvState::Idle; let s = s.transition_to(ConvState::Generating).unwrap(); let s = s.transition_to(ConvState::Compressed).unwrap(); let s = s.transition_to(ConvState::Generating).unwrap(); let s = s.transition_to(ConvState::Idle).unwrap(); assert_eq!(s, ConvState::Idle); } #[test] fn test_full_lifecycle_error_retry() { // 错误恢复:Idle → Generating → Error → Generating → Idle let s = ConvState::Idle; let s = s.transition_to(ConvState::Generating).unwrap(); let s = s.transition_to(ConvState::Error).unwrap(); let s = s.transition_to(ConvState::Generating).unwrap(); let s = s.transition_to(ConvState::Idle).unwrap(); assert_eq!(s, ConvState::Idle); } #[test] fn test_invalid_transition_error_display() { // Display 包含 from / to,供日志诊断 let err = ConvState::Idle .transition_to(ConvState::Stopping) .unwrap_err(); let msg = format!("{}", err); assert!(msg.contains("Idle"), "Display 应含 from: {}", msg); assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg); } }