Skip to main content

lean_ctx/core/
agents.rs

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