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