1use std::path::{Path, PathBuf};
24use std::sync::{Arc, Mutex, PoisonError};
25use std::time::Duration;
26
27use anyhow::{anyhow, bail, Context, Result};
28use async_trait::async_trait;
29use git2::Repository;
30use serde_json::{json, Value};
31use tokio::task::JoinHandle;
32use tokio_util::sync::CancellationToken;
33
34use crate::daemon::service::{
35 DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
36};
37use crate::daemon::services::worktrees::focus_window;
38use crate::sessions::{
39 DeliveryStats, EventStamp, ObserveRequest, Origin, SessionEntry, SessionState,
40 SessionsRegistry, WindowReport,
41};
42
43pub const SERVICE_NAME: &str = "sessions";
45
46const SUBMENU_TITLE: &str = "Agent Sessions";
49
50struct WatcherTask {
53 token: CancellationToken,
55 handles: Vec<JoinHandle<()>>,
57}
58
59pub struct SessionsService {
61 registry: Arc<SessionsRegistry>,
64 watcher: Mutex<Option<WatcherTask>>,
67 journal_dir: Option<PathBuf>,
71}
72
73impl SessionsService {
74 #[must_use]
78 pub fn new() -> Self {
79 Self {
80 registry: Arc::new(SessionsRegistry::new()),
81 watcher: Mutex::new(None),
82 journal_dir: None,
83 }
84 }
85
86 #[must_use]
90 pub fn with_journal_dir(mut self, dir: PathBuf) -> Self {
91 self.journal_dir = Some(dir);
92 self
93 }
94
95 pub fn start_watcher(&self) {
105 if tokio::runtime::Handle::try_current().is_err() {
106 tracing::debug!("no tokio runtime; sessions transcript watcher not started");
107 return;
108 }
109 let mut guard = self.watcher.lock().unwrap_or_else(PoisonError::into_inner);
110 if guard.is_some() {
111 return;
112 }
113 let token = CancellationToken::new();
114 let mut handles = vec![
115 crate::sessions::watcher::spawn(self.registry.clone(), token.clone()),
116 crate::sessions::codex_watcher::spawn(self.registry.clone(), token.clone()),
117 crate::sessions::pid_watcher::spawn(self.registry.clone(), token.clone()),
118 ];
119 if let Some(dir) = &self.journal_dir {
120 handles.push(crate::sessions::journal_watcher::spawn(
121 self.registry.clone(),
122 dir.clone(),
123 Arc::new(|cwd: &Path| repo_name_for(cwd)),
124 token.clone(),
125 ));
126 }
127 *guard = Some(WatcherTask { token, handles });
128 }
129
130 #[cfg(test)]
132 pub(crate) fn registry(&self) -> &Arc<SessionsRegistry> {
133 &self.registry
134 }
135}
136
137impl Default for SessionsService {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143#[async_trait]
144impl DaemonService for SessionsService {
145 fn name(&self) -> &'static str {
146 SERVICE_NAME
147 }
148
149 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
150 match op {
151 "observe" => {
152 let stamp = stamp_of(&payload);
156 let mut req: ObserveRequest =
157 serde_json::from_value(payload).context("invalid `observe` payload")?;
158 if req.session_id.trim().is_empty() {
159 bail!("`observe` requires a non-empty `session_id`");
160 }
161 if req.repo.is_none() {
166 if let Some(cwd) = req.cwd.clone() {
167 req.repo = match tokio::task::spawn_blocking(move || repo_name_for(&cwd))
168 .await
169 {
170 Ok(repo) => repo,
171 Err(error) => {
172 tracing::warn!(session_id = %req.session_id, %error, "sessions_repo_enrichment_failed");
173 None
174 }
175 };
176 }
177 }
178 self.registry.observe_stamped(req, stamp, Origin::Socket);
179 Ok(json!({ "ok": true }))
180 }
181 "end" => {
182 let session_id = require_str(&payload, "session_id", "end")?;
183 let reason = payload.get("reason").and_then(Value::as_str);
184 let pid = payload
187 .get("pid")
188 .and_then(Value::as_u64)
189 .and_then(|pid| u32::try_from(pid).ok());
190 let ended = self.registry.end_stamped(
191 session_id,
192 reason,
193 pid,
194 stamp_of(&payload),
195 Origin::Socket,
196 );
197 Ok(json!({ "ended": ended }))
198 }
199 "window" => {
200 let req: WindowReport =
201 serde_json::from_value(payload).context("invalid `window` payload")?;
202 if req.key.trim().is_empty() {
203 bail!("`window` requires a non-empty `key`");
204 }
205 self.registry.report_window(req);
206 Ok(json!({ "ok": true }))
207 }
208 "window-unregister" => {
209 let key = require_str(&payload, "key", "window-unregister")?;
210 Ok(json!({ "removed": self.registry.unregister_window(key) }))
211 }
212 "list" => Ok(sessions_payload(&self.registry)),
213 other => bail!("unknown sessions op: {other}"),
214 }
215 }
216
217 fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
218 if op != "subscribe" {
222 return None;
223 }
224 Some(Box::new(SessionsStream {
225 registry: self.registry.clone(),
226 changes: self.registry.subscribe_changes(),
229 }))
230 }
231
232 fn menu(&self) -> MenuSnapshot {
233 MenuSnapshot {
237 title: SUBMENU_TITLE.to_string(),
238 items: menu_items_for(&self.registry.list()),
239 }
240 }
241
242 async fn menu_action(&self, action_id: &str) -> Result<()> {
243 if let Some(session_id) = action_id.strip_prefix("focus:") {
244 let folder = self.registry.focus_folder(session_id).ok_or_else(|| {
245 anyhow!("session {session_id} is not open in a known VS Code window")
246 })?;
247 focus_window(&folder)?;
248 return Ok(());
249 }
250 bail!("unknown sessions menu action: {action_id}")
251 }
252
253 async fn status(&self) -> ServiceStatus {
254 let sessions = self.registry.list();
255 let delivery = self.registry.delivery();
256 let summary = format!(
257 "{}{}",
258 status_summary(&sessions),
259 delivery_summary(delivery)
260 );
261 ServiceStatus {
262 name: SERVICE_NAME.to_string(),
263 healthy: true,
264 summary,
265 detail: json!({ "sessions": sessions, "delivery": delivery }),
266 }
267 }
268
269 fn credit_accept_outage(&self, outage: Duration) {
270 self.registry.credit_window_outage(outage);
271 }
272
273 async fn shutdown(&self) {
274 let task = self
278 .watcher
279 .lock()
280 .unwrap_or_else(PoisonError::into_inner)
281 .take();
282 if let Some(task) = task {
283 task.token.cancel();
284 for handle in task.handles {
285 let _ = handle.await;
286 }
287 }
288 }
289}
290
291fn sessions_payload(registry: &SessionsRegistry) -> Value {
295 json!({ "sessions": registry.list() })
296}
297
298struct SessionsStream {
306 registry: Arc<SessionsRegistry>,
308 changes: tokio::sync::watch::Receiver<u64>,
313}
314
315#[async_trait]
316impl ServiceStream for SessionsStream {
317 async fn changed(&mut self) {
318 if self.changes.changed().await.is_err() {
324 std::future::pending::<()>().await;
325 }
326 }
327
328 async fn snapshot(&self) -> Value {
329 sessions_payload(&self.registry)
330 }
331}
332
333fn stamp_of(payload: &Value) -> Option<EventStamp> {
336 serde_json::from_value(payload.get("stamp")?.clone()).ok()
337}
338
339fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
342 payload
343 .get(field)
344 .and_then(Value::as_str)
345 .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
346}
347
348fn repo_name_for(cwd: &Path) -> Option<String> {
353 let repo = Repository::discover(cwd).ok()?;
354 main_repo_name(repo.commondir())
355}
356
357fn main_repo_name(commondir: &Path) -> Option<String> {
362 let file_name = commondir.file_name()?.to_string_lossy().into_owned();
363 if file_name == ".git" {
364 commondir
365 .parent()
366 .and_then(Path::file_name)
367 .map(|n| n.to_string_lossy().into_owned())
368 } else {
369 Some(
370 file_name
371 .strip_suffix(".git")
372 .unwrap_or(&file_name)
373 .to_string(),
374 )
375 }
376}
377
378fn status_summary(sessions: &[SessionEntry]) -> String {
380 if sessions.is_empty() {
381 return "0 session(s)".to_string();
382 }
383 let mut working = 0;
384 let mut waiting = 0;
385 let mut idle = 0;
386 for s in sessions {
387 match s.state {
388 SessionState::Working => working += 1,
389 SessionState::WaitingForInput | SessionState::WaitingForPermission => waiting += 1,
390 SessionState::Starting | SessionState::Idle | SessionState::Ended => idle += 1,
392 }
393 }
394 format!(
395 "{} session(s): {working} working, {waiting} waiting, {idle} idle",
396 sessions.len()
397 )
398}
399
400fn delivery_summary(delivery: DeliveryStats) -> String {
405 if delivery == DeliveryStats::default() {
406 return String::new();
407 }
408 format!(
409 " · events: {} via socket, {} recovered from journals, {} superseded, {} replayed",
410 delivery.socket, delivery.recovered, delivery.superseded, delivery.replayed
411 )
412}
413
414fn state_glyph(state: SessionState) -> &'static str {
416 match state {
417 SessionState::Starting => "…",
418 SessionState::Working => "⚙",
419 SessionState::Idle => "◦",
420 SessionState::WaitingForInput => "?",
421 SessionState::WaitingForPermission => "!",
422 SessionState::Ended => "×",
423 }
424}
425
426fn display_name(entry: &SessionEntry) -> String {
429 if let Some(repo) = &entry.repo {
430 return repo.clone();
431 }
432 if let Some(cwd) = &entry.cwd {
433 if let Some(name) = cwd.file_name() {
434 return name.to_string_lossy().into_owned();
435 }
436 }
437 entry.session_id.chars().take(8).collect()
439}
440
441fn menu_items_for(sessions: &[SessionEntry]) -> Vec<MenuItem> {
447 use crate::sessions::Source;
448 if sessions.is_empty() {
449 return vec![MenuItem::Label("No active sessions".to_string())];
450 }
451 sessions
452 .iter()
453 .map(|entry| {
454 let label = format!(
455 "{} · {} {} {}",
456 entry.agent.display_name(),
457 display_name(entry),
458 state_glyph(entry.state),
459 state_label(entry.state),
460 );
461 match &entry.source {
462 Source::VsCode { .. } => MenuItem::Action(MenuAction {
463 id: format!("focus:{}", entry.session_id),
464 label,
465 enabled: true,
466 }),
467 Source::Terminal => MenuItem::Label(label),
468 }
469 })
470 .collect()
471}
472
473fn state_label(state: SessionState) -> &'static str {
476 match state {
477 SessionState::Starting => "starting",
478 SessionState::Working => "working",
479 SessionState::Idle => "idle",
480 SessionState::WaitingForInput => "waiting",
481 SessionState::WaitingForPermission => "permission",
482 SessionState::Ended => "ended",
483 }
484}
485
486#[cfg(test)]
487#[allow(clippy::unwrap_used, clippy::expect_used)]
488mod tests {
489 use super::*;
490 use crate::sessions::{NotificationKind, SessionEvent, Source};
491 use std::collections::VecDeque;
492 use std::path::PathBuf;
493
494 fn service() -> SessionsService {
495 SessionsService::new()
496 }
497
498 #[tokio::test]
499 async fn observe_then_list_round_trips() {
500 let svc = service();
501 let ok = svc
502 .handle(
503 "observe",
504 json!({ "session_id": "s1", "event": "session_start", "cwd": "/tmp/x" }),
505 )
506 .await
507 .unwrap();
508 assert_eq!(ok, json!({ "ok": true }));
509
510 let listed = svc.handle("list", Value::Null).await.unwrap();
511 let sessions = listed["sessions"].as_array().unwrap();
512 assert_eq!(sessions.len(), 1);
513 assert_eq!(sessions[0]["session_id"], "s1");
514 assert_eq!(sessions[0]["state"], "starting");
515 }
516
517 #[tokio::test]
518 async fn observe_rejects_blank_session_id() {
519 let svc = service();
520 let err = svc
521 .handle("observe", json!({ "session_id": " ", "event": "stop" }))
522 .await
523 .unwrap_err();
524 assert!(err.to_string().contains("session_id"), "{err}");
525 }
526
527 #[tokio::test]
528 async fn end_marks_ended() {
529 let svc = service();
530 svc.handle(
531 "observe",
532 json!({ "session_id": "s1", "event": "pre_tool_use" }),
533 )
534 .await
535 .unwrap();
536 let reply = svc
537 .handle("end", json!({ "session_id": "s1", "reason": "clear" }))
538 .await
539 .unwrap();
540 assert_eq!(reply, json!({ "ended": true }));
541 let reply = svc
543 .handle("end", json!({ "session_id": "ghost" }))
544 .await
545 .unwrap();
546 assert_eq!(reply, json!({ "ended": false }));
547 }
548
549 #[tokio::test]
550 async fn a_started_service_replays_the_journals_beside_its_socket() {
551 use crate::sessions::journal::{self, JournalRecord};
554 let tmp = tempfile::tempdir().unwrap();
555 let dir =
556 crate::daemon::paths::sessions_journal_dir_for_socket(&tmp.path().join("daemon.sock"));
557 let id = "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c";
558 let stamp = EventStamp {
559 ts: chrono::Utc::now() - chrono::Duration::seconds(15),
560 seq: "7-1".to_string(),
561 };
562 let req = ObserveRequest {
563 agent_id: None,
564 session_id: id.to_string(),
565 cwd: Some(PathBuf::from("/nonexistent/proj")),
566 transcript_path: None,
567 event: SessionEvent::SessionStart,
568 repo: None,
569 model: None,
570 agent: crate::sessions::Agent::Claude,
571 pid: None,
572 };
573 journal::append(&dir, &JournalRecord::observe(&req, &stamp)).unwrap();
574
575 let svc = SessionsService::new().with_journal_dir(dir);
576 svc.start_watcher();
577 let mut found = None;
580 for _ in 0..100 {
581 let listed = svc.handle("list", Value::Null).await.unwrap();
582 found = listed["sessions"]
583 .as_array()
584 .unwrap()
585 .iter()
586 .find(|s| s["session_id"] == id)
587 .cloned();
588 if found.is_some() {
589 break;
590 }
591 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
592 }
593 let found = found.expect("the journaled session is listed");
594 assert_eq!(found["state"], "starting");
595 assert_eq!(found["cwd"], "/nonexistent/proj");
596 svc.shutdown().await;
597 }
598
599 #[tokio::test]
600 async fn a_stamped_event_delivered_twice_is_applied_once() {
601 let svc = service();
604 let working = json!({
605 "ts": "2026-10-03T03:40:00Z", "seq": "9-1",
606 });
607 let stopped = json!({
608 "ts": "2026-10-03T03:40:05Z", "seq": "9-2",
609 });
610 for (event, stamp) in [
611 ("pre_tool_use", &working),
612 ("stop", &stopped),
613 ("pre_tool_use", &working),
614 ] {
615 svc.handle(
616 "observe",
617 json!({ "session_id": "s1", "event": event, "stamp": stamp }),
618 )
619 .await
620 .unwrap();
621 }
622 let listed = svc.handle("list", Value::Null).await.unwrap();
623 assert_eq!(listed["sessions"][0]["state"], "idle");
624 }
625
626 #[tokio::test]
627 async fn a_malformed_stamp_is_ignored_not_an_error() {
628 let svc = service();
629 for stamp in [
630 json!("nope"),
631 json!({ "ts": "yesterday", "seq": 3 }),
632 Value::Null,
633 ] {
634 svc.handle(
635 "observe",
636 json!({ "session_id": "s1", "event": "pre_tool_use", "stamp": stamp }),
637 )
638 .await
639 .unwrap();
640 svc.handle("end", json!({ "session_id": "s1", "stamp": stamp }))
641 .await
642 .unwrap();
643 }
644 assert_eq!(svc.registry().list().len(), 1);
645 }
646
647 #[tokio::test]
648 async fn a_stamped_end_is_deduplicated_too() {
649 let svc = service();
650 svc.handle(
651 "observe",
652 json!({ "session_id": "s1", "event": "session_start",
653 "stamp": { "ts": "2026-10-03T03:40:00Z", "seq": "9-1" } }),
654 )
655 .await
656 .unwrap();
657 let end =
658 json!({ "session_id": "s1", "stamp": { "ts": "2026-10-03T03:41:00Z", "seq": "9-2" } });
659 assert_eq!(
660 svc.handle("end", end.clone()).await.unwrap(),
661 json!({ "ended": true })
662 );
663 assert_eq!(
665 svc.handle("end", end).await.unwrap(),
666 json!({ "ended": true })
667 );
668 let listed = svc.handle("list", Value::Null).await.unwrap();
669 assert_eq!(listed["sessions"][0]["state"], "ended");
670 }
671
672 #[tokio::test]
673 async fn window_report_tags_source_and_unregister_removes() {
674 let svc = service();
675 svc.handle(
676 "observe",
677 json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
678 )
679 .await
680 .unwrap();
681 svc.handle(
682 "window",
683 json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
684 )
685 .await
686 .unwrap();
687 let listed = svc.handle("list", Value::Null).await.unwrap();
688 assert_eq!(listed["sessions"][0]["source"]["kind"], "vs_code");
689 assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
690
691 let removed = svc
692 .handle("window-unregister", json!({ "key": "w1" }))
693 .await
694 .unwrap();
695 assert_eq!(removed, json!({ "removed": true }));
696 let listed = svc.handle("list", Value::Null).await.unwrap();
697 assert_eq!(listed["sessions"][0]["source"]["kind"], "terminal");
698 }
699
700 #[tokio::test]
704 async fn credit_accept_outage_reaches_the_window_reports_without_dropping_a_live_one() {
705 let svc = service();
706 svc.handle(
707 "observe",
708 json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
709 )
710 .await
711 .unwrap();
712 svc.handle(
713 "window",
714 json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
715 )
716 .await
717 .unwrap();
718
719 svc.credit_accept_outage(std::time::Duration::from_secs(5));
720
721 let listed = svc.handle("list", Value::Null).await.unwrap();
722 assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
723 }
724
725 #[tokio::test]
726 async fn unknown_op_errors() {
727 let svc = service();
728 let err = svc.handle("frobnicate", Value::Null).await.unwrap_err();
729 assert!(err.to_string().contains("unknown sessions op"), "{err}");
730 }
731
732 #[tokio::test]
733 async fn status_summarizes_states() {
734 let svc = service();
735 svc.registry().observe(ObserveRequest {
736 agent_id: None,
737 pid: None,
738 agent: crate::sessions::Agent::Claude,
739 session_id: "w".to_string(),
740 cwd: None,
741 transcript_path: None,
742 event: SessionEvent::PreToolUse,
743 repo: None,
744 model: None,
745 });
746 svc.registry().observe(ObserveRequest {
747 agent_id: None,
748 pid: None,
749 agent: crate::sessions::Agent::Claude,
750 session_id: "p".to_string(),
751 cwd: None,
752 transcript_path: None,
753 event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
754 repo: None,
755 model: None,
756 });
757 let status = svc.status().await;
758 assert!(status.healthy);
759 assert!(
760 status.summary.contains("2 session(s)"),
761 "{}",
762 status.summary
763 );
764 assert!(status.summary.contains("1 working"), "{}", status.summary);
765 assert!(status.summary.contains("1 waiting"), "{}", status.summary);
766 }
767
768 #[test]
769 fn menu_items_placeholder_when_empty() {
770 let items = menu_items_for(&[]);
771 assert_eq!(items.len(), 1);
772 assert!(matches!(&items[0], MenuItem::Label(l) if l.contains("No active")));
773 }
774
775 #[test]
776 fn menu_item_is_clickable_only_for_vscode_sessions() {
777 let now = chrono::Utc::now();
778 let base = |source: Source| SessionEntry {
779 subagent_base: None,
780 subagent_waits: std::collections::HashMap::new(),
781 pid: None,
782 pid_start: None,
783 prompted: false,
784 streamed: false,
785 replaced_pids: VecDeque::new(),
786 recent_seqs: VecDeque::new(),
787 superseded_seqs: VecDeque::new(),
788 latest_stamp_ts: None,
789 reported: false,
790 agent: crate::sessions::Agent::Claude,
791 session_id: "sid-12345678".to_string(),
792 cwd: Some(PathBuf::from("/p")),
793 transcript_path: None,
794 repo: Some("proj".to_string()),
795 model: None,
796 state: SessionState::Working,
797 source,
798 last_event: SessionEvent::PreToolUse,
799 started_at: now,
800 last_seen: now,
801 last_active: std::time::Duration::ZERO,
802 };
803 let terminal = menu_items_for(&[base(Source::Terminal)]);
805 assert!(
806 matches!(&terminal[0], MenuItem::Label(l) if l.contains("proj") && l.contains("working"))
807 );
808 let vscode = menu_items_for(&[base(Source::VsCode {
810 window_key: "w1".to_string(),
811 })]);
812 match &vscode[0] {
813 MenuItem::Action(a) => assert_eq!(a.id, "focus:sid-12345678"),
814 other => panic!("expected an action, got {other:?}"),
815 }
816 }
817
818 #[test]
819 fn repo_name_for_non_repo_is_none() {
820 assert_eq!(repo_name_for(Path::new("/nonexistent/xyz")), None);
822 }
823
824 #[test]
825 fn main_repo_name_handles_layouts() {
826 assert_eq!(
827 main_repo_name(Path::new("/home/me/proj/.git")).as_deref(),
828 Some("proj")
829 );
830 assert_eq!(
831 main_repo_name(Path::new("/home/me/bare.git")).as_deref(),
832 Some("bare")
833 );
834 }
835
836 fn entry(id: &str, state: SessionState, repo: Option<&str>, cwd: Option<&str>) -> SessionEntry {
838 let now = chrono::Utc::now();
839 SessionEntry {
840 subagent_base: None,
841 subagent_waits: std::collections::HashMap::new(),
842 pid: None,
843 pid_start: None,
844 prompted: false,
845 streamed: false,
846 replaced_pids: VecDeque::new(),
847 recent_seqs: VecDeque::new(),
848 superseded_seqs: VecDeque::new(),
849 latest_stamp_ts: None,
850 reported: false,
851 agent: crate::sessions::Agent::Claude,
852 session_id: id.to_string(),
853 cwd: cwd.map(PathBuf::from),
854 transcript_path: None,
855 repo: repo.map(str::to_string),
856 model: None,
857 state,
858 source: Source::Terminal,
859 last_event: SessionEvent::PreToolUse,
860 started_at: now,
861 last_seen: now,
862 last_active: std::time::Duration::ZERO,
863 }
864 }
865
866 #[test]
867 fn default_constructs_an_empty_service() {
868 let svc = SessionsService::default();
869 assert!(svc.registry().list().is_empty());
870 }
871
872 #[test]
873 fn start_watcher_is_a_noop_outside_a_runtime() {
874 let svc = SessionsService::new();
876 svc.start_watcher();
877 assert!(svc
878 .watcher
879 .lock()
880 .unwrap_or_else(PoisonError::into_inner)
881 .is_none());
882 }
883
884 #[tokio::test]
885 async fn start_watcher_is_idempotent_and_shutdown_stops_it() {
886 let svc = SessionsService::new();
887 svc.start_watcher();
888 svc.start_watcher();
890 assert!(svc
891 .watcher
892 .lock()
893 .unwrap_or_else(PoisonError::into_inner)
894 .is_some());
895 svc.shutdown().await;
897 assert!(svc
898 .watcher
899 .lock()
900 .unwrap_or_else(PoisonError::into_inner)
901 .is_none());
902 }
903
904 #[test]
905 fn menu_renders_every_state_and_name_fallback() {
906 let sessions = vec![
907 entry("s1", SessionState::Starting, Some("repo-a"), Some("/a")),
908 entry("s2", SessionState::Working, None, Some("/home/me/proj")),
909 entry("s3", SessionState::Idle, None, None),
910 entry("s4", SessionState::WaitingForInput, Some("r"), Some("/b")),
911 entry(
912 "s5",
913 SessionState::WaitingForPermission,
914 Some("r"),
915 Some("/c"),
916 ),
917 entry("s6", SessionState::Ended, Some("r"), Some("/d")),
918 ];
919 let items = menu_items_for(&sessions);
920 assert_eq!(items.len(), 6);
921 let labels: Vec<&str> = items
924 .iter()
925 .map(|i| match i {
926 MenuItem::Label(l) => l.as_str(),
927 _ => panic!("terminal sessions render as labels"),
928 })
929 .collect();
930 assert!(labels[0].contains("repo-a") && labels[0].contains("starting"));
931 assert!(labels[1].contains("proj") && labels[1].contains("working")); assert!(labels[2].contains("s3") && labels[2].contains("idle")); assert!(labels[3].contains("waiting"));
934 assert!(labels[4].contains("permission"));
935 assert!(labels[5].contains("ended"));
936 }
937
938 #[test]
939 fn menu_rows_are_prefixed_with_their_agent() {
940 let mut pi = entry("p1", SessionState::Idle, Some("repo-p"), None);
941 pi.agent = crate::sessions::Agent::Pi;
942 let mut codex = entry("c1", SessionState::Working, Some("repo-c"), None);
943 codex.agent = crate::sessions::Agent::Codex;
944 let claude = entry("s1", SessionState::Idle, Some("repo-a"), None);
945 let items = menu_items_for(&[claude, pi, codex]);
946 let labels: Vec<&str> = items
947 .iter()
948 .map(|i| match i {
949 MenuItem::Label(l) => l.as_str(),
950 _ => panic!("terminal sessions render as labels"),
951 })
952 .collect();
953 assert_eq!(labels[0], "Claude · repo-a ◦ idle");
954 assert_eq!(labels[1], "pi · repo-p ◦ idle");
955 assert_eq!(labels[2], "Codex · repo-c ⚙ working");
956 assert_eq!(SUBMENU_TITLE, "Agent Sessions");
957 }
958
959 #[test]
960 fn menu_serves_the_snapshot_title() {
961 let svc = SessionsService::new();
962 svc.registry()
963 .observe(observe_req("s1", SessionEvent::Stop, None));
964 let snapshot = svc.menu();
965 assert_eq!(snapshot.title, SUBMENU_TITLE);
966 assert_eq!(snapshot.items.len(), 1);
967 }
968
969 fn observe_req(id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
971 ObserveRequest {
972 agent_id: None,
973 pid: None,
974 agent: crate::sessions::Agent::Claude,
975 session_id: id.to_string(),
976 cwd: cwd.map(PathBuf::from),
977 transcript_path: None,
978 event,
979 repo: None,
980 model: None,
981 }
982 }
983
984 #[tokio::test]
985 async fn menu_action_errors_on_unknown_and_missing_window() {
986 let svc = SessionsService::new();
987 let err = svc.menu_action("frobnicate").await.unwrap_err();
989 assert!(
990 err.to_string().contains("unknown sessions menu action"),
991 "{err}"
992 );
993 let err = svc.menu_action("focus:nope").await.unwrap_err();
995 assert!(
996 err.to_string()
997 .contains("not open in a known VS Code window"),
998 "{err}"
999 );
1000 }
1001
1002 #[tokio::test]
1003 async fn end_op_ignores_the_replaced_process_by_pid() {
1004 let svc = SessionsService::new();
1005 for pid in [100, 200] {
1006 svc.handle(
1007 "observe",
1008 json!({ "session_id": "s1", "event": "session_start", "pid": pid }),
1009 )
1010 .await
1011 .unwrap();
1012 }
1013 svc.handle("end", json!({ "session_id": "s1", "pid": 100 }))
1014 .await
1015 .unwrap();
1016 assert_eq!(svc.registry().list()[0].state, SessionState::Starting);
1017 svc.handle("end", json!({ "session_id": "s1", "pid": 200 }))
1018 .await
1019 .unwrap();
1020 assert_eq!(svc.registry().list()[0].state, SessionState::Ended);
1021 }
1022
1023 #[test]
1024 fn repo_name_for_resolves_a_real_repo() {
1025 let tmp = tempfile::tempdir().unwrap();
1026 let repo_dir = tmp.path().join("myrepo");
1027 std::fs::create_dir(&repo_dir).unwrap();
1028 git2::Repository::init(&repo_dir).unwrap();
1029 assert_eq!(repo_name_for(&repo_dir).as_deref(), Some("myrepo"));
1031 }
1032
1033 #[tokio::test]
1034 async fn status_summary_counts_idle_and_ended() {
1035 let svc = SessionsService::new();
1036 svc.registry()
1037 .observe(observe_req("i", SessionEvent::Stop, None)); svc.registry().end("i2", None, None); svc.registry()
1040 .observe(observe_req("i2", SessionEvent::PreToolUse, None));
1041 svc.registry().end("i2", Some("done"), None); svc.registry()
1043 .observe(observe_req("s", SessionEvent::SessionStart, None)); let status = svc.status().await;
1045 assert!(status.summary.contains("3 idle"), "{}", status.summary);
1048 assert!(status.summary.contains("0 working"), "{}", status.summary);
1049 }
1050
1051 #[tokio::test]
1052 async fn status_reports_how_events_were_delivered() {
1053 let svc = service();
1054 let quiet = svc.status().await;
1056 assert!(!quiet.summary.contains("events:"), "{}", quiet.summary);
1057 assert_eq!(
1058 quiet.detail["delivery"],
1059 json!({ "socket": 0, "recovered": 0, "superseded": 0, "replayed": 0 })
1060 );
1061
1062 svc.handle(
1063 "observe",
1064 json!({ "session_id": "s1", "event": "pre_tool_use" }),
1065 )
1066 .await
1067 .unwrap();
1068 svc.handle("observe", json!({ "session_id": "s1", "event": "stop" }))
1069 .await
1070 .unwrap();
1071 let status = svc.status().await;
1072 assert!(
1073 status.summary.contains(
1074 "events: 2 via socket, 0 recovered from journals, 0 superseded, 0 replayed"
1075 ),
1076 "{}",
1077 status.summary
1078 );
1079 assert_eq!(status.detail["delivery"]["socket"], 2);
1080 assert!(
1082 status.summary.starts_with("1 session(s)"),
1083 "{}",
1084 status.summary
1085 );
1086 }
1087
1088 #[test]
1089 fn delivery_summary_is_empty_until_the_first_event() {
1090 assert_eq!(delivery_summary(DeliveryStats::default()), "");
1091 assert_eq!(
1092 delivery_summary(DeliveryStats {
1093 socket: 0,
1094 recovered: 1,
1095 superseded: 0,
1096 replayed: 0
1097 }),
1098 " · events: 0 via socket, 1 recovered from journals, 0 superseded, 0 replayed"
1099 );
1100 }
1101
1102 #[tokio::test]
1105 async fn subscribe_streams_only_for_the_subscribe_op() {
1106 let svc = service();
1107 assert!(svc.subscribe("subscribe", &Value::Null).is_some());
1110 assert!(svc.subscribe("list", &Value::Null).is_none());
1111 assert!(svc.subscribe("observe", &Value::Null).is_none());
1112 assert!(svc.subscribe("window", &Value::Null).is_none());
1113 assert!(svc.subscribe("bogus", &Value::Null).is_none());
1114 }
1115
1116 #[tokio::test]
1117 async fn subscribe_snapshot_matches_the_list_op() {
1118 let svc = service();
1119 let stream = svc
1120 .subscribe("subscribe", &Value::Null)
1121 .expect("subscribe stream");
1122 assert_eq!(stream.snapshot().await, json!({ "sessions": [] }));
1124
1125 svc.handle(
1126 "observe",
1127 json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/tmp/x" }),
1128 )
1129 .await
1130 .unwrap();
1131 let snap = stream.snapshot().await;
1134 let listed = svc.handle("list", Value::Null).await.unwrap();
1135 assert_eq!(snap, listed);
1136 assert_eq!(snap["sessions"][0]["state"], json!("working"));
1137 }
1138
1139 #[tokio::test]
1140 async fn subscribe_changed_wakes_on_a_visible_change() {
1141 let svc = service();
1142 let mut stream = svc
1143 .subscribe("subscribe", &Value::Null)
1144 .expect("subscribe stream");
1145 tokio::select! {
1147 () = stream.changed() => panic!("changed resolved with no registry change"),
1148 () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
1149 }
1150 svc.handle(
1152 "observe",
1153 json!({ "session_id": "s1", "event": "session_start" }),
1154 )
1155 .await
1156 .unwrap();
1157 tokio::time::timeout(std::time::Duration::from_secs(2), stream.changed())
1158 .await
1159 .expect("changed should resolve after a visible change");
1160 }
1161
1162 #[tokio::test]
1163 async fn changed_parks_when_every_sender_is_gone() {
1164 let (tx, rx) = tokio::sync::watch::channel(0u64);
1169 let mut stream = SessionsStream {
1170 registry: Arc::new(SessionsRegistry::new()),
1171 changes: rx,
1172 };
1173 drop(tx);
1174 tokio::select! {
1175 () = stream.changed() => panic!("changed resolved after the sender was dropped"),
1176 () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
1177 }
1178 }
1179}