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