Skip to main content

omni_dev/daemon/services/
sessions.rs

1//! The Claude Code sessions daemon service.
2//!
3//! A thin adapter that hosts the cross-window [`SessionsRegistry`] under the
4//! daemon's lifecycle and exposes the ingest ops (`observe`/`end`/`window`/
5//! `window-unregister`) and the read op (`list`) over the control socket, plus a
6//! tray submenu with a per-session "focus" action for sessions embedded in a VS
7//! Code window.
8//!
9//! All registry state and liveness logic (the two `Mutex<HashMap>`s, TTL
10//! reaping, the entry caps, the `Source` join) lives in [`crate::sessions`]; this
11//! adapter only routes ops, enriches a session's `repo` from its `cwd` via
12//! `git2` (the disk I/O the engine deliberately avoids, off the registry lock on
13//! a blocking thread — the worktrees `git_status` precedent, #1186), renders the
14//! menu/status, and drives the shared VS Code launcher. Like the Snowflake and
15//! worktrees services it is a cheap, in-memory adapter — no async setup, no
16//! secret persisted.
17//!
18//! Phase 3 additionally starts the engine-owned **transcript watcher**
19//! ([`start_watcher`](SessionsService::start_watcher)) so sessions started before
20//! the daemon — or working through the hook-silent "thinking window" — are still
21//! discovered and marked active. See ADR-0052.
22
23use std::path::Path;
24use std::sync::{Arc, Mutex, PoisonError};
25
26use anyhow::{anyhow, bail, Context, Result};
27use async_trait::async_trait;
28use git2::Repository;
29use serde_json::{json, Value};
30use tokio::task::JoinHandle;
31use tokio_util::sync::CancellationToken;
32
33use crate::daemon::service::{
34    DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
35};
36use crate::daemon::services::worktrees::focus_window;
37use crate::sessions::{ObserveRequest, SessionEntry, SessionState, SessionsRegistry, WindowReport};
38
39/// The sessions service name (the control-socket routing key).
40pub const SERVICE_NAME: &str = "sessions";
41
42/// The tray submenu title.
43const SUBMENU_TITLE: &str = "Claude Sessions";
44
45/// A running background transcript-watcher task and the token that stops it.
46struct WatcherTask {
47    /// Cancelled by `shutdown` to end the watch loop.
48    token: CancellationToken,
49    /// The spawned loop, awaited on shutdown so it fully unwinds.
50    handle: JoinHandle<()>,
51}
52
53/// Hosts the cross-window [`SessionsRegistry`] as a [`DaemonService`].
54pub struct SessionsService {
55    /// The registry this adapter routes ops to. Behind an `Arc` so the
56    /// background transcript-watcher task can feed it off the main thread.
57    registry: Arc<SessionsRegistry>,
58    /// The background transcript-watcher task, once started (`None` in tests /
59    /// with no runtime).
60    watcher: Mutex<Option<WatcherTask>>,
61}
62
63impl SessionsService {
64    /// Creates the service with an empty registry. Cheap — no I/O and no task;
65    /// the daemon calls [`start_watcher`](Self::start_watcher) to begin the
66    /// transcript watcher, while tests use the bare service.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            registry: Arc::new(SessionsRegistry::new()),
71            watcher: Mutex::new(None),
72        }
73    }
74
75    /// Starts the engine-owned transcript watcher (Feed 2): a background task
76    /// that scans `~/.claude/projects/**/*.jsonl` for new/growing transcripts and
77    /// feeds the registry, so a session started before the daemon — or working
78    /// through the hook-silent thinking window — is still discovered and marked
79    /// active. Idempotent, and a no-op outside a tokio runtime (mirroring the
80    /// worktrees menu-refresh and Snowflake keep-alive tasks), so unit tests that
81    /// build a bare service start no watcher.
82    pub fn start_watcher(&self) {
83        if tokio::runtime::Handle::try_current().is_err() {
84            tracing::debug!("no tokio runtime; sessions transcript watcher not started");
85            return;
86        }
87        let mut guard = self.watcher.lock().unwrap_or_else(PoisonError::into_inner);
88        if guard.is_some() {
89            return;
90        }
91        let token = CancellationToken::new();
92        let handle = crate::sessions::watcher::spawn(self.registry.clone(), token.clone());
93        *guard = Some(WatcherTask { token, handle });
94    }
95
96    /// The registry, for tests driving the service directly.
97    #[cfg(test)]
98    pub(crate) fn registry(&self) -> &Arc<SessionsRegistry> {
99        &self.registry
100    }
101}
102
103impl Default for SessionsService {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109#[async_trait]
110impl DaemonService for SessionsService {
111    fn name(&self) -> &'static str {
112        SERVICE_NAME
113    }
114
115    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
116        match op {
117            "observe" => {
118                let mut req: ObserveRequest =
119                    serde_json::from_value(payload).context("invalid `observe` payload")?;
120                if req.session_id.trim().is_empty() {
121                    bail!("`observe` requires a non-empty `session_id`");
122                }
123                // Enrich `repo` from `cwd` on a blocking thread (git2 disk I/O),
124                // exactly like the worktrees adapter — the engine stores only what
125                // it is handed and never touches the disk under its lock. Skip when
126                // a caller already supplied `repo`.
127                if req.repo.is_none() {
128                    if let Some(cwd) = req.cwd.clone() {
129                        req.repo = tokio::task::spawn_blocking(move || repo_name_for(&cwd))
130                            .await
131                            .unwrap_or_default();
132                    }
133                }
134                self.registry.observe(req);
135                Ok(json!({ "ok": true }))
136            }
137            "end" => {
138                let session_id = require_str(&payload, "session_id", "end")?;
139                let reason = payload.get("reason").and_then(Value::as_str);
140                Ok(json!({ "ended": self.registry.end(session_id, reason) }))
141            }
142            "window" => {
143                let req: WindowReport =
144                    serde_json::from_value(payload).context("invalid `window` payload")?;
145                if req.key.trim().is_empty() {
146                    bail!("`window` requires a non-empty `key`");
147                }
148                self.registry.report_window(req);
149                Ok(json!({ "ok": true }))
150            }
151            "window-unregister" => {
152                let key = require_str(&payload, "key", "window-unregister")?;
153                Ok(json!({ "removed": self.registry.unregister_window(key) }))
154            }
155            "list" => Ok(sessions_payload(&self.registry)),
156            other => bail!("unknown sessions op: {other}"),
157        }
158    }
159
160    fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
161        // The single streaming op: a live push of the same `sessions` snapshot
162        // `list` serves. Every other op falls through to the request→reply
163        // `handle`.
164        if op != "subscribe" {
165            return None;
166        }
167        Some(Box::new(SessionsStream {
168            registry: self.registry.clone(),
169            // Capture the change source *now* — before the server takes its
170            // initial snapshot — so a change racing that snapshot still wakes us.
171            changes: self.registry.subscribe_changes(),
172        }))
173    }
174
175    fn menu(&self) -> MenuSnapshot {
176        // Pure formatting of stored entries — `repo` was enriched at observe
177        // time, so `menu()` does no git I/O and honours the trait's "cheap, must
178        // not block" contract without a background cache (unlike worktrees).
179        MenuSnapshot {
180            title: SUBMENU_TITLE.to_string(),
181            items: menu_items_for(&self.registry.list()),
182        }
183    }
184
185    async fn menu_action(&self, action_id: &str) -> Result<()> {
186        if let Some(session_id) = action_id.strip_prefix("focus:") {
187            let folder = self.registry.focus_folder(session_id).ok_or_else(|| {
188                anyhow!("session {session_id} is not open in a known VS Code window")
189            })?;
190            focus_window(&folder)?;
191            return Ok(());
192        }
193        bail!("unknown sessions menu action: {action_id}")
194    }
195
196    async fn status(&self) -> ServiceStatus {
197        let sessions = self.registry.list();
198        let summary = status_summary(&sessions);
199        ServiceStatus {
200            name: SERVICE_NAME.to_string(),
201            healthy: true,
202            summary,
203            detail: json!({ "sessions": sessions }),
204        }
205    }
206
207    async fn shutdown(&self) {
208        // Stop the transcript watcher; the registry itself is in-memory with
209        // nothing to drain. Take the task out from under the lock first so the
210        // `std::Mutex` is never held across the `.await`.
211        let task = self
212            .watcher
213            .lock()
214            .unwrap_or_else(PoisonError::into_inner)
215            .take();
216        if let Some(task) = task {
217            task.token.cancel();
218            let _ = task.handle.await;
219        }
220    }
221}
222
223/// The live session set as the wire payload — the single body both the one-shot
224/// `list` op and the `subscribe` stream serve, so a fetch and a push can never
225/// disagree.
226fn sessions_payload(registry: &SessionsRegistry) -> Value {
227    json!({ "sessions": registry.list() })
228}
229
230/// The live push stream behind the `subscribe` op (#1414).
231///
232/// Unlike the worktrees stream this needs no coalescing snapshot cache (#1303):
233/// `repo` is enriched at `observe` time, so [`SessionsRegistry::list`] is pure
234/// CPU under a lock with no disk I/O — cheap enough to run once per subscriber
235/// per wakeup, and cheap enough that [`snapshot`](ServiceStream::snapshot) needs
236/// no blocking thread.
237struct SessionsStream {
238    /// The registry each snapshot is read from.
239    registry: Arc<SessionsRegistry>,
240    /// Wakes on each consumer-visible change (a new or ended session, a state
241    /// transition, a window report that alters the source join). A burst
242    /// coalesces into one wakeup; the server's diff drops any snapshot that ends
243    /// up identical.
244    changes: tokio::sync::watch::Receiver<u64>,
245}
246
247#[async_trait]
248impl ServiceStream for SessionsStream {
249    async fn changed(&mut self) {
250        // `watch::Receiver::changed` marks the newest version seen, so a burst of
251        // bumps collapses into a single wakeup. If every sender is gone (the
252        // registry — and thus the daemon — is tearing down) it returns `Err`;
253        // park instead of returning, so this arm can never spin the server's
254        // `select!` (the tick and shutdown arms still drive teardown).
255        if self.changes.changed().await.is_err() {
256            std::future::pending::<()>().await;
257        }
258    }
259
260    async fn snapshot(&self) -> Value {
261        sessions_payload(&self.registry)
262    }
263}
264
265/// Extracts a required string `field` from an op payload, erroring with the op
266/// name when it is absent or not a string.
267fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
268    payload
269        .get(field)
270        .and_then(Value::as_str)
271        .ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
272}
273
274/// The repository name for a session's `cwd`, derived from the discovered
275/// repo's common dir (so a session inside a linked worktree names its parent
276/// repo, matching the worktrees enrichment). Best-effort: `None` when `cwd` is
277/// not inside a git repo. Pure disk I/O; called on a blocking thread.
278fn repo_name_for(cwd: &Path) -> Option<String> {
279    let repo = Repository::discover(cwd).ok()?;
280    main_repo_name(repo.commondir())
281}
282
283/// The main repository's directory name from git's common dir — the same
284/// derivation the worktrees adapter uses: for the `<repo>/.git` layout the
285/// working-tree directory's name, for a bare `<name>.git` that name with the
286/// suffix stripped.
287fn main_repo_name(commondir: &Path) -> Option<String> {
288    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
289    if file_name == ".git" {
290        commondir
291            .parent()
292            .and_then(Path::file_name)
293            .map(|n| n.to_string_lossy().into_owned())
294    } else {
295        Some(
296            file_name
297                .strip_suffix(".git")
298                .unwrap_or(&file_name)
299                .to_string(),
300        )
301    }
302}
303
304/// A one-line `status` summary: the live session count and a per-state tally.
305fn status_summary(sessions: &[SessionEntry]) -> String {
306    if sessions.is_empty() {
307        return "0 session(s)".to_string();
308    }
309    let mut working = 0;
310    let mut waiting = 0;
311    let mut idle = 0;
312    for s in sessions {
313        match s.state {
314            SessionState::Working | SessionState::Starting => working += 1,
315            SessionState::WaitingForInput | SessionState::WaitingForPermission => waiting += 1,
316            SessionState::Idle | SessionState::Ended => idle += 1,
317        }
318    }
319    format!(
320        "{} session(s): {working} working, {waiting} waiting, {idle} idle",
321        sessions.len()
322    )
323}
324
325/// A short glyph marking a session's state in the tray label.
326fn state_glyph(state: SessionState) -> &'static str {
327    match state {
328        SessionState::Starting => "…",
329        SessionState::Working => "⚙",
330        SessionState::Idle => "◦",
331        SessionState::WaitingForInput => "?",
332        SessionState::WaitingForPermission => "!",
333        SessionState::Ended => "×",
334    }
335}
336
337/// A short human name for a session: its repo, else its cwd basename, else the
338/// truncated session id.
339fn display_name(entry: &SessionEntry) -> String {
340    if let Some(repo) = &entry.repo {
341        return repo.clone();
342    }
343    if let Some(cwd) = &entry.cwd {
344        if let Some(name) = cwd.file_name() {
345            return name.to_string_lossy().into_owned();
346        }
347    }
348    // A session with neither repo nor cwd: a short id prefix is still a handle.
349    entry.session_id.chars().take(8).collect()
350}
351
352/// The tray items for the session set: a placeholder when empty, else one line
353/// per session (`<name> <glyph> <state>`). A session embedded in a VS Code window
354/// is clickable (its click focuses that window via the `focus:` action); a
355/// terminal session is a non-clickable status line, since the daemon has no
356/// window to focus.
357fn menu_items_for(sessions: &[SessionEntry]) -> Vec<MenuItem> {
358    use crate::sessions::Source;
359    if sessions.is_empty() {
360        return vec![MenuItem::Label("No active sessions".to_string())];
361    }
362    sessions
363        .iter()
364        .map(|entry| {
365            let label = format!(
366                "{} {} {}",
367                display_name(entry),
368                state_glyph(entry.state),
369                state_label(entry.state),
370            );
371            match &entry.source {
372                Source::VsCode { .. } => MenuItem::Action(MenuAction {
373                    id: format!("focus:{}", entry.session_id),
374                    label,
375                    enabled: true,
376                }),
377                Source::Terminal => MenuItem::Label(label),
378            }
379        })
380        .collect()
381}
382
383/// A short lowercase label for a session state, for the tray line and any
384/// human-readable rendering.
385fn state_label(state: SessionState) -> &'static str {
386    match state {
387        SessionState::Starting => "starting",
388        SessionState::Working => "working",
389        SessionState::Idle => "idle",
390        SessionState::WaitingForInput => "waiting",
391        SessionState::WaitingForPermission => "permission",
392        SessionState::Ended => "ended",
393    }
394}
395
396#[cfg(test)]
397#[allow(clippy::unwrap_used, clippy::expect_used)]
398mod tests {
399    use super::*;
400    use crate::sessions::{NotificationKind, SessionEvent, Source};
401    use std::path::PathBuf;
402
403    fn service() -> SessionsService {
404        SessionsService::new()
405    }
406
407    #[tokio::test]
408    async fn observe_then_list_round_trips() {
409        let svc = service();
410        let ok = svc
411            .handle(
412                "observe",
413                json!({ "session_id": "s1", "event": "session_start", "cwd": "/tmp/x" }),
414            )
415            .await
416            .unwrap();
417        assert_eq!(ok, json!({ "ok": true }));
418
419        let listed = svc.handle("list", Value::Null).await.unwrap();
420        let sessions = listed["sessions"].as_array().unwrap();
421        assert_eq!(sessions.len(), 1);
422        assert_eq!(sessions[0]["session_id"], "s1");
423        assert_eq!(sessions[0]["state"], "starting");
424    }
425
426    #[tokio::test]
427    async fn observe_rejects_blank_session_id() {
428        let svc = service();
429        let err = svc
430            .handle("observe", json!({ "session_id": "  ", "event": "stop" }))
431            .await
432            .unwrap_err();
433        assert!(err.to_string().contains("session_id"), "{err}");
434    }
435
436    #[tokio::test]
437    async fn end_marks_ended() {
438        let svc = service();
439        svc.handle(
440            "observe",
441            json!({ "session_id": "s1", "event": "pre_tool_use" }),
442        )
443        .await
444        .unwrap();
445        let reply = svc
446            .handle("end", json!({ "session_id": "s1", "reason": "clear" }))
447            .await
448            .unwrap();
449        assert_eq!(reply, json!({ "ended": true }));
450        // Ending an unknown session reports false, not an error.
451        let reply = svc
452            .handle("end", json!({ "session_id": "ghost" }))
453            .await
454            .unwrap();
455        assert_eq!(reply, json!({ "ended": false }));
456    }
457
458    #[tokio::test]
459    async fn window_report_tags_source_and_unregister_removes() {
460        let svc = service();
461        svc.handle(
462            "observe",
463            json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
464        )
465        .await
466        .unwrap();
467        svc.handle(
468            "window",
469            json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
470        )
471        .await
472        .unwrap();
473        let listed = svc.handle("list", Value::Null).await.unwrap();
474        assert_eq!(listed["sessions"][0]["source"]["kind"], "vs_code");
475        assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
476
477        let removed = svc
478            .handle("window-unregister", json!({ "key": "w1" }))
479            .await
480            .unwrap();
481        assert_eq!(removed, json!({ "removed": true }));
482        let listed = svc.handle("list", Value::Null).await.unwrap();
483        assert_eq!(listed["sessions"][0]["source"]["kind"], "terminal");
484    }
485
486    #[tokio::test]
487    async fn unknown_op_errors() {
488        let svc = service();
489        let err = svc.handle("frobnicate", Value::Null).await.unwrap_err();
490        assert!(err.to_string().contains("unknown sessions op"), "{err}");
491    }
492
493    #[tokio::test]
494    async fn status_summarizes_states() {
495        let svc = service();
496        svc.registry().observe(ObserveRequest {
497            session_id: "w".to_string(),
498            cwd: None,
499            transcript_path: None,
500            event: SessionEvent::PreToolUse,
501            repo: None,
502            model: None,
503        });
504        svc.registry().observe(ObserveRequest {
505            session_id: "p".to_string(),
506            cwd: None,
507            transcript_path: None,
508            event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
509            repo: None,
510            model: None,
511        });
512        let status = svc.status().await;
513        assert!(status.healthy);
514        assert!(
515            status.summary.contains("2 session(s)"),
516            "{}",
517            status.summary
518        );
519        assert!(status.summary.contains("1 working"), "{}", status.summary);
520        assert!(status.summary.contains("1 waiting"), "{}", status.summary);
521    }
522
523    #[test]
524    fn menu_items_placeholder_when_empty() {
525        let items = menu_items_for(&[]);
526        assert_eq!(items.len(), 1);
527        assert!(matches!(&items[0], MenuItem::Label(l) if l.contains("No active")));
528    }
529
530    #[test]
531    fn menu_item_is_clickable_only_for_vscode_sessions() {
532        let now = chrono::Utc::now();
533        let base = |source: Source| SessionEntry {
534            session_id: "sid-12345678".to_string(),
535            cwd: Some(PathBuf::from("/p")),
536            transcript_path: None,
537            repo: Some("proj".to_string()),
538            model: None,
539            state: SessionState::Working,
540            source,
541            last_event: SessionEvent::PreToolUse,
542            started_at: now,
543            last_seen: now,
544        };
545        // A terminal session is a non-clickable label.
546        let terminal = menu_items_for(&[base(Source::Terminal)]);
547        assert!(
548            matches!(&terminal[0], MenuItem::Label(l) if l.contains("proj") && l.contains("working"))
549        );
550        // A VS Code session is a clickable focus action.
551        let vscode = menu_items_for(&[base(Source::VsCode {
552            window_key: "w1".to_string(),
553        })]);
554        match &vscode[0] {
555            MenuItem::Action(a) => assert_eq!(a.id, "focus:sid-12345678"),
556            other => panic!("expected an action, got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn repo_name_for_non_repo_is_none() {
562        // A path that is not inside any git repo enriches to no repo.
563        assert_eq!(repo_name_for(Path::new("/nonexistent/xyz")), None);
564    }
565
566    #[test]
567    fn main_repo_name_handles_layouts() {
568        assert_eq!(
569            main_repo_name(Path::new("/home/me/proj/.git")).as_deref(),
570            Some("proj")
571        );
572        assert_eq!(
573            main_repo_name(Path::new("/home/me/bare.git")).as_deref(),
574            Some("bare")
575        );
576    }
577
578    /// Builds a session entry for the pure menu/label tests.
579    fn entry(id: &str, state: SessionState, repo: Option<&str>, cwd: Option<&str>) -> SessionEntry {
580        let now = chrono::Utc::now();
581        SessionEntry {
582            session_id: id.to_string(),
583            cwd: cwd.map(PathBuf::from),
584            transcript_path: None,
585            repo: repo.map(str::to_string),
586            model: None,
587            state,
588            source: Source::Terminal,
589            last_event: SessionEvent::PreToolUse,
590            started_at: now,
591            last_seen: now,
592        }
593    }
594
595    #[test]
596    fn default_constructs_an_empty_service() {
597        let svc = SessionsService::default();
598        assert!(svc.registry().list().is_empty());
599    }
600
601    #[test]
602    fn start_watcher_is_a_noop_outside_a_runtime() {
603        // No tokio runtime → the watcher is not started, and shutdown is a no-op.
604        let svc = SessionsService::new();
605        svc.start_watcher();
606        assert!(svc
607            .watcher
608            .lock()
609            .unwrap_or_else(PoisonError::into_inner)
610            .is_none());
611    }
612
613    #[tokio::test]
614    async fn start_watcher_is_idempotent_and_shutdown_stops_it() {
615        let svc = SessionsService::new();
616        svc.start_watcher();
617        // A second call is a no-op (the guard is already set), not a second task.
618        svc.start_watcher();
619        assert!(svc
620            .watcher
621            .lock()
622            .unwrap_or_else(PoisonError::into_inner)
623            .is_some());
624        // Shutdown cancels and joins the task, clearing the slot.
625        svc.shutdown().await;
626        assert!(svc
627            .watcher
628            .lock()
629            .unwrap_or_else(PoisonError::into_inner)
630            .is_none());
631    }
632
633    #[test]
634    fn menu_renders_every_state_and_name_fallback() {
635        let sessions = vec![
636            entry("s1", SessionState::Starting, Some("repo-a"), Some("/a")),
637            entry("s2", SessionState::Working, None, Some("/home/me/proj")),
638            entry("s3", SessionState::Idle, None, None),
639            entry("s4", SessionState::WaitingForInput, Some("r"), Some("/b")),
640            entry(
641                "s5",
642                SessionState::WaitingForPermission,
643                Some("r"),
644                Some("/c"),
645            ),
646            entry("s6", SessionState::Ended, Some("r"), Some("/d")),
647        ];
648        let items = menu_items_for(&sessions);
649        assert_eq!(items.len(), 6);
650        // Every line is a non-clickable label (all terminal sessions) and carries
651        // the state label + a name (repo, else cwd basename, else id prefix).
652        let labels: Vec<&str> = items
653            .iter()
654            .map(|i| match i {
655                MenuItem::Label(l) => l.as_str(),
656                _ => panic!("terminal sessions render as labels"),
657            })
658            .collect();
659        assert!(labels[0].contains("repo-a") && labels[0].contains("starting"));
660        assert!(labels[1].contains("proj") && labels[1].contains("working")); // cwd basename
661        assert!(labels[2].contains("s3") && labels[2].contains("idle")); // id-prefix fallback
662        assert!(labels[3].contains("waiting"));
663        assert!(labels[4].contains("permission"));
664        assert!(labels[5].contains("ended"));
665    }
666
667    #[test]
668    fn menu_serves_the_snapshot_title() {
669        let svc = SessionsService::new();
670        svc.registry()
671            .observe(observe_req("s1", SessionEvent::Stop, None));
672        let snapshot = svc.menu();
673        assert_eq!(snapshot.title, SUBMENU_TITLE);
674        assert_eq!(snapshot.items.len(), 1);
675    }
676
677    /// A small `observe` builder for the adapter tests.
678    fn observe_req(id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
679        ObserveRequest {
680            session_id: id.to_string(),
681            cwd: cwd.map(PathBuf::from),
682            transcript_path: None,
683            event,
684            repo: None,
685            model: None,
686        }
687    }
688
689    #[tokio::test]
690    async fn menu_action_errors_on_unknown_and_missing_window() {
691        let svc = SessionsService::new();
692        // An unrecognised action id.
693        let err = svc.menu_action("frobnicate").await.unwrap_err();
694        assert!(
695            err.to_string().contains("unknown sessions menu action"),
696            "{err}"
697        );
698        // A focus of a session that resolves to no VS Code window folder.
699        let err = svc.menu_action("focus:nope").await.unwrap_err();
700        assert!(
701            err.to_string()
702                .contains("not open in a known VS Code window"),
703            "{err}"
704        );
705    }
706
707    #[test]
708    fn repo_name_for_resolves_a_real_repo() {
709        let tmp = tempfile::tempdir().unwrap();
710        let repo_dir = tmp.path().join("myrepo");
711        std::fs::create_dir(&repo_dir).unwrap();
712        git2::Repository::init(&repo_dir).unwrap();
713        // A path inside the repo enriches to the repo's directory name.
714        assert_eq!(repo_name_for(&repo_dir).as_deref(), Some("myrepo"));
715    }
716
717    #[tokio::test]
718    async fn status_summary_counts_idle_and_ended() {
719        let svc = SessionsService::new();
720        svc.registry()
721            .observe(observe_req("i", SessionEvent::Stop, None)); // idle
722        svc.registry().end("i2", None); // unknown → no-op
723        svc.registry()
724            .observe(observe_req("i2", SessionEvent::PreToolUse, None));
725        svc.registry().end("i2", Some("done")); // ended
726        let status = svc.status().await;
727        // Idle + ended both count toward the "idle" tally.
728        assert!(status.summary.contains("2 idle"), "{}", status.summary);
729    }
730
731    // --- Push subscription (#1414) -----------------------------------------
732
733    #[tokio::test]
734    async fn subscribe_streams_only_for_the_subscribe_op() {
735        let svc = service();
736        // The one streaming op yields a stream; every other op declines, so the
737        // server dispatches them normally through `handle`.
738        assert!(svc.subscribe("subscribe", &Value::Null).is_some());
739        assert!(svc.subscribe("list", &Value::Null).is_none());
740        assert!(svc.subscribe("observe", &Value::Null).is_none());
741        assert!(svc.subscribe("window", &Value::Null).is_none());
742        assert!(svc.subscribe("bogus", &Value::Null).is_none());
743    }
744
745    #[tokio::test]
746    async fn subscribe_snapshot_matches_the_list_op() {
747        let svc = service();
748        let stream = svc
749            .subscribe("subscribe", &Value::Null)
750            .expect("subscribe stream");
751        // Empty registry: the push and the one-shot fetch agree.
752        assert_eq!(stream.snapshot().await, json!({ "sessions": [] }));
753
754        svc.handle(
755            "observe",
756            json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/tmp/x" }),
757        )
758        .await
759        .unwrap();
760        // …and still agree byte-for-byte once there is state to serve, which is
761        // what lets the extension treat a pushed frame and a `list` reply the same.
762        let snap = stream.snapshot().await;
763        let listed = svc.handle("list", Value::Null).await.unwrap();
764        assert_eq!(snap, listed);
765        assert_eq!(snap["sessions"][0]["state"], json!("working"));
766    }
767
768    #[tokio::test]
769    async fn subscribe_changed_wakes_on_a_visible_change() {
770        let svc = service();
771        let mut stream = svc
772            .subscribe("subscribe", &Value::Null)
773            .expect("subscribe stream");
774        // Idle: `changed()` must not resolve without a registry change.
775        tokio::select! {
776            () = stream.changed() => panic!("changed resolved with no registry change"),
777            () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
778        }
779        // A new session bumps the change-notify → `changed()` resolves promptly.
780        svc.handle(
781            "observe",
782            json!({ "session_id": "s1", "event": "session_start" }),
783        )
784        .await
785        .unwrap();
786        tokio::time::timeout(std::time::Duration::from_secs(2), stream.changed())
787            .await
788            .expect("changed should resolve after a visible change");
789    }
790
791    #[tokio::test]
792    async fn changed_parks_when_every_sender_is_gone() {
793        // With the sender dropped, `watch::Receiver::changed` returns `Err`
794        // immediately and forever. `changed()` must park on that rather than
795        // return, or the arm would spin the server's `select!` for the rest of
796        // the connection's life.
797        let (tx, rx) = tokio::sync::watch::channel(0u64);
798        let mut stream = SessionsStream {
799            registry: Arc::new(SessionsRegistry::new()),
800            changes: rx,
801        };
802        drop(tx);
803        tokio::select! {
804            () = stream.changed() => panic!("changed resolved after the sender was dropped"),
805            () = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
806        }
807    }
808}