//! Persisted announcement tracking state for session resumption. //! //! Tracks which MCP servers and skills have already been announced //! via `` messages so that resumed sessions don't //! re-inject duplicate listings. use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; /// Persisted announcement tracking state. /// /// Restored on session resume so the fresh actor "remembers" what was /// already announced. The existing delta/fingerprint comparison logic /// then correctly handles changes (new/removed/updated servers or skills) /// without creating duplicates. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct AnnouncementState { /// Fingerprints of MCP servers that have been announced. /// Maps server_name → `McpServerFingerprint`. /// /// The hash values use FNV-1a (deterministic, portable) so that /// persisted fingerprints remain valid across Rust versions, build /// profiles, and CPU architectures. pub mcp_server_fingerprints: HashMap, /// Names of skills already announced via system-reminder. /// Uses the skill's `dedup_key()` (which is the skill name). pub announced_skill_names: HashSet, } /// Serializable MCP server fingerprint for persistence. /// /// This is the serializable counterpart of the in-memory /// `ServerFingerprint` type alias `(usize, u64, u64)`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct McpServerFingerprint { pub tool_count: usize, pub description_hash: u64, pub tool_names_hash: u64, } /// Convert from in-memory fingerprint map to persistable map. pub fn to_persisted_fingerprints( in_memory: &HashMap, ) -> HashMap { in_memory .iter() .map(|(name, &(tc, dh, tnh))| { ( name.clone(), McpServerFingerprint { tool_count: tc, description_hash: dh, tool_names_hash: tnh, }, ) }) .collect() } /// Convert from persisted fingerprint map to in-memory map. pub fn from_persisted_fingerprints( persisted: &HashMap, ) -> HashMap { persisted .iter() .map(|(name, fp)| { ( name.clone(), (fp.tool_count, fp.description_hash, fp.tool_names_hash), ) }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn serde_round_trip() { let state = AnnouncementState { mcp_server_fingerprints: HashMap::from([( "github".to_string(), McpServerFingerprint { tool_count: 5, description_hash: 12345678, tool_names_hash: 87654321, }, )]), announced_skill_names: HashSet::from(["commit".to_string(), "review".to_string()]), }; let json = serde_json::to_string(&state).unwrap(); let loaded: AnnouncementState = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.mcp_server_fingerprints.len(), 1); assert_eq!(loaded.announced_skill_names.len(), 2); let fp = &loaded.mcp_server_fingerprints["github"]; assert_eq!(fp.tool_count, 5); assert_eq!(fp.description_hash, 12345678); assert_eq!(fp.tool_names_hash, 87654321); } #[test] fn backward_compat_empty_json() { let loaded: AnnouncementState = serde_json::from_str("{}").unwrap(); assert!(loaded.mcp_server_fingerprints.is_empty()); assert!(loaded.announced_skill_names.is_empty()); } #[test] fn fingerprint_conversion_round_trip() { let in_memory: HashMap = HashMap::from([("srv".to_string(), (3, 111, 222))]); let persisted = to_persisted_fingerprints(&in_memory); let back = from_persisted_fingerprints(&persisted); assert_eq!(in_memory, back); } }