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 if !is_process_alive(agent.pid) {
286 agent.status = AgentStatus::Finished;
287 }
288 }
289
290 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
517pub(crate) struct FileLock {
518 path: PathBuf,
519}
520
521impl FileLock {
522 pub(crate) 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}