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