Skip to main content

agentic_comm/
contracts.rs

1//! Contracts bridge — implements agentic-sdk v0.2.0 traits for Comm.
2//!
3//! This module provides `CommSister`, a contracts-compliant wrapper
4//! around the core `CommStore`. It implements:
5//!
6//! - `Sister` — lifecycle management
7//! - `SessionManagement` — append-only sequential sessions
8//! - `Grounding` — word-overlap claim verification against messages
9//! - `Queryable` — unified query interface
10//! - `FileFormatReader/FileFormatWriter` — .acomm file I/O
11//!
12//! The MCP server can use `CommSister` instead of raw CommStore
13//! to get compile-time contracts compliance.
14
15use agentic_sdk::prelude::*;
16use std::path::{Path, PathBuf};
17use std::time::Instant;
18
19use crate::{CommError, CommStore};
20
21// ═══════════════════════════════════════════════════════════════════
22// ERROR BRIDGE: CommError → SisterError
23// ═══════════════════════════════════════════════════════════════════
24
25impl From<CommError> for SisterError {
26    fn from(e: CommError) -> Self {
27        match &e {
28            CommError::ChannelNotFound(id) => {
29                SisterError::not_found(format!("channel {}", id))
30            }
31            CommError::MessageNotFound(id) => {
32                SisterError::not_found(format!("message {}", id))
33            }
34            CommError::SubscriptionNotFound(id) => {
35                SisterError::not_found(format!("subscription {}", id))
36            }
37            CommError::KeyNotFound(id) => {
38                SisterError::not_found(format!("key {}", id))
39            }
40            CommError::NotFound(what) => SisterError::not_found(what.clone()),
41            CommError::InvalidChannelName(name) => SisterError::new(
42                ErrorCode::InvalidInput,
43                format!("Invalid channel name: {}", name),
44            ),
45            CommError::InvalidContent(reason) => SisterError::new(
46                ErrorCode::InvalidInput,
47                format!("Invalid content: {}", reason),
48            ),
49            CommError::InvalidSender(reason) => SisterError::new(
50                ErrorCode::InvalidInput,
51                format!("Invalid sender: {}", reason),
52            ),
53            CommError::ChannelFull(id) => SisterError::new(
54                ErrorCode::ResourceExhausted,
55                format!("Channel {} is full", id),
56            ),
57            CommError::ChannelStateViolation(id, state) => SisterError::new(
58                ErrorCode::InvalidState,
59                format!("Channel {} is {} — operation not allowed", id, state),
60            ),
61            CommError::ConsentDenied { reason } => SisterError::new(
62                ErrorCode::PermissionDenied,
63                format!("Consent denied: {}", reason),
64            ),
65            CommError::RateLimitExceeded { limit } => SisterError::new(
66                ErrorCode::ResourceExhausted,
67                format!("Rate limit exceeded: {}", limit),
68            ),
69            CommError::InvalidFile(reason) => SisterError::new(
70                ErrorCode::ChecksumMismatch,
71                format!("Invalid .acomm file: {}", reason),
72            ),
73            CommError::Io(io_err) => {
74                SisterError::new(ErrorCode::StorageError, format!("I/O error: {}", io_err))
75            }
76            CommError::LockError(reason) => {
77                SisterError::new(ErrorCode::StorageError, format!("Lock error: {}", reason))
78            }
79            CommError::Serialization(reason) => SisterError::new(
80                ErrorCode::StorageError,
81                format!("Serialization error: {}", reason),
82            ),
83            _ => SisterError::new(ErrorCode::Internal, e.to_string()),
84        }
85    }
86}
87
88// ═══════════════════════════════════════════════════════════════════
89// SESSION STATE
90// ═══════════════════════════════════════════════════════════════════
91
92/// Session record for tracking sessions in CommSister.
93#[derive(Debug, Clone)]
94struct SessionRecord {
95    id: ContextId,
96    session_id: u32,
97    name: String,
98    created_at: chrono::DateTime<chrono::Utc>,
99    message_count_at_start: usize,
100}
101
102// ═══════════════════════════════════════════════════════════════════
103// COMM SISTER — The contracts-compliant facade
104// ═══════════════════════════════════════════════════════════════════
105
106/// Contracts-compliant Comm sister.
107///
108/// Wraps `CommStore` and implements all v0.2.0 traits.
109/// This is the canonical "Comm as a sister" interface.
110pub struct CommSister {
111    store: CommStore,
112    file_path: Option<PathBuf>,
113    start_time: Instant,
114
115    // Session state
116    current_session: Option<SessionRecord>,
117    sessions: Vec<SessionRecord>,
118    next_session_id: u32,
119}
120
121impl CommSister {
122    /// Create from an existing store (for migration from SessionManager).
123    pub fn from_store(store: CommStore, file_path: Option<PathBuf>) -> Self {
124        Self {
125            store,
126            file_path,
127            start_time: Instant::now(),
128            current_session: None,
129            sessions: vec![],
130            next_session_id: 1,
131        }
132    }
133
134    /// Get a reference to the underlying store.
135    pub fn store(&self) -> &CommStore {
136        &self.store
137    }
138
139    /// Get a mutable reference to the underlying store.
140    pub fn store_mut(&mut self) -> &mut CommStore {
141        &mut self.store
142    }
143
144    /// Get the current u32 session ID (for interop with existing code).
145    pub fn current_session_id(&self) -> Option<u32> {
146        self.current_session.as_ref().map(|s| s.session_id)
147    }
148
149    /// Total item count across channels and messages.
150    fn total_items(&self) -> usize {
151        self.store.channels.len() + self.store.messages.len()
152    }
153}
154
155// ═══════════════════════════════════════════════════════════════════
156// SISTER TRAIT
157// ═══════════════════════════════════════════════════════════════════
158
159impl Sister for CommSister {
160    const SISTER_TYPE: SisterType = SisterType::Comm;
161    const FILE_EXTENSION: &'static str = "acomm";
162
163    fn init(config: SisterConfig) -> SisterResult<Self>
164    where
165        Self: Sized,
166    {
167        let file_path = config.data_path.clone();
168
169        let store = if let Some(ref path) = file_path {
170            if path.exists() {
171                CommStore::load(path).map_err(SisterError::from)?
172            } else if config.create_if_missing {
173                CommStore::new()
174            } else {
175                return Err(SisterError::new(
176                    ErrorCode::NotFound,
177                    format!("Comm store not found: {}", path.display()),
178                ));
179            }
180        } else {
181            CommStore::new()
182        };
183
184        Ok(Self::from_store(store, file_path))
185    }
186
187    fn health(&self) -> HealthStatus {
188        HealthStatus {
189            healthy: true,
190            status: Status::Ready,
191            uptime: self.start_time.elapsed(),
192            resources: ResourceUsage {
193                memory_bytes: self.total_items() * 512, // rough estimate
194                disk_bytes: 0,
195                open_handles: if self.file_path.is_some() { 1 } else { 0 },
196            },
197            warnings: vec![],
198            last_error: None,
199        }
200    }
201
202    fn version(&self) -> Version {
203        Version::new(0, 1, 0) // matches agentic-comm crate version
204    }
205
206    fn shutdown(&mut self) -> SisterResult<()> {
207        // End current session if active
208        if self.current_session.is_some() {
209            let _ = SessionManagement::end_session(self);
210        }
211
212        // Save to file if path is set
213        if let Some(ref path) = self.file_path {
214            self.store.save(path).map_err(SisterError::from)?;
215        }
216
217        Ok(())
218    }
219
220    fn capabilities(&self) -> Vec<Capability> {
221        vec![
222            Capability::new("comm_channel", "Manage communication channels"),
223            Capability::new("comm_message", "Send, receive, and manage messages"),
224            Capability::new("comm_semantic", "Semantic messaging and NLP analysis"),
225            Capability::new("comm_affect", "Emotional state tracking and propagation"),
226            Capability::new("comm_hive", "Hive-mind collective intelligence"),
227            Capability::new("comm_consent", "Consent management and privacy gates"),
228            Capability::new("comm_trust", "Trust level management between agents"),
229            Capability::new("comm_keys", "Cryptographic key management"),
230            Capability::new("comm_query", "Query relationships and grounding"),
231            Capability::new("comm_session", "Session lifecycle and logging"),
232        ]
233    }
234}
235
236// ═══════════════════════════════════════════════════════════════════
237// SESSION MANAGEMENT
238// ═══════════════════════════════════════════════════════════════════
239
240impl SessionManagement for CommSister {
241    fn start_session(&mut self, name: &str) -> SisterResult<ContextId> {
242        // End current session if active
243        if self.current_session.is_some() {
244            self.end_session()?;
245        }
246
247        let session_id = self.next_session_id;
248        self.next_session_id += 1;
249        let context_id = ContextId::new();
250
251        let record = SessionRecord {
252            id: context_id,
253            session_id,
254            name: name.to_string(),
255            created_at: chrono::Utc::now(),
256            message_count_at_start: self.store.messages.len(),
257        };
258
259        self.current_session = Some(record.clone());
260        self.sessions.push(record);
261
262        Ok(context_id)
263    }
264
265    fn end_session(&mut self) -> SisterResult<()> {
266        if self.current_session.is_none() {
267            return Err(SisterError::new(
268                ErrorCode::InvalidState,
269                "No active session to end",
270            ));
271        }
272        self.current_session = None;
273        Ok(())
274    }
275
276    fn current_session(&self) -> Option<ContextId> {
277        self.current_session.as_ref().map(|s| s.id)
278    }
279
280    fn current_session_info(&self) -> SisterResult<ContextInfo> {
281        let session = self
282            .current_session
283            .as_ref()
284            .ok_or_else(|| SisterError::new(ErrorCode::InvalidState, "No active session"))?;
285
286        let messages_in_session =
287            self.store.messages.len().saturating_sub(session.message_count_at_start);
288
289        Ok(ContextInfo {
290            id: session.id,
291            name: session.name.clone(),
292            created_at: session.created_at,
293            updated_at: chrono::Utc::now(),
294            item_count: messages_in_session,
295            size_bytes: messages_in_session * 512,
296            metadata: Metadata::new(),
297        })
298    }
299
300    fn list_sessions(&self) -> SisterResult<Vec<ContextSummary>> {
301        Ok(self
302            .sessions
303            .iter()
304            .rev() // most recent first
305            .map(|s| ContextSummary {
306                id: s.id,
307                name: s.name.clone(),
308                created_at: s.created_at,
309                updated_at: s.created_at,
310                item_count: 0,
311                size_bytes: 0,
312            })
313            .collect())
314    }
315
316    fn export_session(&self, id: ContextId) -> SisterResult<ContextSnapshot> {
317        let session = self
318            .sessions
319            .iter()
320            .find(|s| s.id == id)
321            .ok_or_else(|| SisterError::context_not_found(id.to_string()))?;
322
323        // Export messages from this session's time window
324        let session_messages: Vec<&crate::Message> = self
325            .store
326            .messages
327            .values()
328            .filter(|m| m.timestamp >= session.created_at)
329            .collect();
330
331        let data = serde_json::to_vec(&session_messages)
332            .map_err(|e| SisterError::new(ErrorCode::Internal, e.to_string()))?;
333        let checksum = *blake3::hash(&data).as_bytes();
334
335        Ok(ContextSnapshot {
336            sister_type: SisterType::Comm,
337            version: Version::new(0, 1, 0),
338            context_info: ContextInfo {
339                id,
340                name: session.name.clone(),
341                created_at: session.created_at,
342                updated_at: chrono::Utc::now(),
343                item_count: session_messages.len(),
344                size_bytes: data.len(),
345                metadata: Metadata::new(),
346            },
347            data,
348            checksum,
349            snapshot_at: chrono::Utc::now(),
350        })
351    }
352
353    fn import_session(&mut self, snapshot: ContextSnapshot) -> SisterResult<ContextId> {
354        if !snapshot.verify() {
355            return Err(SisterError::new(
356                ErrorCode::ChecksumMismatch,
357                "Session snapshot checksum verification failed",
358            ));
359        }
360
361        // Start a new session for the imported data
362        let context_id = self.start_session(&snapshot.context_info.name)?;
363
364        // Deserialize and ingest the messages
365        let messages: Vec<crate::Message> = serde_json::from_slice(&snapshot.data)
366            .map_err(|e| SisterError::new(ErrorCode::InvalidInput, e.to_string()))?;
367
368        for msg in messages {
369            self.store.messages.insert(msg.id, msg);
370        }
371
372        Ok(context_id)
373    }
374}
375
376// ═══════════════════════════════════════════════════════════════════
377// GROUNDING
378// ═══════════════════════════════════════════════════════════════════
379
380impl Grounding for CommSister {
381    fn ground(&self, claim: &str) -> SisterResult<GroundingResult> {
382        let matches = self.store.search_messages(claim, 10);
383
384        if matches.is_empty() {
385            let mut recent_msgs: Vec<&crate::Message> =
386                self.store.messages.values().collect();
387            recent_msgs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
388            let suggestions: Vec<String> = recent_msgs
389                .iter()
390                .take(3)
391                .map(|m| m.content.clone())
392                .collect();
393
394            return Ok(
395                GroundingResult::ungrounded(claim, "No matching messages found")
396                    .with_suggestions(suggestions),
397            );
398        }
399
400        let evidence: Vec<GroundingEvidence> = matches
401            .iter()
402            .enumerate()
403            .map(|(i, msg)| {
404                let score = 1.0 - (i as f64 * 0.1); // decreasing relevance
405                GroundingEvidence::new(
406                    "comm_message",
407                    format!("msg_{}", msg.id),
408                    score,
409                    &msg.content,
410                )
411                .with_data("sender", msg.sender.clone())
412                .with_data("channel_id", msg.channel_id)
413                .with_data("message_type", format!("{}", msg.message_type))
414                .with_data("timestamp", msg.timestamp.to_rfc3339())
415            })
416            .collect();
417
418        let confidence = if matches.len() >= 3 { 0.8 } else { 0.4 };
419
420        if confidence > 0.5 {
421            Ok(GroundingResult::verified(claim, confidence)
422                .with_evidence(evidence)
423                .with_reason("Found matching messages via content search"))
424        } else {
425            Ok(GroundingResult::partial(claim, confidence)
426                .with_evidence(evidence)
427                .with_reason("Some evidence found but limited matches"))
428        }
429    }
430
431    fn evidence(&self, query: &str, max_results: usize) -> SisterResult<Vec<EvidenceDetail>> {
432        let matches = self.store.search_messages(query, max_results);
433
434        Ok(matches
435            .iter()
436            .enumerate()
437            .map(|(i, msg)| {
438                let score = 1.0 - (i as f64 * 0.05);
439                EvidenceDetail {
440                    evidence_type: "comm_message".to_string(),
441                    id: format!("msg_{}", msg.id),
442                    score,
443                    created_at: msg.timestamp,
444                    source_sister: SisterType::Comm,
445                    content: msg.content.clone(),
446                    data: {
447                        let mut meta = Metadata::new();
448                        if let Ok(v) = serde_json::to_value(&msg.sender) {
449                            meta.insert("sender".to_string(), v);
450                        }
451                        if let Ok(v) = serde_json::to_value(msg.channel_id) {
452                            meta.insert("channel_id".to_string(), v);
453                        }
454                        if let Ok(v) = serde_json::to_value(format!("{}", msg.message_type)) {
455                            meta.insert("message_type".to_string(), v);
456                        }
457                        meta
458                    },
459                }
460            })
461            .collect())
462    }
463
464    fn suggest(&self, query: &str, limit: usize) -> SisterResult<Vec<GroundingSuggestion>> {
465        // Word-overlap fallback for near-miss suggestions
466        let query_lower = query.to_lowercase();
467        let query_words: Vec<&str> = query_lower.split_whitespace().collect();
468
469        let mut scored: Vec<(f64, &crate::Message)> = self
470            .store
471            .messages
472            .values()
473            .map(|msg| {
474                let content_lower = msg.content.to_lowercase();
475                let matched = query_words
476                    .iter()
477                    .filter(|w| content_lower.contains(**w))
478                    .count();
479                let score = if query_words.is_empty() {
480                    0.0
481                } else {
482                    matched as f64 / query_words.len() as f64
483                };
484                (score, msg)
485            })
486            .filter(|(score, _)| *score > 0.0)
487            .collect();
488
489        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
490
491        Ok(scored
492            .into_iter()
493            .take(limit)
494            .map(|(score, msg)| GroundingSuggestion {
495                item_type: "comm_message".to_string(),
496                id: format!("msg_{}", msg.id),
497                relevance_score: score,
498                description: msg.content.clone(),
499                data: Metadata::new(),
500            })
501            .collect())
502    }
503}
504
505// ═══════════════════════════════════════════════════════════════════
506// QUERYABLE
507// ═══════════════════════════════════════════════════════════════════
508
509impl Queryable for CommSister {
510    fn query(&self, query: Query) -> SisterResult<QueryResult> {
511        let start = Instant::now();
512
513        let results: Vec<serde_json::Value> = match query.query_type.as_str() {
514            "list" => {
515                let limit = query.limit.unwrap_or(50);
516                let offset = query.offset.unwrap_or(0);
517                let mut msgs: Vec<&crate::Message> = self.store.messages.values().collect();
518                msgs.sort_by_key(|m| m.id);
519                msgs.iter()
520                    .skip(offset)
521                    .take(limit)
522                    .map(|m| {
523                        serde_json::json!({
524                            "id": m.id,
525                            "channel_id": m.channel_id,
526                            "sender": m.sender,
527                            "content": m.content,
528                            "message_type": format!("{}", m.message_type),
529                            "timestamp": m.timestamp.to_rfc3339(),
530                        })
531                    })
532                    .collect()
533            }
534            "search" => {
535                let text = query.get_string("text").unwrap_or_default();
536                let max = query.limit.unwrap_or(20);
537                let matches = self.store.search_messages(&text, max);
538                matches
539                    .iter()
540                    .map(|m| {
541                        serde_json::json!({
542                            "id": m.id,
543                            "channel_id": m.channel_id,
544                            "sender": m.sender,
545                            "content": m.content,
546                            "message_type": format!("{}", m.message_type),
547                            "timestamp": m.timestamp.to_rfc3339(),
548                        })
549                    })
550                    .collect()
551            }
552            "recent" => {
553                let count = query.limit.unwrap_or(10);
554                let mut msgs: Vec<&crate::Message> = self.store.messages.values().collect();
555                msgs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
556                msgs.iter()
557                    .take(count)
558                    .map(|m| {
559                        serde_json::json!({
560                            "id": m.id,
561                            "channel_id": m.channel_id,
562                            "sender": m.sender,
563                            "content": m.content,
564                            "message_type": format!("{}", m.message_type),
565                            "timestamp": m.timestamp.to_rfc3339(),
566                        })
567                    })
568                    .collect()
569            }
570            "get" => {
571                let id_str = query.get_string("id").unwrap_or_default();
572                let id: u64 = id_str.parse().unwrap_or(0);
573                if let Some(m) = self.store.messages.get(&id) {
574                    vec![serde_json::json!({
575                        "id": m.id,
576                        "channel_id": m.channel_id,
577                        "sender": m.sender,
578                        "content": m.content,
579                        "message_type": format!("{}", m.message_type),
580                        "timestamp": m.timestamp.to_rfc3339(),
581                    })]
582                } else {
583                    vec![]
584                }
585            }
586            "channels" => {
587                let limit = query.limit.unwrap_or(50);
588                let mut channels: Vec<&crate::Channel> = self.store.channels.values().collect();
589                channels.sort_by_key(|c| c.id);
590                channels
591                    .iter()
592                    .take(limit)
593                    .map(|c| {
594                        serde_json::json!({
595                            "id": c.id,
596                            "name": c.name,
597                            "channel_type": format!("{:?}", c.channel_type),
598                            "state": format!("{:?}", c.state),
599                            "participants": c.participants.len(),
600                            "created_at": c.created_at.to_rfc3339(),
601                        })
602                    })
603                    .collect()
604            }
605            _ => vec![],
606        };
607
608        let total = self.store.messages.len();
609        let has_more = results.len() < total;
610
611        Ok(QueryResult::new(query, results, start.elapsed()).with_pagination(total, has_more))
612    }
613
614    fn supports_query(&self, query_type: &str) -> bool {
615        matches!(
616            query_type,
617            "list" | "search" | "recent" | "get" | "channels"
618        )
619    }
620
621    fn query_types(&self) -> Vec<QueryTypeInfo> {
622        vec![
623            QueryTypeInfo::new("list", "List all messages with pagination")
624                .optional(vec!["limit", "offset"]),
625            QueryTypeInfo::new("search", "Search messages by content")
626                .required(vec!["text"])
627                .optional(vec!["limit"]),
628            QueryTypeInfo::new("recent", "Get most recent messages").optional(vec!["limit"]),
629            QueryTypeInfo::new("get", "Get a specific message by ID").required(vec!["id"]),
630            QueryTypeInfo::new("channels", "List all channels").optional(vec!["limit"]),
631        ]
632    }
633}
634
635// ═══════════════════════════════════════════════════════════════════
636// FILE FORMAT
637// ═══════════════════════════════════════════════════════════════════
638
639impl FileFormatReader for CommSister {
640    fn read_file(path: &Path) -> SisterResult<Self> {
641        let store = CommStore::load(path).map_err(SisterError::from)?;
642        Ok(Self::from_store(store, Some(path.to_path_buf())))
643    }
644
645    fn can_read(path: &Path) -> SisterResult<FileInfo> {
646        let data = std::fs::read(path)
647            .map_err(|e| SisterError::new(ErrorCode::StorageError, e.to_string()))?;
648        if data.len() < crate::format::FileHeader::SIZE_V2 {
649            return Err(SisterError::new(
650                ErrorCode::StorageError,
651                "File too small for .acomm format",
652            ));
653        }
654        // Check magic bytes
655        if &data[..4] != crate::format::MAGIC {
656            return Err(SisterError::new(
657                ErrorCode::ChecksumMismatch,
658                "Not a valid .acomm file (bad magic bytes)",
659            ));
660        }
661        let version = u16::from_le_bytes([data[4], data[5]]);
662
663        let metadata = std::fs::metadata(path)
664            .map_err(|e| SisterError::new(ErrorCode::StorageError, e.to_string()))?;
665
666        Ok(FileInfo {
667            sister_type: SisterType::Comm,
668            version: Version::new(version as u8, 0, 0),
669            created_at: chrono::Utc::now(),
670            updated_at: chrono::DateTime::from(
671                metadata
672                    .modified()
673                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
674            ),
675            content_length: metadata.len(),
676            needs_migration: version < crate::format::FORMAT_VERSION,
677            format_id: "ACOM".to_string(),
678        })
679    }
680
681    fn file_version(path: &Path) -> SisterResult<Version> {
682        let data = std::fs::read(path)
683            .map_err(|e| SisterError::new(ErrorCode::StorageError, e.to_string()))?;
684        if data.len() < 6 {
685            return Err(SisterError::new(
686                ErrorCode::StorageError,
687                "File too small for .acomm format",
688            ));
689        }
690        let version = u16::from_le_bytes([data[4], data[5]]);
691        Ok(Version::new(version as u8, 0, 0))
692    }
693
694    fn migrate(_data: &[u8], _from_version: Version) -> SisterResult<Vec<u8>> {
695        Err(SisterError::new(
696            ErrorCode::NotImplemented,
697            "No migration path available (v2→v3 handled transparently on load)",
698        ))
699    }
700}
701
702impl FileFormatWriter for CommSister {
703    fn write_file(&self, path: &Path) -> SisterResult<()> {
704        self.store.save(path).map_err(SisterError::from)
705    }
706
707    fn to_bytes(&self) -> SisterResult<Vec<u8>> {
708        let serialized = bincode::serialize(&self.store)
709            .map_err(|e| SisterError::new(ErrorCode::StorageError, e.to_string()))?;
710        Ok(crate::format::write_with_header(&serialized))
711    }
712}
713
714// ═══════════════════════════════════════════════════════════════════
715// TESTS
716// ═══════════════════════════════════════════════════════════════════
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    fn make_test_sister() -> CommSister {
723        let config = SisterConfig::stateless();
724        CommSister::init(config).unwrap()
725    }
726
727    fn add_test_messages(sister: &mut CommSister) {
728        // Create a channel first
729        sister
730            .store
731            .create_channel("test-channel", crate::ChannelType::Group, None)
732            .unwrap();
733        let channel_id = sister.store.channels.keys().next().copied().unwrap();
734
735        // Send some messages
736        sister
737            .store
738            .send_message(channel_id, "alice", "The deployment succeeded on staging", crate::MessageType::Text)
739            .unwrap();
740        sister
741            .store
742            .send_message(channel_id, "bob", "Rust compiler caught the bug early", crate::MessageType::Text)
743            .unwrap();
744        sister
745            .store
746            .send_message(channel_id, "carol", "Review the pull request before merging", crate::MessageType::Command)
747            .unwrap();
748    }
749
750    #[test]
751    fn test_sister_trait() {
752        let sister = make_test_sister();
753        assert_eq!(sister.sister_type(), SisterType::Comm);
754        assert_eq!(sister.file_extension(), "acomm");
755        assert_eq!(sister.mcp_prefix(), "comm");
756        assert!(sister.is_healthy());
757        assert_eq!(sister.version(), Version::new(0, 1, 0));
758        assert!(!sister.capabilities().is_empty());
759    }
760
761    #[test]
762    fn test_sister_info() {
763        let sister = make_test_sister();
764        let info = SisterInfo::from_sister(&sister);
765        assert_eq!(info.sister_type, SisterType::Comm);
766        assert_eq!(info.file_extension, "acomm");
767        assert_eq!(info.mcp_prefix, "comm");
768    }
769
770    #[test]
771    fn test_session_management() {
772        let mut sister = make_test_sister();
773
774        // No session initially
775        assert!(sister.current_session().is_none());
776        assert!(sister.current_session_info().is_err());
777
778        // Start session
779        let sid = sister.start_session("test_session").unwrap();
780        assert!(sister.current_session().is_some());
781        assert_eq!(sister.current_session().unwrap(), sid);
782
783        // Session info
784        let info = sister.current_session_info().unwrap();
785        assert_eq!(info.name, "test_session");
786
787        // List sessions
788        let sessions = sister.list_sessions().unwrap();
789        assert_eq!(sessions.len(), 1);
790        assert_eq!(sessions[0].name, "test_session");
791
792        // End session
793        sister.end_session().unwrap();
794        assert!(sister.current_session().is_none());
795
796        // Can't end twice
797        assert!(sister.end_session().is_err());
798    }
799
800    #[test]
801    fn test_grounding_with_data() {
802        let mut sister = make_test_sister();
803        sister.start_session("grounding_test").unwrap();
804        add_test_messages(&mut sister);
805
806        // Ground a claim that should match (substring of message content)
807        let result = sister.ground("deployment succeeded").unwrap();
808        assert!(
809            result.status == GroundingStatus::Verified || result.status == GroundingStatus::Partial,
810            "Expected verified or partial, got {:?}",
811            result.status
812        );
813        assert!(!result.evidence.is_empty());
814
815        // Ground a claim that should NOT match
816        let result = sister.ground("quantum teleportation physics").unwrap();
817        assert_eq!(result.status, GroundingStatus::Ungrounded);
818    }
819
820    #[test]
821    fn test_evidence_query() {
822        let mut sister = make_test_sister();
823        sister.start_session("evidence_test").unwrap();
824        add_test_messages(&mut sister);
825
826        let evidence = sister.evidence("rust", 10).unwrap();
827        assert!(!evidence.is_empty(), "Expected evidence for 'rust' query");
828        assert_eq!(evidence[0].source_sister, SisterType::Comm);
829    }
830
831    #[test]
832    fn test_suggest_fallback() {
833        let mut sister = make_test_sister();
834        sister.start_session("suggest_test").unwrap();
835        add_test_messages(&mut sister);
836
837        let suggestions = sister.suggest("deployment staging", 5).unwrap();
838        assert!(!suggestions.is_empty());
839        assert!(suggestions[0].relevance_score > 0.0);
840    }
841
842    #[test]
843    fn test_queryable_list() {
844        let mut sister = make_test_sister();
845        sister.start_session("query_test").unwrap();
846        add_test_messages(&mut sister);
847
848        let result = sister.query(Query::list().limit(2)).unwrap();
849        assert_eq!(result.len(), 2);
850        assert!(result.has_more);
851    }
852
853    #[test]
854    fn test_queryable_recent() {
855        let mut sister = make_test_sister();
856        sister.start_session("recent_test").unwrap();
857        add_test_messages(&mut sister);
858
859        let result = sister.recent(2).unwrap();
860        assert_eq!(result.len(), 2);
861    }
862
863    #[test]
864    fn test_queryable_search() {
865        let mut sister = make_test_sister();
866        sister.start_session("search_test").unwrap();
867        add_test_messages(&mut sister);
868
869        let result = sister.search("rust").unwrap();
870        assert!(!result.is_empty(), "Expected search results for 'rust'");
871    }
872
873    #[test]
874    fn test_queryable_types() {
875        let sister = make_test_sister();
876        assert!(sister.supports_query("list"));
877        assert!(sister.supports_query("search"));
878        assert!(sister.supports_query("recent"));
879        assert!(sister.supports_query("get"));
880        assert!(sister.supports_query("channels"));
881        assert!(!sister.supports_query("nonexistent"));
882
883        let types = sister.query_types();
884        assert_eq!(types.len(), 5);
885    }
886
887    #[test]
888    fn test_error_bridge() {
889        let comm_err = CommError::ChannelNotFound(42);
890        let sister_err: SisterError = comm_err.into();
891        assert_eq!(sister_err.code, ErrorCode::NotFound);
892        assert!(sister_err.message.contains("42"));
893
894        let comm_err2 = CommError::InvalidFile("corrupt header".to_string());
895        let sister_err2: SisterError = comm_err2.into();
896        assert_eq!(sister_err2.code, ErrorCode::ChecksumMismatch);
897    }
898
899    #[test]
900    fn test_session_export_import() {
901        let mut sister = make_test_sister();
902        let sid = sister.start_session("export_test").unwrap();
903        add_test_messages(&mut sister);
904
905        // Export
906        let snapshot = sister.export_session(sid).unwrap();
907        assert!(snapshot.verify());
908        assert_eq!(snapshot.sister_type, SisterType::Comm);
909
910        // Import into fresh sister
911        let mut sister2 = make_test_sister();
912        let _imported_sid = sister2.import_session(snapshot).unwrap();
913        assert!(sister2.current_session().is_some());
914        // Imported session should have messages
915        assert!(sister2.store().messages.len() > 0);
916    }
917
918    #[test]
919    fn test_config_patterns() {
920        // Stateless config
921        let config = SisterConfig::stateless();
922        let sister = CommSister::init(config).unwrap();
923        assert!(sister.is_healthy());
924    }
925
926    #[test]
927    fn test_shutdown() {
928        let mut sister = make_test_sister();
929        sister.start_session("shutdown_test").unwrap();
930        sister.shutdown().unwrap();
931        // Session should be ended after shutdown
932        assert!(sister.current_session().is_none());
933    }
934}