Skip to main content

lean_ctx/core/
agents.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_SCRATCHPAD_ENTRIES: usize = 200;
6const MAX_DIARY_ENTRIES: usize = 100;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct AgentRegistry {
10    pub agents: Vec<AgentEntry>,
11    pub scratchpad: Vec<ScratchpadEntry>,
12    pub updated_at: DateTime<Utc>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AgentDiary {
17    pub agent_id: String,
18    pub agent_type: String,
19    pub project_root: String,
20    pub entries: Vec<DiaryEntry>,
21    pub created_at: DateTime<Utc>,
22    pub updated_at: DateTime<Utc>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct DiaryEntry {
27    pub entry_type: DiaryEntryType,
28    pub content: String,
29    pub context: Option<String>,
30    pub timestamp: DateTime<Utc>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub enum DiaryEntryType {
35    Discovery,
36    Decision,
37    Blocker,
38    Progress,
39    Insight,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AgentEntry {
44    pub agent_id: String,
45    pub agent_type: String,
46    pub role: Option<String>,
47    pub project_root: String,
48    pub started_at: DateTime<Utc>,
49    pub last_active: DateTime<Utc>,
50    pub pid: u32,
51    pub status: AgentStatus,
52    pub status_message: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub enum AgentStatus {
57    Active,
58    Idle,
59    Finished,
60}
61
62impl std::fmt::Display for AgentStatus {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            AgentStatus::Active => write!(f, "active"),
66            AgentStatus::Idle => write!(f, "idle"),
67            AgentStatus::Finished => write!(f, "finished"),
68        }
69    }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ScratchpadEntry {
74    pub id: String,
75    pub from_agent: String,
76    pub to_agent: Option<String>,
77    pub category: String,
78    pub message: String,
79    pub timestamp: DateTime<Utc>,
80    pub read_by: Vec<String>,
81}
82
83impl AgentRegistry {
84    pub fn new() -> Self {
85        Self {
86            agents: Vec::new(),
87            scratchpad: Vec::new(),
88            updated_at: Utc::now(),
89        }
90    }
91
92    pub fn register(&mut self, agent_type: &str, role: Option<&str>, project_root: &str) -> String {
93        let pid = std::process::id();
94        let agent_id = format!("{}-{}-{}", agent_type, pid, &generate_short_id());
95
96        if let Some(existing) = self.agents.iter_mut().find(|a| a.pid == pid) {
97            existing.last_active = Utc::now();
98            existing.status = AgentStatus::Active;
99            if let Some(r) = role {
100                existing.role = Some(r.to_string());
101            }
102            return existing.agent_id.clone();
103        }
104
105        self.agents.push(AgentEntry {
106            agent_id: agent_id.clone(),
107            agent_type: agent_type.to_string(),
108            role: role.map(|r| r.to_string()),
109            project_root: project_root.to_string(),
110            started_at: Utc::now(),
111            last_active: Utc::now(),
112            pid,
113            status: AgentStatus::Active,
114            status_message: None,
115        });
116
117        self.updated_at = Utc::now();
118        crate::core::events::emit_agent_action(&agent_id, "register", None);
119        agent_id
120    }
121
122    pub fn update_heartbeat(&mut self, agent_id: &str) {
123        if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
124            agent.last_active = Utc::now();
125        }
126    }
127
128    pub fn set_status(&mut self, agent_id: &str, status: AgentStatus, message: Option<&str>) {
129        if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
130            agent.status = status;
131            agent.status_message = message.map(|s| s.to_string());
132            agent.last_active = Utc::now();
133        }
134        self.updated_at = Utc::now();
135    }
136
137    pub fn list_active(&self, project_root: Option<&str>) -> Vec<&AgentEntry> {
138        self.agents
139            .iter()
140            .filter(|a| {
141                if let Some(root) = project_root {
142                    a.project_root == root && a.status != AgentStatus::Finished
143                } else {
144                    a.status != AgentStatus::Finished
145                }
146            })
147            .collect()
148    }
149
150    pub fn list_all(&self) -> &[AgentEntry] {
151        &self.agents
152    }
153
154    pub fn post_message(
155        &mut self,
156        from_agent: &str,
157        to_agent: Option<&str>,
158        category: &str,
159        message: &str,
160    ) -> String {
161        let id = generate_short_id();
162        self.scratchpad.push(ScratchpadEntry {
163            id: id.clone(),
164            from_agent: from_agent.to_string(),
165            to_agent: to_agent.map(|s| s.to_string()),
166            category: category.to_string(),
167            message: message.to_string(),
168            timestamp: Utc::now(),
169            read_by: vec![from_agent.to_string()],
170        });
171
172        if self.scratchpad.len() > MAX_SCRATCHPAD_ENTRIES {
173            self.scratchpad
174                .drain(0..self.scratchpad.len() - MAX_SCRATCHPAD_ENTRIES);
175        }
176
177        self.updated_at = Utc::now();
178        id
179    }
180
181    pub fn read_messages(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
182        let unread: Vec<usize> = self
183            .scratchpad
184            .iter()
185            .enumerate()
186            .filter(|(_, e)| {
187                !e.read_by.contains(&agent_id.to_string())
188                    && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
189            })
190            .map(|(i, _)| i)
191            .collect();
192
193        for i in &unread {
194            self.scratchpad[*i].read_by.push(agent_id.to_string());
195        }
196
197        self.scratchpad
198            .iter()
199            .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
200            .filter(|e| e.from_agent != agent_id)
201            .collect()
202    }
203
204    pub fn read_unread(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
205        let unread_indices: Vec<usize> = self
206            .scratchpad
207            .iter()
208            .enumerate()
209            .filter(|(_, e)| {
210                !e.read_by.contains(&agent_id.to_string())
211                    && e.from_agent != agent_id
212                    && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
213            })
214            .map(|(i, _)| i)
215            .collect();
216
217        for i in &unread_indices {
218            self.scratchpad[*i].read_by.push(agent_id.to_string());
219        }
220
221        self.updated_at = Utc::now();
222
223        self.scratchpad
224            .iter()
225            .filter(|e| {
226                e.from_agent != agent_id
227                    && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
228                    && e.read_by.contains(&agent_id.to_string())
229                    && e.read_by.iter().filter(|r| *r == agent_id).count() == 1
230            })
231            .collect()
232    }
233
234    pub fn cleanup_stale(&mut self, max_age_hours: u64) {
235        let cutoff = Utc::now() - chrono::Duration::hours(max_age_hours as i64);
236
237        for agent in &mut self.agents {
238            if agent.last_active < cutoff
239                && agent.status != AgentStatus::Finished
240                && !is_process_alive(agent.pid)
241            {
242                agent.status = AgentStatus::Finished;
243            }
244        }
245
246        self.agents
247            .retain(|a| !(a.status == AgentStatus::Finished && a.last_active < cutoff));
248
249        self.updated_at = Utc::now();
250    }
251
252    pub fn save(&self) -> Result<(), String> {
253        let dir = agents_dir()?;
254        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
255
256        let path = dir.join("registry.json");
257        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
258
259        let lock_path = dir.join("registry.lock");
260        let _lock = FileLock::acquire(&lock_path)?;
261
262        std::fs::write(&path, json).map_err(|e| e.to_string())
263    }
264
265    pub fn load() -> Option<Self> {
266        let dir = agents_dir().ok()?;
267        let path = dir.join("registry.json");
268        let content = std::fs::read_to_string(&path).ok()?;
269        serde_json::from_str(&content).ok()
270    }
271
272    pub fn load_or_create() -> Self {
273        Self::load().unwrap_or_default()
274    }
275}
276
277impl Default for AgentRegistry {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283impl AgentDiary {
284    pub fn new(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
285        let now = Utc::now();
286        Self {
287            agent_id: agent_id.to_string(),
288            agent_type: agent_type.to_string(),
289            project_root: project_root.to_string(),
290            entries: Vec::new(),
291            created_at: now,
292            updated_at: now,
293        }
294    }
295
296    pub fn add_entry(&mut self, entry_type: DiaryEntryType, content: &str, context: Option<&str>) {
297        self.entries.push(DiaryEntry {
298            entry_type,
299            content: content.to_string(),
300            context: context.map(|s| s.to_string()),
301            timestamp: Utc::now(),
302        });
303        if self.entries.len() > MAX_DIARY_ENTRIES {
304            self.entries
305                .drain(0..self.entries.len() - MAX_DIARY_ENTRIES);
306        }
307        self.updated_at = Utc::now();
308    }
309
310    pub fn format_summary(&self) -> String {
311        if self.entries.is_empty() {
312            return format!("Diary [{}]: empty", self.agent_id);
313        }
314        let mut out = format!(
315            "Diary [{}] ({} entries):\n",
316            self.agent_id,
317            self.entries.len()
318        );
319        for e in self.entries.iter().rev().take(10) {
320            let age = (Utc::now() - e.timestamp).num_minutes();
321            let prefix = match e.entry_type {
322                DiaryEntryType::Discovery => "FOUND",
323                DiaryEntryType::Decision => "DECIDED",
324                DiaryEntryType::Blocker => "BLOCKED",
325                DiaryEntryType::Progress => "DONE",
326                DiaryEntryType::Insight => "INSIGHT",
327            };
328            let ctx = e
329                .context
330                .as_deref()
331                .map(|c| format!(" [{c}]"))
332                .unwrap_or_default();
333            out.push_str(&format!("  [{prefix}] {}{ctx} ({age}m ago)\n", e.content));
334        }
335        out
336    }
337
338    pub fn format_compact(&self) -> String {
339        if self.entries.is_empty() {
340            return String::new();
341        }
342        let items: Vec<String> = self
343            .entries
344            .iter()
345            .rev()
346            .take(5)
347            .map(|e| {
348                let prefix = match e.entry_type {
349                    DiaryEntryType::Discovery => "F",
350                    DiaryEntryType::Decision => "D",
351                    DiaryEntryType::Blocker => "B",
352                    DiaryEntryType::Progress => "P",
353                    DiaryEntryType::Insight => "I",
354                };
355                format!("{prefix}:{}", truncate(&e.content, 50))
356            })
357            .collect();
358        format!("diary:{}|{}", self.agent_id, items.join("|"))
359    }
360
361    pub fn save(&self) -> Result<(), String> {
362        let dir = diary_dir()?;
363        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
364        let path = dir.join(format!("{}.json", sanitize_filename(&self.agent_id)));
365        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
366        std::fs::write(&path, json).map_err(|e| e.to_string())
367    }
368
369    pub fn load(agent_id: &str) -> Option<Self> {
370        let dir = diary_dir().ok()?;
371        let path = dir.join(format!("{}.json", sanitize_filename(agent_id)));
372        let content = std::fs::read_to_string(&path).ok()?;
373        serde_json::from_str(&content).ok()
374    }
375
376    pub fn load_or_create(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
377        Self::load(agent_id).unwrap_or_else(|| Self::new(agent_id, agent_type, project_root))
378    }
379
380    pub fn list_all() -> Vec<(String, usize, DateTime<Utc>)> {
381        let dir = match diary_dir() {
382            Ok(d) => d,
383            Err(_) => return Vec::new(),
384        };
385        if !dir.exists() {
386            return Vec::new();
387        }
388        let mut results = Vec::new();
389        if let Ok(entries) = std::fs::read_dir(&dir) {
390            for entry in entries.flatten() {
391                if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
392                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
393                        if let Ok(diary) = serde_json::from_str::<AgentDiary>(&content) {
394                            results.push((diary.agent_id, diary.entries.len(), diary.updated_at));
395                        }
396                    }
397                }
398            }
399        }
400        results.sort_by_key(|x| std::cmp::Reverse(x.2));
401        results
402    }
403}
404
405impl std::fmt::Display for DiaryEntryType {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        match self {
408            DiaryEntryType::Discovery => write!(f, "discovery"),
409            DiaryEntryType::Decision => write!(f, "decision"),
410            DiaryEntryType::Blocker => write!(f, "blocker"),
411            DiaryEntryType::Progress => write!(f, "progress"),
412            DiaryEntryType::Insight => write!(f, "insight"),
413        }
414    }
415}
416
417fn diary_dir() -> Result<PathBuf, String> {
418    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
419    Ok(dir.join("agents").join("diaries"))
420}
421
422fn sanitize_filename(name: &str) -> String {
423    name.chars()
424        .map(|c| {
425            if c.is_alphanumeric() || c == '-' || c == '_' {
426                c
427            } else {
428                '_'
429            }
430        })
431        .collect()
432}
433
434fn truncate(s: &str, max: usize) -> String {
435    if s.len() <= max {
436        s.to_string()
437    } else {
438        format!("{}...", &s[..max.saturating_sub(3)])
439    }
440}
441
442fn agents_dir() -> Result<PathBuf, String> {
443    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
444    Ok(dir.join("agents"))
445}
446
447fn generate_short_id() -> String {
448    use std::collections::hash_map::DefaultHasher;
449    use std::hash::{Hash, Hasher};
450    use std::time::SystemTime;
451
452    let mut hasher = DefaultHasher::new();
453    SystemTime::now().hash(&mut hasher);
454    std::process::id().hash(&mut hasher);
455    format!("{:08x}", hasher.finish() as u32)
456}
457
458fn is_process_alive(pid: u32) -> bool {
459    #[cfg(unix)]
460    {
461        std::process::Command::new("kill")
462            .args(["-0", &pid.to_string()])
463            .output()
464            .map(|o| o.status.success())
465            .unwrap_or(false)
466    }
467    #[cfg(not(unix))]
468    {
469        let _ = pid;
470        true
471    }
472}
473
474struct FileLock {
475    path: PathBuf,
476}
477
478impl FileLock {
479    fn acquire(path: &std::path::Path) -> Result<Self, String> {
480        for _ in 0..50 {
481            match std::fs::OpenOptions::new()
482                .write(true)
483                .create_new(true)
484                .open(path)
485            {
486                Ok(_) => {
487                    return Ok(Self {
488                        path: path.to_path_buf(),
489                    })
490                }
491                Err(_) => {
492                    if let Ok(metadata) = std::fs::metadata(path) {
493                        if let Ok(modified) = metadata.modified() {
494                            if modified.elapsed().unwrap_or_default().as_secs() > 5 {
495                                let _ = std::fs::remove_file(path);
496                                continue;
497                            }
498                        }
499                    }
500                    std::thread::sleep(std::time::Duration::from_millis(100));
501                }
502            }
503        }
504        Err("Could not acquire lock after 5 seconds".to_string())
505    }
506}
507
508impl Drop for FileLock {
509    fn drop(&mut self) {
510        let _ = std::fs::remove_file(&self.path);
511    }
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct SharedFact {
516    pub from_agent: String,
517    pub category: String,
518    pub key: String,
519    pub value: String,
520    pub timestamp: DateTime<Utc>,
521    #[serde(default)]
522    pub received_by: Vec<String>,
523}
524
525impl AgentRegistry {
526    pub fn share_knowledge(&mut self, from: &str, category: &str, facts: &[(String, String)]) {
527        for (key, value) in facts {
528            self.scratchpad.push(ScratchpadEntry {
529                id: format!("knowledge-{}", chrono::Utc::now().timestamp_millis()),
530                from_agent: from.to_string(),
531                to_agent: None,
532                category: category.to_string(),
533                message: format!("[knowledge] {key}={value}"),
534                timestamp: Utc::now(),
535                read_by: Vec::new(),
536            });
537        }
538        let shared_path = Self::shared_knowledge_path();
539        let mut existing: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
540            .ok()
541            .and_then(|s| serde_json::from_str(&s).ok())
542            .unwrap_or_default();
543
544        for (key, value) in facts {
545            existing.push(SharedFact {
546                from_agent: from.to_string(),
547                category: category.to_string(),
548                key: key.clone(),
549                value: value.clone(),
550                timestamp: Utc::now(),
551                received_by: Vec::new(),
552            });
553        }
554
555        if existing.len() > 500 {
556            existing.drain(..existing.len() - 500);
557        }
558        if let Ok(json) = serde_json::to_string_pretty(&existing) {
559            let _ = std::fs::write(&shared_path, json);
560        }
561    }
562
563    pub fn receive_shared_knowledge(&mut self, agent_id: &str) -> Vec<SharedFact> {
564        let shared_path = Self::shared_knowledge_path();
565        let mut all: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
566            .ok()
567            .and_then(|s| serde_json::from_str(&s).ok())
568            .unwrap_or_default();
569
570        let mut new_facts = Vec::new();
571        for fact in &mut all {
572            if fact.from_agent != agent_id && !fact.received_by.contains(&agent_id.to_string()) {
573                fact.received_by.push(agent_id.to_string());
574                new_facts.push(fact.clone());
575            }
576        }
577
578        if !new_facts.is_empty() {
579            if let Ok(json) = serde_json::to_string_pretty(&all) {
580                let _ = std::fs::write(&shared_path, json);
581            }
582        }
583        new_facts
584    }
585
586    fn shared_knowledge_path() -> PathBuf {
587        dirs::home_dir()
588            .unwrap_or_else(|| PathBuf::from("."))
589            .join(".lean-ctx")
590            .join("shared_knowledge.json")
591    }
592}
593
594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
595#[serde(rename_all = "snake_case")]
596pub enum AgentRole {
597    Coder,
598    Reviewer,
599    Planner,
600    Explorer,
601    Debugger,
602    Tester,
603    Orchestrator,
604}
605
606impl AgentRole {
607    pub fn from_str_loose(s: &str) -> Self {
608        match s.to_lowercase().as_str() {
609            "review" | "reviewer" | "code_review" => Self::Reviewer,
610            "plan" | "planner" | "architect" => Self::Planner,
611            "explore" | "explorer" | "research" => Self::Explorer,
612            "debug" | "debugger" => Self::Debugger,
613            "test" | "tester" | "qa" => Self::Tester,
614            "orchestrator" | "coordinator" | "manager" => Self::Orchestrator,
615            _ => Self::Coder,
616        }
617    }
618}
619
620#[derive(Debug, Clone)]
621pub struct ContextDepthConfig {
622    pub max_files_full: usize,
623    pub max_files_signatures: usize,
624    pub preferred_mode: &'static str,
625    pub include_graph: bool,
626    pub include_knowledge: bool,
627    pub include_gotchas: bool,
628    pub context_budget_ratio: f64,
629}
630
631impl ContextDepthConfig {
632    pub fn for_role(role: AgentRole) -> Self {
633        match role {
634            AgentRole::Coder => Self {
635                max_files_full: 5,
636                max_files_signatures: 15,
637                preferred_mode: "full",
638                include_graph: true,
639                include_knowledge: true,
640                include_gotchas: true,
641                context_budget_ratio: 0.7,
642            },
643            AgentRole::Reviewer => Self {
644                max_files_full: 3,
645                max_files_signatures: 20,
646                preferred_mode: "signatures",
647                include_graph: true,
648                include_knowledge: true,
649                include_gotchas: true,
650                context_budget_ratio: 0.5,
651            },
652            AgentRole::Planner => Self {
653                max_files_full: 1,
654                max_files_signatures: 10,
655                preferred_mode: "map",
656                include_graph: true,
657                include_knowledge: true,
658                include_gotchas: false,
659                context_budget_ratio: 0.3,
660            },
661            AgentRole::Explorer => Self {
662                max_files_full: 2,
663                max_files_signatures: 8,
664                preferred_mode: "map",
665                include_graph: true,
666                include_knowledge: false,
667                include_gotchas: false,
668                context_budget_ratio: 0.4,
669            },
670            AgentRole::Debugger => Self {
671                max_files_full: 8,
672                max_files_signatures: 5,
673                preferred_mode: "full",
674                include_graph: false,
675                include_knowledge: true,
676                include_gotchas: true,
677                context_budget_ratio: 0.8,
678            },
679            AgentRole::Tester => Self {
680                max_files_full: 4,
681                max_files_signatures: 10,
682                preferred_mode: "full",
683                include_graph: false,
684                include_knowledge: false,
685                include_gotchas: true,
686                context_budget_ratio: 0.6,
687            },
688            AgentRole::Orchestrator => Self {
689                max_files_full: 0,
690                max_files_signatures: 5,
691                preferred_mode: "map",
692                include_graph: true,
693                include_knowledge: true,
694                include_gotchas: false,
695                context_budget_ratio: 0.2,
696            },
697        }
698    }
699
700    pub fn mode_for_rank(&self, rank: usize) -> &'static str {
701        if rank < self.max_files_full {
702            "full"
703        } else if rank < self.max_files_full + self.max_files_signatures {
704            "signatures"
705        } else {
706            "map"
707        }
708    }
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn register_and_list() {
717        let mut reg = AgentRegistry::new();
718        let id = reg.register("cursor", Some("dev"), "/tmp/project");
719        assert!(!id.is_empty());
720        assert_eq!(reg.list_active(None).len(), 1);
721        assert_eq!(reg.list_active(None)[0].agent_type, "cursor");
722    }
723
724    #[test]
725    fn reregister_same_pid() {
726        let mut reg = AgentRegistry::new();
727        let id1 = reg.register("cursor", Some("dev"), "/tmp/project");
728        let id2 = reg.register("cursor", Some("review"), "/tmp/project");
729        assert_eq!(id1, id2);
730        assert_eq!(reg.agents.len(), 1);
731        assert_eq!(reg.agents[0].role, Some("review".to_string()));
732    }
733
734    #[test]
735    fn post_and_read_messages() {
736        let mut reg = AgentRegistry::new();
737        reg.post_message("agent-a", None, "finding", "Found a bug in auth.rs");
738        reg.post_message("agent-b", Some("agent-a"), "request", "Please review");
739
740        let msgs = reg.read_unread("agent-a");
741        assert_eq!(msgs.len(), 1);
742        assert_eq!(msgs[0].category, "request");
743    }
744
745    #[test]
746    fn set_status() {
747        let mut reg = AgentRegistry::new();
748        let id = reg.register("claude", None, "/tmp/project");
749        reg.set_status(&id, AgentStatus::Idle, Some("waiting for review"));
750        assert_eq!(reg.agents[0].status, AgentStatus::Idle);
751        assert_eq!(
752            reg.agents[0].status_message,
753            Some("waiting for review".to_string())
754        );
755    }
756
757    #[test]
758    fn broadcast_message() {
759        let mut reg = AgentRegistry::new();
760        reg.post_message("agent-a", None, "status", "Starting refactor");
761
762        let msgs_b = reg.read_unread("agent-b");
763        assert_eq!(msgs_b.len(), 1);
764        assert_eq!(msgs_b[0].message, "Starting refactor");
765
766        let msgs_a = reg.read_unread("agent-a");
767        assert!(msgs_a.is_empty());
768    }
769
770    #[test]
771    fn diary_add_and_format() {
772        let mut diary = AgentDiary::new("test-agent-001", "cursor", "/tmp/project");
773        diary.add_entry(
774            DiaryEntryType::Discovery,
775            "Found auth module at src/auth.rs",
776            Some("auth"),
777        );
778        diary.add_entry(
779            DiaryEntryType::Decision,
780            "Use JWT RS256 for token signing",
781            None,
782        );
783        diary.add_entry(
784            DiaryEntryType::Progress,
785            "Implemented login endpoint",
786            Some("auth"),
787        );
788
789        assert_eq!(diary.entries.len(), 3);
790
791        let summary = diary.format_summary();
792        assert!(summary.contains("test-agent-001"));
793        assert!(summary.contains("FOUND"));
794        assert!(summary.contains("DECIDED"));
795        assert!(summary.contains("DONE"));
796    }
797
798    #[test]
799    fn diary_compact_format() {
800        let mut diary = AgentDiary::new("test-agent-002", "claude", "/tmp/project");
801        diary.add_entry(DiaryEntryType::Insight, "DB queries are N+1", None);
802        diary.add_entry(
803            DiaryEntryType::Blocker,
804            "Missing API credentials",
805            Some("deploy"),
806        );
807
808        let compact = diary.format_compact();
809        assert!(compact.contains("diary:test-agent-002"));
810        assert!(compact.contains("B:Missing API credentials"));
811        assert!(compact.contains("I:DB queries are N+1"));
812    }
813
814    #[test]
815    fn diary_entry_types() {
816        let types = vec![
817            DiaryEntryType::Discovery,
818            DiaryEntryType::Decision,
819            DiaryEntryType::Blocker,
820            DiaryEntryType::Progress,
821            DiaryEntryType::Insight,
822        ];
823        for t in types {
824            assert!(!format!("{}", t).is_empty());
825        }
826    }
827
828    #[test]
829    fn diary_truncation() {
830        let mut diary = AgentDiary::new("test-agent", "cursor", "/tmp");
831        for i in 0..150 {
832            diary.add_entry(DiaryEntryType::Progress, &format!("Step {i}"), None);
833        }
834        assert!(diary.entries.len() <= 100);
835    }
836}