Skip to main content

lean_ctx/core/agents/
registry.rs

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    /// Atomically registers this MCP process in the shared on-disk registry.
71    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    /// Atomically refreshes a registered MCP process heartbeat.
79    pub fn heartbeat_persistent(agent_id: &str) -> Result<(), String> {
80        mutate_persistent(|registry| registry.update_heartbeat(agent_id))
81    }
82
83    /// Atomically marks a registered MCP process as finished.
84    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    /// Records explicit logical-session presence supplied by an owning editor
105    /// integration. Tool activity is deliberately never treated as a session.
106    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        // Remove finished agents older than the cutoff to keep recent history visible.
331        // Drop each retired agent's budget entry too — a finished/dead agent can't read
332        // again, so removing its budget loses no live enforcement and bounds BUDGETS.
333        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    /// Atomically load, mutate, and persist the registry under a single file
372    /// lock. `load_or_create()` + mutate + `save()` is a read-modify-write
373    /// race: `save()` only locks the final write, so two concurrent callers
374    /// (two MCP sessions registering, or the dashboard's own poll-triggered
375    /// `cleanup_stale` + save) can each load a stale snapshot and the last
376    /// writer silently drops the other's changes — e.g. a second session's
377    /// registration vanishing from the dashboard. Holding the lock across
378    /// the re-read closes that window: the read inside always sees the
379    /// latest on-disk state.
380    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    /// #419: the wake-up briefing scopes agents to the current project via
559    /// `list_active(Some(root))`. Peers working on *other* projects must never
560    /// leak into the briefing.
561    #[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        // Unscoped still sees both.
574        assert_eq!(reg.list_active(None).len(), 2);
575    }
576
577    /// #419: a crashed/exited MCP process leaves an `Active` entry behind.
578    /// `cleanup_stale` must flip it to `Finished` (regardless of age) so
579    /// `list_active` no longer surfaces it as a live peer — the ghost the
580    /// briefing used to show. Previously `#[cfg(unix)]`-only, which is why
581    /// the non-unix `is_process_alive` hardcoded-`true` regression (see its
582    /// doc comment) shipped unnoticed: this exact test never ran on Windows.
583    #[test]
584    fn cleanup_stale_prunes_dead_pid_from_active_list() {
585        // Reap a child so its PID is guaranteed dead at assertion time.
586        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    /// Regression: concurrent load-mutate-save cycles must not silently drop
620    /// each other's changes. Before `mutate_locked`, `save()` only locked the
621    /// final write — the preceding `load()` was unlocked, so a second writer
622    /// could load a stale snapshot and overwrite the first writer's addition
623    /// (e.g. a second Claude Code session's agent registration vanishing
624    /// from the dashboard).
625    #[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}