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}