//! AgentSession —— 智能体"会话"实例。 //! //! 设计要点(参见 `docs/7-agent-runtime.md` §3.2.3 与 `docs/17-phase10-contextslot.md`): //! //! - **会话 = 角色 + 状态**:绑定 `session_id` / `agent` / `bundle`,累计 `turn_index` 和 `cost_so_far` //! - **多上下文分区**(Phase 10):通过 `ContextSlot` 管理多个独立的消息上下文 //! - **最小 reference impl**:`submit_turn` 演示"组装 LlmCycle → submit_with_tools → 累计 cost"的标准流程 //! - **不做业务循环**:多轮策略、错误重试、记忆回写由上层应用或具体 `TaskAgent` 决定 //! - **不持有 ConversationMemory**:上层可独立 new 一个 `ConversationMemory`,在合适的时机调 `add_message` use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; use futures_core::Stream; use crate::agent::agent::Agent; use crate::agent::context::{ ContextSlot, DeriveStrategy, SlotConfig, SlotMode, }; // SlotSource 仅在 `mod tests` 中使用(通过 `use super::*;` 引入),lib 主体保留以避免测试 import 变更。 #[allow(unused_imports)] use crate::agent::context::SlotSource; use crate::agent::error::AgentError; use crate::agent::runtime::RuntimeBundle; use crate::agent::session_memory::SessionMemory; use crate::agent::summary::{format_messages_as_text, SummaryConfig}; use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot}; use crate::engine::EngineError; use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle}; use crate::llm::error::LlmError; use crate::llm::hooks::{HookContext, HookEvent}; use crate::llm::provider::LlmProvider; use crate::llm::stream::StreamEvent; use crate::llm::types::message::Message; use crate::llm::types::response_v2::MessageResponse; use crate::memory::store::{InMemoryStore, MemoryStore}; /// Agent 会话实例。 /// /// 同一 `Agent` 可被多个 `AgentSession` 复用(不同 session_id 互不干扰)。 /// `submit_turn` 一次只跑一轮 LLM 调用(含自动 tool 循环)。 /// /// **Phase 10 新增**:通过 `slots: HashMap` 管理多个独立的对话上下文。 /// `submit_turn` 默认写入当前活跃 slot(`current_slot_id`)。 /// /// **不实现 `Clone`**:session 持有累计 `turn_index` / `cost_so_far` / `session_memory`, /// 共享这些状态需要显式 sync 语义;如果上层需要并发访问,自己用 `Arc>` 包装。 pub struct AgentSession { /// 会话 ID(由调用方指定,用于日志/追踪/记忆关联)。 pub session_id: String, /// 角色(可热切换为同 bundle 下的其他角色)。 pub agent: Arc, bundle: Arc, turn_index: u32, cost_so_far: CostTracker, /// 会话级记忆(Phase 4c 替换内联 HashMap)。 pub session_memory: SessionMemory, /// Phase 10 新增:所有 slot(id → ContextSlot)。 slots: HashMap, /// Phase 10 新增:当前活跃 slot 的 id。 current_slot_id: String, /// Phase 16 新增:上次摘要生成时的 `turn_index`(用于 `debounce_turns` 防抖)。 /// `None` 表示从未生成过摘要(首次触发不受防抖约束)。 last_summary_turn: Option, /// Phase 17 新增:`from_snapshot()` 后暂存的待写回条目。 /// `None` 表示无 pending restore(正常状态)。 /// 调用 `restore_memory()` 后会被消费并设为 `None`。 /// 这是 transient state,不参与序列化(AgentSession 本身不 derive Serialize)。 pending_memory_restore: Option>, } impl std::fmt::Debug for AgentSession { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AgentSession") .field("session_id", &self.session_id) .field("agent", &self.agent.name()) .field("turn_index", &self.turn_index) .field("cost_so_far", &self.cost_so_far.total()) .field("session_memory", &"") .field("slots", &self.slots.keys().collect::>()) .field("current_slot_id", &self.current_slot_id) .finish() } } impl AgentSession { /// 创建一个新的会话实例。 /// /// `agent` 与 `bundle` 共同决定 `submit_turn` 行为:system_prompt / 工具集 / LLM 后端均来自它们。 /// /// Phase 10 新增:自动创建 `"default"` slot,确保简单场景无感使用。 /// /// **注意**:`new()` 是同步函数,无法执行异步的 `ContextSlot::load()`。 /// 因此始终创建空的 default slot。若需要从存储恢复 session 历史, /// 可在创建后调用 `switch_slot("default")` 尝试从存储加载。 /// (v0.2 简化:`switch_slot` 在 HashMap 中已有 key 时不会重载——如需恢复, /// 请在清空 `slots` 后调用 `switch_slot`,或等待 v0.3 的懒加载支持。) pub fn new( agent: Arc, session_id: impl Into, bundle: Arc, ) -> Self { let session_id_str = session_id.into(); let backend = bundle .session_memory_backend .clone() .unwrap_or_else(|| Arc::new(InMemoryStore::new())); let session_memory = SessionMemory::new(backend, &session_id_str); // 自动创建 "default" slot let default_slot = ContextSlot::new( &session_id_str, "default", SlotConfig::default(), ); let mut slots = HashMap::new(); slots.insert("default".to_string(), default_slot); Self { session_id: session_id_str, agent, bundle, turn_index: 0, cost_so_far: CostTracker::default(), session_memory, slots, current_slot_id: "default".to_string(), last_summary_turn: None, pending_memory_restore: None, } } /// 当前 turn 序号(0-based:第一次 `submit_turn` 完成后变 1)。 pub fn turn_index(&self) -> u32 { self.turn_index } /// 累计用量(跨所有 turn)。 pub fn usage(&self) -> &CostTracker { &self.cost_so_far } /// 会话级记忆引用。 pub fn session_memory(&self) -> &SessionMemory { &self.session_memory } /// RuntimeBundle 引用(Phase 17 新增,供 SessionManager::create_child 继承父 bundle)。 pub(crate) fn bundle(&self) -> &Arc { &self.bundle } /// 写入一条会话级数据(覆盖同名 key)。 pub async fn set_session_data( &mut self, key: impl Into, value: impl Into, ) -> Result<(), AgentError> { self.session_memory .set(&key.into(), &value.into()) .await } /// 读取一条会话级数据。 pub async fn get_session_data(&self, key: &str) -> Result, AgentError> { self.session_memory.get(key).await } /// Phase 10: 当前 slot id。 pub fn current_slot_id(&self) -> &str { &self.current_slot_id } /// Phase 10: 列出所有 slot 的不可变引用(按 id 顺序)。 pub fn slots(&self) -> impl Iterator { self.slots.iter() } /// Phase 10: 解析存储后端。 /// fallback 链:`session_memory_backend` → `memory_store` → `InMemoryStore`。 fn resolve_store(&self) -> Arc { self.bundle .session_memory_backend .clone() .or_else(|| self.bundle.memory_store.clone()) .unwrap_or_else(|| Arc::new(InMemoryStore::new())) } // ====== Phase 10: Slot 管理方法 ====== /// 创建新 slot(config 可选,不传则使用默认值)。 pub async fn create_slot( &mut self, id: impl Into, config: Option, ) -> Result<(), AgentError> { let id = id.into(); if self.slots.contains_key(&id) { return Err(AgentError::SlotAlreadyExists(id)); } let slot = ContextSlot::new( &self.session_id, &id, config.unwrap_or_default(), ); slot.save(&*self.resolve_store()).await?; self.slots.insert(id, slot); Ok(()) } /// 切换到指定 slot。 /// - 如果 slot 已在内存中,直接切换 current_slot_id /// - 如果不在内存中,尝试从存储加载(config 自动从 slot_config key 恢复) /// - 存储中也不存在则返回 `SlotNotFound` pub async fn switch_slot(&mut self, id: &str) -> Result<(), AgentError> { if !self.slots.contains_key(id) { let store = self.resolve_store(); match ContextSlot::load(id, &self.session_id, &*store).await? { Some(slot) => { self.slots.insert(id.to_string(), slot); } None => return Err(AgentError::SlotNotFound(id.to_string())), } } self.current_slot_id = id.to_string(); Ok(()) } /// 列出所有 slot id。 pub fn list_slots(&self) -> impl Iterator { self.slots.keys() } /// 从父 slot 派生新 slot(继承父 slot 的全量或聚焦消息)。 pub async fn derive_slot( &mut self, id: impl Into, parent_id: &str, strategy: DeriveStrategy, ) -> Result<(), AgentError> { let slot_id = id.into(); if self.slots.contains_key(&slot_id) { return Err(AgentError::SlotAlreadyExists(slot_id)); } let parent = self .slots .get(parent_id) .ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?; let child = parent.fork(slot_id.clone(), strategy); child.save(&*self.resolve_store()).await?; self.slots.insert(slot_id, child); Ok(()) } /// 删除一个 slot。 /// - 禁止删除 `"default"` slot /// - 至少保留一个 slot /// - 已删除后再 load 返回 None pub async fn delete_slot(&mut self, id: &str) -> Result<(), AgentError> { if id == "default" { return Err(AgentError::Config("Cannot delete the 'default' slot".into())); } if self.slots.len() <= 1 { return Err(AgentError::Config("Cannot delete the last slot".into())); } ContextSlot::delete(id, &self.session_id, &*self.resolve_store()).await?; self.slots.remove(id); if self.current_slot_id == id { self.current_slot_id = "default".to_string(); } Ok(()) } // ====== 原始方法 ====== /// 提交一轮对话(含自动 tool 循环),返回 LLM 响应。 /// /// Phase 10 改造: /// - 从当前 slot 加载历史(Focused 模式读时过滤) /// - 提交完成后**增量追加**本轮新增消息到当前 slot(不覆盖,确保 Focused 语义不丢数据) /// /// 流程: /// 1. 检查当前 slot 不是 Readonly /// 2. 触发 `OnTurnStart` hook /// 3. 加载当前 slot 的历史消息 /// 4. 组装 `LlmCycle`(注入 system_prompt / compact_config / 历史) /// 5. `submit_with_tools` 跑单轮对话 /// 6. 累计 `cost_so_far` /// 7. **增量追加**本轮新增消息到当前 slot + 保存到 store /// 8. 触发 `OnTurnEnd` hook /// 9. `turn_index += 1` pub async fn submit_turn( &mut self, user_input: impl Into, ) -> Result { let turn_index = self.turn_index; let hook_executor = Arc::clone(&self.bundle.hook_executor); // 0. Readonly slot 拒绝 submit_turn { let slot = self .slots .get(&self.current_slot_id) .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?; if matches!(slot.config.mode, SlotMode::Readonly) { return Err(AgentError::SlotReadonly(format!( "Cannot submit turn on Readonly slot '{}'", self.current_slot_id ))); } } // 1. 触发 OnTurnStart hook let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index); hook_executor .execute(HookEvent::OnTurnStart, &start_ctx) .await; // 2. 从当前 slot 加载历史消息 let history = self .slots .get(&self.current_slot_id) .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))? .load_messages(); // 3. 组装 LlmCycle let _ = self.agent.tool_definitions(&self.bundle); let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()); let mut messages_with_prompt = history; if let Some(prompt) = self.agent.system_prompt() { messages_with_prompt.insert(0, Message::system(prompt)); } let input_len = messages_with_prompt.len(); cycle = cycle.with_messages(messages_with_prompt); if let Some(cfg) = self.bundle.config.compact_config.clone() { cycle = cycle.with_compact_config(cfg); } // 4. 提交 let response = cycle .submit_with_tools(user_input.into(), &self.bundle.tool_registry) .await?; // 5. 累计 cost self.cost_so_far.add(&response.usage); // 6. 只将本轮新增消息追加到当前 slot(保留全量历史,确保 Focused 模式的"读时过滤"语义不丢失数据) // cycle.messages() 包含 [system_prompt?, history..., user_input, tool_calls..., final_response] // 新增消息 = cycle.messages()[input_len..](跳过 initial_messages,即跳过已被持久化的内容) let new_messages: Vec = cycle .messages() .iter() .skip(input_len) .cloned() .collect(); let store = self.resolve_store(); if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { slot.append_messages(new_messages)?; slot.save(&*store).await?; } // 7. 触发 OnTurnEnd hook let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index); hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await; // 7.5 Phase 16: 摘要自动生成检查点 self.maybe_summarize(turn_index).await; // 8. turn_index 递增 self.turn_index += 1; Ok(response) } /// 提交一轮对话(流式版本,含自动 tool 循环),返回 `StreamEvent` 流。 /// /// Phase 10 改造: /// - 从当前 slot 加载历史(Focused 模式读时过滤) /// - finalize_turn 需要传入本轮新增消息列表 /// /// **运行时要求**:内部委托 `submit_with_tools_stream`,需要 tokio 多线程运行时。 pub async fn submit_turn_stream( &mut self, user_input: impl Into, ) -> Result + Send>>, AgentError> { let turn_index = self.turn_index; let hook_executor = Arc::clone(&self.bundle.hook_executor); // 0. Readonly 检查 { let slot = self .slots .get(&self.current_slot_id) .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))?; if matches!(slot.config.mode, SlotMode::Readonly) { return Err(AgentError::SlotReadonly(format!( "Cannot submit turn stream on Readonly slot '{}'", self.current_slot_id ))); } } // 1. 触发 OnTurnStart hook let start_ctx = HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index); hook_executor .execute(HookEvent::OnTurnStart, &start_ctx) .await; // 2. 触发子 trait 覆盖(白名单/过滤)的副作用 let _ = self.agent.tool_definitions(&self.bundle); // 3. 从当前 slot 加载历史 let history = self .slots .get(&self.current_slot_id) .ok_or_else(|| AgentError::SlotNotFound(self.current_slot_id.clone()))? .load_messages(); // 4. 组装 LlmCycle let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()); let mut messages_with_prompt = history; if let Some(prompt) = self.agent.system_prompt() { messages_with_prompt.insert(0, Message::system(prompt)); } cycle = cycle.with_messages(messages_with_prompt); if let Some(cfg) = self.bundle.config.compact_config.clone() { cycle = cycle.with_compact_config(cfg); } // 5. 调用流式工具循环 let stream = cycle .submit_with_tools_stream( user_input.into(), Arc::clone(&self.bundle.tool_registry), ) .await?; // 6. turn_index 递增 —— 配合 finalize_turn 用 (turn_index - 1) 传递正确的 OnTurnEnd 序号 self.turn_index += 1; Ok(stream) } /// 完成一轮 turn:累计 cost + 触发 OnTurnEnd hook + 增量追加消息到当前 slot。 /// /// Phase 10 改造: /// - 新增 `new_messages_from_cycle` 参数:流式场景下,本轮新增的消息列表 /// (由消费者在流消费完毕后从 `cycle.messages()[input_len..]` 获取并传入) /// - 仅**增量追加**到当前 slot(不覆盖已有消息),与 submit_turn 行为一致 /// - 返回类型从 `()` 改为 `Result<(), AgentError>`,错误传播更清晰 /// /// 由消费者在收到 `MessageComplete.full_response` 后调用。 pub async fn finalize_turn( &mut self, response: &MessageResponse, new_messages_from_cycle: Vec, ) -> Result<(), AgentError> { self.cost_so_far.add(&response.usage); // 防御性检查:current_slot_id 必须在 slots 中(与 submit_turn 行为一致)。 // 正常流程:submit_turn_stream 已注册 slot,finalize_turn 不应触发此分支。 if !self.slots.contains_key(&self.current_slot_id) { return Err(AgentError::SlotNotFound(self.current_slot_id.clone())); } // 增量追加到当前 slot(仅 Full/Focused 模式允许,Readonly 阻断) let store = self.resolve_store(); if let Some(slot) = self.slots.get_mut(&self.current_slot_id) { if matches!(slot.config.mode, SlotMode::Readonly) { return Err(AgentError::SlotReadonly(format!( "Cannot finalize turn on Readonly slot '{}'", self.current_slot_id ))); } slot.append_messages(new_messages_from_cycle)?; slot.save(&*store).await?; } // 防御性 saturating_sub 防止误用 panic。 let end_ctx = HookContext::new(HookEvent::OnTurnEnd) .with_turn_index(self.turn_index.saturating_sub(1)); self.bundle .hook_executor .execute(HookEvent::OnTurnEnd, &end_ctx) .await; // Phase 16: 摘要检查点(流式路径 turn_index 已被 submit_turn_stream 提前 ++1) self.maybe_summarize(self.turn_index.saturating_sub(1)).await; Ok(()) } // ====== Phase 17: 快照序列化 ====== /// 将当前状态拍平为 `SessionSnapshot`。 /// /// **需要 async**:因为 `session_memory` 的条目存储在 `MemoryStore` 中,读取需异步 I/O。 /// 通过 `SessionMemory::list_entries()` 获取完整条目(保留 `metadata` 和 `created_at`)。 /// /// `Arc` 和 `Arc` **不进入快照**——由 `from_snapshot()` 调用方注入。 pub async fn to_snapshot(&self) -> SessionSnapshot { // 拍平 session_memory → HashMap // 失败时回退到空 map(错误已记录,不阻断 checkpoint 主流程)。 let session_memory_data = match self.session_memory.list_entries().await { Ok(entries) => entries .into_iter() .map(|(key, value, metadata, created_at)| { ( key, SessionMemoryEntry { value, metadata, created_at: Some(created_at), }, ) }) .collect(), Err(e) => { tracing::error!("session_memory list_entries failed: {}", e); HashMap::new() } }; SessionSnapshot { session_id: self.session_id.clone(), agent_name: self.agent.name().to_string(), turn_index: self.turn_index, cost_so_far: self.cost_so_far.clone(), slots: self.slots.clone(), current_slot_id: self.current_slot_id.clone(), last_summary_turn: self.last_summary_turn, session_memory_data, } } /// 从 `SessionSnapshot` + agent + bundle **纯同步**重建 `AgentSession`。 /// /// **不执行任何 I/O**:`session_memory_data` 暂存于 `pending_memory_restore` 字段, /// 由调用方显式 `await session.restore_memory()` 写回持久层。 /// /// 调用方负责提供与 `snapshot.agent_name` 对应的 `Arc`(引擎层只保留名字做调试用)。 pub fn from_snapshot( snapshot: SessionSnapshot, agent: Arc, bundle: Arc, ) -> Result { // 校验 bundle 的 session_memory_backend 与 snapshot 兼容 // (v0.3 不强制同 backend——以新构造的 session_memory 所属 backend 为准) let backend = bundle .session_memory_backend .clone() .unwrap_or_else(|| Arc::new(InMemoryStore::new())); let session_memory = SessionMemory::new(backend, &snapshot.session_id); // 解析 agent_name 仅供调试(不强制匹配,因为不同进程的 Agent 实现可能不同) let _ = snapshot.agent_name.as_str(); // 确保至少有一个 slot(与 new() 行为一致) let mut slots = snapshot.slots; if slots.is_empty() { slots.insert( "default".to_string(), ContextSlot::new( &snapshot.session_id, "default", SlotConfig::default(), ), ); } Ok(Self { session_id: snapshot.session_id, agent, bundle, turn_index: snapshot.turn_index, cost_so_far: snapshot.cost_so_far, session_memory, slots, current_slot_id: snapshot.current_slot_id, last_summary_turn: snapshot.last_summary_turn, pending_memory_restore: if snapshot.session_memory_data.is_empty() { None } else { Some(snapshot.session_memory_data) }, }) } /// 将 `from_snapshot()` 暂存的 `session_memory_data` 写回 `SessionMemory` 持久层。 /// /// **从 `from_snapshot()` 中剥离的异步操作**:确保构造函数是纯同步的。 /// 调用方在 `from_snapshot()` 后显式 `await`。 /// /// **错误处理**:逐条写入。某条失败时返回 `Err` 但**不回滚**已写入条目。 /// 调用方可选择重试或忽略——不影响 AgentSession 内存状态。 /// /// **幂等性**:重复调用安全(首次成功后 `pending_memory_restore` 已被设为 `None`, /// 第二次调用立即返回 `Ok(())`)。 /// /// **完整恢复**:使用 `SessionMemory::set_with_meta()` 保留原始 `metadata` 和 `created_at` /// ——不像 `set()` 会清空 metadata 并把 created_at 设为当前时间。 pub async fn restore_memory(&mut self) -> Result<(), EngineError> { // 取出 pending 并立即清空(避免重复 restore 时二次写入;幂等性保证) let entries = self.pending_memory_restore.take(); let entries = match entries { Some(m) if !m.is_empty() => m, _ => return Ok(()), // 无 pending 或已被清空 → 立即返回 }; for (key, entry) in entries { self.session_memory .set_with_meta( &key, &entry.value, entry.metadata.clone(), entry.created_at, ) .await .map_err(EngineError::Agent)?; } Ok(()) } /// 是否有待写回的 `session_memory_data`(`from_snapshot()` 后尚未 `restore_memory()`)。 pub fn has_pending_memory_restore(&self) -> bool { self.pending_memory_restore .as_ref() .map(|m| !m.is_empty()) .unwrap_or(false) } // ====== Phase 16: 摘要自动生成 ====== /// 读取 SessionMemory 中最新的对话摘要(`None` 表示从未生成过)。 pub async fn get_conversation_summary(&self) -> Result, AgentError> { self.session_memory.get("conversation_summary").await } /// 水位 + 防抖检查:是否应当触发摘要生成。 /// 防抖只对"上一轮与本轮之间的间隔"起作用——首次(`last_summary_turn.is_none()`)不阻塞。 /// `current_turn` 显式传入而非读 `self.turn_index`,因为流式路径中 `submit_turn_stream` 已提前 ++1, /// `finalize_turn` 会用 `saturating_sub(1)` 修正后的值传入此函数。 fn should_summarize(&self, cfg: &SummaryConfig, current_turn: u32) -> bool { let debounce_ok = match self.last_summary_turn { None => true, Some(last) => current_turn.saturating_sub(last) >= cfg.debounce_turns, }; debounce_ok && self.cost_so_far.total().total_tokens as f64 >= cfg.max_context_tokens as f64 * cfg.trigger_token_ratio } /// 检查点入口:水位超阈值时调 LLM 生成摘要,写入 slot config 与 SessionMemory。 /// 所有错误(含 LLM error、save 失败、session_memory 写失败)均静默(`tracing::error!` 后返回)。 async fn maybe_summarize(&mut self, current_turn: u32) { let cfg = match self.bundle.config.summary_config.clone() { Some(c) => c, None => return, }; if !self.should_summarize(&cfg, current_turn) { return; } // 先 clone 出 &self 借用范围内所需数据,后续释放借用再 await/mut let provider = Arc::clone(&self.bundle.provider); let messages = self .slots .get(&self.current_slot_id) .map(|s| s.messages.clone()) .unwrap_or_default(); if messages.is_empty() { return; } let max_tool_result_chars = cfg.max_tool_result_chars; let model = cfg.summary_model.clone(); let prompt = cfg.summary_prompt.clone(); let result = Self::generate_summary(&provider, &messages, &prompt, model.as_deref(), max_tool_result_chars) .await; match result { Ok(text) => { tracing::info!(turn = current_turn, summary_len = text.len(), "摘要自动生成成功"); // Resolve store first (immutable borrow on self) before mutable borrow on slots. let store = self.resolve_store(); if let Some(slot) = self.slots.get_mut(&self.current_slot_id) && let SlotMode::Focused(ref mut focused_cfg) = slot.config.mode { focused_cfg.summary_override = Some(text.clone()); // Full 模式下 summary_override 未被修改,无需持久化 slot if let Err(e) = slot.save(&*store).await { tracing::error!("summary config persist failed: {}", e); } } if let Err(e) = self.session_memory.set("conversation_summary", &text).await { tracing::error!("summary session_memory write failed: {}", e); } self.last_summary_turn = Some(current_turn); } Err(e) => { tracing::error!("摘要自动生成失败 (turn={}): {}", current_turn, e); } } } /// 关联函数:调一次 LLM 生成摘要。空消息列表直接返回空串(不浪费 LLM 调用)。 /// `summary_model=None` 时沿用 `CycleConfig::default()` 的默认模型(避免硬编码到非 OpenAI 用户不适配的 `"gpt-4o"`)。 async fn generate_summary( provider: &Arc, messages: &[Message], prompt_template: &str, summary_model: Option<&str>, max_tool_result_chars: usize, ) -> Result { if messages.is_empty() { return Ok(String::new()); } let messages_text = format_messages_as_text(messages, max_tool_result_chars); let prompt = prompt_template.replace("{messages}", &messages_text); let config = CycleConfig { max_tokens: Some(1024), ..CycleConfig::default() }; let config = if let Some(model) = summary_model { CycleConfig { model: model.to_string(), ..config } } else { config }; let mut cycle = LlmCycle::new_with_arc(Arc::clone(provider), config); // submit_messages 使用自身参数构造 request,不读 self.messages——prompt 必须放在 messages 参数里 let response = cycle .submit_messages(vec![Message::user_text(prompt)], vec![]) .await?; Ok(response.text()) } } #[cfg(test)] mod tests { use super::*; use crate::agent::builder::AgentBuilder; use crate::agent::FocusedConfig; use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult}; use crate::llm::mock::MockProvider; use crate::llm::stream::StreamEvent; use crate::llm::types::message::ContentBlock; use crate::llm::types::response_v2::{MessageResponse, StopReason}; use crate::tools::ToolRegistry; use async_trait::async_trait; use futures_util::StreamExt; use std::sync::atomic::{AtomicU32, Ordering}; /// 计数 hook —— 每被调用一次 +1。 struct CountHook(AtomicU32); #[async_trait] impl Hook for CountHook { async fn execute(&self, _ctx: &HookContext<'_>) -> HookResult { self.0.fetch_add(1, Ordering::SeqCst); HookResult::allow() } } /// 把 `Arc` 包装为 `Box`(dyn Hook 不能直接来自 Arc)。 struct CountHookAdapter(Arc); #[async_trait] impl Hook for CountHookAdapter { async fn execute(&self, ctx: &HookContext<'_>) -> HookResult { self.0.execute(ctx).await } } struct StubAgent { name: String, prompt: Option, } impl Agent for StubAgent { fn name(&self) -> &str { &self.name } fn system_prompt(&self) -> Option<&str> { self.prompt.as_deref() } } fn assistant_text(text: &str) -> MessageResponse { MessageResponse { id: String::new(), model: String::new(), message: Message::Assistant { content: vec![ContentBlock::Text { text: text.into() }], }, usage: crate::llm::types::Usage::from_input_output(10, 5), stop_reason: StopReason::Stop, extra: std::collections::HashMap::new(), } } fn build_session(provider_responses: Vec) -> (AgentSession, Arc, Arc) { let mut hook_executor = HookExecutor::new(); let start_count = Arc::new(CountHook(AtomicU32::new(0))); let end_count = Arc::new(CountHook(AtomicU32::new(0))); hook_executor.register( HookEvent::OnTurnStart, Box::new(CountHookAdapter(start_count.clone())), ); hook_executor.register( HookEvent::OnTurnEnd, Box::new(CountHookAdapter(end_count.clone())), ); let provider = Arc::new(MockProvider::new(provider_responses)); let agent = Arc::new(StubAgent { name: "stub".into(), prompt: Some("you are a test agent".into()), }); let bundle = Arc::new( AgentBuilder::new() .provider(provider) .tool_registry(Arc::new(ToolRegistry::new())) .hook_executor(Arc::new(hook_executor)) .build() .unwrap(), ); let session = AgentSession::new(agent, "test-session", bundle); (session, start_count, end_count) } /// 烟雾测试 1:AgentSession::submit_turn 跑通 mock provider(向后兼容)。 #[tokio::test] async fn submit_turn_runs_with_mock_provider() { let (mut session, start_count, end_count) = build_session(vec![assistant_text("hello back")]); assert_eq!(session.turn_index(), 0); let response = session.submit_turn("hi").await.unwrap(); assert_eq!(extract_text(&response.message), "hello back"); assert_eq!(session.turn_index(), 1); assert_eq!(session.usage().total().prompt_tokens, 10); assert_eq!(session.usage().total().completion_tokens, 5); // hook 触发 assert_eq!(start_count.0.load(Ordering::SeqCst), 1); assert_eq!(end_count.0.load(Ordering::SeqCst), 1); } /// 烟雾测试 2:session_data 读写。 #[tokio::test] async fn session_data_set_get() { let (mut session, _, _) = build_session(vec![]); assert!(session.get_session_data("k").await.unwrap().is_none()); session.set_session_data("k", "v").await.unwrap(); assert_eq!( session.get_session_data("k").await.unwrap(), Some("v".into()) ); // 覆盖写 session.set_session_data("k", "v2").await.unwrap(); assert_eq!( session.get_session_data("k").await.unwrap(), Some("v2".into()) ); } /// 烟雾测试 3:submit_turn 触发 OnTurnStart / OnTurnEnd hook。 #[tokio::test] async fn submit_turn_triggers_turn_hooks() { let (mut session, start_count, end_count) = build_session(vec![ assistant_text("ok"), assistant_text("ok 2"), ]); session.submit_turn("hi").await.unwrap(); assert_eq!(start_count.0.load(Ordering::SeqCst), 1); assert_eq!(end_count.0.load(Ordering::SeqCst), 1); session.submit_turn("hi again").await.unwrap(); assert_eq!(start_count.0.load(Ordering::SeqCst), 2); assert_eq!(end_count.0.load(Ordering::SeqCst), 2); } /// 提取 Message 的第一个 Text block(测试辅助)。 fn extract_text(msg: &Message) -> &str { use crate::llm::types::message::ContentBlock; let blocks = match msg { Message::System { content } | Message::User { content } | Message::Assistant { content } => content, Message::UserImage { .. } => return "", Message::ToolResult { content, .. } => content, }; for block in blocks { if let ContentBlock::Text { text } = block { return text; } } "" } // ====== Phase 10 新增测试 ====== /// Phase 10: 默认 slot 自动创建。 #[tokio::test] async fn default_slot_auto_created() { let (session, _, _) = build_session(vec![]); assert_eq!(session.current_slot_id(), "default"); let slots: Vec<_> = session.list_slots().collect(); assert_eq!(slots.len(), 1); assert!(slots.contains(&&"default".to_string())); } /// Phase 10: submit_turn 写入当前 slot。 #[tokio::test] async fn submit_turn_writes_to_current_slot() { let (mut session, _, _) = build_session(vec![assistant_text("resp")]); session.submit_turn("user input").await.unwrap(); // 检查 default slot 内存中的消息 let slot = session.slots.get("default").expect("default slot exists"); // submit_turn 增量追加的是 cycle.messages()[input_len..] 部分, // 即 [user_input, tool_results?, final_response](不含 system_prompt,system 由 agent 提供) assert!(slot.messages.len() >= 2, "应至少包含 user 和 assistant"); // 验证 user 输入和 assistant 响应都已写入 let has_user = slot.messages.iter().any(|m| extract_text(m) == "user input"); let has_resp = slot.messages.iter().any(|m| extract_text(m) == "resp"); assert!(has_user && has_resp, "slot 应包含 user input 和 assistant response"); } /// Phase 10: create_slot 创建新 slot。 #[tokio::test] async fn create_slot_basic() { let (mut session, _, _) = build_session(vec![]); session.create_slot("scratch", None).await.unwrap(); let slots: Vec<_> = session.list_slots().cloned().collect(); assert!(slots.contains(&"default".to_string())); assert!(slots.contains(&"scratch".to_string())); assert_eq!(slots.len(), 2); } /// Phase 10: create_slot 拒绝重复 id。 #[tokio::test] async fn create_slot_rejects_duplicate() { let (mut session, _, _) = build_session(vec![]); session.create_slot("dup", None).await.unwrap(); let err = session.create_slot("dup", None).await.unwrap_err(); assert!(matches!(err, AgentError::SlotAlreadyExists(_))); } /// Phase 10: switch_slot 切换并保留各自消息。 #[tokio::test] async fn switch_slot_isolates_messages() { let (mut session, _, _) = build_session(vec![ assistant_text("resp a"), assistant_text("resp b"), assistant_text("resp c"), ]); // 1. 在 default 中提交一次 session.submit_turn("msg in default").await.unwrap(); // 2. 创建 slot_a session.create_slot("slot_a", None).await.unwrap(); session.switch_slot("slot_a").await.unwrap(); assert_eq!(session.current_slot_id(), "slot_a"); session.submit_turn("msg in slot_a").await.unwrap(); // 3. 检查 slot_a 的消息数 let slot_a = session.slots.get("slot_a").unwrap(); let slot_a_count = slot_a.messages.len(); assert!(slot_a_count >= 2, "slot_a 至少 2 条消息,实际 {}", slot_a_count); // 4. 切回 default,验证 default 不包含 slot_a 的消息 session.switch_slot("default").await.unwrap(); let slot_default = session.slots.get("default").unwrap(); let default_count = slot_default.messages.len(); assert!(default_count >= 2); // 验证 default 中没有 "msg in slot_a" let default_has_a = slot_default .messages .iter() .any(|m| extract_text(m) == "msg in slot_a"); assert!(!default_has_a, "default 不应包含 slot_a 的消息"); // 验证 slot_a 中没有 "msg in default" let slot_a = session.slots.get("slot_a").unwrap(); let a_has_default = slot_a .messages .iter() .any(|m| extract_text(m) == "msg in default"); assert!(!a_has_default, "slot_a 不应包含 default 的消息"); } /// Phase 10: Readonly slot 拒绝写入。 #[tokio::test] async fn readonly_slot_rejects_submit_turn() { let (mut session, _, _) = build_session(vec![assistant_text("resp")]); session .create_slot( "ro", Some(SlotConfig { mode: SlotMode::Readonly, source: SlotSource::New, budget: Default::default(), compact: true, }), ) .await .unwrap(); session.switch_slot("ro").await.unwrap(); let err = session.submit_turn("blocked").await.unwrap_err(); assert!(matches!(err, AgentError::SlotReadonly(_))); } /// Phase 10: delete_slot 删除非 default。 #[tokio::test] async fn delete_slot_removes_non_default() { let (mut session, _, _) = build_session(vec![]); session.create_slot("to_delete", None).await.unwrap(); session.delete_slot("to_delete").await.unwrap(); let slots: Vec<_> = session.list_slots().cloned().collect(); assert!(!slots.contains(&"to_delete".to_string())); assert_eq!(slots.len(), 1); } /// Phase 10: delete_slot 禁止删 default。 #[tokio::test] async fn delete_slot_rejects_default() { let (mut session, _, _) = build_session(vec![]); let err = session.delete_slot("default").await.unwrap_err(); assert!(matches!(err, AgentError::Config(_))); } /// Phase 10: delete_slot 禁止删最后一个 slot。 #[tokio::test] async fn delete_slot_rejects_last() { let (mut session, _, _) = build_session(vec![]); // 只有 default 一个 slot let err = session.delete_slot("default").await.unwrap_err(); assert!(matches!(err, AgentError::Config(_))); } /// Phase 10: delete_slot 后 current 回退到 default。 #[tokio::test] async fn delete_slot_falls_back_to_default() { let (mut session, _, _) = build_session(vec![]); session.create_slot("temp", None).await.unwrap(); session.switch_slot("temp").await.unwrap(); assert_eq!(session.current_slot_id(), "temp"); session.delete_slot("temp").await.unwrap(); assert_eq!(session.current_slot_id(), "default"); } /// Phase 10: derive_slot Full 策略复制父 slot 消息。 #[tokio::test] async fn derive_slot_full_copies_parent() { let (mut session, _, _) = build_session(vec![assistant_text("resp")]); session.submit_turn("parent msg").await.unwrap(); session .derive_slot("child", "default", DeriveStrategy::Full) .await .unwrap(); let child = session.slots.get("child").unwrap(); assert!(matches!(child.config.source, SlotSource::Derived { .. })); // child 应有 parent 的消息拷贝 let has_parent = child .messages .iter() .any(|m| extract_text(m) == "parent msg"); assert!(has_parent); } /// Phase 10: derive_slot 拒绝重复 id。 #[tokio::test] async fn derive_slot_rejects_duplicate() { let (mut session, _, _) = build_session(vec![]); session.create_slot("child", None).await.unwrap(); let err = session .derive_slot("child", "default", DeriveStrategy::Full) .await .unwrap_err(); assert!(matches!(err, AgentError::SlotAlreadyExists(_))); } /// Phase 10: derive_slot 父 slot 不存在返回 SlotNotFound。 #[tokio::test] async fn derive_slot_parent_not_found() { let (mut session, _, _) = build_session(vec![]); let err = session .derive_slot("child", "nonexistent", DeriveStrategy::Full) .await .unwrap_err(); assert!(matches!(err, AgentError::SlotNotFound(_))); } /// Phase 10: switch_slot 加载不存在的 slot 返回 SlotNotFound。 #[tokio::test] async fn switch_slot_not_found() { let (mut session, _, _) = build_session(vec![]); let err = session.switch_slot("missing").await.unwrap_err(); assert!(matches!(err, AgentError::SlotNotFound(_))); } /// Phase 10: slot 数据持久化到 storage,switch 时可恢复。 /// 使用 session_memory_backend 配置可验证持久化。 #[tokio::test] async fn slot_persistence_roundtrip() { // 创建一个共享的 InMemoryStore 作为后端 let backend = Arc::new(InMemoryStore::new()); let provider = Arc::new(MockProvider::new(vec![assistant_text("resp")])); let agent = Arc::new(StubAgent { name: "stub".into(), prompt: None, }); let bundle = Arc::new( AgentBuilder::new() .provider(provider) .tool_registry(Arc::new(ToolRegistry::new())) .hook_executor(Arc::new(HookExecutor::new())) .session_memory_backend(backend.clone()) .build() .unwrap(), ); let mut session = AgentSession::new(agent, "persist-session", bundle); session.create_slot("persist_test", None).await.unwrap(); session.switch_slot("persist_test").await.unwrap(); session.submit_turn("hi").await.unwrap(); // 验证 data/meta/config 三个 key 都已写入共享 backend let stored_data = backend .get(&ContextSlot::data_key("persist-session", "persist_test")) .await .unwrap(); assert!(stored_data.is_some(), "data 应已持久化"); let stored_meta = backend .get(&ContextSlot::meta_key("persist-session", "persist_test")) .await .unwrap(); assert!(stored_meta.is_some(), "meta 应已持久化"); let stored_config = backend .get(&ContextSlot::config_key("persist-session", "persist_test")) .await .unwrap(); assert!(stored_config.is_some(), "config 应已持久化"); } /// Phase 10: 当只有 `memory_store`(无 `session_memory_backend`)时,resolve_store /// 应 fallback 到 `memory_store`。 #[tokio::test] async fn resolve_store_falls_back_to_memory_store() { let backend = Arc::new(InMemoryStore::new()); let provider = Arc::new(MockProvider::new(vec![assistant_text("ok")])); let agent = Arc::new(StubAgent { name: "stub".into(), prompt: None, }); // 注意:这里只设置 memory_store,不设置 session_memory_backend let bundle = Arc::new( AgentBuilder::new() .provider(provider) .tool_registry(Arc::new(ToolRegistry::new())) .hook_executor(Arc::new(HookExecutor::new())) .memory_store(backend.clone()) .build() .unwrap(), ); let mut session = AgentSession::new(agent, "fb-session", bundle); session.create_slot("fb_slot", None).await.unwrap(); // 验证 backend 中已存在 fb_slot 的数据 let stored = backend .get(&ContextSlot::data_key("fb-session", "fb_slot")) .await .unwrap(); assert!(stored.is_some(), "memory_store fallback 应生效"); } /// Phase 10: finalize_turn 在 current_slot 不存在时返回 SlotNotFound(与 submit_turn 一致)。 #[tokio::test] async fn finalize_turn_slot_not_found() { let (mut session, _, _) = build_session(vec![assistant_text("ok")]); // 强制 current_slot_id 指向不存在的 slot(模拟异常状态) session.current_slot_id = "ghost".to_string(); let response = assistant_text("ok"); let err = session.finalize_turn(&response, vec![]).await.unwrap_err(); assert!(matches!(err, AgentError::SlotNotFound(_))); } /// Phase 10: finalize_turn 在 Readonly slot 上返回 SlotReadonly。 #[tokio::test] async fn finalize_turn_readonly_rejects() { let (mut session, _, _) = build_session(vec![assistant_text("ok")]); session .create_slot( "ro", Some(SlotConfig { mode: SlotMode::Readonly, source: SlotSource::New, budget: Default::default(), compact: true, }), ) .await .unwrap(); session.switch_slot("ro").await.unwrap(); let response = assistant_text("ok"); let err = session .finalize_turn(&response, vec![Message::user_text("x")]) .await .unwrap_err(); assert!(matches!(err, AgentError::SlotReadonly(_))); } // ====== Phase 9 Step 5: 集成测试 ====== /// Phase 9 Step 5.1 — `submit_turn_stream` 端到端链路。 /// /// 验证:mock provider → `submit_turn_stream` 消费流 → 收到 TextDelta + MessageComplete /// → `finalize_turn` 后 `cost_so_far` 正确更新,turn_index 递增。 #[tokio::test(flavor = "multi_thread")] async fn submit_turn_stream_end_to_end() { let (mut session, _, _) = build_session(vec![assistant_text("hi back")]); let mut stream = session .submit_turn_stream("user msg") .await .expect("submit_turn_stream 应成功"); // 消费流并提取 MessageComplete let mut final_response: Option = None; while let Some(ev) = stream.next().await { if let StreamEvent::MessageComplete { full_response } = &ev { final_response = Some(full_response.clone()); } } let response = final_response.expect("流中应有 MessageComplete"); // consumer 负责构造本轮新增消息列表(user_input + assistant_response)。 // submit_turn_stream 不会自动写入 self.slots(流是延迟的), // 消费者需在 finalize_turn 时把 [user_input, ...tool_results, final_response] 一并传入。 let new_messages = vec![Message::user_text("user msg"), response.message.clone()]; session .finalize_turn(&response, new_messages) .await .expect("finalize_turn 应成功"); // cost_so_far 已累计(assistant_text 的 usage 是 from_input_output(10, 5)) assert_eq!(session.usage().total().prompt_tokens, 10); assert_eq!(session.usage().total().completion_tokens, 5); // turn_index 已递增 assert_eq!(session.turn_index(), 1); // default slot 应包含 user 输入和 assistant 响应 let slot = session.slots.get("default").expect("default slot"); let has_user = slot .messages .iter() .any(|m| matches!(m, Message::User { .. })); let has_resp = slot.messages.iter().any(|m| extract_text(m) == "hi back"); assert!(has_user && has_resp, "default slot 应包含 user 和 assistant 消息"); } /// Phase 9 Step 5.2 — `submit_turn_stream` 触发 OnTurnStart / OnTurnEnd hook。 /// /// 验证:OnTurnStart 在 `submit_turn_stream` 返回流之前已触发; /// OnTurnEnd 在 `finalize_turn` 调用后才触发。 #[tokio::test(flavor = "multi_thread")] async fn submit_turn_stream_triggers_turn_hooks() { let (mut session, start_count, end_count) = build_session(vec![assistant_text("ok")]); // 初始状态:两个 hook 计数都是 0 assert_eq!(start_count.0.load(Ordering::SeqCst), 0); assert_eq!(end_count.0.load(Ordering::SeqCst), 0); // 调 submit_turn_stream let mut stream = session .submit_turn_stream("user msg") .await .expect("submit_turn_stream 应成功"); // OnTurnStart 应在流返回前已触发 assert_eq!( start_count.0.load(Ordering::SeqCst), 1, "OnTurnStart 应在 submit_turn_stream 返回流之前触发" ); // OnTurnEnd 此时尚未触发 assert_eq!( end_count.0.load(Ordering::SeqCst), 0, "OnTurnEnd 不应在 submit_turn_stream 阶段触发" ); // 消费流 let mut final_response: Option = None; while let Some(ev) = stream.next().await { if let StreamEvent::MessageComplete { full_response } = &ev { final_response = Some(full_response.clone()); } } // finalize_turn let response = final_response.expect("流中应有 MessageComplete"); session .finalize_turn(&response, vec![]) .await .expect("finalize_turn 应成功"); // OnTurnEnd 已触发 assert_eq!( end_count.0.load(Ordering::SeqCst), 1, "OnTurnEnd 应在 finalize_turn 后触发" ); } // ====== Phase 16: 摘要自动生成测试 ====== /// 构造带 `SummaryConfig` 的 session。 /// mock provider 队列按 `[conv_1, summary_1, conv_2, summary_2, ...]` 交错排列, /// 因为每轮 `submit_turn` 中 conversation LLM 调用先于 summary LLM 调用。 fn build_session_with_summary( provider_responses: Vec, summary_responses: Vec, cfg: SummaryConfig, ) -> AgentSession { let mut interleaved = Vec::new(); let max_len = provider_responses.len().max(summary_responses.len()); for i in 0..max_len { if let Some(r) = provider_responses.get(i) { interleaved.push(r.clone()); } if let Some(r) = summary_responses.get(i) { interleaved.push(r.clone()); } } let provider = Arc::new(MockProvider::new(interleaved)); let agent = Arc::new(StubAgent { name: "stub".into(), prompt: None, }); let bundle = Arc::new( AgentBuilder::new() .provider(provider) .tool_registry(Arc::new(ToolRegistry::new())) .hook_executor(Arc::new(HookExecutor::new())) .summary_config(cfg) .build() .unwrap(), ); AgentSession::new(agent, "summary-session", bundle) } /// 默认用法:token 用量 ~15,远低于默认 32K 窗口的 0.75=24K 阈值 → 不触发摘要。 #[tokio::test] async fn summary_not_generated_below_threshold() { let mut session = build_session_with_summary( vec![assistant_text("a"), assistant_text("b"), assistant_text("c")], vec![assistant_text("should_not_appear")], SummaryConfig::default(), ); for i in 0..3 { session .submit_turn(&format!("msg {}", i)) .await .expect("submit_turn 应成功"); } let summary = session.get_conversation_summary().await.unwrap(); assert!(summary.is_none(), "未达阈值时不应生成摘要"); } /// 设置极低 max_context_tokens=100 + 0.5 比例 → 第一轮触发(usage 为 10+5=15 > 50)。 #[tokio::test] async fn summary_generated_above_threshold() { let mut session = build_session_with_summary( vec![assistant_text("a"), assistant_text("b"), assistant_text("c")], vec![ assistant_text("summary-1"), assistant_text("summary-2"), assistant_text("summary-3"), ], SummaryConfig { max_context_tokens: 20, // 阈值 20 * 0.5 = 10 trigger_token_ratio: 0.5, debounce_turns: 0, // 关闭防抖便于测试 ..SummaryConfig::default() }, ); // 第 1 轮:usage=15 ≥ 10,debounce=0 → 触发 session.submit_turn("m1").await.unwrap(); let summary = session.get_conversation_summary().await.unwrap(); assert!(summary.is_some(), "应触发摘要"); } /// 防抖:trigger 触发后,debounce_turns=3 内即使再次达阈值也不重复。 #[tokio::test] async fn summary_debounce_works() { let mut session = build_session_with_summary( vec![ assistant_text("r1"), assistant_text("r2"), assistant_text("r3"), assistant_text("r4"), ], vec![assistant_text("sum-1")], SummaryConfig { max_context_tokens: 20, trigger_token_ratio: 0.5, debounce_turns: 3, ..SummaryConfig::default() }, ); session.submit_turn("m1").await.unwrap(); let first_summary = session.get_conversation_summary().await.unwrap(); assert_eq!(first_summary.as_deref(), Some("sum-1")); // 第 2、3 轮:即使都超阈值,debounce 阻止再次触发 for _ in 0..2 { session.submit_turn("m").await.unwrap(); } let still_summary = session.get_conversation_summary().await.unwrap(); assert_eq!( still_summary.as_deref(), Some("sum-1"), "debounce 内不应重复生成(Provider 上没有更多预设摘要响应可用)" ); } /// Full 模式:摘要被生成并写入 session_memory,但 slot config.summary_override 仍为 None。 #[tokio::test] async fn summary_written_to_session_memory_but_full_mode_does_not_inject() { let mut session = build_session_with_summary( vec![assistant_text("a"), assistant_text("b"), assistant_text("c")], vec![assistant_text("captured-summary")], SummaryConfig { max_context_tokens: 20, trigger_token_ratio: 0.5, debounce_turns: 0, ..SummaryConfig::default() }, ); session.submit_turn("m1").await.unwrap(); let summary = session.get_conversation_summary().await.unwrap(); assert_eq!(summary.as_deref(), Some("captured-summary")); // default slot 是 Full 模式 → summary_override 应为 None(filter_focused 不会触发) let slot = session.slots.get("default").unwrap(); assert!(matches!(slot.config.mode, SlotMode::Full)); } /// 摘要生成失败不阻断 submit_turn(Provider 队列只够对话轮次,摘要调用返回 Other 错误)。 #[tokio::test] async fn summary_failure_does_not_block_turn() { // 故意只提供 1 个对话响应;摘要调用时队列耗尽,MockProvider 返回 LlmError::Other let mut session = build_session_with_summary( vec![assistant_text("only-one")], // 后续摘要会失败 vec![], // 无摘要响应 SummaryConfig { max_context_tokens: 20, trigger_token_ratio: 0.5, debounce_turns: 0, ..SummaryConfig::default() }, ); let response = session .submit_turn("m1") .await .expect("submit_turn 应成功(即便摘要失败)"); assert_eq!(extract_text(&response.message), "only-one"); // 摘要未生成(Provider 已耗尽) let summary = session.get_conversation_summary().await.unwrap(); assert!(summary.is_none()); } /// 未配置 SummaryConfig 时零影响。 #[tokio::test] async fn summary_skipped_when_not_configured() { let (mut session, _, _) = build_session(vec![assistant_text("r1"), assistant_text("r2")]); for _ in 0..2 { session.submit_turn("m").await.unwrap(); } let summary = session.get_conversation_summary().await.unwrap(); assert!(summary.is_none()); } /// 流式路径(submit_turn_stream + finalize_turn):摘要检查点正确触发。 #[tokio::test(flavor = "multi_thread")] async fn summary_stream_path_triggers_check() { let mut session = build_session_with_summary( vec![assistant_text("stream-resp")], vec![assistant_text("stream-summary")], SummaryConfig { max_context_tokens: 20, trigger_token_ratio: 0.5, debounce_turns: 0, ..SummaryConfig::default() }, ); let mut stream = session .submit_turn_stream("user msg") .await .expect("stream ok"); let mut response: Option = None; while let Some(ev) = stream.next().await { if let StreamEvent::MessageComplete { full_response } = &ev { response = Some(full_response.clone()); } } let resp = response.expect("MessageComplete event"); // finalize_turn 需要本轮新增消息:用户输入 + assistant 响应。 // slot.append_messages 之后才会被 maybe_summarize 看到。 let new_messages = vec![Message::user_text("user msg"), resp.message.clone()]; session .finalize_turn(&resp, new_messages) .await .expect("finalize_turn ok"); let summary = session.get_conversation_summary().await.unwrap(); assert_eq!(summary.as_deref(), Some("stream-summary")); } /// W7:Focused 模式摘要写入 `summary_override` + `slot.save()` 正向验证。 #[tokio::test] async fn summary_written_to_focused_slot_config() { let mut session = build_session_with_summary( vec![assistant_text("a"), assistant_text("b")], vec![assistant_text("the-summary")], SummaryConfig { max_context_tokens: 20, trigger_token_ratio: 0.5, debounce_turns: 0, ..SummaryConfig::default() }, ); // 1. 把 default slot 切到 Focused 模式 session .create_slot( "focused", Some(SlotConfig { mode: SlotMode::Focused(FocusedConfig { keep_system: false, recent_messages: 5, summary_override: None, }), source: SlotSource::New, budget: Default::default(), compact: true, }), ) .await .unwrap(); session.switch_slot("focused").await.unwrap(); // 2. 触发摘要 session.submit_turn("m1").await.unwrap(); // 3. SessionMemory 有值 let summary = session.get_conversation_summary().await.unwrap(); assert_eq!(summary.as_deref(), Some("the-summary")); // 4. Focused slot 的 summary_override 也应有值(正向验证) let slot = session.slots.get("focused").unwrap(); assert!( matches!(&slot.config.mode, SlotMode::Focused(focused) if focused.summary_override.is_some()), "Focused 模式下 summary_override 应被写入" ); } /// W1: 空消息守卫——`generate_summary` 空消息直接返回 `""`,不调用 LLM。 /// 这里通过构建一个空 slot 触发,第一次 `submit_turn` 后 slot 才有消息。 /// 验证:先调用 `format_messages_as_text` 走纯函数路径检查。 #[tokio::test] async fn summary_skipped_for_empty_messages() { // 直接走 format_messages_as_text,验证空消息返回空串。 // 这等同于 generate_summary 入口守卫(见 session.rs:560-562)。 let text = format_messages_as_text(&[], 500); assert_eq!(text, ""); } /// W1: `max_context_tokens` 设置过大时永不触发摘要。 #[tokio::test] async fn summary_not_generated_if_max_context_unreachable() { let mut session = build_session_with_summary( vec![ assistant_text("r1"), assistant_text("r2"), assistant_text("r3"), assistant_text("r4"), ], vec![assistant_text("should-not-appear")], SummaryConfig { max_context_tokens: 1_000_000, // 远大于任何合理累计 token trigger_token_ratio: 0.75, debounce_turns: 0, ..SummaryConfig::default() }, ); // 多轮 submit_turn,全部 15 token/轮,远低于 0.75 * 1M = 750K 阈值 for i in 0..4 { session.submit_turn(&format!("m{}", i)).await.unwrap(); } let summary = session.get_conversation_summary().await.unwrap(); assert!(summary.is_none(), "巨型 max_context_tokens 应永不触发"); } }