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.status == AgentStatus::Finished {
260                continue;
261            }
262            // Mark as finished if process is no longer running (regardless of age)
263            if !is_process_alive(agent.pid) {
264                agent.status = AgentStatus::Finished;
265            }
266        }
267
268        // Remove finished agents older than the cutoff to keep recent history visible
269        self.agents
270            .retain(|a| !(a.status == AgentStatus::Finished && a.last_active < cutoff));
271
272        self.updated_at = Utc::now();
273    }
274
275    pub fn save(&self) -> Result<(), String> {
276        let dir = agents_dir()?;
277        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
278
279        let path = dir.join("registry.json");
280        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
281
282        let lock_path = dir.join("registry.lock");
283        let _lock = FileLock::acquire(&lock_path)?;
284
285        std::fs::write(&path, json).map_err(|e| e.to_string())
286    }
287
288    pub fn load() -> Option<Self> {
289        let dir = agents_dir().ok()?;
290        let path = dir.join("registry.json");
291        let content = std::fs::read_to_string(&path).ok()?;
292        serde_json::from_str(&content).ok()
293    }
294
295    pub fn load_or_create() -> Self {
296        Self::load().unwrap_or_default()
297    }
298}
299
300impl Default for AgentRegistry {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306impl AgentDiary {
307    pub fn new(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
308        let now = Utc::now();
309        Self {
310            agent_id: agent_id.to_string(),
311            agent_type: agent_type.to_string(),
312            project_root: project_root.to_string(),
313            entries: Vec::new(),
314            created_at: now,
315            updated_at: now,
316        }
317    }
318
319    pub fn add_entry(&mut self, entry_type: DiaryEntryType, content: &str, context: Option<&str>) {
320        self.entries.push(DiaryEntry {
321            entry_type,
322            content: content.to_string(),
323            context: context.map(std::string::ToString::to_string),
324            timestamp: Utc::now(),
325        });
326        if self.entries.len() > MAX_DIARY_ENTRIES {
327            self.entries
328                .drain(0..self.entries.len() - MAX_DIARY_ENTRIES);
329        }
330        self.updated_at = Utc::now();
331    }
332
333    pub fn format_summary(&self) -> String {
334        if self.entries.is_empty() {
335            return format!("Diary [{}]: empty", self.agent_id);
336        }
337        let mut out = format!(
338            "Diary [{}] ({} entries):\n",
339            self.agent_id,
340            self.entries.len()
341        );
342        for e in self.entries.iter().rev().take(10) {
343            let age = (Utc::now() - e.timestamp).num_minutes();
344            let prefix = match e.entry_type {
345                DiaryEntryType::Discovery => "FOUND",
346                DiaryEntryType::Decision => "DECIDED",
347                DiaryEntryType::Blocker => "BLOCKED",
348                DiaryEntryType::Progress => "DONE",
349                DiaryEntryType::Insight => "INSIGHT",
350            };
351            let ctx = e
352                .context
353                .as_deref()
354                .map(|c| format!(" [{c}]"))
355                .unwrap_or_default();
356            out.push_str(&format!("  [{prefix}] {}{ctx} ({age}m ago)\n", e.content));
357        }
358        out
359    }
360
361    pub fn format_compact(&self) -> String {
362        if self.entries.is_empty() {
363            return String::new();
364        }
365        let items: Vec<String> = self
366            .entries
367            .iter()
368            .rev()
369            .take(5)
370            .map(|e| {
371                let prefix = match e.entry_type {
372                    DiaryEntryType::Discovery => "F",
373                    DiaryEntryType::Decision => "D",
374                    DiaryEntryType::Blocker => "B",
375                    DiaryEntryType::Progress => "P",
376                    DiaryEntryType::Insight => "I",
377                };
378                format!("{prefix}:{}", truncate(&e.content, 50))
379            })
380            .collect();
381        format!("diary:{}|{}", self.agent_id, items.join("|"))
382    }
383
384    pub fn save(&self) -> Result<(), String> {
385        let dir = diary_dir()?;
386        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
387        let path = dir.join(format!("{}.json", sanitize_filename(&self.agent_id)));
388        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
389        std::fs::write(&path, json).map_err(|e| e.to_string())
390    }
391
392    pub fn load(agent_id: &str) -> Option<Self> {
393        let dir = diary_dir().ok()?;
394        let path = dir.join(format!("{}.json", sanitize_filename(agent_id)));
395        let content = std::fs::read_to_string(&path).ok()?;
396        serde_json::from_str(&content).ok()
397    }
398
399    pub fn load_or_create(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
400        Self::load(agent_id).unwrap_or_else(|| Self::new(agent_id, agent_type, project_root))
401    }
402
403    pub fn list_all() -> Vec<(String, usize, DateTime<Utc>)> {
404        let Ok(dir) = diary_dir() else {
405            return Vec::new();
406        };
407        if !dir.exists() {
408            return Vec::new();
409        }
410        let mut results = Vec::new();
411        if let Ok(entries) = std::fs::read_dir(&dir) {
412            for entry in entries.flatten() {
413                if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
414                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
415                        if let Ok(diary) = serde_json::from_str::<AgentDiary>(&content) {
416                            results.push((diary.agent_id, diary.entries.len(), diary.updated_at));
417                        }
418                    }
419                }
420            }
421        }
422        results.sort_by_key(|x| std::cmp::Reverse(x.2));
423        results
424    }
425}
426
427impl std::fmt::Display for DiaryEntryType {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        match self {
430            DiaryEntryType::Discovery => write!(f, "discovery"),
431            DiaryEntryType::Decision => write!(f, "decision"),
432            DiaryEntryType::Blocker => write!(f, "blocker"),
433            DiaryEntryType::Progress => write!(f, "progress"),
434            DiaryEntryType::Insight => write!(f, "insight"),
435        }
436    }
437}
438
439fn diary_dir() -> Result<PathBuf, String> {
440    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
441    Ok(dir.join("agents").join("diaries"))
442}
443
444fn sanitize_filename(name: &str) -> String {
445    name.chars()
446        .map(|c| {
447            if c.is_alphanumeric() || c == '-' || c == '_' {
448                c
449            } else {
450                '_'
451            }
452        })
453        .collect()
454}
455
456fn truncate(s: &str, max: usize) -> String {
457    if s.len() <= max {
458        s.to_string()
459    } else {
460        format!("{}...", &s[..max.saturating_sub(3)])
461    }
462}
463
464fn agents_dir() -> Result<PathBuf, String> {
465    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
466    Ok(dir.join("agents"))
467}
468
469fn generate_short_id() -> String {
470    use std::collections::hash_map::DefaultHasher;
471    use std::hash::{Hash, Hasher};
472    use std::time::SystemTime;
473
474    let mut hasher = DefaultHasher::new();
475    SystemTime::now().hash(&mut hasher);
476    std::process::id().hash(&mut hasher);
477    format!("{:08x}", hasher.finish() as u32)
478}
479
480pub fn is_process_alive(pid: u32) -> bool {
481    #[cfg(unix)]
482    {
483        std::process::Command::new("kill")
484            .args(["-0", &pid.to_string()])
485            .output()
486            .is_ok_and(|o| o.status.success())
487    }
488    #[cfg(not(unix))]
489    {
490        let _ = pid;
491        true
492    }
493}
494
495struct FileLock {
496    path: PathBuf,
497}
498
499impl FileLock {
500    fn acquire(path: &std::path::Path) -> Result<Self, String> {
501        for _ in 0..50 {
502            if std::fs::OpenOptions::new()
503                .write(true)
504                .create_new(true)
505                .open(path)
506                .is_ok()
507            {
508                return Ok(Self {
509                    path: path.to_path_buf(),
510                });
511            }
512            if let Ok(metadata) = std::fs::metadata(path) {
513                if let Ok(modified) = metadata.modified() {
514                    if modified.elapsed().unwrap_or_default().as_secs() > 5 {
515                        let _ = std::fs::remove_file(path);
516                        continue;
517                    }
518                }
519            }
520            std::thread::sleep(std::time::Duration::from_millis(100));
521        }
522        Err("Could not acquire lock after 5 seconds".to_string())
523    }
524}
525
526impl Drop for FileLock {
527    fn drop(&mut self) {
528        let _ = std::fs::remove_file(&self.path);
529    }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct SharedFact {
534    pub from_agent: String,
535    pub category: String,
536    pub key: String,
537    pub value: String,
538    pub timestamp: DateTime<Utc>,
539    #[serde(default)]
540    pub received_by: Vec<String>,
541}
542
543impl AgentRegistry {
544    pub fn share_knowledge(&mut self, from: &str, category: &str, facts: &[(String, String)]) {
545        for (key, value) in facts {
546            self.scratchpad.push(ScratchpadEntry {
547                id: format!("knowledge-{}", chrono::Utc::now().timestamp_millis()),
548                from_agent: from.to_string(),
549                to_agent: None,
550                task_id: None,
551                category: category.to_string(),
552                priority: MessagePriority::default(),
553                privacy: PrivacyLevel::Team,
554                message: format!("[knowledge] {key}={value}"),
555                metadata: HashMap::new(),
556                project_root: None,
557                timestamp: Utc::now(),
558                read_by: Vec::new(),
559                expires_at: None,
560            });
561        }
562        let shared_path = Self::shared_knowledge_path();
563        let mut existing: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
564            .ok()
565            .and_then(|s| serde_json::from_str(&s).ok())
566            .unwrap_or_default();
567
568        for (key, value) in facts {
569            existing.push(SharedFact {
570                from_agent: from.to_string(),
571                category: category.to_string(),
572                key: key.clone(),
573                value: value.clone(),
574                timestamp: Utc::now(),
575                received_by: Vec::new(),
576            });
577        }
578
579        if existing.len() > 500 {
580            existing.drain(..existing.len() - 500);
581        }
582        if let Ok(json) = serde_json::to_string_pretty(&existing) {
583            let _ = std::fs::write(&shared_path, json);
584        }
585    }
586
587    pub fn receive_shared_knowledge(&mut self, agent_id: &str) -> Vec<SharedFact> {
588        let shared_path = Self::shared_knowledge_path();
589        let mut all: Vec<SharedFact> = std::fs::read_to_string(&shared_path)
590            .ok()
591            .and_then(|s| serde_json::from_str(&s).ok())
592            .unwrap_or_default();
593
594        let mut new_facts = Vec::new();
595        for fact in &mut all {
596            if fact.from_agent != agent_id && !fact.received_by.contains(&agent_id.to_string()) {
597                fact.received_by.push(agent_id.to_string());
598                new_facts.push(fact.clone());
599            }
600        }
601
602        if !new_facts.is_empty() {
603            if let Ok(json) = serde_json::to_string_pretty(&all) {
604                let _ = std::fs::write(&shared_path, json);
605            }
606        }
607        new_facts
608    }
609
610    fn shared_knowledge_path() -> PathBuf {
611        dirs::home_dir()
612            .unwrap_or_else(|| PathBuf::from("."))
613            .join(".lean-ctx")
614            .join("shared_knowledge.json")
615    }
616}
617
618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
619#[serde(rename_all = "snake_case")]
620pub enum AgentRole {
621    Coder,
622    Reviewer,
623    Planner,
624    Explorer,
625    Debugger,
626    Tester,
627    Orchestrator,
628}
629
630impl AgentRole {
631    pub fn from_str_loose(s: &str) -> Self {
632        match s.to_lowercase().as_str() {
633            "review" | "reviewer" | "code_review" => Self::Reviewer,
634            "plan" | "planner" | "architect" => Self::Planner,
635            "explore" | "explorer" | "research" => Self::Explorer,
636            "debug" | "debugger" => Self::Debugger,
637            "test" | "tester" | "qa" => Self::Tester,
638            "orchestrator" | "coordinator" | "manager" => Self::Orchestrator,
639            _ => Self::Coder,
640        }
641    }
642}
643
644#[derive(Debug, Clone)]
645pub struct ContextDepthConfig {
646    pub max_files_full: usize,
647    pub max_files_signatures: usize,
648    pub preferred_mode: &'static str,
649    pub include_graph: bool,
650    pub include_knowledge: bool,
651    pub include_gotchas: bool,
652    pub context_budget_ratio: f64,
653}
654
655impl ContextDepthConfig {
656    pub fn for_role(role: AgentRole) -> Self {
657        match role {
658            AgentRole::Coder => Self {
659                max_files_full: 5,
660                max_files_signatures: 15,
661                preferred_mode: "full",
662                include_graph: true,
663                include_knowledge: true,
664                include_gotchas: true,
665                context_budget_ratio: 0.7,
666            },
667            AgentRole::Reviewer => Self {
668                max_files_full: 3,
669                max_files_signatures: 20,
670                preferred_mode: "signatures",
671                include_graph: true,
672                include_knowledge: true,
673                include_gotchas: true,
674                context_budget_ratio: 0.5,
675            },
676            AgentRole::Planner => Self {
677                max_files_full: 1,
678                max_files_signatures: 10,
679                preferred_mode: "map",
680                include_graph: true,
681                include_knowledge: true,
682                include_gotchas: false,
683                context_budget_ratio: 0.3,
684            },
685            AgentRole::Explorer => Self {
686                max_files_full: 2,
687                max_files_signatures: 8,
688                preferred_mode: "map",
689                include_graph: true,
690                include_knowledge: false,
691                include_gotchas: false,
692                context_budget_ratio: 0.4,
693            },
694            AgentRole::Debugger => Self {
695                max_files_full: 8,
696                max_files_signatures: 5,
697                preferred_mode: "full",
698                include_graph: false,
699                include_knowledge: true,
700                include_gotchas: true,
701                context_budget_ratio: 0.8,
702            },
703            AgentRole::Tester => Self {
704                max_files_full: 4,
705                max_files_signatures: 10,
706                preferred_mode: "full",
707                include_graph: false,
708                include_knowledge: false,
709                include_gotchas: true,
710                context_budget_ratio: 0.6,
711            },
712            AgentRole::Orchestrator => Self {
713                max_files_full: 0,
714                max_files_signatures: 5,
715                preferred_mode: "map",
716                include_graph: true,
717                include_knowledge: true,
718                include_gotchas: false,
719                context_budget_ratio: 0.2,
720            },
721        }
722    }
723
724    pub fn mode_for_rank(&self, rank: usize) -> &'static str {
725        if rank < self.max_files_full {
726            "full"
727        } else if rank < self.max_files_full + self.max_files_signatures {
728            "signatures"
729        } else {
730            "map"
731        }
732    }
733}
734
735impl From<ScratchpadEntry> for A2AMessage {
736    fn from(entry: ScratchpadEntry) -> Self {
737        Self {
738            id: entry.id,
739            from_agent: entry.from_agent,
740            to_agent: entry.to_agent,
741            task_id: entry.task_id,
742            category: MessageCategory::parse_str(&entry.category),
743            priority: entry.priority,
744            privacy: entry.privacy,
745            content: entry.message,
746            metadata: entry.metadata,
747            project_root: entry.project_root,
748            timestamp: entry.timestamp,
749            read_by: entry.read_by,
750            expires_at: entry.expires_at,
751        }
752    }
753}
754
755impl From<A2AMessage> for ScratchpadEntry {
756    fn from(msg: A2AMessage) -> Self {
757        Self {
758            id: msg.id,
759            from_agent: msg.from_agent,
760            to_agent: msg.to_agent,
761            task_id: msg.task_id,
762            category: msg.category.to_string(),
763            priority: msg.priority,
764            privacy: msg.privacy,
765            message: msg.content,
766            metadata: msg.metadata,
767            project_root: msg.project_root,
768            timestamp: msg.timestamp,
769            read_by: msg.read_by,
770            expires_at: msg.expires_at,
771        }
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778
779    #[test]
780    fn register_and_list() {
781        let mut reg = AgentRegistry::new();
782        let id = reg.register("cursor", Some("dev"), "/tmp/project");
783        assert!(!id.is_empty());
784        assert_eq!(reg.list_active(None).len(), 1);
785        assert_eq!(reg.list_active(None)[0].agent_type, "cursor");
786    }
787
788    #[test]
789    fn reregister_same_pid() {
790        let mut reg = AgentRegistry::new();
791        let id1 = reg.register("cursor", Some("dev"), "/tmp/project");
792        let id2 = reg.register("cursor", Some("review"), "/tmp/project");
793        assert_eq!(id1, id2);
794        assert_eq!(reg.agents.len(), 1);
795        assert_eq!(reg.agents[0].role, Some("review".to_string()));
796    }
797
798    #[test]
799    fn post_and_read_messages() {
800        let mut reg = AgentRegistry::new();
801        reg.post_message("agent-a", None, "finding", "Found a bug in auth.rs");
802        reg.post_message("agent-b", Some("agent-a"), "request", "Please review");
803
804        let msgs = reg.read_unread("agent-a");
805        assert_eq!(msgs.len(), 1);
806        assert_eq!(msgs[0].category, "request");
807    }
808
809    #[test]
810    fn set_status() {
811        let mut reg = AgentRegistry::new();
812        let id = reg.register("claude", None, "/tmp/project");
813        reg.set_status(&id, AgentStatus::Idle, Some("waiting for review"));
814        assert_eq!(reg.agents[0].status, AgentStatus::Idle);
815        assert_eq!(
816            reg.agents[0].status_message,
817            Some("waiting for review".to_string())
818        );
819    }
820
821    #[test]
822    fn broadcast_message() {
823        let mut reg = AgentRegistry::new();
824        reg.post_message("agent-a", None, "status", "Starting refactor");
825
826        let msgs_b = reg.read_unread("agent-b");
827        assert_eq!(msgs_b.len(), 1);
828        assert_eq!(msgs_b[0].message, "Starting refactor");
829
830        let msgs_a = reg.read_unread("agent-a");
831        assert!(msgs_a.is_empty());
832    }
833
834    #[test]
835    fn diary_add_and_format() {
836        let mut diary = AgentDiary::new("test-agent-001", "cursor", "/tmp/project");
837        diary.add_entry(
838            DiaryEntryType::Discovery,
839            "Found auth module at src/auth.rs",
840            Some("auth"),
841        );
842        diary.add_entry(
843            DiaryEntryType::Decision,
844            "Use JWT RS256 for token signing",
845            None,
846        );
847        diary.add_entry(
848            DiaryEntryType::Progress,
849            "Implemented login endpoint",
850            Some("auth"),
851        );
852
853        assert_eq!(diary.entries.len(), 3);
854
855        let summary = diary.format_summary();
856        assert!(summary.contains("test-agent-001"));
857        assert!(summary.contains("FOUND"));
858        assert!(summary.contains("DECIDED"));
859        assert!(summary.contains("DONE"));
860    }
861
862    #[test]
863    fn diary_compact_format() {
864        let mut diary = AgentDiary::new("test-agent-002", "claude", "/tmp/project");
865        diary.add_entry(DiaryEntryType::Insight, "DB queries are N+1", None);
866        diary.add_entry(
867            DiaryEntryType::Blocker,
868            "Missing API credentials",
869            Some("deploy"),
870        );
871
872        let compact = diary.format_compact();
873        assert!(compact.contains("diary:test-agent-002"));
874        assert!(compact.contains("B:Missing API credentials"));
875        assert!(compact.contains("I:DB queries are N+1"));
876    }
877
878    #[test]
879    fn diary_entry_types() {
880        let types = vec![
881            DiaryEntryType::Discovery,
882            DiaryEntryType::Decision,
883            DiaryEntryType::Blocker,
884            DiaryEntryType::Progress,
885            DiaryEntryType::Insight,
886        ];
887        for t in types {
888            assert!(!format!("{t}").is_empty());
889        }
890    }
891
892    #[test]
893    fn diary_truncation() {
894        let mut diary = AgentDiary::new("test-agent", "cursor", "/tmp");
895        for i in 0..150 {
896            diary.add_entry(DiaryEntryType::Progress, &format!("Step {i}"), None);
897        }
898        assert!(diary.entries.len() <= 100);
899    }
900}