//! ContextSlot —— 多上下文槽位管理。 //! //! 设计要点(参见 `docs/17-phase10-contextslot.md`): //! //! - **多上下文分区**:单个 session 内可创建/切换/派生多个独立消息上下文 //! - **三种模式**:Full(完整历史)/ Focused(读时过滤)/ Readonly(禁止写入) //! - **三种来源**:New(全新)/ Derived(派生)/ Static(静态) //! - **基于 MemoryStore trait 持久化**:JSON blob 批次存储,每 slot 3-4 条 MemoryItem 记录 //! - **零新依赖方向**:放在 `agent/` 下利用已有的 `agent → memory` 依赖 use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::agent::error::AgentError; use crate::llm::types::message::Message; use crate::memory::store::MemoryStore; use crate::memory::types::{MemoryFilter, MemoryItem}; /// 上下文槽 —— 一段带策略配置的消息列表。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextSlot { /// 当前 slot 的唯一标识(同一个 session_id 内唯一)。 pub id: String, /// 所属 session。 pub session_id: String, /// 槽配置。 pub config: SlotConfig, /// 消息列表(全量,Focused/Readonly 在读取时做策略过滤)。 pub messages: Vec, /// 槽元数据。 pub meta: SlotMeta, } /// 槽配置。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlotConfig { /// 槽模式(Full / Focused / Readonly)。 pub mode: SlotMode, /// 槽来源(New / Derived / Static)。 pub source: SlotSource, /// 上下文预算(v0.2 纯数据结构,无消费逻辑)。 pub budget: ContextBudget, /// 是否启用自动压缩(v0.2 保留字段,LlmCycle 内部自行判断)。 pub compact: bool, } impl Default for SlotConfig { fn default() -> Self { Self { mode: SlotMode::Full, source: SlotSource::New, budget: ContextBudget::default(), compact: true, } } } /// 槽模式。 #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub enum SlotMode { /// 完整对话历史(全部消息)。 Full, /// 聚焦模式 —— 读取时按策略过滤,保持 LLM 注意力。 Focused(FocusedConfig), /// 只读参考上下文 —— 禁止写入。 Readonly, } /// 聚焦模式配置。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FocusedConfig { /// 是否保留 system prompt。 pub keep_system: bool, /// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。 pub recent_messages: usize, /// 摘要覆盖(消费端:手动或自动生成的摘要会注入到消息列表末尾)。 /// v0.3 Phase 16 起,`AgentBuilder::summary_config(cfg)` 内联检查点会 /// 自动调用 LLM 生成摘要并写入此字段,详见 `docs/22-phase16-summary-auto-generation.md`。 pub summary_override: Option, } /// 槽来源。 #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub enum SlotSource { /// 全新空槽。 New, /// 从父 slot 派生(记录 parent_id)。 Derived { parent_id: String, strategy: DeriveStrategy, }, /// 预置静态消息(不持久化,随 session 生命周期存在)。 Static(Vec), } /// 派生策略。 #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DeriveStrategy { /// 完整复制父 slot 的消息。 Full, /// 按聚焦策略复制父 slot 的消息。 Focused(FocusedConfig), } /// 合并策略 —— Phase 13 新增,控制 `ContextSlot::merge` 如何将子 slot 消息合入父 slot。 /// /// `#[non_exhaustive]` 预留未来扩展(如 `Summarize` 变体)。 #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub enum MergeStrategy { /// 子 slot 消息追加到父 slot 末尾。 Append, /// 用子 slot 消息替换父 slot 内容。 Replace, } /// 上下文预算(v0.2 纯数据结构,无消费逻辑)。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextBudget { /// system prompt 预算。 pub system: u32, /// 对话历史预算。 pub history: u32, /// 工具定义预算。 pub tools: u32, /// 工具结果预算。 pub tool_results: u32, /// 预留 buffer。 pub reserve: u32, } impl Default for ContextBudget { fn default() -> Self { Self { system: 8_000, history: 80_000, tools: 10_000, tool_results: 20_000, reserve: 10_000, } } } impl ContextBudget { /// 自动分配:按上下文窗口的固定比例分配预算。 /// v0.2 只做占位实现,v0.3 将根据实际 provider 的 context_window 计算。 pub fn auto() -> Self { Self::default() } } /// 槽元数据。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlotMeta { /// 父 slot id(仅 Derived 来源有值)。 pub parent_id: Option, /// 消息总数。 pub message_count: usize, /// 总 token 估算值(由 add_messages 时累计,v0.2 为近似值)。 pub total_tokens: u32, /// 创建时间(Unix 时间戳,秒)。 pub created_at: u64, } impl SlotMeta { pub fn new() -> Self { Self { parent_id: None, message_count: 0, total_tokens: 0, created_at: std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0), } } } impl Default for SlotMeta { fn default() -> Self { Self::new() } } impl ContextSlot { /// 持久化 key 前缀。 const KEY_DATA: &'static str = "slot_data"; const KEY_META: &'static str = "slot_meta"; const KEY_CONFIG: &'static str = "slot_config"; const KEY_REL: &'static str = "slot_rel"; /// 校验 id 不含冒号(避免破坏 key 格式与 list prefix 过滤)。 /// 失败时 panic —— 这是开发者错误而非用户错误。 fn assert_no_colon(id: &str, field: &str) { if id.contains(':') { panic!( "{field} '{id}' contains ':' which would break key format. \ Use only letters, digits, hyphens and underscores." ); } } pub(crate) fn data_key(session_id: &str, slot_id: &str) -> String { Self::assert_no_colon(session_id, "session_id"); Self::assert_no_colon(slot_id, "slot_id"); format!("{}:{}:{}", Self::KEY_DATA, session_id, slot_id) } pub(crate) fn meta_key(session_id: &str, slot_id: &str) -> String { Self::assert_no_colon(session_id, "session_id"); Self::assert_no_colon(slot_id, "slot_id"); format!("{}:{}:{}", Self::KEY_META, session_id, slot_id) } pub(crate) fn config_key(session_id: &str, slot_id: &str) -> String { Self::assert_no_colon(session_id, "session_id"); Self::assert_no_colon(slot_id, "slot_id"); format!("{}:{}:{}", Self::KEY_CONFIG, session_id, slot_id) } pub(crate) fn rel_key(session_id: &str, child_id: &str) -> String { Self::assert_no_colon(session_id, "session_id"); Self::assert_no_colon(child_id, "child_id"); format!("{}:{}:{}", Self::KEY_REL, session_id, child_id) } /// 构造 MemoryItem 的辅助函数。 fn make_item(key: String, content: String) -> MemoryItem { MemoryItem { id: key, content, metadata: serde_json::json!({}), created_at: OffsetDateTime::now_utc(), } } /// 创建一个新的空 ContextSlot(不持久化,仅内存构造)。 pub fn new( session_id: impl Into, slot_id: impl Into, config: SlotConfig, ) -> Self { Self { id: slot_id.into(), session_id: session_id.into(), config, messages: Vec::new(), meta: SlotMeta::new(), } } /// 保存 slot 数据到存储后端(全量写入,含 config)。 pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> { let data = serde_json::to_string(&self.messages) .map_err(|e| AgentError::Other(e.to_string()))?; let meta = serde_json::to_string(&self.meta) .map_err(|e| AgentError::Other(e.to_string()))?; let config = serde_json::to_string(&self.config) .map_err(|e| AgentError::Other(e.to_string()))?; store .save(Self::make_item( Self::data_key(&self.session_id, &self.id), data, )) .await .map_err(AgentError::Memory)?; store .save(Self::make_item( Self::meta_key(&self.session_id, &self.id), meta, )) .await .map_err(AgentError::Memory)?; store .save(Self::make_item( Self::config_key(&self.session_id, &self.id), config, )) .await .map_err(AgentError::Memory)?; // 派生关系 if let SlotSource::Derived { parent_id, .. } = &self.config.source { store .save(Self::make_item( Self::rel_key(&self.session_id, &self.id), parent_id.clone(), )) .await .map_err(AgentError::Memory)?; } Ok(()) } /// 从存储加载 slot,config 从 `slot_config` key 自行恢复。 /// 若 config 记录不存在(旧版本升级场景),使用 `SlotConfig::default()`。 pub async fn load( id: &str, session_id: &str, store: &dyn MemoryStore, ) -> Result, AgentError> { let meta_item = store .get(&Self::meta_key(session_id, id)) .await .map_err(AgentError::Memory)?; let data_item = store .get(&Self::data_key(session_id, id)) .await .map_err(AgentError::Memory)?; let config_item = store .get(&Self::config_key(session_id, id)) .await .map_err(AgentError::Memory)?; match (meta_item, data_item) { (Some(m), Some(d)) => { let meta: SlotMeta = serde_json::from_str(&m.content) .map_err(|e| AgentError::Other(e.to_string()))?; let messages: Vec = serde_json::from_str(&d.content) .map_err(|e| AgentError::Other(e.to_string()))?; // config 从存储恢复;不存在则使用 default(兼容旧版本) let config = match config_item { Some(c) => serde_json::from_str(&c.content) .map_err(|e| AgentError::Other(e.to_string()))?, None => SlotConfig::default(), }; Ok(Some(Self { id: id.to_string(), session_id: session_id.to_string(), config, messages, meta, })) } _ => Ok(None), } } /// 列出某 session 下的所有 slot 元数据。 pub async fn list( session_id: &str, store: &dyn MemoryStore, ) -> Result, AgentError> { let prefix_str = format!("{}:{}:", Self::KEY_META, session_id); let filter = MemoryFilter { prefix: Some(prefix_str), ..Default::default() }; let items = store.list(&filter).await.map_err(AgentError::Memory)?; let mut metas = Vec::new(); for item in items { if let Ok(meta) = serde_json::from_str::(&item.content) { metas.push(meta); } } Ok(metas) } /// 删除 slot 的所有存储记录(slot_data + slot_meta + slot_config + slot_rel)。 pub async fn delete( id: &str, session_id: &str, store: &dyn MemoryStore, ) -> Result<(), AgentError> { store .delete(&Self::data_key(session_id, id)) .await .map_err(AgentError::Memory)?; store .delete(&Self::meta_key(session_id, id)) .await .map_err(AgentError::Memory)?; store .delete(&Self::config_key(session_id, id)) .await .map_err(AgentError::Memory)?; // slot_rel 是 best-effort(仅 Derived 来源的 slot 才有此 key) let _ = store.delete(&Self::rel_key(session_id, id)).await; Ok(()) } /// 追加消息(Readonly 模式下返回 `SlotReadonly` 错误)。 /// Full / Focused 模式下允许追加。 pub fn append_messages(&mut self, new_messages: Vec) -> Result<(), AgentError> { if matches!(self.config.mode, SlotMode::Readonly) { return Err(AgentError::SlotReadonly( "Readonly slot does not allow writes".into(), )); } let count = new_messages.len(); self.messages.extend(new_messages); self.meta.message_count += count; Ok(()) } /// 按 FocusedConfig 过滤消息(静态辅助函数,被 `load_messages` 和 `derive_slot` 复用)。 /// /// 过滤逻辑: /// 1. 保留第一条 system 消息(如果 `keep_system=true`) /// 2. 取最近 `recent_messages` 条非 system 消息(如果 `recent_messages > 0`) /// 3. 追加摘要消息(如果 `summary_override` 存在) pub fn filter_focused(messages: &[Message], cfg: &FocusedConfig) -> Vec { let mut result = Vec::new(); // 保留 system prompt if cfg.keep_system && let Some(msg) = messages .iter() .find(|m| matches!(m, Message::System { .. })) { result.push(msg.clone()); } // 处理 recent_messages=0 边界:上面已处理 system,下面仅取最近 N 条 if cfg.recent_messages > 0 { let recent: Vec<&Message> = messages .iter() .filter(|m| !matches!(m, Message::System { .. })) .collect(); let start = recent.len().saturating_sub(cfg.recent_messages); for msg in recent.iter().skip(start) { result.push((*msg).clone()); } } // 注入摘要 if let Some(summary) = &cfg.summary_override { result.push(Message::system(format!("[上下文摘要] {}", summary))); } result } /// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。 pub fn load_messages(&self) -> Vec { match &self.config.mode { SlotMode::Focused(cfg) => Self::filter_focused(&self.messages, cfg), _ => self.messages.clone(), } } /// 从当前 slot 派生出独立的子 slot(不持久化,调用方负责 `save`)。 /// /// 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 `message_count`。 /// 子 slot 的 `source` 标记为 `Derived { parent_id, strategy }`,血缘可追溯。 pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot { let messages = match &strategy { DeriveStrategy::Full => self.messages.clone(), DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg), }; tracing::debug!( parent_id = %self.id, child_id = %child_id, ?strategy, "ContextSlot::fork" ); ContextSlot { id: child_id, session_id: self.session_id.clone(), config: SlotConfig { mode: match &strategy { DeriveStrategy::Full => SlotMode::Full, DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()), }, source: SlotSource::Derived { parent_id: self.id.clone(), strategy, }, budget: self.config.budget.clone(), compact: self.config.compact, }, messages, meta: SlotMeta::new(), } } /// 将子 slot 的消息合并到当前 slot。 /// /// **注意**:本方法仅操作内存数据,不自动持久化。 /// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。 /// /// 防御性检查: /// - 禁止 self-merge(`self.id == child.id`) /// - 禁止跨 session merge /// - 禁止合并到 Readonly slot pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> { if self.id == child.id { return Err(AgentError::Config("不能将 slot 合并到自身".into())); } if self.session_id != child.session_id { return Err(AgentError::Config("不能合并不同 session 的 slot".into())); } if matches!(self.config.mode, SlotMode::Readonly) { return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into())); } tracing::debug!( self_id = %self.id, child_id = %child.id, ?strategy, "ContextSlot::merge" ); match strategy { MergeStrategy::Append => { let count = child.messages.len(); self.messages.extend(child.messages); self.meta.message_count += count; } MergeStrategy::Replace => { self.messages = child.messages; self.meta.message_count = self.messages.len(); } } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::memory::store::InMemoryStore; fn make_store() -> std::sync::Arc { std::sync::Arc::new(InMemoryStore::new()) } fn make_slot(id: &str, session: &str) -> ContextSlot { ContextSlot::new(session, id, SlotConfig::default()) } /// 提取 `Message` 的第一个 Text block 的内容(用于测试断言)。 /// 返回 None 表示该消息不含纯文本 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; } } "" } // ===== 持久化 ===== #[tokio::test] async fn slot_save_load_roundtrip() { let store = make_store(); let mut slot = make_slot("default", "s1"); slot.append_messages(vec![Message::user_text("hi")]).unwrap(); slot.append_messages(vec![Message::assistant("hello")]).unwrap(); slot.save(&*store).await.unwrap(); let loaded = ContextSlot::load("default", "s1", &*store).await.unwrap(); let loaded = loaded.expect("slot should exist after save"); assert_eq!(loaded.id, "default"); assert_eq!(loaded.session_id, "s1"); assert_eq!(loaded.messages.len(), 2); assert_eq!(loaded.meta.message_count, 2); } #[tokio::test] async fn slot_session_isolation() { let store = make_store(); let mut a = make_slot("main", "sA"); a.append_messages(vec![Message::user_text("only in A")]) .unwrap(); a.save(&*store).await.unwrap(); let mut b = make_slot("main", "sB"); b.append_messages(vec![Message::user_text("only in B")]) .unwrap(); b.save(&*store).await.unwrap(); let loaded_a = ContextSlot::load("main", "sA", &*store).await.unwrap().unwrap(); let loaded_b = ContextSlot::load("main", "sB", &*store).await.unwrap().unwrap(); assert_eq!(extract_text(&loaded_a.messages[0]), "only in A"); assert_eq!(extract_text(&loaded_b.messages[0]), "only in B"); } #[tokio::test] async fn slot_derived_parent_id_recorded() { let store = make_store(); let slot = ContextSlot::new( "s1", "child", SlotConfig { mode: SlotMode::Full, source: SlotSource::Derived { parent_id: "default".to_string(), strategy: DeriveStrategy::Full, }, budget: ContextBudget::default(), compact: true, }, ); slot.save(&*store).await.unwrap(); // rel_key 直接读 let rel = store .get(&ContextSlot::rel_key("s1", "child")) .await .unwrap() .unwrap(); assert_eq!(rel.content, "default"); } #[tokio::test] async fn slot_readonly_rejects_write() { let mut slot = make_slot("ro", "s1"); slot.config.mode = SlotMode::Readonly; let result = slot.append_messages(vec![Message::user_text("nope")]); assert!(matches!(result, Err(AgentError::SlotReadonly(_)))); assert!(slot.messages.is_empty()); } #[tokio::test] async fn slot_delete_then_load_none() { let store = make_store(); let mut slot = make_slot("to_delete", "s1"); slot.append_messages(vec![Message::user_text("hi")]).unwrap(); slot.save(&*store).await.unwrap(); ContextSlot::delete("to_delete", "s1", &*store).await.unwrap(); let loaded = ContextSlot::load("to_delete", "s1", &*store).await.unwrap(); assert!(loaded.is_none()); } #[tokio::test] async fn slot_list_multiple() { let store = make_store(); for id in ["alpha", "beta", "gamma"] { let mut s = make_slot(id, "sX"); s.append_messages(vec![Message::user_text(id)]).unwrap(); s.save(&*store).await.unwrap(); } // 不同 session 不该列出 let mut s2 = make_slot("alpha", "sY"); s2.append_messages(vec![Message::user_text("y")]).unwrap(); s2.save(&*store).await.unwrap(); let metas = ContextSlot::list("sX", &*store).await.unwrap(); assert_eq!(metas.len(), 3); let metas_y = ContextSlot::list("sY", &*store).await.unwrap(); assert_eq!(metas_y.len(), 1); } // ===== Focused 模式 ===== #[tokio::test] async fn slot_focused_recent_messages() { let mut slot = make_slot("f", "s1"); slot.append_messages(vec![Message::system("sys")]).unwrap(); for i in 0..5 { slot.append_messages(vec![Message::user_text(format!("u{i}"))]) .unwrap(); slot.append_messages(vec![Message::assistant(format!("a{i}"))]) .unwrap(); } slot.config.mode = SlotMode::Focused(FocusedConfig { keep_system: true, recent_messages: 3, summary_override: None, }); let loaded = slot.load_messages(); // system + 最近 3 条 (assistant 4, user 4, assistant 5 实际是按 vec 顺序取最近 3 条非 system) let has_sys = loaded.iter().any(|m| matches!(m, Message::System { .. })); assert!(has_sys, "system 提示应保留"); // 最近 3 条非 system 应该是 a4, u4, a5 (按 messages 存储顺序的最后 3 条) assert_eq!(loaded.len(), 1 + 3); } #[tokio::test] async fn slot_focused_summary_override() { let mut slot = make_slot("f", "s1"); slot.append_messages(vec![Message::user_text("u")]).unwrap(); slot.append_messages(vec![Message::assistant("a")]).unwrap(); slot.config.mode = SlotMode::Focused(FocusedConfig { keep_system: false, recent_messages: 100, summary_override: Some("讨论了 X".to_string()), }); let loaded = slot.load_messages(); // 2 条原始 + 1 条摘要 system = 3 assert_eq!(loaded.len(), 3); // 最后一条是摘要 if let Message::System { content } = &loaded[2] { let text = format!("{:?}", content); assert!(text.contains("上下文摘要")); assert!(text.contains("讨论了 X")); } else { panic!("最后一条应为 system 摘要"); } } #[tokio::test] async fn slot_focused_zero_messages() { let mut slot = make_slot("f", "s1"); slot.append_messages(vec![Message::system("sys")]).unwrap(); slot.append_messages(vec![Message::user_text("u")]).unwrap(); slot.config.mode = SlotMode::Focused(FocusedConfig { keep_system: true, recent_messages: 0, summary_override: None, }); let loaded = slot.load_messages(); // recent_messages=0 但 keep_system=true 应只含 system assert_eq!(loaded.len(), 1); assert!(matches!(loaded[0], Message::System { .. })); } // ===== 边界 ===== #[tokio::test] async fn slot_empty_messages_roundtrip() { let store = make_store(); let slot = make_slot("empty", "s1"); slot.save(&*store).await.unwrap(); let loaded = ContextSlot::load("empty", "s1", &*store).await.unwrap().unwrap(); assert!(loaded.messages.is_empty()); assert_eq!(loaded.meta.message_count, 0); } #[tokio::test] async fn slot_save_on_readonly_side_effect() { let store = make_store(); let mut slot = make_slot("ro", "s1"); slot.config.mode = SlotMode::Readonly; // save 本身允许(只禁止 append) slot.save(&*store).await.unwrap(); let loaded = ContextSlot::load("ro", "s1", &*store).await.unwrap(); assert!(loaded.is_some()); } // ===== 派生 (derive_slot 行为) ===== #[tokio::test] async fn derive_full_copies_parent_messages() { let mut parent = make_slot("p", "s1"); for i in 0..3 { parent.append_messages(vec![Message::user_text(format!("u{i}"))]) .unwrap(); } // 模拟 derive_slot 内部 Full 策略 let child_messages = parent.messages.clone(); let child = ContextSlot::new( "s1", "c", SlotConfig { mode: SlotMode::Full, source: SlotSource::Derived { parent_id: "p".to_string(), strategy: DeriveStrategy::Full, }, budget: ContextBudget::default(), compact: true, }, ); let mut child = child; child.messages = child_messages; let store = make_store(); child.save(&*store).await.unwrap(); let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap(); assert_eq!(loaded.messages.len(), 3); assert!(matches!(loaded.config.source, SlotSource::Derived { .. })); } #[tokio::test] async fn derive_focused_filters_parent_messages() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::system("sys")]).unwrap(); for i in 0..5 { parent.append_messages(vec![Message::user_text(format!("u{i}"))]) .unwrap(); } // 模拟 derive_slot 内部 Focused 策略:按 FocusedConfig 过滤 let cfg = FocusedConfig { keep_system: true, recent_messages: 2, summary_override: None, }; // 应用 load_messages 同样的过滤 let mut filtered = Vec::new(); if cfg.keep_system && let Some(m) = parent .messages .iter() .find(|m| matches!(m, Message::System { .. })) { filtered.push(m.clone()); } if cfg.recent_messages > 0 { let recent: Vec<&Message> = parent .messages .iter() .filter(|m| !matches!(m, Message::System { .. })) .collect(); let start = recent.len().saturating_sub(cfg.recent_messages); for m in recent.iter().skip(start) { filtered.push((*m).clone()); } } assert_eq!(filtered.len(), 1 + 2); // system + 2 条 } #[tokio::test] async fn derived_slot_loadable_independently() { let store = make_store(); let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::user_text("u")]).unwrap(); parent.save(&*store).await.unwrap(); // 派生 child let mut child = ContextSlot::new( "s1", "c", SlotConfig { mode: SlotMode::Full, source: SlotSource::Derived { parent_id: "p".to_string(), strategy: DeriveStrategy::Full, }, budget: ContextBudget::default(), compact: true, }, ); child.append_messages(vec![Message::user_text("derived msg")]) .unwrap(); child.save(&*store).await.unwrap(); // child 可独立加载 let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap(); assert_eq!(loaded.messages.len(), 1); assert_eq!(extract_text(&loaded.messages[0]), "derived msg"); } // ===== delete 保护 (AgentSession 层,但 ContextSlot.delete 不保护;逻辑测试在 session.rs) ===== #[tokio::test] async fn slot_delete_cleans_all_records() { let store = make_store(); let mut slot = ContextSlot::new( "s1", "x", SlotConfig { mode: SlotMode::Full, source: SlotSource::Derived { parent_id: "p".to_string(), strategy: DeriveStrategy::Full, }, budget: ContextBudget::default(), compact: true, }, ); slot.append_messages(vec![Message::user_text("u")]).unwrap(); slot.save(&*store).await.unwrap(); // 确认所有记录存在 assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_some()); assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_some()); assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_some()); assert!(store.get(&ContextSlot::rel_key("s1", "x")).await.unwrap().is_some()); ContextSlot::delete("x", "s1", &*store).await.unwrap(); // data/meta/config 已删 assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_none()); assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_none()); assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_none()); } // ===== 基础类型测试 ===== #[test] fn slot_meta_new_sets_zero_message_count() { let m = SlotMeta::new(); assert_eq!(m.message_count, 0); assert_eq!(m.total_tokens, 0); assert!(m.parent_id.is_none()); } #[test] fn context_budget_default_sum_128k() { let b = ContextBudget::default(); assert_eq!(b.system + b.history + b.tools + b.tool_results + b.reserve, 128_000); } #[test] fn slot_config_default_is_full_new() { let c = SlotConfig::default(); assert!(matches!(c.mode, SlotMode::Full)); assert!(matches!(c.source, SlotSource::New)); assert!(c.compact); } #[test] fn focused_config_serializes_roundtrip() { let cfg = FocusedConfig { keep_system: true, recent_messages: 5, summary_override: Some("sum".into()), }; let json = serde_json::to_string(&cfg).unwrap(); let back: FocusedConfig = serde_json::from_str(&json).unwrap(); assert_eq!(back.recent_messages, 5); assert_eq!(back.summary_override.as_deref(), Some("sum")); } // ====== filter_focused 静态方法(被 load_messages 和 derive_slot 复用) ====== #[test] fn filter_focused_keeps_system_and_recent() { let mut messages = vec![Message::system("sys")]; for i in 0..5 { messages.push(Message::user_text(format!("u{i}"))); messages.push(Message::assistant(format!("a{i}"))); } let cfg = FocusedConfig { keep_system: true, recent_messages: 3, summary_override: None, }; let filtered = ContextSlot::filter_focused(&messages, &cfg); // system + 3 条最近的非 system 消息 assert_eq!(filtered.len(), 1 + 3); assert!(matches!(filtered[0], Message::System { .. })); } #[test] fn filter_focused_injects_summary() { let messages = vec![ Message::user_text("u"), Message::assistant("a"), ]; let cfg = FocusedConfig { keep_system: false, recent_messages: 100, summary_override: Some("讨论了 X".into()), }; let filtered = ContextSlot::filter_focused(&messages, &cfg); // 2 条原始 + 1 条摘要 system assert_eq!(filtered.len(), 3); if let Message::System { content } = &filtered[2] { let text = format!("{:?}", content); assert!(text.contains("上下文摘要")); } else { panic!("最后一条应为 system 摘要"); } } // ====== Phase 13: fork/merge ====== #[test] fn fork_full_copies_messages() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::user_text("a")]).unwrap(); parent.append_messages(vec![Message::assistant("b")]).unwrap(); let child = parent.fork("c".into(), DeriveStrategy::Full); assert_eq!(child.messages.len(), 2); assert!(matches!(child.config.mode, SlotMode::Full)); } #[test] fn fork_focused_filters_messages() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::system("sys")]).unwrap(); for i in 0..5 { parent .append_messages(vec![Message::user_text(format!("u{i}"))]) .unwrap(); } let cfg = FocusedConfig { keep_system: true, recent_messages: 2, summary_override: None, }; let child = parent.fork("c".into(), DeriveStrategy::Focused(cfg)); // system + 最近 2 条非 system assert_eq!(child.messages.len(), 1 + 2); assert!(matches!(child.config.mode, SlotMode::Focused(_))); } #[test] fn fork_preserves_independence() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::user_text("a")]).unwrap(); let mut child = parent.fork("c".into(), DeriveStrategy::Full); let child_count_at_fork = child.messages.len(); // 父 slot 追加 parent .append_messages(vec![Message::user_text("b")]) .unwrap(); // 子 slot 追加 child.append_messages(vec![Message::user_text("c")]).unwrap(); assert_eq!(parent.messages.len(), 2); assert_eq!(child.messages.len(), child_count_at_fork + 1); assert_eq!(extract_text(child.messages.last().unwrap()), "c"); } #[test] fn fork_sets_derived_source() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::user_text("a")]).unwrap(); let child = parent.fork("c".into(), DeriveStrategy::Full); match &child.config.source { SlotSource::Derived { parent_id, strategy } => { assert_eq!(parent_id, "p"); assert!(matches!(strategy, DeriveStrategy::Full)); } _ => panic!("子 slot source 应为 Derived"), } // 子 slot 的 meta 全新创建 assert_eq!(child.meta.message_count, 0); assert!(child.meta.parent_id.is_none()); } #[test] fn merge_append_appends_messages() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::user_text("p1")]).unwrap(); let child = { let mut c = parent.fork("c".into(), DeriveStrategy::Full); // fork 时 child 继承父的 "p1";再追加一条 c1 c.append_messages(vec![Message::user_text("c1")]).unwrap(); c }; parent .merge(child, MergeStrategy::Append) .expect("merge ok"); // Append 追加 child 全部消息到父:1 (p1) + 2 (p1 + c1) = 3 assert_eq!(parent.messages.len(), 3); assert_eq!(parent.meta.message_count, 3); assert_eq!(extract_text(&parent.messages[2]), "c1"); } #[test] fn merge_replace_replaces_messages() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::user_text("p1")]).unwrap(); parent.append_messages(vec![Message::user_text("p2")]).unwrap(); let child = { let mut c = parent.fork("c".into(), DeriveStrategy::Full); // 清空 child 再追加 c.messages.clear(); c.append_messages(vec![Message::user_text("c-only")]) .unwrap(); c }; parent .merge(child, MergeStrategy::Replace) .expect("merge ok"); assert_eq!(parent.messages.len(), 1); assert_eq!(extract_text(&parent.messages[0]), "c-only"); assert_eq!(parent.meta.message_count, 1); } #[test] fn merge_self_rejected() { let mut slot = make_slot("p", "s1"); slot.append_messages(vec![Message::user_text("a")]).unwrap(); let child = slot.fork("p".into(), DeriveStrategy::Full); let err = slot.merge(child, MergeStrategy::Append).unwrap_err(); assert!(matches!(err, AgentError::Config(_))); } #[test] fn merge_readonly_rejected() { let mut parent = make_slot("p", "s1"); parent.config.mode = SlotMode::Readonly; let child = ContextSlot::new("s1", "c", SlotConfig::default()); let err = parent.merge(child, MergeStrategy::Append).unwrap_err(); assert!(matches!(err, AgentError::SlotReadonly(_))); } #[test] fn merge_cross_session_rejected() { let mut parent = make_slot("p", "s1"); parent.append_messages(vec![Message::user_text("a")]).unwrap(); let child = ContextSlot::new("OTHER_SESSION", "c", SlotConfig::default()); let err = parent.merge(child, MergeStrategy::Append).unwrap_err(); assert!(matches!(err, AgentError::Config(_))); } // ====== Colon 校验(key 格式保护) ====== #[test] #[should_panic(expected = "session_id 's:1' contains ':'")] fn key_constructor_rejects_colon_in_session_id() { // 通过 make_slot 间接调用 slot.save 时会触发 data_key -> assert_no_colon let store = make_store(); let slot = ContextSlot::new("s:1", "default", SlotConfig::default()); let _ = tokio_test_runtime(slot.save(&*store)); } #[test] #[should_panic(expected = "slot_id 'a:b' contains ':'")] fn key_constructor_rejects_colon_in_slot_id() { let store = make_store(); let slot = ContextSlot::new("s1", "a:b", SlotConfig::default()); let _ = tokio_test_runtime(slot.save(&*store)); } /// 在同步测试中运行 future 的辅助函数。 fn tokio_test_runtime(f: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(f) } }