use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use async_trait::async_trait;
use git2::Repository;
use serde_json::{json, Value};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::daemon::service::{
DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus, ServiceStream,
};
use crate::daemon::services::worktrees::focus_window;
use crate::sessions::{
DeliveryStats, EventStamp, ObserveRequest, Origin, SessionEntry, SessionState,
SessionsRegistry, WindowReport,
};
pub const SERVICE_NAME: &str = "sessions";
const SUBMENU_TITLE: &str = "Agent Sessions";
struct WatcherTask {
token: CancellationToken,
handles: Vec<JoinHandle<()>>,
}
pub struct SessionsService {
registry: Arc<SessionsRegistry>,
watcher: Mutex<Option<WatcherTask>>,
journal_dir: Option<PathBuf>,
}
impl SessionsService {
#[must_use]
pub fn new() -> Self {
Self {
registry: Arc::new(SessionsRegistry::new()),
watcher: Mutex::new(None),
journal_dir: None,
}
}
#[must_use]
pub fn with_journal_dir(mut self, dir: PathBuf) -> Self {
self.journal_dir = Some(dir);
self
}
pub fn start_watcher(&self) {
if tokio::runtime::Handle::try_current().is_err() {
tracing::debug!("no tokio runtime; sessions transcript watcher not started");
return;
}
let mut guard = self.watcher.lock().unwrap_or_else(PoisonError::into_inner);
if guard.is_some() {
return;
}
let token = CancellationToken::new();
let mut handles = vec![
crate::sessions::watcher::spawn(self.registry.clone(), token.clone()),
crate::sessions::codex_watcher::spawn(self.registry.clone(), token.clone()),
crate::sessions::pid_watcher::spawn(self.registry.clone(), token.clone()),
];
if let Some(dir) = &self.journal_dir {
handles.push(crate::sessions::journal_watcher::spawn(
self.registry.clone(),
dir.clone(),
Arc::new(|cwd: &Path| repo_name_for(cwd)),
token.clone(),
));
}
*guard = Some(WatcherTask { token, handles });
}
#[cfg(test)]
pub(crate) fn registry(&self) -> &Arc<SessionsRegistry> {
&self.registry
}
}
impl Default for SessionsService {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl DaemonService for SessionsService {
fn name(&self) -> &'static str {
SERVICE_NAME
}
async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
match op {
"observe" => {
let stamp = stamp_of(&payload);
let mut req: ObserveRequest =
serde_json::from_value(payload).context("invalid `observe` payload")?;
if req.session_id.trim().is_empty() {
bail!("`observe` requires a non-empty `session_id`");
}
if req.repo.is_none() {
if let Some(cwd) = req.cwd.clone() {
req.repo = match tokio::task::spawn_blocking(move || repo_name_for(&cwd))
.await
{
Ok(repo) => repo,
Err(error) => {
tracing::warn!(session_id = %req.session_id, %error, "sessions_repo_enrichment_failed");
None
}
};
}
}
self.registry.observe_stamped(req, stamp, Origin::Socket);
Ok(json!({ "ok": true }))
}
"end" => {
let session_id = require_str(&payload, "session_id", "end")?;
let reason = payload.get("reason").and_then(Value::as_str);
let pid = payload
.get("pid")
.and_then(Value::as_u64)
.and_then(|pid| u32::try_from(pid).ok());
let ended = self.registry.end_stamped(
session_id,
reason,
pid,
stamp_of(&payload),
Origin::Socket,
);
Ok(json!({ "ended": ended }))
}
"window" => {
let req: WindowReport =
serde_json::from_value(payload).context("invalid `window` payload")?;
if req.key.trim().is_empty() {
bail!("`window` requires a non-empty `key`");
}
self.registry.report_window(req);
Ok(json!({ "ok": true }))
}
"window-unregister" => {
let key = require_str(&payload, "key", "window-unregister")?;
Ok(json!({ "removed": self.registry.unregister_window(key) }))
}
"list" => Ok(sessions_payload(&self.registry)),
other => bail!("unknown sessions op: {other}"),
}
}
fn subscribe(&self, op: &str, _payload: &Value) -> Option<Box<dyn ServiceStream>> {
if op != "subscribe" {
return None;
}
Some(Box::new(SessionsStream {
registry: self.registry.clone(),
changes: self.registry.subscribe_changes(),
}))
}
fn menu(&self) -> MenuSnapshot {
MenuSnapshot {
title: SUBMENU_TITLE.to_string(),
items: menu_items_for(&self.registry.list()),
}
}
async fn menu_action(&self, action_id: &str) -> Result<()> {
if let Some(session_id) = action_id.strip_prefix("focus:") {
let folder = self.registry.focus_folder(session_id).ok_or_else(|| {
anyhow!("session {session_id} is not open in a known VS Code window")
})?;
focus_window(&folder)?;
return Ok(());
}
bail!("unknown sessions menu action: {action_id}")
}
async fn status(&self) -> ServiceStatus {
let sessions = self.registry.list();
let delivery = self.registry.delivery();
let summary = format!(
"{}{}",
status_summary(&sessions),
delivery_summary(delivery)
);
ServiceStatus {
name: SERVICE_NAME.to_string(),
healthy: true,
summary,
detail: json!({ "sessions": sessions, "delivery": delivery }),
}
}
fn credit_accept_outage(&self, outage: Duration) {
self.registry.credit_window_outage(outage);
}
async fn shutdown(&self) {
let task = self
.watcher
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(task) = task {
task.token.cancel();
for handle in task.handles {
let _ = handle.await;
}
}
}
}
fn sessions_payload(registry: &SessionsRegistry) -> Value {
json!({ "sessions": registry.list() })
}
struct SessionsStream {
registry: Arc<SessionsRegistry>,
changes: tokio::sync::watch::Receiver<u64>,
}
#[async_trait]
impl ServiceStream for SessionsStream {
async fn changed(&mut self) {
if self.changes.changed().await.is_err() {
std::future::pending::<()>().await;
}
}
async fn snapshot(&self) -> Value {
sessions_payload(&self.registry)
}
}
fn stamp_of(payload: &Value) -> Option<EventStamp> {
serde_json::from_value(payload.get("stamp")?.clone()).ok()
}
fn require_str<'a>(payload: &'a Value, field: &str, op: &str) -> Result<&'a str> {
payload
.get(field)
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("`{op}` requires `{field}`"))
}
fn repo_name_for(cwd: &Path) -> Option<String> {
let repo = Repository::discover(cwd).ok()?;
main_repo_name(repo.commondir())
}
fn main_repo_name(commondir: &Path) -> Option<String> {
let file_name = commondir.file_name()?.to_string_lossy().into_owned();
if file_name == ".git" {
commondir
.parent()
.and_then(Path::file_name)
.map(|n| n.to_string_lossy().into_owned())
} else {
Some(
file_name
.strip_suffix(".git")
.unwrap_or(&file_name)
.to_string(),
)
}
}
fn status_summary(sessions: &[SessionEntry]) -> String {
if sessions.is_empty() {
return "0 session(s)".to_string();
}
let mut working = 0;
let mut waiting = 0;
let mut idle = 0;
for s in sessions {
match s.state {
SessionState::Working => working += 1,
SessionState::WaitingForInput | SessionState::WaitingForPermission => waiting += 1,
SessionState::Starting | SessionState::Idle | SessionState::Ended => idle += 1,
}
}
format!(
"{} session(s): {working} working, {waiting} waiting, {idle} idle",
sessions.len()
)
}
fn delivery_summary(delivery: DeliveryStats) -> String {
if delivery == DeliveryStats::default() {
return String::new();
}
format!(
" · events: {} via socket, {} recovered from journals, {} superseded, {} replayed",
delivery.socket, delivery.recovered, delivery.superseded, delivery.replayed
)
}
fn state_glyph(state: SessionState) -> &'static str {
match state {
SessionState::Starting => "…",
SessionState::Working => "⚙",
SessionState::Idle => "◦",
SessionState::WaitingForInput => "?",
SessionState::WaitingForPermission => "!",
SessionState::Ended => "×",
}
}
fn display_name(entry: &SessionEntry) -> String {
if let Some(repo) = &entry.repo {
return repo.clone();
}
if let Some(cwd) = &entry.cwd {
if let Some(name) = cwd.file_name() {
return name.to_string_lossy().into_owned();
}
}
entry.session_id.chars().take(8).collect()
}
fn menu_items_for(sessions: &[SessionEntry]) -> Vec<MenuItem> {
use crate::sessions::Source;
if sessions.is_empty() {
return vec![MenuItem::Label("No active sessions".to_string())];
}
sessions
.iter()
.map(|entry| {
let label = format!(
"{} · {} {} {}",
entry.agent.display_name(),
display_name(entry),
state_glyph(entry.state),
state_label(entry.state),
);
match &entry.source {
Source::VsCode { .. } => MenuItem::Action(MenuAction {
id: format!("focus:{}", entry.session_id),
label,
enabled: true,
}),
Source::Terminal => MenuItem::Label(label),
}
})
.collect()
}
fn state_label(state: SessionState) -> &'static str {
match state {
SessionState::Starting => "starting",
SessionState::Working => "working",
SessionState::Idle => "idle",
SessionState::WaitingForInput => "waiting",
SessionState::WaitingForPermission => "permission",
SessionState::Ended => "ended",
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::sessions::{NotificationKind, SessionEvent, Source};
use std::collections::VecDeque;
use std::path::PathBuf;
fn service() -> SessionsService {
SessionsService::new()
}
#[tokio::test]
async fn observe_then_list_round_trips() {
let svc = service();
let ok = svc
.handle(
"observe",
json!({ "session_id": "s1", "event": "session_start", "cwd": "/tmp/x" }),
)
.await
.unwrap();
assert_eq!(ok, json!({ "ok": true }));
let listed = svc.handle("list", Value::Null).await.unwrap();
let sessions = listed["sessions"].as_array().unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0]["session_id"], "s1");
assert_eq!(sessions[0]["state"], "starting");
}
#[tokio::test]
async fn observe_rejects_blank_session_id() {
let svc = service();
let err = svc
.handle("observe", json!({ "session_id": " ", "event": "stop" }))
.await
.unwrap_err();
assert!(err.to_string().contains("session_id"), "{err}");
}
#[tokio::test]
async fn end_marks_ended() {
let svc = service();
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "pre_tool_use" }),
)
.await
.unwrap();
let reply = svc
.handle("end", json!({ "session_id": "s1", "reason": "clear" }))
.await
.unwrap();
assert_eq!(reply, json!({ "ended": true }));
let reply = svc
.handle("end", json!({ "session_id": "ghost" }))
.await
.unwrap();
assert_eq!(reply, json!({ "ended": false }));
}
#[tokio::test]
async fn a_started_service_replays_the_journals_beside_its_socket() {
use crate::sessions::journal::{self, JournalRecord};
let tmp = tempfile::tempdir().unwrap();
let dir =
crate::daemon::paths::sessions_journal_dir_for_socket(&tmp.path().join("daemon.sock"));
let id = "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c";
let stamp = EventStamp {
ts: chrono::Utc::now() - chrono::Duration::seconds(15),
seq: "7-1".to_string(),
};
let req = ObserveRequest {
agent_id: None,
session_id: id.to_string(),
cwd: Some(PathBuf::from("/nonexistent/proj")),
transcript_path: None,
event: SessionEvent::SessionStart,
repo: None,
model: None,
agent: crate::sessions::Agent::Claude,
pid: None,
};
journal::append(&dir, &JournalRecord::observe(&req, &stamp)).unwrap();
let svc = SessionsService::new().with_journal_dir(dir);
svc.start_watcher();
let mut found = None;
for _ in 0..100 {
let listed = svc.handle("list", Value::Null).await.unwrap();
found = listed["sessions"]
.as_array()
.unwrap()
.iter()
.find(|s| s["session_id"] == id)
.cloned();
if found.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
let found = found.expect("the journaled session is listed");
assert_eq!(found["state"], "starting");
assert_eq!(found["cwd"], "/nonexistent/proj");
svc.shutdown().await;
}
#[tokio::test]
async fn a_stamped_event_delivered_twice_is_applied_once() {
let svc = service();
let working = json!({
"ts": "2026-10-03T03:40:00Z", "seq": "9-1",
});
let stopped = json!({
"ts": "2026-10-03T03:40:05Z", "seq": "9-2",
});
for (event, stamp) in [
("pre_tool_use", &working),
("stop", &stopped),
("pre_tool_use", &working),
] {
svc.handle(
"observe",
json!({ "session_id": "s1", "event": event, "stamp": stamp }),
)
.await
.unwrap();
}
let listed = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(listed["sessions"][0]["state"], "idle");
}
#[tokio::test]
async fn a_malformed_stamp_is_ignored_not_an_error() {
let svc = service();
for stamp in [
json!("nope"),
json!({ "ts": "yesterday", "seq": 3 }),
Value::Null,
] {
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "pre_tool_use", "stamp": stamp }),
)
.await
.unwrap();
svc.handle("end", json!({ "session_id": "s1", "stamp": stamp }))
.await
.unwrap();
}
assert_eq!(svc.registry().list().len(), 1);
}
#[tokio::test]
async fn a_stamped_end_is_deduplicated_too() {
let svc = service();
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "session_start",
"stamp": { "ts": "2026-10-03T03:40:00Z", "seq": "9-1" } }),
)
.await
.unwrap();
let end =
json!({ "session_id": "s1", "stamp": { "ts": "2026-10-03T03:41:00Z", "seq": "9-2" } });
assert_eq!(
svc.handle("end", end.clone()).await.unwrap(),
json!({ "ended": true })
);
assert_eq!(
svc.handle("end", end).await.unwrap(),
json!({ "ended": true })
);
let listed = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(listed["sessions"][0]["state"], "ended");
}
#[tokio::test]
async fn window_report_tags_source_and_unregister_removes() {
let svc = service();
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
)
.await
.unwrap();
svc.handle(
"window",
json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
)
.await
.unwrap();
let listed = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(listed["sessions"][0]["source"]["kind"], "vs_code");
assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
let removed = svc
.handle("window-unregister", json!({ "key": "w1" }))
.await
.unwrap();
assert_eq!(removed, json!({ "removed": true }));
let listed = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(listed["sessions"][0]["source"]["kind"], "terminal");
}
#[tokio::test]
async fn credit_accept_outage_reaches_the_window_reports_without_dropping_a_live_one() {
let svc = service();
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/home/me/proj/sub" }),
)
.await
.unwrap();
svc.handle(
"window",
json!({ "key": "w1", "folders": ["/home/me/proj"], "tabs": 1, "terminals": 0 }),
)
.await
.unwrap();
svc.credit_accept_outage(std::time::Duration::from_secs(5));
let listed = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(listed["sessions"][0]["source"]["window_key"], "w1");
}
#[tokio::test]
async fn unknown_op_errors() {
let svc = service();
let err = svc.handle("frobnicate", Value::Null).await.unwrap_err();
assert!(err.to_string().contains("unknown sessions op"), "{err}");
}
#[tokio::test]
async fn status_summarizes_states() {
let svc = service();
svc.registry().observe(ObserveRequest {
agent_id: None,
pid: None,
agent: crate::sessions::Agent::Claude,
session_id: "w".to_string(),
cwd: None,
transcript_path: None,
event: SessionEvent::PreToolUse,
repo: None,
model: None,
});
svc.registry().observe(ObserveRequest {
agent_id: None,
pid: None,
agent: crate::sessions::Agent::Claude,
session_id: "p".to_string(),
cwd: None,
transcript_path: None,
event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
repo: None,
model: None,
});
let status = svc.status().await;
assert!(status.healthy);
assert!(
status.summary.contains("2 session(s)"),
"{}",
status.summary
);
assert!(status.summary.contains("1 working"), "{}", status.summary);
assert!(status.summary.contains("1 waiting"), "{}", status.summary);
}
#[test]
fn menu_items_placeholder_when_empty() {
let items = menu_items_for(&[]);
assert_eq!(items.len(), 1);
assert!(matches!(&items[0], MenuItem::Label(l) if l.contains("No active")));
}
#[test]
fn menu_item_is_clickable_only_for_vscode_sessions() {
let now = chrono::Utc::now();
let base = |source: Source| SessionEntry {
subagent_base: None,
subagent_waits: std::collections::HashMap::new(),
pid: None,
pid_start: None,
prompted: false,
streamed: false,
replaced_pids: VecDeque::new(),
recent_seqs: VecDeque::new(),
superseded_seqs: VecDeque::new(),
latest_stamp_ts: None,
reported: false,
agent: crate::sessions::Agent::Claude,
session_id: "sid-12345678".to_string(),
cwd: Some(PathBuf::from("/p")),
transcript_path: None,
repo: Some("proj".to_string()),
model: None,
state: SessionState::Working,
source,
last_event: SessionEvent::PreToolUse,
started_at: now,
last_seen: now,
last_active: std::time::Duration::ZERO,
};
let terminal = menu_items_for(&[base(Source::Terminal)]);
assert!(
matches!(&terminal[0], MenuItem::Label(l) if l.contains("proj") && l.contains("working"))
);
let vscode = menu_items_for(&[base(Source::VsCode {
window_key: "w1".to_string(),
})]);
match &vscode[0] {
MenuItem::Action(a) => assert_eq!(a.id, "focus:sid-12345678"),
other => panic!("expected an action, got {other:?}"),
}
}
#[test]
fn repo_name_for_non_repo_is_none() {
assert_eq!(repo_name_for(Path::new("/nonexistent/xyz")), None);
}
#[test]
fn main_repo_name_handles_layouts() {
assert_eq!(
main_repo_name(Path::new("/home/me/proj/.git")).as_deref(),
Some("proj")
);
assert_eq!(
main_repo_name(Path::new("/home/me/bare.git")).as_deref(),
Some("bare")
);
}
fn entry(id: &str, state: SessionState, repo: Option<&str>, cwd: Option<&str>) -> SessionEntry {
let now = chrono::Utc::now();
SessionEntry {
subagent_base: None,
subagent_waits: std::collections::HashMap::new(),
pid: None,
pid_start: None,
prompted: false,
streamed: false,
replaced_pids: VecDeque::new(),
recent_seqs: VecDeque::new(),
superseded_seqs: VecDeque::new(),
latest_stamp_ts: None,
reported: false,
agent: crate::sessions::Agent::Claude,
session_id: id.to_string(),
cwd: cwd.map(PathBuf::from),
transcript_path: None,
repo: repo.map(str::to_string),
model: None,
state,
source: Source::Terminal,
last_event: SessionEvent::PreToolUse,
started_at: now,
last_seen: now,
last_active: std::time::Duration::ZERO,
}
}
#[test]
fn default_constructs_an_empty_service() {
let svc = SessionsService::default();
assert!(svc.registry().list().is_empty());
}
#[test]
fn start_watcher_is_a_noop_outside_a_runtime() {
let svc = SessionsService::new();
svc.start_watcher();
assert!(svc
.watcher
.lock()
.unwrap_or_else(PoisonError::into_inner)
.is_none());
}
#[tokio::test]
async fn start_watcher_is_idempotent_and_shutdown_stops_it() {
let svc = SessionsService::new();
svc.start_watcher();
svc.start_watcher();
assert!(svc
.watcher
.lock()
.unwrap_or_else(PoisonError::into_inner)
.is_some());
svc.shutdown().await;
assert!(svc
.watcher
.lock()
.unwrap_or_else(PoisonError::into_inner)
.is_none());
}
#[test]
fn menu_renders_every_state_and_name_fallback() {
let sessions = vec![
entry("s1", SessionState::Starting, Some("repo-a"), Some("/a")),
entry("s2", SessionState::Working, None, Some("/home/me/proj")),
entry("s3", SessionState::Idle, None, None),
entry("s4", SessionState::WaitingForInput, Some("r"), Some("/b")),
entry(
"s5",
SessionState::WaitingForPermission,
Some("r"),
Some("/c"),
),
entry("s6", SessionState::Ended, Some("r"), Some("/d")),
];
let items = menu_items_for(&sessions);
assert_eq!(items.len(), 6);
let labels: Vec<&str> = items
.iter()
.map(|i| match i {
MenuItem::Label(l) => l.as_str(),
_ => panic!("terminal sessions render as labels"),
})
.collect();
assert!(labels[0].contains("repo-a") && labels[0].contains("starting"));
assert!(labels[1].contains("proj") && labels[1].contains("working")); assert!(labels[2].contains("s3") && labels[2].contains("idle")); assert!(labels[3].contains("waiting"));
assert!(labels[4].contains("permission"));
assert!(labels[5].contains("ended"));
}
#[test]
fn menu_rows_are_prefixed_with_their_agent() {
let mut pi = entry("p1", SessionState::Idle, Some("repo-p"), None);
pi.agent = crate::sessions::Agent::Pi;
let mut codex = entry("c1", SessionState::Working, Some("repo-c"), None);
codex.agent = crate::sessions::Agent::Codex;
let claude = entry("s1", SessionState::Idle, Some("repo-a"), None);
let items = menu_items_for(&[claude, pi, codex]);
let labels: Vec<&str> = items
.iter()
.map(|i| match i {
MenuItem::Label(l) => l.as_str(),
_ => panic!("terminal sessions render as labels"),
})
.collect();
assert_eq!(labels[0], "Claude · repo-a ◦ idle");
assert_eq!(labels[1], "pi · repo-p ◦ idle");
assert_eq!(labels[2], "Codex · repo-c ⚙ working");
assert_eq!(SUBMENU_TITLE, "Agent Sessions");
}
#[test]
fn menu_serves_the_snapshot_title() {
let svc = SessionsService::new();
svc.registry()
.observe(observe_req("s1", SessionEvent::Stop, None));
let snapshot = svc.menu();
assert_eq!(snapshot.title, SUBMENU_TITLE);
assert_eq!(snapshot.items.len(), 1);
}
fn observe_req(id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
ObserveRequest {
agent_id: None,
pid: None,
agent: crate::sessions::Agent::Claude,
session_id: id.to_string(),
cwd: cwd.map(PathBuf::from),
transcript_path: None,
event,
repo: None,
model: None,
}
}
#[tokio::test]
async fn menu_action_errors_on_unknown_and_missing_window() {
let svc = SessionsService::new();
let err = svc.menu_action("frobnicate").await.unwrap_err();
assert!(
err.to_string().contains("unknown sessions menu action"),
"{err}"
);
let err = svc.menu_action("focus:nope").await.unwrap_err();
assert!(
err.to_string()
.contains("not open in a known VS Code window"),
"{err}"
);
}
#[tokio::test]
async fn end_op_ignores_the_replaced_process_by_pid() {
let svc = SessionsService::new();
for pid in [100, 200] {
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "session_start", "pid": pid }),
)
.await
.unwrap();
}
svc.handle("end", json!({ "session_id": "s1", "pid": 100 }))
.await
.unwrap();
assert_eq!(svc.registry().list()[0].state, SessionState::Starting);
svc.handle("end", json!({ "session_id": "s1", "pid": 200 }))
.await
.unwrap();
assert_eq!(svc.registry().list()[0].state, SessionState::Ended);
}
#[test]
fn repo_name_for_resolves_a_real_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo_dir = tmp.path().join("myrepo");
std::fs::create_dir(&repo_dir).unwrap();
git2::Repository::init(&repo_dir).unwrap();
assert_eq!(repo_name_for(&repo_dir).as_deref(), Some("myrepo"));
}
#[tokio::test]
async fn status_summary_counts_idle_and_ended() {
let svc = SessionsService::new();
svc.registry()
.observe(observe_req("i", SessionEvent::Stop, None)); svc.registry().end("i2", None, None); svc.registry()
.observe(observe_req("i2", SessionEvent::PreToolUse, None));
svc.registry().end("i2", Some("done"), None); svc.registry()
.observe(observe_req("s", SessionEvent::SessionStart, None)); let status = svc.status().await;
assert!(status.summary.contains("3 idle"), "{}", status.summary);
assert!(status.summary.contains("0 working"), "{}", status.summary);
}
#[tokio::test]
async fn status_reports_how_events_were_delivered() {
let svc = service();
let quiet = svc.status().await;
assert!(!quiet.summary.contains("events:"), "{}", quiet.summary);
assert_eq!(
quiet.detail["delivery"],
json!({ "socket": 0, "recovered": 0, "superseded": 0, "replayed": 0 })
);
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "pre_tool_use" }),
)
.await
.unwrap();
svc.handle("observe", json!({ "session_id": "s1", "event": "stop" }))
.await
.unwrap();
let status = svc.status().await;
assert!(
status.summary.contains(
"events: 2 via socket, 0 recovered from journals, 0 superseded, 0 replayed"
),
"{}",
status.summary
);
assert_eq!(status.detail["delivery"]["socket"], 2);
assert!(
status.summary.starts_with("1 session(s)"),
"{}",
status.summary
);
}
#[test]
fn delivery_summary_is_empty_until_the_first_event() {
assert_eq!(delivery_summary(DeliveryStats::default()), "");
assert_eq!(
delivery_summary(DeliveryStats {
socket: 0,
recovered: 1,
superseded: 0,
replayed: 0
}),
" · events: 0 via socket, 1 recovered from journals, 0 superseded, 0 replayed"
);
}
#[tokio::test]
async fn subscribe_streams_only_for_the_subscribe_op() {
let svc = service();
assert!(svc.subscribe("subscribe", &Value::Null).is_some());
assert!(svc.subscribe("list", &Value::Null).is_none());
assert!(svc.subscribe("observe", &Value::Null).is_none());
assert!(svc.subscribe("window", &Value::Null).is_none());
assert!(svc.subscribe("bogus", &Value::Null).is_none());
}
#[tokio::test]
async fn subscribe_snapshot_matches_the_list_op() {
let svc = service();
let stream = svc
.subscribe("subscribe", &Value::Null)
.expect("subscribe stream");
assert_eq!(stream.snapshot().await, json!({ "sessions": [] }));
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "pre_tool_use", "cwd": "/tmp/x" }),
)
.await
.unwrap();
let snap = stream.snapshot().await;
let listed = svc.handle("list", Value::Null).await.unwrap();
assert_eq!(snap, listed);
assert_eq!(snap["sessions"][0]["state"], json!("working"));
}
#[tokio::test]
async fn subscribe_changed_wakes_on_a_visible_change() {
let svc = service();
let mut stream = svc
.subscribe("subscribe", &Value::Null)
.expect("subscribe stream");
tokio::select! {
() = stream.changed() => panic!("changed resolved with no registry change"),
() = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
svc.handle(
"observe",
json!({ "session_id": "s1", "event": "session_start" }),
)
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), stream.changed())
.await
.expect("changed should resolve after a visible change");
}
#[tokio::test]
async fn changed_parks_when_every_sender_is_gone() {
let (tx, rx) = tokio::sync::watch::channel(0u64);
let mut stream = SessionsStream {
registry: Arc::new(SessionsRegistry::new()),
changes: rx,
};
drop(tx);
tokio::select! {
() = stream.changed() => panic!("changed resolved after the sender was dropped"),
() = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
}
}