Skip to main content

lean_ctx/core/
agents.rs

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