1use chrono::Utc;
2use std::collections::HashMap;
3
4#[cfg(test)]
5use super::diary::{AgentDiary, DiaryEntryType, truncate};
6use super::persistence::{
7 FileLock, agents_dir, generate_short_id, is_process_alive, mutate_persistent,
8};
9use super::{AgentEntry, AgentRegistry, AgentStatus, LogicalSessionPresence, ScratchpadEntry};
10use crate::core::a2a::message::{MessagePriority, PrivacyLevel};
11
12const MAX_SCRATCHPAD_ENTRIES: usize = 200;
13pub(crate) const LOGICAL_SESSION_TTL_SECONDS: u64 = 180;
14const LOGICAL_SESSION_SOURCE_MAX_BYTES: usize = 64;
15const LOGICAL_SESSION_WORKSPACE_MAX_BYTES: usize = 4096;
16const LOGICAL_SESSION_ID_MAX_BYTES: usize = 256;
17
18impl AgentRegistry {
19 pub fn new() -> Self {
20 Self {
21 agents: Vec::new(),
22 scratchpad: Vec::new(),
23 logical_sessions: Vec::new(),
24 logical_session_telemetry_seen: false,
25 updated_at: Utc::now(),
26 }
27 }
28
29 pub fn register(&mut self, agent_type: &str, role: Option<&str>, project_root: &str) -> String {
30 self.register_process(agent_type, role, project_root, std::process::id())
31 }
32
33 fn register_process(
34 &mut self,
35 agent_type: &str,
36 role: Option<&str>,
37 project_root: &str,
38 pid: u32,
39 ) -> String {
40 let agent_id = format!("{}-{}-{}", agent_type, pid, generate_short_id());
41
42 if let Some(existing) = self.agents.iter_mut().find(|a| a.pid == pid) {
43 existing.last_active = Utc::now();
44 existing.status = AgentStatus::Active;
45 existing.agent_type = agent_type.to_string();
46 existing.project_root = project_root.to_string();
47 if let Some(r) = role {
48 existing.role = Some(r.to_string());
49 }
50 return existing.agent_id.clone();
51 }
52
53 self.agents.push(AgentEntry {
54 agent_id: agent_id.clone(),
55 agent_type: agent_type.to_string(),
56 role: role.map(std::string::ToString::to_string),
57 project_root: project_root.to_string(),
58 started_at: Utc::now(),
59 last_active: Utc::now(),
60 pid,
61 status: AgentStatus::Active,
62 status_message: None,
63 });
64
65 self.updated_at = Utc::now();
66 crate::core::events::emit_agent_action(&agent_id, "register", None);
67 agent_id
68 }
69
70 pub fn register_mcp_process(project_root: &str) -> Result<String, String> {
72 mutate_persistent(|registry| {
73 registry.cleanup_stale(24);
74 registry.register("mcp", Some("context-engine"), project_root)
75 })
76 }
77
78 pub fn heartbeat_persistent(agent_id: &str) -> Result<(), String> {
80 mutate_persistent(|registry| registry.update_heartbeat(agent_id))
81 }
82
83 pub fn finish_persistent(agent_id: &str) -> Result<(), String> {
85 mutate_persistent(|registry| {
86 registry.set_status(agent_id, AgentStatus::Finished, Some("connection closed"));
87 })
88 }
89
90 pub fn update_heartbeat(&mut self, agent_id: &str) {
91 if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
92 agent.last_active = Utc::now();
93 }
94 }
95
96 pub fn set_status(&mut self, agent_id: &str, status: AgentStatus, message: Option<&str>) {
97 if let Some(agent) = self.agents.iter_mut().find(|a| a.agent_id == agent_id) {
98 agent.status = status;
99 agent.status_message = message.map(std::string::ToString::to_string);
100 agent.last_active = Utc::now();
101 }
102 self.updated_at = Utc::now();
103 }
104 pub fn open_or_heartbeat_logical_session(
107 &mut self,
108 source: &str,
109 workspace: &str,
110 session_id: &str,
111 ) {
112 let now = Utc::now();
113 self.logical_session_telemetry_seen = true;
114 if let Some(session) = self.logical_sessions.iter_mut().find(|session| {
115 session.source == source
116 && session.workspace == workspace
117 && session.session_id == session_id
118 }) {
119 session.last_heartbeat = now;
120 } else {
121 self.logical_sessions.push(LogicalSessionPresence {
122 source: source.to_string(),
123 workspace: workspace.to_string(),
124 session_id: session_id.to_string(),
125 opened_at: now,
126 last_heartbeat: now,
127 });
128 }
129 self.updated_at = now;
130 }
131
132 pub fn close_logical_session(
133 &mut self,
134 source: &str,
135 workspace: &str,
136 session_id: &str,
137 ) -> bool {
138 self.logical_session_telemetry_seen = true;
139 let previous_len = self.logical_sessions.len();
140 self.logical_sessions.retain(|session| {
141 session.source != source
142 || session.workspace != workspace
143 || session.session_id != session_id
144 });
145 let removed = self.logical_sessions.len() != previous_len;
146 self.updated_at = Utc::now();
147 removed
148 }
149
150 pub fn cleanup_stale_logical_sessions(&mut self, max_age_seconds: u64) {
151 let seconds = i64::try_from(max_age_seconds).unwrap_or(i64::MAX);
152 let cutoff = Utc::now() - chrono::Duration::seconds(seconds);
153 self.logical_sessions
154 .retain(|session| session.last_heartbeat >= cutoff);
155 self.updated_at = Utc::now();
156 }
157
158 pub fn record_logical_session_presence(
159 event: &str,
160 source: &str,
161 workspace: &str,
162 session_id: &str,
163 ) -> Result<(), String> {
164 let valid_field = |value: &str, max_bytes: usize| {
165 !value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control)
166 };
167 if !valid_field(source, LOGICAL_SESSION_SOURCE_MAX_BYTES)
168 || !valid_field(workspace, LOGICAL_SESSION_WORKSPACE_MAX_BYTES)
169 || !valid_field(session_id, LOGICAL_SESSION_ID_MAX_BYTES)
170 {
171 return Err(
172 "presence fields are empty, too long, or contain control characters".to_string(),
173 );
174 }
175 if !matches!(event, "open" | "heartbeat" | "close") {
176 return Err("event must be open, heartbeat, or close".to_string());
177 }
178
179 mutate_persistent(|registry| {
180 registry.cleanup_stale_logical_sessions(LOGICAL_SESSION_TTL_SECONDS);
181 match event {
182 "open" | "heartbeat" => {
183 registry.open_or_heartbeat_logical_session(source, workspace, session_id);
184 }
185 "close" => {
186 registry.close_logical_session(source, workspace, session_id);
187 }
188 _ => unreachable!("event validated above"),
189 }
190 })
191 }
192
193 pub fn list_active(&self, project_root: Option<&str>) -> Vec<&AgentEntry> {
194 self.agents
195 .iter()
196 .filter(|a| {
197 if let Some(root) = project_root {
198 a.project_root == root && a.status != AgentStatus::Finished
199 } else {
200 a.status != AgentStatus::Finished
201 }
202 })
203 .collect()
204 }
205
206 pub fn list_all(&self) -> &[AgentEntry] {
207 &self.agents
208 }
209
210 pub fn post_message(
211 &mut self,
212 from_agent: &str,
213 to_agent: Option<&str>,
214 category: &str,
215 message: &str,
216 ) -> String {
217 self.post_message_full(
218 from_agent,
219 to_agent,
220 category,
221 message,
222 PrivacyLevel::default(),
223 MessagePriority::default(),
224 None,
225 )
226 }
227
228 pub fn post_message_full(
229 &mut self,
230 from_agent: &str,
231 to_agent: Option<&str>,
232 category: &str,
233 message: &str,
234 privacy: PrivacyLevel,
235 priority: MessagePriority,
236 ttl_hours: Option<u64>,
237 ) -> String {
238 let id = generate_short_id();
239 let expires_at = ttl_hours.map(|h| Utc::now() + chrono::Duration::hours(h as i64));
240 self.scratchpad.push(ScratchpadEntry {
241 id: id.clone(),
242 from_agent: from_agent.to_string(),
243 to_agent: to_agent.map(std::string::ToString::to_string),
244 task_id: None,
245 category: category.to_string(),
246 priority,
247 privacy,
248 message: message.to_string(),
249 metadata: HashMap::new(),
250 project_root: None,
251 timestamp: Utc::now(),
252 read_by: vec![from_agent.to_string()],
253 expires_at,
254 });
255
256 if self.scratchpad.len() > MAX_SCRATCHPAD_ENTRIES {
257 self.scratchpad
258 .drain(0..self.scratchpad.len() - MAX_SCRATCHPAD_ENTRIES);
259 }
260
261 self.updated_at = Utc::now();
262 id
263 }
264
265 pub fn read_messages(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
266 let unread: Vec<usize> = self
267 .scratchpad
268 .iter()
269 .enumerate()
270 .filter(|(_, e)| {
271 !e.read_by.contains(&agent_id.to_string())
272 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
273 })
274 .map(|(i, _)| i)
275 .collect();
276
277 for i in &unread {
278 self.scratchpad[*i].read_by.push(agent_id.to_string());
279 }
280
281 self.scratchpad
282 .iter()
283 .filter(|e| e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
284 .filter(|e| e.from_agent != agent_id)
285 .collect()
286 }
287
288 pub fn read_unread(&mut self, agent_id: &str) -> Vec<&ScratchpadEntry> {
289 let unread_indices: Vec<usize> = self
290 .scratchpad
291 .iter()
292 .enumerate()
293 .filter(|(_, e)| {
294 !e.read_by.contains(&agent_id.to_string())
295 && e.from_agent != agent_id
296 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
297 })
298 .map(|(i, _)| i)
299 .collect();
300
301 for i in &unread_indices {
302 self.scratchpad[*i].read_by.push(agent_id.to_string());
303 }
304
305 self.updated_at = Utc::now();
306
307 self.scratchpad
308 .iter()
309 .filter(|e| {
310 e.from_agent != agent_id
311 && (e.to_agent.is_none() || e.to_agent.as_deref() == Some(agent_id))
312 && e.read_by.contains(&agent_id.to_string())
313 && e.read_by.iter().filter(|r| *r == agent_id).count() == 1
314 })
315 .collect()
316 }
317
318 pub fn cleanup_stale(&mut self, max_age_hours: u64) {
319 let cutoff = Utc::now() - chrono::Duration::hours(max_age_hours as i64);
320
321 for agent in &mut self.agents {
322 if agent.status == AgentStatus::Finished {
323 continue;
324 }
325 if !is_process_alive(agent.pid) {
326 agent.status = AgentStatus::Finished;
327 }
328 }
329
330 self.agents.retain(|a| {
334 let retire = a.status == AgentStatus::Finished && a.last_active < cutoff;
335 if retire {
336 crate::core::agent_budget::remove(&a.agent_id);
337 }
338 !retire
339 });
340
341 self.updated_at = Utc::now();
342 }
343
344 pub fn save(&self) -> Result<(), String> {
345 let dir = agents_dir()?;
346 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
347
348 let lock_path = dir.join("registry.lock");
349 let _lock = FileLock::acquire(&lock_path)?;
350
351 self.save_locked(&dir)
352 }
353
354 fn save_locked(&self, dir: &std::path::Path) -> Result<(), String> {
355 let path = dir.join("registry.json");
356 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
357 std::fs::write(&path, json).map_err(|e| e.to_string())
358 }
359
360 pub fn load() -> Option<Self> {
361 let dir = agents_dir().ok()?;
362 let path = dir.join("registry.json");
363 let content = std::fs::read_to_string(&path).ok()?;
364 serde_json::from_str(&content).ok()
365 }
366
367 pub fn load_or_create() -> Self {
368 Self::load().unwrap_or_default()
369 }
370
371 pub fn mutate_locked<T>(f: impl FnOnce(&mut Self) -> T) -> Result<(Self, T), String> {
381 let dir = agents_dir()?;
382 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
383
384 let lock_path = dir.join("registry.lock");
385 let _lock = FileLock::acquire(&lock_path)?;
386
387 let mut registry = Self::load().unwrap_or_default();
388 let out = f(&mut registry);
389 registry.save_locked(&dir)?;
390 Ok((registry, out))
391 }
392}
393
394impl Default for AgentRegistry {
395 fn default() -> Self {
396 Self::new()
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn register_and_list() {
406 let mut reg = AgentRegistry::new();
407 let id = reg.register("cursor", Some("dev"), "/tmp/project");
408 assert!(!id.is_empty());
409 assert_eq!(reg.list_active(None).len(), 1);
410 assert_eq!(reg.list_active(None)[0].agent_type, "cursor");
411 }
412
413 #[test]
414 fn reregister_same_pid() {
415 let mut reg = AgentRegistry::new();
416 let id1 = reg.register("cursor", Some("dev"), "/tmp/project");
417 let id2 = reg.register("cursor", Some("review"), "/tmp/project");
418 assert_eq!(id1, id2);
419 assert_eq!(reg.agents.len(), 1);
420 assert_eq!(reg.agents[0].role, Some("review".to_string()));
421 }
422
423 #[test]
424 fn post_and_read_messages() {
425 let mut reg = AgentRegistry::new();
426 reg.post_message("agent-a", None, "finding", "Found a bug in auth.rs");
427 reg.post_message("agent-b", Some("agent-a"), "request", "Please review");
428
429 let msgs = reg.read_unread("agent-a");
430 assert_eq!(msgs.len(), 1);
431 assert_eq!(msgs[0].category, "request");
432 }
433
434 #[test]
435 fn set_status() {
436 let mut reg = AgentRegistry::new();
437 let id = reg.register("claude", None, "/tmp/project");
438 reg.set_status(&id, AgentStatus::Idle, Some("waiting for review"));
439 assert_eq!(reg.agents[0].status, AgentStatus::Idle);
440 assert_eq!(
441 reg.agents[0].status_message,
442 Some("waiting for review".to_string())
443 );
444 }
445
446 #[test]
447 fn broadcast_message() {
448 let mut reg = AgentRegistry::new();
449 reg.post_message("agent-a", None, "status", "Starting refactor");
450
451 let msgs_b = reg.read_unread("agent-b");
452 assert_eq!(msgs_b.len(), 1);
453 assert_eq!(msgs_b[0].message, "Starting refactor");
454
455 let msgs_a = reg.read_unread("agent-a");
456 assert!(msgs_a.is_empty());
457 }
458
459 #[test]
460 fn diary_add_and_format() {
461 let mut diary = AgentDiary::new("test-agent-001", "cursor", "/tmp/project");
462 diary.add_entry(
463 DiaryEntryType::Discovery,
464 "Found auth module at src/auth.rs",
465 Some("auth"),
466 );
467 diary.add_entry(
468 DiaryEntryType::Decision,
469 "Use JWT RS256 for token signing",
470 None,
471 );
472 diary.add_entry(
473 DiaryEntryType::Progress,
474 "Implemented login endpoint",
475 Some("auth"),
476 );
477
478 assert_eq!(diary.entries.len(), 3);
479
480 let summary = diary.format_summary();
481 assert!(summary.contains("test-agent-001"));
482 assert!(summary.contains("FOUND"));
483 assert!(summary.contains("DECIDED"));
484 assert!(summary.contains("DONE"));
485 }
486
487 #[test]
488 fn diary_compact_format() {
489 let mut diary = AgentDiary::new("test-agent-002", "claude", "/tmp/project");
490 diary.add_entry(DiaryEntryType::Insight, "DB queries are N+1", None);
491 diary.add_entry(
492 DiaryEntryType::Blocker,
493 "Missing API credentials",
494 Some("deploy"),
495 );
496
497 let compact = diary.format_compact();
498 assert!(compact.contains("diary:test-agent-002"));
499 assert!(compact.contains("B:Missing API credentials"));
500 assert!(compact.contains("I:DB queries are N+1"));
501 }
502
503 #[test]
504 fn diary_entry_types() {
505 let types = vec![
506 DiaryEntryType::Discovery,
507 DiaryEntryType::Decision,
508 DiaryEntryType::Blocker,
509 DiaryEntryType::Progress,
510 DiaryEntryType::Insight,
511 ];
512 for t in types {
513 assert!(!format!("{t}").is_empty());
514 }
515 }
516
517 #[test]
518 fn diary_truncation() {
519 let mut diary = AgentDiary::new("test-agent", "cursor", "/tmp");
520 for i in 0..150 {
521 diary.add_entry(DiaryEntryType::Progress, &format!("Step {i}"), None);
522 }
523 assert!(diary.entries.len() <= 100);
524 }
525
526 #[test]
527 fn truncate_utf8_emoji_no_panic() {
528 let result = truncate("Agent 🤖 Name ist lang genug", 15);
529 assert!(result.ends_with("..."));
530 }
531
532 #[test]
533 fn truncate_utf8_cyrillic_no_panic() {
534 let result = truncate("агент выполняет длинную задачу", 15);
535 assert!(result.ends_with("..."));
536 }
537
538 #[test]
539 fn truncate_short_utf8_unchanged() {
540 assert_eq!(truncate("短い", 20), "短い");
541 }
542
543 fn test_entry(agent_id: &str, project_root: &str, pid: u32) -> AgentEntry {
544 let now = Utc::now();
545 AgentEntry {
546 agent_id: agent_id.to_string(),
547 agent_type: "cursor".to_string(),
548 role: Some("dev".to_string()),
549 project_root: project_root.to_string(),
550 started_at: now,
551 last_active: now,
552 pid,
553 status: AgentStatus::Active,
554 status_message: None,
555 }
556 }
557
558 #[test]
562 fn list_active_scopes_to_project_root() {
563 let mut reg = AgentRegistry::new();
564 reg.agents
565 .push(test_entry("a-1", "/proj/a", std::process::id()));
566 reg.agents
567 .push(test_entry("b-1", "/proj/b", std::process::id()));
568
569 let active_a = reg.list_active(Some("/proj/a"));
570 assert_eq!(active_a.len(), 1);
571 assert_eq!(active_a[0].agent_id, "a-1");
572
573 assert_eq!(reg.list_active(None).len(), 2);
575 }
576
577 #[test]
584 fn cleanup_stale_prunes_dead_pid_from_active_list() {
585 let reaped = {
587 let mut cmd = if cfg!(windows) {
588 let mut c = std::process::Command::new("cmd");
589 c.args(["/C", "exit"]);
590 c
591 } else {
592 std::process::Command::new("true")
593 };
594 let mut child = cmd.spawn().expect("spawn short-lived helper process");
595 let pid = child.id();
596 child.wait().expect("reap helper process");
597 pid
598 };
599
600 let mut reg = AgentRegistry::new();
601 reg.agents.push(test_entry("ghost", "/proj/a", reaped));
602 reg.agents
603 .push(test_entry("live", "/proj/a", std::process::id()));
604
605 reg.cleanup_stale(24);
606
607 let ids: Vec<&str> = reg
608 .list_active(Some("/proj/a"))
609 .iter()
610 .map(|a| a.agent_id.as_str())
611 .collect();
612 assert!(ids.contains(&"live"), "live same-project agent must remain");
613 assert!(
614 !ids.contains(&"ghost"),
615 "dead-pid agent must be pruned from the active list (#419)"
616 );
617 }
618
619 #[test]
626 fn mutate_locked_survives_concurrent_writers() {
627 let _iso = crate::core::data_dir::isolated_data_dir();
628
629 let handles: Vec<_> = (0..8)
630 .map(|i| {
631 std::thread::spawn(move || {
632 AgentRegistry::mutate_locked(|registry| {
633 registry.agents.push(AgentEntry {
634 agent_id: format!("agent-{i}"),
635 agent_type: "test".to_string(),
636 role: None,
637 project_root: "/tmp/project".to_string(),
638 started_at: Utc::now(),
639 last_active: Utc::now(),
640 pid: 10_000 + i,
641 status: AgentStatus::Active,
642 status_message: None,
643 });
644 })
645 })
646 })
647 .collect();
648
649 for h in handles {
650 h.join()
651 .expect("writer thread must not panic")
652 .expect("mutate_locked must succeed");
653 }
654
655 let registry = AgentRegistry::load_or_create();
656 assert_eq!(
657 registry.agents.len(),
658 8,
659 "all 8 concurrent registrations must survive, got {}",
660 registry.agents.len()
661 );
662 }
663}
664
665#[cfg(test)]
666mod presence_tests {
667 use super::*;
668
669 #[test]
670 fn persistent_presence_preserves_multiple_processes_and_lifecycle() {
671 let isolated = crate::core::data_dir::isolated_data_dir();
672 let mut registry = AgentRegistry::new();
673 let first = registry.register_process("mcp", Some("context-engine"), "/project", 101);
674 let second = registry.register_process("mcp", Some("context-engine"), "/project", 202);
675 registry.save().expect("save registry");
676
677 assert_ne!(first, second);
678 assert_eq!(AgentRegistry::load().expect("registry").agents.len(), 2);
679
680 AgentRegistry::heartbeat_persistent(&first).expect("heartbeat");
681 AgentRegistry::finish_persistent(&second).expect("finish");
682 let loaded = AgentRegistry::load().expect("registry");
683 assert_eq!(
684 loaded
685 .agents
686 .iter()
687 .find(|agent| agent.agent_id == second)
688 .expect("second agent")
689 .status,
690 AgentStatus::Finished
691 );
692 assert!(isolated.path().join("agents/registry.json").exists());
693 }
694
695 #[test]
696 fn reregistering_process_refreshes_metadata_without_duplication() {
697 let mut registry = AgentRegistry::new();
698 let first = registry.register_process("unknown", None, "/old", 303);
699 let second = registry.register_process("mcp", Some("context-engine"), "/new", 303);
700
701 assert_eq!(first, second);
702 assert_eq!(registry.agents.len(), 1);
703 assert_eq!(registry.agents[0].agent_type, "mcp");
704 assert_eq!(registry.agents[0].project_root, "/new");
705 assert_eq!(registry.agents[0].role.as_deref(), Some("context-engine"));
706 }
707
708 #[test]
709 fn logical_sessions_are_keyed_independently_of_transport_processes() {
710 let mut registry = AgentRegistry::new();
711 registry.register_process("mcp", Some("context-engine"), "/project", 303);
712 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
713 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-b");
714 let opened_at = registry.logical_sessions[0].opened_at;
715
716 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
717
718 assert_eq!(registry.agents.len(), 1);
719 assert_eq!(registry.logical_sessions.len(), 2);
720 assert_eq!(registry.logical_sessions[0].opened_at, opened_at);
721 assert!(registry.logical_session_telemetry_seen);
722 assert!(registry.close_logical_session("vscode", "/project", "chat-b"));
723 assert_eq!(registry.logical_sessions.len(), 1);
724 }
725
726 #[test]
727 fn persistent_logical_session_presence_validates_and_roundtrips() {
728 let _isolated = crate::core::data_dir::isolated_data_dir();
729
730 AgentRegistry::record_logical_session_presence(
731 "open",
732 "vscode",
733 "/project",
734 "editor-session-a",
735 )
736 .expect("open presence");
737
738 let registry = AgentRegistry::load().expect("persisted registry");
739 assert_eq!(registry.logical_sessions.len(), 1);
740 assert_eq!(registry.logical_sessions[0].session_id, "editor-session-a");
741 assert!(registry.logical_session_telemetry_seen);
742
743 assert!(
744 AgentRegistry::record_logical_session_presence(
745 "invalid",
746 "vscode",
747 "/project",
748 "editor-session-a",
749 )
750 .is_err()
751 );
752 assert!(
753 AgentRegistry::record_logical_session_presence(
754 "heartbeat",
755 "",
756 "/project",
757 "editor-session-a",
758 )
759 .is_err()
760 );
761
762 AgentRegistry::record_logical_session_presence(
763 "close",
764 "vscode",
765 "/project",
766 "editor-session-a",
767 )
768 .expect("close presence");
769 assert!(
770 AgentRegistry::load()
771 .expect("persisted registry")
772 .logical_sessions
773 .is_empty()
774 );
775 }
776
777 #[test]
778 fn logical_session_expiry_is_bounded_by_heartbeat_not_tool_activity() {
779 let mut registry = AgentRegistry::new();
780 registry.open_or_heartbeat_logical_session("vscode", "/project", "chat-a");
781 registry.logical_sessions[0].last_heartbeat = Utc::now() - chrono::Duration::seconds(181);
782
783 registry.cleanup_stale_logical_sessions(180);
784
785 assert!(registry.logical_sessions.is_empty());
786 assert!(registry.logical_session_telemetry_seen);
787 }
788
789 #[test]
790 fn legacy_registry_deserializes_without_claiming_session_telemetry() {
791 let registry: AgentRegistry = serde_json::from_str(
792 r#"{"agents":[],"scratchpad":[],"updated_at":"2026-01-01T00:00:00Z"}"#,
793 )
794 .expect("legacy registry");
795
796 assert!(registry.logical_sessions.is_empty());
797 assert!(!registry.logical_session_telemetry_seen);
798 }
799}