//! Per-session "pending interrupt" message. //! //! Holds a single-use marker per `SessionId` indicating that the //! user has clicked the agent-window mask's stop button. The next //! browser-input-dispatching `tool.*` call for that session is rejected //! with `ErrorCode::UserAborted`; passive reads and session-lifecycle //! RPCs pass through transparently and do not consume the marker. //! //! The marker is single-use and has no expiry: it sits in the //! registry until consumed by an input-dispatching call or until the //! session is torn down. This lets the user's interrupt survive an LLM //! thinking phase of arbitrary length — the v1 time-window //! mechanism dropped interrupts whenever the LLM took longer to //! respond than the window allowed. //! //! Independent of `SessionRegistry` because the signal is a //! transient runtime control state, not a session lifecycle //! attribute. use std::collections::HashSet; use std::sync::Mutex; use super::sessions::SessionId; #[derive(Default)] pub struct SessionInterruptRegistry { inner: Mutex>, } impl std::fmt::Debug for SessionInterruptRegistry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let len = self.inner.lock().map(|g| g.len()).unwrap_or(0); f.debug_struct("SessionInterruptRegistry") .field("len", &len) .finish() } } impl SessionInterruptRegistry { pub fn new() -> Self { Self::default() } /// Mark `sid` as having a pending interrupt. Idempotent — /// repeated marks are a no-op (the marker is a single-use flag, /// not a counter). pub fn mark(&self, sid: &SessionId) { let mut guard = self.inner.lock().expect("session_interrupt poisoned"); guard.insert(sid.clone()); } /// Whether `sid` currently has a pending interrupt marker. pub fn is_pending(&self, sid: &SessionId) -> bool { let guard = self.inner.lock().expect("session_interrupt poisoned"); guard.contains(sid) } /// Probe + consume. If `sid` has a pending interrupt, remove it /// and return `true`. Otherwise return `false` without /// modifying the registry. /// /// Single-use semantics: once consumed by one call, subsequent /// `try_consume` calls for the same session return `false` /// until the session is marked again. pub fn try_consume(&self, sid: &SessionId) -> bool { let mut guard = self.inner.lock().expect("session_interrupt poisoned"); guard.remove(sid) } /// Drop any pending entry for `sid`. **Every** session-teardown /// path MUST call this so a session torn down while a signal /// was hot does not leak the entry into the registry /// indefinitely. Current call sites: /// /// * `stop_session` (session.stop RPC) /// * `forget_session` (extension closed the agent window) /// * `purge_browser` cascade (browser disconnect — see /// `daemon/ws.rs`) /// /// If a future code path adds a fourth teardown route, add the /// `drop_session` call there too — there is no static check /// enforcing this. pub fn drop_session(&self, sid: &SessionId) { let mut guard = self.inner.lock().expect("session_interrupt poisoned"); guard.remove(sid); } } #[cfg(test)] mod tests { use super::*; fn sid(s: &str) -> SessionId { SessionId(s.to_string()) } #[test] fn new_registry_is_empty() { let reg = SessionInterruptRegistry::new(); assert!(reg.inner.lock().unwrap().is_empty()); } #[test] fn mark_inserts_an_entry() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); assert!(reg.inner.lock().unwrap().contains(&sid("A"))); } #[test] fn mark_is_idempotent_per_session() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); reg.mark(&sid("A")); assert_eq!(reg.inner.lock().unwrap().len(), 1); } #[test] fn mark_distinguishes_sessions() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); reg.mark(&sid("B")); assert_eq!(reg.inner.lock().unwrap().len(), 2); } #[test] fn is_pending_reflects_mark_without_consuming() { let reg = SessionInterruptRegistry::new(); assert!(!reg.is_pending(&sid("A"))); reg.mark(&sid("A")); assert!(reg.is_pending(&sid("A"))); assert!(reg.try_consume(&sid("A"))); assert!(!reg.is_pending(&sid("A"))); } #[test] fn try_consume_on_unmarked_session_returns_false() { let reg = SessionInterruptRegistry::new(); assert!(!reg.try_consume(&sid("ghost"))); assert!(reg.inner.lock().unwrap().is_empty()); } #[test] fn try_consume_after_mark_returns_true_and_removes() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); assert!(reg.try_consume(&sid("A"))); assert!(!reg.inner.lock().unwrap().contains(&sid("A"))); } #[test] fn try_consume_is_single_use() { // Two consecutive consumes: only the first wins. let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); assert!(reg.try_consume(&sid("A"))); assert!(!reg.try_consume(&sid("A"))); } #[test] fn try_consume_does_not_affect_other_sessions() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); reg.mark(&sid("B")); assert!(reg.try_consume(&sid("A"))); assert!(reg.inner.lock().unwrap().contains(&sid("B"))); } #[test] fn drop_session_clears_pending_entry() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); reg.drop_session(&sid("A")); assert!(!reg.inner.lock().unwrap().contains(&sid("A"))); } #[test] fn drop_session_on_unknown_session_is_noop() { let reg = SessionInterruptRegistry::new(); reg.drop_session(&sid("ghost")); assert!(reg.inner.lock().unwrap().is_empty()); } #[test] fn drop_session_does_not_affect_other_sessions() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); reg.mark(&sid("B")); reg.drop_session(&sid("A")); assert!(!reg.inner.lock().unwrap().contains(&sid("A"))); assert!(reg.inner.lock().unwrap().contains(&sid("B"))); } #[test] fn drop_session_after_mark_makes_subsequent_try_consume_return_false() { let reg = SessionInterruptRegistry::new(); reg.mark(&sid("A")); reg.drop_session(&sid("A")); assert!(!reg.try_consume(&sid("A"))); } }