1use std::collections::HashMap;
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use serde::{Deserialize, Serialize};
26
27use super::auth_patterns::{match_line, AuthPatternMatch};
28
29const SESSION_TIMEOUT_SECS: u64 = 600;
33
34const FINISHED_RETENTION_SECS: u64 = 300;
38
39const SWEEP_INTERVAL_SECS: u64 = 30;
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
43#[ts(export, export_to = "../../../frontend/types/rpc/")]
44#[serde(
45 tag = "status",
46 rename_all = "kebab-case",
47 rename_all_fields = "camelCase"
48)]
49pub enum AuthSessionStatus {
50 Pending,
53 UrlAvailable { auth_url: String },
56 CodeEmitted {
58 device_code: String,
59 verification_url: String,
60 },
61 Success {
64 bundle_id: String,
65 email: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
74 #[ts(optional)]
75 account_id: Option<String>,
76 },
77 Failed { error: String },
80}
81
82impl AuthSessionStatus {
83 pub fn is_terminal(&self) -> bool {
84 matches!(self, Self::Success { .. } | Self::Failed { .. })
85 }
86}
87
88#[derive(Debug)]
89struct Session {
90 provider_id: String,
91 status: AuthSessionStatus,
92 captured_url: Option<String>,
95 captured_device_code: Option<(String, String)>,
96 captured_email: Option<String>,
97 started_at: Instant,
98 finished_at: Option<Instant>,
101 exclusive_key: Option<String>,
104 transcript: Vec<String>,
108}
109
110impl Session {
111 fn new(provider_id: String, exclusive_key: Option<String>) -> Self {
112 Self {
113 provider_id,
114 status: AuthSessionStatus::Pending,
115 captured_url: None,
116 captured_device_code: None,
117 captured_email: None,
118 started_at: Instant::now(),
119 finished_at: None,
120 exclusive_key,
121 transcript: Vec::new(),
122 }
123 }
124
125 fn finish(&mut self, status: AuthSessionStatus) -> bool {
127 if self.status.is_terminal() {
128 return false;
129 }
130 self.status = status;
131 self.finished_at = Some(Instant::now());
132 true
133 }
134
135 fn timed_out(&self) -> bool {
136 self.started_at.elapsed() > Duration::from_secs(SESSION_TIMEOUT_SECS)
137 }
138}
139
140#[derive(Debug, Clone, Serialize, ts_rs::TS)]
145#[ts(export, export_to = "../../../frontend/types/rpc/")]
146#[serde(rename_all = "camelCase")]
147pub struct StartSessionResult {
148 pub session_id: String,
149 pub auth_url: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, ts_rs::TS)]
158#[ts(export, export_to = "../../../frontend/types/rpc/")]
159#[serde(rename_all = "camelCase")]
160pub struct PollSessionResult {
161 pub provider_id: String,
162 #[serde(flatten)]
163 pub status: AuthSessionStatus,
164}
165
166#[derive(Default)]
171struct ProcessRefs {
172 drain_tasks: HashMap<String, tokio::task::JoinHandle<()>>,
173 stdin_senders: HashMap<String, tokio::sync::mpsc::Sender<String>>,
174 pty_pids: HashMap<String, u32>,
178}
179
180#[derive(Default)]
181pub struct AuthSessionManager {
182 sessions: Arc<Mutex<HashMap<String, Session>>>,
183 process_refs: Arc<Mutex<ProcessRefs>>,
184}
185
186impl AuthSessionManager {
187 pub fn new() -> Self {
188 Self::default()
189 }
190
191 pub fn start_session(&self, provider_id: String, exclusive_key: Option<String>) -> StartSessionResult {
200 let session_id = format!("auth-{}", uuid::Uuid::new_v4());
201 let superseded: Vec<String> = {
205 let mut sessions = self.sessions.lock().unwrap();
206 let superseded: Vec<String> = match exclusive_key.as_deref() {
209 Some(key) => sessions
210 .iter_mut()
211 .filter(|(_, s)| s.exclusive_key.as_deref() == Some(key))
212 .map(|(id, s)| {
213 s.finish(AuthSessionStatus::Failed {
214 error: "replaced by a newer sign-in for the same account".to_string(),
215 });
216 id.clone()
217 })
218 .collect(),
219 None => Vec::new(),
220 };
221 sessions.insert(session_id.clone(), Session::new(provider_id, exclusive_key));
222 superseded
223 };
224 for id in &superseded {
225 self.stop_process(id);
226 }
227 StartSessionResult {
228 session_id,
229 auth_url: None,
230 }
231 }
232
233 pub fn record_line(&self, session_id: &str, line: &str) -> Option<AuthPatternMatch> {
237 let mut sessions = self.sessions.lock().unwrap();
238 let session = sessions.get_mut(session_id)?;
239 session.transcript.push(line.to_string());
240 let m = match_line(&session.provider_id, line)?;
241 match &m {
242 AuthPatternMatch::OAuthUrl(url) => {
243 if session.captured_url.is_none() {
244 session.captured_url = Some(url.clone());
245 if matches!(session.status, AuthSessionStatus::Pending) {
246 session.status = AuthSessionStatus::UrlAvailable {
247 auth_url: url.clone(),
248 };
249 }
250 }
251 }
252 AuthPatternMatch::DeviceCode {
253 code,
254 verification_url,
255 } => {
256 if session.captured_device_code.is_none() {
257 session.captured_device_code =
258 Some((code.clone(), verification_url.clone()));
259 if matches!(
260 session.status,
261 AuthSessionStatus::Pending | AuthSessionStatus::UrlAvailable { .. }
262 ) {
263 session.status = AuthSessionStatus::CodeEmitted {
264 device_code: code.clone(),
265 verification_url: verification_url.clone(),
266 };
267 }
268 }
269 }
270 AuthPatternMatch::LoginSuccess { email } => {
271 if session.captured_email.is_none() {
276 session.captured_email = email.clone();
277 }
278 }
279 }
280 Some(m)
281 }
282
283 pub fn finish_success(
289 &self,
290 session_id: &str,
291 bundle_id: String,
292 account_id: Option<String>,
293 ) -> bool {
294 let mut sessions = self.sessions.lock().unwrap();
295 let Some(session) = sessions.get_mut(session_id) else {
296 return false;
297 };
298 let email = session.captured_email.clone();
299 session.finish(AuthSessionStatus::Success {
300 bundle_id,
301 email,
302 account_id,
303 })
304 }
305
306 pub fn finish_failure(&self, session_id: &str, error: String) -> bool {
307 let mut sessions = self.sessions.lock().unwrap();
308 let Some(session) = sessions.get_mut(session_id) else {
309 return false;
310 };
311 session.finish(AuthSessionStatus::Failed { error })
312 }
313
314 pub fn poll_session(&self, session_id: &str) -> Option<PollSessionResult> {
317 let timed_out = {
318 let sessions = self.sessions.lock().unwrap();
319 let session = sessions.get(session_id)?;
320 !session.status.is_terminal() && session.timed_out()
321 };
322 if timed_out {
323 self.end_session(session_id, timeout_error());
324 }
325 let sessions = self.sessions.lock().unwrap();
326 let session = sessions.get(session_id)?;
327 Some(PollSessionResult {
328 provider_id: session.provider_id.clone(),
329 status: session.status.clone(),
330 })
331 }
332
333 pub fn sweep(&self) {
338 let (expired, stale): (Vec<String>, Vec<String>) = {
339 let sessions = self.sessions.lock().unwrap();
340 let expired = sessions
341 .iter()
342 .filter(|(_, s)| !s.status.is_terminal() && s.timed_out())
343 .map(|(id, _)| id.clone())
344 .collect();
345 let stale = sessions
346 .iter()
347 .filter(|(_, s)| {
348 s.finished_at
349 .is_some_and(|t| t.elapsed() > Duration::from_secs(FINISHED_RETENTION_SECS))
350 })
351 .map(|(id, _)| id.clone())
352 .collect();
353 (expired, stale)
354 };
355 for id in &expired {
356 tracing::info!(session_id = %id, "auth session timed out: ending it");
357 self.end_session(id, timeout_error());
358 }
359 if !stale.is_empty() {
360 for id in &stale {
364 self.stop_process(id);
365 }
366 let mut sessions = self.sessions.lock().unwrap();
367 for id in &stale {
368 sessions.remove(id);
369 }
370 }
371 }
372
373 pub fn spawn_sweeper(self: &Arc<Self>) {
375 let mgr = Arc::clone(self);
376 tokio::spawn(async move {
377 let mut tick = tokio::time::interval(Duration::from_secs(SWEEP_INTERVAL_SECS));
378 loop {
379 tick.tick().await;
380 mgr.sweep();
381 }
382 });
383 }
384
385 pub fn cancel_session(&self, session_id: &str) -> bool {
391 self.end_session(session_id, "cancelled by user".to_string())
392 }
393
394 fn end_session(&self, session_id: &str, error: String) -> bool {
399 let transitioned = self.finish_failure(session_id, error);
400 self.stop_process(session_id);
401 transitioned
402 }
403
404 fn stop_process(&self, session_id: &str) {
408 let mut refs = self.process_refs.lock().unwrap();
409 if let Some(handle) = refs.drain_tasks.remove(session_id) {
410 handle.abort();
411 }
412 refs.stdin_senders.remove(session_id);
413 if let Some(pid) = refs.pty_pids.remove(session_id) {
417 if let Err(e) = kill_pid(pid) {
418 tracing::warn!(pid, session_id, error = %e, "end_session: kill_pid failed");
419 } else {
420 tracing::info!(pid, session_id, "end_session: PTY child killed");
421 }
422 }
423 }
424
425 pub fn ended(&self, session_id: &str) -> bool {
429 self.is_terminal(session_id)
430 }
431
432 fn is_terminal(&self, session_id: &str) -> bool {
433 self.sessions
434 .lock()
435 .unwrap()
436 .get(session_id)
437 .is_none_or(|s| s.status.is_terminal())
438 }
439
440 pub fn attach_pty_pid(&self, session_id: &str, pid: u32) {
444 self.process_refs.lock().unwrap().pty_pids.insert(session_id.to_string(), pid);
445 if self.is_terminal(session_id) {
449 self.stop_process(session_id);
450 }
451 }
452
453 pub fn attach_process(
456 &self,
457 session_id: &str,
458 drain_task: tokio::task::JoinHandle<()>,
459 stdin_sender: tokio::sync::mpsc::Sender<String>,
460 ) {
461 {
462 let mut refs = self.process_refs.lock().unwrap();
463 refs.drain_tasks.insert(session_id.to_string(), drain_task);
464 refs.stdin_senders.insert(session_id.to_string(), stdin_sender);
465 }
466 if self.is_terminal(session_id) {
468 self.stop_process(session_id);
469 }
470 }
471
472 pub async fn send_to_stdin(&self, session_id: &str, line: String) -> bool {
477 let sender = {
478 let refs = self.process_refs.lock().unwrap();
479 refs.stdin_senders.get(session_id).cloned()
480 };
481 match sender {
482 Some(s) => s.send(line).await.is_ok(),
483 None => false,
484 }
485 }
486
487 pub fn detach_process(&self, session_id: &str) {
491 let mut refs = self.process_refs.lock().unwrap();
492 refs.drain_tasks.remove(session_id);
493 refs.stdin_senders.remove(session_id);
494 refs.pty_pids.remove(session_id);
495 }
496
497 pub fn captured_email(&self, session_id: &str) -> Option<String> {
507 self.sessions
508 .lock()
509 .unwrap()
510 .get(session_id)
511 .and_then(|s| s.captured_email.clone())
512 }
513
514 #[allow(dead_code)]
518 pub fn transcript(&self, session_id: &str) -> Option<Vec<String>> {
519 self.sessions
520 .lock()
521 .unwrap()
522 .get(session_id)
523 .map(|s| s.transcript.clone())
524 }
525
526 #[cfg(test)]
530 fn force_age(&self, session_id: &str) {
531 if let Some(s) = self.sessions.lock().unwrap().get_mut(session_id) {
532 s.started_at = Instant::now() - Duration::from_secs(SESSION_TIMEOUT_SECS + 1);
533 }
534 }
535
536 #[cfg(test)]
538 fn force_finished_long_ago(&self, session_id: &str) {
539 if let Some(s) = self.sessions.lock().unwrap().get_mut(session_id) {
540 s.finished_at = Some(Instant::now() - Duration::from_secs(FINISHED_RETENTION_SECS + 1));
541 }
542 }
543}
544
545fn timeout_error() -> String {
546 format!("auth session timed out after {SESSION_TIMEOUT_SECS}s")
547}
548
549use agentmux_common::process::kill_pid;
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 fn mgr() -> AuthSessionManager {
560 AuthSessionManager::new()
561 }
562
563 #[test]
564 fn start_creates_pending_session() {
565 let m = mgr();
566 let r = m.start_session("claude".to_string(), None);
567 assert!(!r.session_id.is_empty());
568 assert!(r.auth_url.is_none());
569 let p = m.poll_session(&r.session_id).expect("session exists");
570 assert_eq!(p.provider_id, "claude");
571 assert!(matches!(p.status, AuthSessionStatus::Pending));
572 }
573
574 #[test]
575 fn url_line_transitions_to_url_available() {
576 let m = mgr();
577 let r = m.start_session("claude".to_string(), None);
578 let _ = m.record_line(
579 &r.session_id,
580 "Open https://console.anthropic.com/oauth/authorize?state=xyz",
581 );
582 let p = m.poll_session(&r.session_id).unwrap();
583 match p.status {
584 AuthSessionStatus::UrlAvailable { auth_url } => {
585 assert!(auth_url.contains("anthropic.com/oauth"));
586 }
587 other => panic!("expected UrlAvailable, got {other:?}"),
588 }
589 }
590
591 #[test]
592 fn device_code_line_transitions_to_code_emitted() {
593 let m = mgr();
594 let r = m.start_session("copilot".to_string(), None);
595 let _ = m.record_line(&r.session_id, "! Copy your one-time code: ABCD-1234");
596 let p = m.poll_session(&r.session_id).unwrap();
597 match p.status {
598 AuthSessionStatus::CodeEmitted {
599 device_code,
600 verification_url,
601 } => {
602 assert_eq!(device_code, "ABCD-1234");
603 assert_eq!(verification_url, "https://github.com/login/device");
604 }
605 other => panic!("expected CodeEmitted, got {other:?}"),
606 }
607 }
608
609 #[test]
610 fn login_success_line_does_not_transition_state_alone() {
611 let m = mgr();
614 let r = m.start_session("claude".to_string(), None);
615 let _ = m.record_line(&r.session_id, "Successfully logged in as asaf@example.com");
616 let p = m.poll_session(&r.session_id).unwrap();
617 assert!(matches!(p.status, AuthSessionStatus::Pending));
619 }
620
621 #[test]
622 fn finish_success_carries_email_from_transcript() {
623 let m = mgr();
624 let r = m.start_session("claude".to_string(), None);
625 let _ = m.record_line(&r.session_id, "Successfully logged in as asaf@example.com");
626 assert!(m.finish_success(&r.session_id, "bundle-1".to_string(), None));
627 let p = m.poll_session(&r.session_id).unwrap();
628 match p.status {
629 AuthSessionStatus::Success { bundle_id, email, account_id } => {
630 assert_eq!(bundle_id, "bundle-1");
631 assert_eq!(email.as_deref(), Some("asaf@example.com"));
632 assert_eq!(account_id, None);
633 }
634 other => panic!("expected Success, got {other:?}"),
635 }
636 }
637
638 #[test]
639 fn cancel_transitions_to_failed() {
640 let m = mgr();
641 let r = m.start_session("claude".to_string(), None);
642 assert!(m.cancel_session(&r.session_id));
643 let p = m.poll_session(&r.session_id).unwrap();
644 match p.status {
645 AuthSessionStatus::Failed { error } => assert!(error.contains("cancelled")),
646 other => panic!("expected Failed, got {other:?}"),
647 }
648 }
649
650 #[test]
651 #[ignore = "force_age underflows Instant on low-uptime CI runners; make the timeout mockable, then un-ignore"]
660 fn timeout_transitions_pending_to_failed_on_poll() {
661 let m = mgr();
662 let r = m.start_session("claude".to_string(), None);
663 m.force_age(&r.session_id);
664 let p = m.poll_session(&r.session_id).unwrap();
665 match p.status {
666 AuthSessionStatus::Failed { error } => assert!(error.contains("timed out")),
667 other => panic!("expected timeout Failed, got {other:?}"),
668 }
669 }
670
671 #[test]
672 fn terminal_states_cannot_be_re_transitioned() {
673 let m = mgr();
674 let r = m.start_session("claude".to_string(), None);
675 assert!(m.finish_success(&r.session_id, "bundle-1".to_string(), None));
676 assert!(!m.finish_failure(&r.session_id, "should be ignored".to_string()));
678 let p = m.poll_session(&r.session_id).unwrap();
679 assert!(matches!(p.status, AuthSessionStatus::Success { .. }));
680 }
681
682 #[test]
683 fn multiple_url_lines_keep_the_first_url() {
684 let m = mgr();
687 let r = m.start_session("claude".to_string(), None);
688 let _ = m.record_line(
689 &r.session_id,
690 "Open https://console.anthropic.com/oauth/authorize?state=first",
691 );
692 let _ = m.record_line(
693 &r.session_id,
694 "Open https://console.anthropic.com/oauth/authorize?state=second",
695 );
696 let p = m.poll_session(&r.session_id).unwrap();
697 match p.status {
698 AuthSessionStatus::UrlAvailable { auth_url } => {
699 assert!(auth_url.contains("state=first"));
700 }
701 _ => panic!("expected UrlAvailable"),
702 }
703 }
704
705 #[test]
706 fn status_serializes_with_camelcase_field_names() {
707 let s = AuthSessionStatus::UrlAvailable {
712 auth_url: "https://example.com/oauth".to_string(),
713 };
714 let v = serde_json::to_value(&s).unwrap();
715 assert_eq!(
716 v,
717 serde_json::json!({
718 "status": "url-available",
719 "authUrl": "https://example.com/oauth"
720 })
721 );
722
723 let s = AuthSessionStatus::CodeEmitted {
724 device_code: "ABCD-1234".to_string(),
725 verification_url: "https://github.com/login/device".to_string(),
726 };
727 let v = serde_json::to_value(&s).unwrap();
728 assert_eq!(
729 v,
730 serde_json::json!({
731 "status": "code-emitted",
732 "deviceCode": "ABCD-1234",
733 "verificationUrl": "https://github.com/login/device"
734 })
735 );
736
737 let s = AuthSessionStatus::Success {
738 bundle_id: "bundle-1".to_string(),
739 email: Some("asaf@example.com".to_string()),
740 account_id: None,
741 };
742 let v = serde_json::to_value(&s).unwrap();
743 assert_eq!(
744 v,
745 serde_json::json!({
746 "status": "success",
747 "bundleId": "bundle-1",
748 "email": "asaf@example.com"
749 })
750 );
751
752 let s = AuthSessionStatus::Success {
755 bundle_id: String::new(),
756 email: Some("asaf@example.com".to_string()),
757 account_id: Some("acc-1".to_string()),
758 };
759 let v = serde_json::to_value(&s).unwrap();
760 assert_eq!(
761 v,
762 serde_json::json!({
763 "status": "success",
764 "bundleId": "",
765 "email": "asaf@example.com",
766 "accountId": "acc-1"
767 })
768 );
769 }
770
771 #[test]
772 fn unknown_session_polls_to_none() {
773 let m = mgr();
774 assert!(m.poll_session("does-not-exist").is_none());
775 }
776
777 #[test]
778 fn transcript_records_all_lines_including_non_matching() {
779 let m = mgr();
780 let r = m.start_session("claude".to_string(), None);
781 let _ = m.record_line(&r.session_id, "Starting auth flow...");
782 let _ = m.record_line(&r.session_id, "Open https://console.anthropic.com/oauth/authorize");
783 let _ = m.record_line(&r.session_id, "Waiting for callback...");
784 let t = m.transcript(&r.session_id).unwrap();
785 assert_eq!(t.len(), 3);
786 assert_eq!(t[0], "Starting auth flow...");
787 assert!(t[1].contains("anthropic.com/oauth"));
788 assert_eq!(t[2], "Waiting for callback...");
789 }
790
791 #[tokio::test]
792 async fn sweep_ends_a_timed_out_session_nobody_polls_and_drops_its_stdin() {
793 let m = mgr();
794 let r = m.start_session("claude".to_string(), None);
795 let (tx, _rx) = tokio::sync::mpsc::channel::<String>(1);
796 m.attach_process(&r.session_id, tokio::spawn(async {}), tx);
797 m.force_age(&r.session_id);
798
799 m.sweep();
800
801 let status = m.sessions.lock().unwrap().get(&r.session_id).unwrap().status.clone();
802 assert!(matches!(status, AuthSessionStatus::Failed { ref error } if error.contains("timed out")), "{status:?}");
803 assert!(!m.send_to_stdin(&r.session_id, "code".to_string()).await, "stdin should be gone");
804 }
805
806 #[test]
807 fn sweep_drops_a_session_finished_long_ago_and_keeps_a_recent_one() {
808 let m = mgr();
809 let old = m.start_session("claude".to_string(), None);
810 let recent = m.start_session("claude".to_string(), None);
811 m.finish_failure(&old.session_id, "x".to_string());
812 m.finish_failure(&recent.session_id, "x".to_string());
813 m.force_finished_long_ago(&old.session_id);
814
815 m.sweep();
816
817 assert!(m.poll_session(&old.session_id).is_none());
818 assert!(m.poll_session(&recent.session_id).is_some());
819 }
820
821 #[test]
822 fn a_new_login_for_the_same_account_dir_replaces_the_live_one() {
823 let m = mgr();
824 let dir = Some("/home/u/.agentmux/accounts/claude-1".to_string());
825 let first = m.start_session("claude".to_string(), dir.clone());
826 let other = m.start_session("claude".to_string(), Some("/elsewhere".to_string()));
827 let second = m.start_session("claude".to_string(), dir);
828
829 let first_status = m.poll_session(&first.session_id).unwrap().status;
830 assert!(
831 matches!(first_status, AuthSessionStatus::Failed { ref error } if error.contains("replaced")),
832 "{first_status:?}"
833 );
834 assert_eq!(m.poll_session(&second.session_id).unwrap().status, AuthSessionStatus::Pending);
835 assert_eq!(m.poll_session(&other.session_id).unwrap().status, AuthSessionStatus::Pending);
836 }
837
838 #[tokio::test]
839 async fn a_cli_attached_after_its_session_was_replaced_is_stopped_at_once() {
840 let m = mgr();
841 let dir = Some("/home/u/.agentmux/accounts/claude-1".to_string());
842 let older = m.start_session("claude".to_string(), dir.clone());
843 let _newer = m.start_session("claude".to_string(), dir);
845 let (tx, _rx) = tokio::sync::mpsc::channel::<String>(1);
847 m.attach_process(&older.session_id, tokio::spawn(std::future::pending::<()>()), tx);
848
849 assert!(!m.send_to_stdin(&older.session_id, "code".to_string()).await, "the replaced CLI keeps no stdin");
850 assert!(m.process_refs.lock().unwrap().drain_tasks.get(&older.session_id).is_none());
851 }
852
853 #[tokio::test]
854 async fn a_new_login_stops_the_cli_of_a_finished_session_for_the_same_dir() {
855 let m = mgr();
856 let dir = Some("/home/u/.agentmux/accounts/claude-1".to_string());
857 let done = m.start_session("claude".to_string(), dir.clone());
858 let (tx, _rx) = tokio::sync::mpsc::channel::<String>(1);
859 m.attach_process(&done.session_id, tokio::spawn(std::future::pending::<()>()), tx);
860 m.finish_success(&done.session_id, String::new(), Some("acct".to_string()));
862
863 let _newer = m.start_session("claude".to_string(), dir);
864
865 assert!(m.process_refs.lock().unwrap().drain_tasks.get(&done.session_id).is_none(), "its CLI was stopped");
866 assert!(matches!(m.poll_session(&done.session_id).unwrap().status, AuthSessionStatus::Success { .. }), "a finished session keeps its result");
867 }
868
869 #[tokio::test]
870 async fn sweep_stops_a_still_running_cli_before_dropping_its_finished_session() {
871 let m = mgr();
872 let r = m.start_session("claude".to_string(), Some("/d".to_string()));
873 let (tx, _rx) = tokio::sync::mpsc::channel::<String>(1);
874 m.attach_process(&r.session_id, tokio::spawn(std::future::pending::<()>()), tx);
875 m.finish_success(&r.session_id, String::new(), None);
876 m.force_finished_long_ago(&r.session_id);
877
878 m.sweep();
879
880 assert!(m.poll_session(&r.session_id).is_none());
881 assert!(m.process_refs.lock().unwrap().drain_tasks.get(&r.session_id).is_none(), "its CLI was stopped");
882 }
883
884 #[test]
885 fn ended_is_true_once_replaced_and_for_an_unknown_session() {
886 let m = mgr();
887 let first = m.start_session("claude".to_string(), Some("/d".to_string()));
888 assert!(!m.ended(&first.session_id));
889 let _second = m.start_session("claude".to_string(), Some("/d".to_string()));
890 assert!(m.ended(&first.session_id));
891 assert!(m.ended("auth-nope"));
892 }
893}