1use std::path::{Path, PathBuf};
24use std::sync::{Arc, RwLock};
25
26use futures::future::BoxFuture;
27use tokio::sync::Mutex as AsyncMutex;
28
29use crate::core::agent_session::events::{
30 AgentSessionEvent, SessionBeforeForkPosition, SessionBeforeSwitchReason, SessionShutdownReason,
31 SessionStartReason,
32};
33use crate::core::agent_session::{AgentSession, ReplacedSessionContext};
34use crate::core::session_transfer::SessionImportFileNotFoundError;
35use crate::core::sessions::{
36 NewSessionOptions as SessionManagerNewSessionOptions, SessionError, SessionManager,
37 assert_session_cwd_exists,
38};
39
40#[derive(Clone, Debug)]
51pub struct AgentSessionRuntimeServices {
52 pub cwd: PathBuf,
54 pub agent_dir: PathBuf,
56}
57
58#[derive(Debug)]
64pub struct CreateAgentSessionRuntimeOptions {
65 pub cwd: String,
67 pub agent_dir: String,
69 pub session_manager: SessionManager,
71 pub start_reason: SessionStartReason,
73 pub previous_session_file: Option<String>,
75}
76
77pub struct CreateAgentSessionRuntimeResult {
79 pub session: Arc<AgentSession>,
81 pub services: AgentSessionRuntimeServices,
83 pub diagnostics: Vec<crate::core::agent_session_services::AgentSessionRuntimeDiagnostic>,
85 pub model_fallback_message: Option<String>,
87}
88
89pub trait CreateAgentSessionRuntimeFactory: Send + Sync {
95 fn create(
102 &self,
103 options: CreateAgentSessionRuntimeOptions,
104 ) -> BoxFuture<'_, Result<CreateAgentSessionRuntimeResult, AgentSessionRuntimeError>>;
105}
106
107pub type RebindSessionCallback =
114 Arc<dyn Fn(Arc<AgentSession>) -> BoxFuture<'static, ()> + Send + Sync>;
115
116pub type BeforeSessionInvalidateCallback = Arc<dyn Fn() + Send + Sync>;
120
121#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub struct SwitchOutcome {
128 pub cancelled: bool,
130}
131
132#[derive(Clone, Debug, Default)]
134pub struct ForkOutcome {
135 pub cancelled: bool,
137 pub selected_text: Option<String>,
139}
140
141#[derive(Clone, Debug, Default)]
143pub struct NewSessionOptions {
144 pub parent_session: Option<String>,
146}
147
148#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
150pub enum ForkPosition {
151 #[default]
153 Before,
154 At,
156}
157
158#[derive(Clone, Debug, Default)]
160pub struct SwitchSessionOptions {
161 pub cwd_override: Option<String>,
163}
164
165#[derive(Debug, thiserror::Error)]
171pub enum AgentSessionRuntimeError {
172 #[error(transparent)]
174 Session(#[from] SessionError),
175 #[error("Stored session working directory does not exist")]
177 MissingSessionCwd,
178 #[error(transparent)]
180 ImportNotFound(#[from] SessionImportFileNotFoundError),
181 #[error("Persisted session is missing a session file")]
183 MissingSessionFile,
184 #[error("Invalid entry ID for forking")]
186 InvalidForkEntry,
187 #[error(
189 "This session has not been saved yet. Wait for the first assistant response before cloning or forking it."
190 )]
191 UnflushedSession,
192 #[error("{0}")]
194 Transfer(String),
195 #[error("runtime replacement failed: {0}")]
197 Factory(String),
198}
199
200pub struct AgentSessionRuntime {
212 session: RwLock<Arc<AgentSession>>,
213 services: RwLock<AgentSessionRuntimeServices>,
214 factory: Arc<dyn CreateAgentSessionRuntimeFactory>,
215 diagnostics: RwLock<Vec<crate::core::agent_session_services::AgentSessionRuntimeDiagnostic>>,
216 model_fallback_message: RwLock<Option<String>>,
217 rebind_session: RwLock<Option<RebindSessionCallback>>,
218 before_session_invalidate: RwLock<Option<BeforeSessionInvalidateCallback>>,
219 replacement_lock: AsyncMutex<()>,
221}
222
223impl AgentSessionRuntime {
224 #[must_use]
226 pub fn new(
227 session: Arc<AgentSession>,
228 services: AgentSessionRuntimeServices,
229 factory: Arc<dyn CreateAgentSessionRuntimeFactory>,
230 diagnostics: Vec<crate::core::agent_session_services::AgentSessionRuntimeDiagnostic>,
231 model_fallback_message: Option<String>,
232 ) -> Self {
233 Self {
234 session: RwLock::new(session),
235 services: RwLock::new(services),
236 factory,
237 diagnostics: RwLock::new(diagnostics),
238 model_fallback_message: RwLock::new(model_fallback_message),
239 rebind_session: RwLock::new(None),
240 before_session_invalidate: RwLock::new(None),
241 replacement_lock: AsyncMutex::new(()),
242 }
243 }
244
245 #[must_use]
247 pub fn session(&self) -> Arc<AgentSession> {
248 self.read_session()
249 }
250
251 #[must_use]
253 pub fn cwd(&self) -> String {
254 self.services
255 .read()
256 .map(|g| g.cwd.to_string_lossy().into_owned())
257 .unwrap_or_default()
258 }
259
260 #[must_use]
262 pub fn agent_dir(&self) -> String {
263 self.services
264 .read()
265 .map(|g| g.agent_dir.to_string_lossy().into_owned())
266 .unwrap_or_default()
267 }
268
269 #[must_use]
271 pub fn diagnostics(
272 &self,
273 ) -> Vec<crate::core::agent_session_services::AgentSessionRuntimeDiagnostic> {
274 self.diagnostics
275 .read()
276 .map(|g| g.clone())
277 .unwrap_or_default()
278 }
279
280 #[must_use]
282 pub fn model_fallback_message(&self) -> Option<String> {
283 self.model_fallback_message
284 .read()
285 .map(|g| g.clone())
286 .unwrap_or_default()
287 }
288
289 pub fn set_rebind_session(&self, callback: Option<RebindSessionCallback>) {
291 if let Ok(mut g) = self.rebind_session.write() {
292 *g = callback;
293 }
294 }
295
296 pub fn set_before_session_invalidate(&self, callback: Option<BeforeSessionInvalidateCallback>) {
298 if let Ok(mut g) = self.before_session_invalidate.write() {
299 *g = callback;
300 }
301 }
302
303 pub async fn switch_session(
311 &self,
312 session_path: &str,
313 options: SwitchSessionOptions,
314 ) -> Result<SwitchOutcome, AgentSessionRuntimeError> {
315 let _guard = self.replacement_lock.lock().await;
316 let before = self
317 .emit_before_switch(SessionStartReason::Resume, Some(session_path))
318 .await;
319 if before.cancelled {
320 return Ok(before);
321 }
322
323 let previous_session_file = self.session_file_for_teardown().await;
324 let session_manager =
325 SessionManager::open(session_path, None, options.cwd_override.as_deref())?;
326 self.assert_cwd(&session_manager)?;
327 let new_cwd = session_manager.get_cwd().to_owned();
328 let target_session_file = session_manager.get_session_file().map(str::to_owned);
329 let result = self
330 .factory
331 .create(CreateAgentSessionRuntimeOptions {
332 cwd: new_cwd.clone(),
333 agent_dir: self.agent_dir(),
334 session_manager,
335 start_reason: SessionStartReason::Resume,
336 previous_session_file,
337 })
338 .await?;
339 self.teardown_current(
340 SessionShutdownReason::Resume,
341 target_session_file.as_deref(),
342 )
343 .await;
344 self.apply(result);
345 self.finish_session_replacement(None).await;
346 Ok(SwitchOutcome { cancelled: false })
347 }
348
349 pub async fn new_session(
355 &self,
356 options: NewSessionOptions,
357 ) -> Result<SwitchOutcome, AgentSessionRuntimeError> {
358 let _guard = self.replacement_lock.lock().await;
359 let before = self.emit_before_switch(SessionStartReason::New, None).await;
360 if before.cancelled {
361 return Ok(before);
362 }
363
364 let previous_session_file = self.session_file_for_teardown().await;
365 let cwd = self.cwd();
366 let session_manager = {
367 let session = self.read_session();
368 let sm = session.session_manager();
369 let sm = sm.lock().await;
370 if sm.is_persisted() {
371 let session_dir = sm.get_session_dir().to_owned();
372 let mut new_sm = SessionManager::create(&cwd, Some(&session_dir), None)?;
373 if let Some(parent) = options.parent_session.as_deref() {
374 new_sm.new_session(Some(SessionManagerNewSessionOptions {
375 id: None,
376 parent_session: Some(parent.to_owned()),
377 }))?;
378 }
379 new_sm
380 } else {
381 let opts = options.parent_session.as_deref().map(|parent| {
382 SessionManagerNewSessionOptions {
383 id: None,
384 parent_session: Some(parent.to_owned()),
385 }
386 });
387 SessionManager::in_memory(Some(&cwd), opts)?
388 }
389 };
390 let target_session_file = session_manager.get_session_file().map(str::to_owned);
391 let result = self
392 .factory
393 .create(CreateAgentSessionRuntimeOptions {
394 cwd: cwd.clone(),
395 agent_dir: self.agent_dir(),
396 session_manager,
397 start_reason: SessionStartReason::New,
398 previous_session_file,
399 })
400 .await?;
401 self.teardown_current(SessionShutdownReason::New, target_session_file.as_deref())
402 .await;
403 self.apply(result);
404 self.finish_session_replacement(None).await;
405 Ok(SwitchOutcome { cancelled: false })
406 }
407
408 pub async fn fork(
425 &self,
426 entry_id: &str,
427 position: ForkPosition,
428 ) -> Result<ForkOutcome, AgentSessionRuntimeError> {
429 let _guard = self.replacement_lock.lock().await;
430 let before = self.emit_before_fork(entry_id, position).await;
431 if before.cancelled {
432 return Ok(ForkOutcome {
433 cancelled: true,
434 selected_text: None,
435 });
436 }
437
438 let (target_leaf_id, selected_text) = {
439 let session = self.read_session();
440 let sm = session.session_manager();
441 let sm = sm.lock().await;
442 let selected_entry = sm
443 .get_entry(entry_id)
444 .ok_or(AgentSessionRuntimeError::InvalidForkEntry)?;
445 match position {
446 ForkPosition::At => (selected_entry.id().map(str::to_owned), None),
447 ForkPosition::Before => {
448 let is_user = matches!(
449 selected_entry,
450 crate::core::sessions::SessionEntry::Message(m) if m.message.role() == "user"
451 );
452 if !is_user {
453 return Err(AgentSessionRuntimeError::InvalidForkEntry);
454 }
455 let parent = selected_entry.parent_id().map(str::to_owned);
456 let text = extract_user_message_text_from_entry(selected_entry);
457 (parent, text)
458 }
459 }
460 };
461
462 let previous_session_file = self.session_file_for_teardown().await;
463 let cwd = self.cwd();
464 let agent_dir = self.agent_dir();
465
466 let session = self.read_session();
468 let sm = session.session_manager();
469 let sm_guard = sm.lock().await;
470 let session_manager = if sm_guard.is_persisted() {
471 let current_session_file = sm_guard
472 .get_session_file()
473 .map(str::to_owned)
474 .ok_or(AgentSessionRuntimeError::MissingSessionFile)?;
475 let session_dir = sm_guard.get_session_dir().to_owned();
476 match target_leaf_id.as_deref() {
477 None => {
478 let mut new_sm = SessionManager::create(&cwd, Some(&session_dir), None)?;
479 new_sm.new_session(Some(SessionManagerNewSessionOptions {
480 id: None,
481 parent_session: Some(current_session_file.clone()),
482 }))?;
483 new_sm
484 }
485 Some(leaf) => {
486 if !Path::new(¤t_session_file).exists() {
487 return Err(AgentSessionRuntimeError::UnflushedSession);
488 }
489 let mut reopened =
490 SessionManager::open(¤t_session_file, Some(&session_dir), None)?;
491 let forked_path = reopened.create_branched_session(leaf)?;
492 if forked_path.is_none() {
493 return Err(AgentSessionRuntimeError::InvalidForkEntry);
494 }
495 reopened
496 }
497 }
498 } else {
499 let opts = match target_leaf_id.as_deref() {
502 Some(_) => None, None => Some(SessionManagerNewSessionOptions {
504 id: None,
505 parent_session: previous_session_file.clone(),
506 }),
507 };
508 SessionManager::in_memory(Some(&cwd), opts)?
509 };
510 let new_cwd = session_manager.get_cwd().to_owned();
511 let target_session_file = session_manager.get_session_file().map(str::to_owned);
512 drop(sm_guard);
513 drop(session);
514
515 let result = self
516 .factory
517 .create(CreateAgentSessionRuntimeOptions {
518 cwd: new_cwd,
519 agent_dir,
520 session_manager,
521 start_reason: SessionStartReason::Fork,
522 previous_session_file,
523 })
524 .await?;
525 self.teardown_current(SessionShutdownReason::Fork, target_session_file.as_deref())
526 .await;
527 self.apply(result);
528 self.finish_session_replacement(None).await;
529 Ok(ForkOutcome {
530 cancelled: false,
531 selected_text,
532 })
533 }
534
535 pub async fn import_from_jsonl(
542 &self,
543 input_path: &str,
544 cwd_override: Option<&str>,
545 ) -> Result<SwitchOutcome, AgentSessionRuntimeError> {
546 let _guard = self.replacement_lock.lock().await;
547 let resolved = crate::core::config::resolve_path(input_path)
548 .to_string_lossy()
549 .into_owned();
550 if !Path::new(&resolved).exists() {
551 return Err(AgentSessionRuntimeError::ImportNotFound(
552 SessionImportFileNotFoundError::new(&resolved),
553 ));
554 }
555
556 let session_dir = {
557 let session = self.read_session();
558 let sm = session.session_manager();
559 let sm = sm.lock().await;
560 sm.get_session_dir().to_owned()
561 };
562 if !Path::new(&session_dir).exists() {
563 std::fs::create_dir_all(&session_dir)
564 .map_err(|e| AgentSessionRuntimeError::Transfer(e.to_string()))?;
565 }
566
567 let file_name = Path::new(&resolved).file_name().map_or_else(
568 || "imported.jsonl".to_owned(),
569 |name| name.to_string_lossy().into_owned(),
570 );
571 let destination = Path::new(&session_dir)
572 .join(&file_name)
573 .to_string_lossy()
574 .into_owned();
575
576 let before = self
577 .emit_before_switch(SessionStartReason::Resume, Some(&destination))
578 .await;
579 if before.cancelled {
580 return Ok(before);
581 }
582
583 let previous_session_file = self.session_file_for_teardown().await;
584 let dest_canonical = std::fs::canonicalize(&destination).map_or_else(
585 |_| destination.clone(),
586 |path| path.to_string_lossy().into_owned(),
587 );
588 let resolved_canonical = std::fs::canonicalize(&resolved).map_or_else(
589 |_| resolved.clone(),
590 |path| path.to_string_lossy().into_owned(),
591 );
592 if dest_canonical != resolved_canonical {
593 std::fs::copy(&resolved, &destination)
594 .map_err(|e| AgentSessionRuntimeError::Transfer(e.to_string()))?;
595 }
596
597 let session_manager = SessionManager::open(&destination, Some(&session_dir), cwd_override)?;
598 self.assert_cwd(&session_manager)?;
599 let new_cwd = session_manager.get_cwd().to_owned();
600 let target_session_file = session_manager.get_session_file().map(str::to_owned);
601 let result = self
602 .factory
603 .create(CreateAgentSessionRuntimeOptions {
604 cwd: new_cwd,
605 agent_dir: self.agent_dir(),
606 session_manager,
607 start_reason: SessionStartReason::Resume,
608 previous_session_file,
609 })
610 .await?;
611 self.teardown_current(
612 SessionShutdownReason::Resume,
613 target_session_file.as_deref(),
614 )
615 .await;
616 self.apply(result);
617 self.finish_session_replacement(None).await;
618 Ok(SwitchOutcome { cancelled: false })
619 }
620
621 pub async fn dispose(&self) {
623 let _guard = self.replacement_lock.lock().await;
624 self.teardown_current(SessionShutdownReason::Quit, None)
625 .await;
626 }
627
628 fn read_session(&self) -> Arc<AgentSession> {
631 self.session.read().map_or_else(
632 |poisoned| Arc::clone(&*poisoned.into_inner()),
633 |guard| Arc::clone(&*guard),
634 )
635 }
636
637 async fn session_file_for_teardown(&self) -> Option<String> {
638 self.read_session().session_file().await
639 }
640
641 async fn emit_before_switch(
643 &self,
644 reason: SessionStartReason,
645 target: Option<&str>,
646 ) -> SwitchOutcome {
647 let runner = self.read_session().extension_runner();
648 if !runner.has_handlers("session_before_switch") {
649 return SwitchOutcome { cancelled: false };
650 }
651 let reason = if reason == SessionStartReason::Resume {
652 SessionBeforeSwitchReason::Resume
653 } else {
654 SessionBeforeSwitchReason::New
655 };
656 match runner
657 .emit(AgentSessionEvent::SessionBeforeSwitch {
658 reason,
659 target_session_file: target.map(str::to_owned),
660 })
661 .await
662 {
663 Ok(result) => SwitchOutcome {
664 cancelled: result.is_some_and(|result| result.cancel),
665 },
666 Err(error) => {
667 runner.emit_error(error.to_string());
668 SwitchOutcome { cancelled: false }
669 }
670 }
671 }
672
673 async fn emit_before_fork(&self, entry_id: &str, position: ForkPosition) -> SwitchOutcome {
675 let runner = self.read_session().extension_runner();
676 if !runner.has_handlers("session_before_fork") {
677 return SwitchOutcome { cancelled: false };
678 }
679 let position = match position {
680 ForkPosition::Before => SessionBeforeForkPosition::Before,
681 ForkPosition::At => SessionBeforeForkPosition::At,
682 };
683 match runner
684 .emit(AgentSessionEvent::SessionBeforeFork {
685 entry_id: entry_id.to_owned(),
686 position,
687 })
688 .await
689 {
690 Ok(result) => SwitchOutcome {
691 cancelled: result.is_some_and(|result| result.cancel),
692 },
693 Err(error) => {
694 runner.emit_error(error.to_string());
695 SwitchOutcome { cancelled: false }
696 }
697 }
698 }
699
700 async fn teardown_current(
708 &self,
709 reason: SessionShutdownReason,
710 target_session_file: Option<&str>,
711 ) {
712 let session = self.read_session();
713 let runner = session.extension_runner();
714 let _ = runner
715 .emit(AgentSessionEvent::SessionShutdown {
716 reason,
717 target_session_file: target_session_file.map(str::to_owned),
718 })
719 .await;
720 let runtime_cb = self
723 .before_session_invalidate
724 .read()
725 .ok()
726 .and_then(|g| g.clone());
727 if let Some(cb) = runtime_cb {
728 cb();
729 } else {
730 session.invoke_extension_shutdown_handler();
731 }
732 session.dispose().await;
734 }
735
736 fn apply(&self, result: CreateAgentSessionRuntimeResult) {
738 if let Ok(mut g) = self.session.write() {
739 *g = result.session;
740 }
741 if let Ok(mut g) = self.services.write() {
742 *g = result.services;
743 }
744 if let Ok(mut g) = self.diagnostics.write() {
745 *g = result.diagnostics;
746 }
747 if let Ok(mut g) = self.model_fallback_message.write() {
748 *g = result.model_fallback_message;
749 }
750 }
751
752 async fn finish_session_replacement(
756 &self,
757 with_session: Option<
758 Arc<dyn Fn(ReplacedSessionContext) -> BoxFuture<'static, ()> + Send + Sync>,
759 >,
760 ) {
761 let session = self.read_session();
762 let rebind = self.rebind_session.read().ok().and_then(|g| g.clone());
763 if let Some(rebind) = rebind {
764 rebind(Arc::clone(&session)).await;
765 }
766 if let Some(with) = with_session {
767 let ctx = session.create_replaced_session_context().await;
768 with(ctx).await;
769 }
770 }
771
772 fn assert_cwd(&self, session_manager: &SessionManager) -> Result<(), AgentSessionRuntimeError> {
774 assert_session_cwd_exists(session_manager, &self.cwd())
775 .map_err(|_| AgentSessionRuntimeError::MissingSessionCwd)
776 }
777}
778
779fn extract_user_message_text_from_entry(
785 entry: &crate::core::sessions::SessionEntry,
786) -> Option<String> {
787 use crate::core::sessions::SessionEntry;
788 let SessionEntry::Message(m) = entry else {
789 return None;
790 };
791 let text = crate::core::agent_session::tree::extract_user_message_text_pub(&m.message);
792 if text.is_empty() { None } else { Some(text) }
793}
794
795pub async fn create_agent_session_runtime(
801 factory: Arc<dyn CreateAgentSessionRuntimeFactory>,
802 cwd: String,
803 agent_dir: String,
804 session_manager: SessionManager,
805) -> Result<AgentSessionRuntime, AgentSessionRuntimeError> {
806 let result = factory
807 .create(CreateAgentSessionRuntimeOptions {
808 cwd,
809 agent_dir,
810 session_manager,
811 start_reason: SessionStartReason::Startup,
812 previous_session_file: None,
813 })
814 .await?;
815 Ok(AgentSessionRuntime::new(
816 result.session,
817 result.services,
818 factory,
819 result.diagnostics,
820 result.model_fallback_message,
821 ))
822}
823
824pub use crate::core::session_transfer::SessionImportFileNotFoundError as SessionImportFileNotFound;
829
830#[cfg(test)]
835mod tests {
836 use super::*;
837 use crate::core::agent_session::AgentSessionConfig;
838 use futures::stream::{self, BoxStream, StreamExt};
839 use pi_ai::{
840 AssistantMessageEvent, Context, Model, ModelCost, ModelInput, Provider, ProviderError,
841 StreamOptions,
842 };
843 use std::io;
844 use std::sync::Mutex;
845 use std::sync::atomic::{AtomicUsize, Ordering};
846
847 type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
848
849 fn failure(message: &'static str) -> io::Error {
850 io::Error::other(message)
851 }
852
853 fn test_model() -> Model {
854 Model {
855 id: "m".to_owned(),
856 name: "m".to_owned(),
857 api: "test-api".to_owned(),
858 provider: "test-provider".to_owned(),
859 base_url: String::new(),
860 reasoning: false,
861 thinking_level_map: None,
862 input: vec![ModelInput::Text],
863 cost: ModelCost::default(),
864 context_window: 8_192,
865 max_tokens: 1_024,
866 headers: None,
867 compat: None,
868 extra: std::collections::BTreeMap::new(),
869 }
870 }
871
872 #[derive(Clone)]
873 struct StubProvider;
874
875 impl Provider for StubProvider {
876 fn stream(
877 &self,
878 _model: &Model,
879 _context: Context,
880 _options: StreamOptions,
881 ) -> BoxStream<'static, Result<AssistantMessageEvent, ProviderError>> {
882 stream::empty().boxed()
883 }
884 }
885
886 struct TestFactory {
888 calls: Arc<AtomicUsize>,
889 }
890
891 impl TestFactory {
892 fn new() -> Self {
893 Self {
894 calls: Arc::new(AtomicUsize::new(0)),
895 }
896 }
897 }
898
899 impl CreateAgentSessionRuntimeFactory for TestFactory {
900 fn create(
901 &self,
902 options: CreateAgentSessionRuntimeOptions,
903 ) -> BoxFuture<'_, Result<CreateAgentSessionRuntimeResult, AgentSessionRuntimeError>>
904 {
905 self.calls.fetch_add(1, Ordering::SeqCst);
906 Box::pin(async move {
907 let config = AgentSessionConfig::test_config(Arc::new(StubProvider), test_model())
908 .map_err(|e| AgentSessionRuntimeError::Factory(e.to_string()))?;
909 let session = AgentSession::new(config)
910 .map_err(|e| AgentSessionRuntimeError::Factory(e.to_string()))?;
911 Ok(CreateAgentSessionRuntimeResult {
912 session,
913 services: AgentSessionRuntimeServices {
914 cwd: PathBuf::from(&options.cwd),
915 agent_dir: PathBuf::from(&options.agent_dir),
916 },
917 diagnostics: Vec::new(),
918 model_fallback_message: None,
919 })
920 })
921 }
922 }
923
924 struct EmitRecordingRunner {
927 log: Mutex<Vec<String>>,
928 }
929
930 impl EmitRecordingRunner {
931 fn new() -> Self {
932 Self {
933 log: Mutex::new(Vec::new()),
934 }
935 }
936
937 fn log_clone(&self) -> Vec<String> {
938 self.log
939 .lock()
940 .map_or_else(|p| p.into_inner().clone(), |g| g.clone())
941 }
942 }
943
944 impl crate::core::agent_session::ExtensionRunner for EmitRecordingRunner {
945 fn has_handlers(&self, _event: &str) -> bool {
946 true
947 }
948
949 fn emit(
950 &self,
951 event: AgentSessionEvent,
952 ) -> BoxFuture<
953 '_,
954 Result<
955 Option<crate::core::agent_session::CancelResult>,
956 crate::core::agent_session::ExtensionRunnerError,
957 >,
958 > {
959 let entry = match &event {
960 AgentSessionEvent::SessionStart {
961 reason,
962 previous_session_file,
963 } => format!(
964 "session_start:{}:{}",
965 reason.as_str(),
966 previous_session_file.as_deref().unwrap_or("-")
967 ),
968 AgentSessionEvent::SessionShutdown {
969 reason,
970 target_session_file,
971 } => format!(
972 "session_shutdown:{}:{}",
973 reason.as_str(),
974 target_session_file.as_deref().unwrap_or("-")
975 ),
976 other => other.type_name().to_owned(),
977 };
978 if let Ok(mut g) = self.log.lock() {
979 g.push(entry);
980 }
981 Box::pin(async { Ok(None) })
982 }
983
984 fn emit_message_end(
985 &self,
986 message: pi_agent::AgentMessage,
987 ) -> BoxFuture<
988 '_,
989 Result<
990 Option<pi_agent::AgentMessage>,
991 crate::core::agent_session::ExtensionRunnerError,
992 >,
993 > {
994 Box::pin(async move { Ok(Some(message)) })
995 }
996
997 fn emit_tool_call(
998 &self,
999 _tool_name: &str,
1000 _tool_call_id: &str,
1001 _input: serde_json::Map<String, serde_json::Value>,
1002 ) -> BoxFuture<
1003 '_,
1004 Result<
1005 Option<pi_agent::BeforeToolCallResult>,
1006 crate::core::agent_session::ExtensionRunnerError,
1007 >,
1008 > {
1009 Box::pin(async { Ok(None) })
1010 }
1011
1012 fn emit_tool_result(
1013 &self,
1014 _tool_name: &str,
1015 _tool_call_id: &str,
1016 _input: serde_json::Map<String, serde_json::Value>,
1017 _content: Vec<pi_ai::ToolResultContent>,
1018 _details: serde_json::Value,
1019 _is_error: bool,
1020 ) -> BoxFuture<
1021 '_,
1022 Result<
1023 Option<pi_agent::AfterToolCallResult>,
1024 crate::core::agent_session::ExtensionRunnerError,
1025 >,
1026 > {
1027 Box::pin(async { Ok(None) })
1028 }
1029
1030 fn emit_input(
1031 &self,
1032 _text: &str,
1033 _images: Option<serde_json::Value>,
1034 _source: &str,
1035 _streaming_behavior: Option<&str>,
1036 ) -> BoxFuture<
1037 '_,
1038 Result<
1039 crate::core::agent_session::InputTransformResult,
1040 crate::core::agent_session::ExtensionRunnerError,
1041 >,
1042 > {
1043 Box::pin(async { Ok(crate::core::agent_session::InputTransformResult::default()) })
1044 }
1045
1046 fn emit_before_agent_start(
1047 &self,
1048 _prompt: &str,
1049 _images: Option<serde_json::Value>,
1050 ) -> BoxFuture<
1051 '_,
1052 Result<
1053 Option<crate::core::agent_session::BeforeAgentStartResult>,
1054 crate::core::agent_session::ExtensionRunnerError,
1055 >,
1056 > {
1057 Box::pin(async { Ok(None) })
1058 }
1059
1060 fn emit_resources_discover(
1061 &self,
1062 _cwd: &str,
1063 _reason: &str,
1064 ) -> BoxFuture<
1065 '_,
1066 Result<
1067 crate::core::resources::ResourceExtensionPaths,
1068 crate::core::agent_session::ExtensionRunnerError,
1069 >,
1070 > {
1071 Box::pin(async { Ok(crate::core::resources::ResourceExtensionPaths::default()) })
1072 }
1073
1074 fn get_registered_commands(&self) -> Vec<String> {
1075 Vec::new()
1076 }
1077
1078 fn execute_command<'a>(
1079 &'a self,
1080 _name: &'a str,
1081 _args: &'a str,
1082 ) -> BoxFuture<'a, Result<bool, crate::core::agent_session::ExtensionRunnerError>> {
1083 Box::pin(async { Ok(false) })
1084 }
1085
1086 fn get_all_registered_tools(
1087 &self,
1088 ) -> std::collections::HashMap<String, Arc<dyn pi_agent::AgentTool>> {
1089 std::collections::HashMap::new()
1090 }
1091
1092 fn get_flag_values(&self) -> std::collections::HashMap<String, serde_json::Value> {
1093 std::collections::HashMap::new()
1094 }
1095
1096 fn invalidate(&self) {}
1097
1098 fn emit_error(&self, _message: String) {}
1099 }
1100
1101 struct RecordingFactory {
1104 reasons: Mutex<Vec<SessionStartReason>>,
1105 runner: Arc<EmitRecordingRunner>,
1106 }
1107
1108 impl RecordingFactory {
1109 fn new(runner: Arc<EmitRecordingRunner>) -> Self {
1110 Self {
1111 reasons: Mutex::new(Vec::new()),
1112 runner,
1113 }
1114 }
1115
1116 fn reasons_clone(&self) -> Vec<SessionStartReason> {
1117 self.reasons
1118 .lock()
1119 .map_or_else(|p| p.into_inner().clone(), |g| g.clone())
1120 }
1121 }
1122
1123 impl CreateAgentSessionRuntimeFactory for RecordingFactory {
1124 fn create(
1125 &self,
1126 options: CreateAgentSessionRuntimeOptions,
1127 ) -> BoxFuture<'_, Result<CreateAgentSessionRuntimeResult, AgentSessionRuntimeError>>
1128 {
1129 if let Ok(mut g) = self.reasons.lock() {
1130 g.push(options.start_reason);
1131 }
1132 Box::pin(async move {
1133 let mut config =
1134 AgentSessionConfig::test_config(Arc::new(StubProvider), test_model())
1135 .map_err(|e| AgentSessionRuntimeError::Factory(e.to_string()))?;
1136 config.session_manager = options.session_manager;
1137 config.extension_runner = Some(Arc::clone(&self.runner)
1138 as Arc<dyn crate::core::agent_session::ExtensionRunner>);
1139 let session = AgentSession::new(config)
1140 .map_err(|e| AgentSessionRuntimeError::Factory(e.to_string()))?;
1141 Ok(CreateAgentSessionRuntimeResult {
1142 session,
1143 services: AgentSessionRuntimeServices {
1144 cwd: PathBuf::from(&options.cwd),
1145 agent_dir: PathBuf::from(&options.agent_dir),
1146 },
1147 diagnostics: Vec::new(),
1148 model_fallback_message: None,
1149 })
1150 })
1151 }
1152 }
1153
1154 struct GatedTestFactory {
1155 calls: AtomicUsize,
1156 active_replacements: AtomicUsize,
1157 entered: tokio::sync::mpsc::Sender<usize>,
1158 gates: [Arc<tokio::sync::Semaphore>; 2],
1159 }
1160
1161 impl GatedTestFactory {
1162 fn new(
1163 entered: tokio::sync::mpsc::Sender<usize>,
1164 gates: [Arc<tokio::sync::Semaphore>; 2],
1165 ) -> Self {
1166 Self {
1167 calls: AtomicUsize::new(0),
1168 active_replacements: AtomicUsize::new(0),
1169 entered,
1170 gates,
1171 }
1172 }
1173 }
1174
1175 impl CreateAgentSessionRuntimeFactory for GatedTestFactory {
1176 fn create(
1177 &self,
1178 options: CreateAgentSessionRuntimeOptions,
1179 ) -> BoxFuture<'_, Result<CreateAgentSessionRuntimeResult, AgentSessionRuntimeError>>
1180 {
1181 let call = self.calls.fetch_add(1, Ordering::SeqCst);
1182 Box::pin(async move {
1183 if call > 0 {
1184 self.entered.try_send(call).map_err(|error| {
1185 AgentSessionRuntimeError::Factory(format!(
1186 "failed to report replacement factory entry {call}: {error}"
1187 ))
1188 })?;
1189 if self.active_replacements.swap(1, Ordering::SeqCst) != 0 {
1190 return Err(AgentSessionRuntimeError::Factory(
1191 "replacement factories overlapped".to_owned(),
1192 ));
1193 }
1194 let gate = self.gates.get(call - 1).ok_or_else(|| {
1195 AgentSessionRuntimeError::Factory(format!(
1196 "unexpected replacement factory call {call}"
1197 ))
1198 })?;
1199 gate.acquire()
1200 .await
1201 .map_err(|error| {
1202 AgentSessionRuntimeError::Factory(format!(
1203 "replacement factory gate {call} closed: {error}"
1204 ))
1205 })?
1206 .forget();
1207 }
1208
1209 let result = (|| {
1210 let config =
1211 AgentSessionConfig::test_config(Arc::new(StubProvider), test_model())
1212 .map_err(|e| AgentSessionRuntimeError::Factory(e.to_string()))?;
1213 let session = AgentSession::new(config)
1214 .map_err(|e| AgentSessionRuntimeError::Factory(e.to_string()))?;
1215 Ok(CreateAgentSessionRuntimeResult {
1216 session,
1217 services: AgentSessionRuntimeServices {
1218 cwd: PathBuf::from(&options.cwd),
1219 agent_dir: PathBuf::from(&options.agent_dir),
1220 },
1221 diagnostics: Vec::new(),
1222 model_fallback_message: None,
1223 })
1224 })();
1225 if call > 0 {
1226 self.active_replacements.store(0, Ordering::SeqCst);
1227 }
1228 result
1229 })
1230 }
1231 }
1232
1233 async fn make_runtime() -> TestResult<AgentSessionRuntime> {
1234 let factory = Arc::new(TestFactory::new());
1235 let session_manager = SessionManager::in_memory(Some("."), None)?;
1236 Ok(create_agent_session_runtime(factory, ".".into(), ".".into(), session_manager).await?)
1237 }
1238
1239 #[tokio::test]
1240 async fn runtime_returns_session_and_cwd() -> TestResult {
1241 let runtime = make_runtime().await?;
1242 let session = runtime.session();
1243 assert!(!session.session_id().await.is_empty());
1244 assert_eq!(runtime.cwd(), ".");
1245 assert_eq!(runtime.agent_dir(), ".");
1246 Ok(())
1247 }
1248
1249 #[tokio::test]
1250 async fn new_session_replaces_session_and_invokes_rebind() -> TestResult {
1251 let runtime = Arc::new(make_runtime().await?);
1252 let rebind_calls = Arc::new(AtomicUsize::new(0));
1253 let rebind_calls_clone = Arc::clone(&rebind_calls);
1254 runtime.set_rebind_session(Some(Arc::new(move |_session| {
1255 let counter = Arc::clone(&rebind_calls_clone);
1256 Box::pin(async move {
1257 counter.fetch_add(1, Ordering::SeqCst);
1258 })
1259 })));
1260
1261 let first_session = runtime.session();
1262 let outcome = runtime.new_session(NewSessionOptions::default()).await?;
1263 assert!(!outcome.cancelled);
1264 let second_session = runtime.session();
1265 assert!(
1266 !Arc::ptr_eq(&first_session, &second_session),
1267 "session should have been replaced"
1268 );
1269 assert_eq!(rebind_calls.load(Ordering::SeqCst), 1);
1270 Ok(())
1271 }
1272
1273 #[tokio::test]
1274 async fn switch_session_to_new_path_succeeds() -> TestResult {
1275 let runtime = Arc::new(make_runtime().await?);
1276 let tmp = tempfile::tempdir()?;
1277 let path = tmp.path().join("switch-target.jsonl");
1278 let path_str = path.to_string_lossy().into_owned();
1279 let outcome = runtime
1280 .switch_session(&path_str, SwitchSessionOptions::default())
1281 .await?;
1282 assert!(!outcome.cancelled);
1283 Ok(())
1284 }
1285
1286 #[tokio::test]
1287 async fn fork_at_clones_branch_and_returns_no_selected_text() -> TestResult {
1288 let runtime = Arc::new(make_runtime().await?);
1289 let entry_id = {
1290 let session = runtime.session();
1291 let sm = session.session_manager();
1292 let mut sm = sm.lock().await;
1293 sm.append_message(&pi_agent::AgentMessage::Llm(Box::new(
1294 pi_ai::Message::Assistant({
1295 let mut a = pi_ai::AssistantMessage::new(
1296 "test-api",
1297 "test-provider",
1298 "m",
1299 pi_agent::now_millis(),
1300 );
1301 a.stop_reason = pi_ai::StopReason::Stop;
1302 a
1303 }),
1304 )))?
1305 };
1306 let outcome = runtime.fork(&entry_id, ForkPosition::At).await?;
1307 assert!(!outcome.cancelled);
1308 assert!(outcome.selected_text.is_none());
1309 Ok(())
1310 }
1311
1312 #[tokio::test]
1313 async fn fork_before_user_message_returns_selected_text() -> TestResult {
1314 let runtime = Arc::new(make_runtime().await?);
1315 let entry_id = {
1316 let session = runtime.session();
1317 let sm = session.session_manager();
1318 let mut sm = sm.lock().await;
1319 sm.append_message(&pi_agent::AgentMessage::Llm(Box::new(
1320 pi_ai::Message::User(pi_ai::UserMessage::new(
1321 pi_ai::UserMessageContent::Text("hello world".into()),
1322 0,
1323 )),
1324 )))?
1325 };
1326 let outcome = runtime.fork(&entry_id, ForkPosition::Before).await?;
1327 assert!(!outcome.cancelled);
1328 assert_eq!(outcome.selected_text.as_deref(), Some("hello world"));
1329 Ok(())
1330 }
1331
1332 #[tokio::test]
1333 async fn fork_before_non_user_entry_errors() -> TestResult {
1334 let runtime = Arc::new(make_runtime().await?);
1335 let entry_id = {
1336 let session = runtime.session();
1337 let sm = session.session_manager();
1338 let mut sm = sm.lock().await;
1339 sm.append_message(&pi_agent::AgentMessage::Llm(Box::new(
1340 pi_ai::Message::Assistant({
1341 let mut a = pi_ai::AssistantMessage::new(
1342 "test-api",
1343 "test-provider",
1344 "m",
1345 pi_agent::now_millis(),
1346 );
1347 a.stop_reason = pi_ai::StopReason::Stop;
1348 a
1349 }),
1350 )))?
1351 };
1352 let Err(err) = runtime.fork(&entry_id, ForkPosition::Before).await else {
1353 return Err(failure("forking before a non-user entry must fail").into());
1354 };
1355 assert!(matches!(err, AgentSessionRuntimeError::InvalidForkEntry));
1356 Ok(())
1357 }
1358
1359 #[tokio::test]
1360 async fn fork_unknown_entry_errors() -> TestResult {
1361 let runtime = Arc::new(make_runtime().await?);
1362 let Err(err) = runtime.fork("missing", ForkPosition::At).await else {
1363 return Err(failure("forking an unknown entry must fail").into());
1364 };
1365 assert!(matches!(err, AgentSessionRuntimeError::InvalidForkEntry));
1366 Ok(())
1367 }
1368
1369 #[tokio::test]
1370 async fn import_from_jsonl_missing_file_errors() -> TestResult {
1371 let runtime = Arc::new(make_runtime().await?);
1372 let Err(err) = runtime
1373 .import_from_jsonl("/nonexistent/path.jsonl", None)
1374 .await
1375 else {
1376 return Err(failure("importing a missing JSONL file must fail").into());
1377 };
1378 assert!(matches!(err, AgentSessionRuntimeError::ImportNotFound(_)));
1379 Ok(())
1380 }
1381
1382 #[tokio::test]
1383 async fn dispose_tears_down_session_without_replacing() -> TestResult {
1384 let runtime = Arc::new(make_runtime().await?);
1385 let session = runtime.session();
1386 runtime.dispose().await;
1387 assert!(Arc::ptr_eq(&runtime.session(), &session));
1388 Ok(())
1389 }
1390
1391 #[tokio::test]
1392 async fn rebind_callback_runs_after_apply_on_new_session() -> TestResult {
1393 let runtime = Arc::new(make_runtime().await?);
1395 let bound_session_ids = Arc::new(std::sync::Mutex::new(Vec::new()));
1396 let bound_ids_clone = Arc::clone(&bound_session_ids);
1397 runtime.set_rebind_session(Some(Arc::new(move |session| {
1398 let ids = Arc::clone(&bound_ids_clone);
1399 Box::pin(async move {
1400 let id = session.session_id().await;
1401 if let Ok(mut ids) = ids.lock() {
1402 ids.push(id);
1403 }
1404 })
1405 })));
1406 runtime.new_session(NewSessionOptions::default()).await?;
1407 let captured = bound_session_ids
1408 .lock()
1409 .map_err(|_| failure("bound session ID mutex poisoned"))?
1410 .clone();
1411 assert_eq!(captured.len(), 1, "rebind should fire once");
1412 assert_eq!(captured[0], runtime.session().session_id().await);
1413 Ok(())
1414 }
1415
1416 #[tokio::test]
1417 async fn set_before_session_invalidate_invoked_during_teardown() -> TestResult {
1418 let runtime = Arc::new(make_runtime().await?);
1419 let called = Arc::new(AtomicUsize::new(0));
1420 let called_clone = Arc::clone(&called);
1421 runtime.set_before_session_invalidate(Some(Arc::new(move || {
1422 called_clone.fetch_add(1, Ordering::SeqCst);
1423 })));
1424 runtime.new_session(NewSessionOptions::default()).await?;
1425 assert_eq!(called.load(Ordering::SeqCst), 1);
1426 Ok(())
1427 }
1428
1429 #[tokio::test]
1430 async fn replacement_serialized_concurrent_new_sessions() -> TestResult {
1431 let (entered_tx, mut entered_rx) = tokio::sync::mpsc::channel(2);
1432 let gates = [
1433 Arc::new(tokio::sync::Semaphore::new(0)),
1434 Arc::new(tokio::sync::Semaphore::new(0)),
1435 ];
1436 let factory = Arc::new(GatedTestFactory::new(
1437 entered_tx,
1438 [Arc::clone(&gates[0]), Arc::clone(&gates[1])],
1439 ));
1440 let session_manager = SessionManager::in_memory(Some("."), None)?;
1441 let runtime = Arc::new(
1442 create_agent_session_runtime(factory, ".".into(), ".".into(), session_manager).await?,
1443 );
1444 let start = Arc::new(tokio::sync::Barrier::new(3));
1445
1446 let first_runtime = Arc::clone(&runtime);
1447 let first_start = Arc::clone(&start);
1448 let first = tokio::spawn(async move {
1449 first_start.wait().await;
1450 first_runtime
1451 .new_session(NewSessionOptions::default())
1452 .await
1453 });
1454 let second_runtime = Arc::clone(&runtime);
1455 let second_start = Arc::clone(&start);
1456 let second = tokio::spawn(async move {
1457 second_start.wait().await;
1458 second_runtime
1459 .new_session(NewSessionOptions::default())
1460 .await
1461 });
1462 start.wait().await;
1463
1464 let first_call = tokio::time::timeout(std::time::Duration::from_secs(1), entered_rx.recv())
1465 .await
1466 .map_err(|_| io::Error::other("timed out waiting for first replacement factory entry"))?
1467 .ok_or_else(|| io::Error::other("replacement factory entry channel closed early"))?;
1468 assert_eq!(first_call, 1);
1469
1470 match tokio::time::timeout(std::time::Duration::from_millis(100), entered_rx.recv()).await {
1471 Ok(Some(call)) => {
1472 return Err(io::Error::other(format!(
1473 "replacement factory call {call} entered before call {first_call} was released"
1474 ))
1475 .into());
1476 }
1477 Ok(None) => {
1478 return Err(io::Error::other(
1479 "replacement factory entry channel closed while first gate was held",
1480 )
1481 .into());
1482 }
1483 Err(_) => {}
1484 }
1485
1486 gates[first_call - 1].add_permits(1);
1487 let second_call =
1488 tokio::time::timeout(std::time::Duration::from_secs(1), entered_rx.recv())
1489 .await
1490 .map_err(|_| {
1491 io::Error::other("timed out waiting for second replacement factory entry")
1492 })?
1493 .ok_or_else(|| {
1494 io::Error::other("replacement factory entry channel closed early")
1495 })?;
1496 assert_eq!(second_call, 2);
1497 gates[second_call - 1].add_permits(1);
1498
1499 let first_result = tokio::time::timeout(std::time::Duration::from_secs(1), first)
1500 .await
1501 .map_err(|_| io::Error::other("timed out joining first new-session task"))??;
1502 let second_result = tokio::time::timeout(std::time::Duration::from_secs(1), second)
1503 .await
1504 .map_err(|_| io::Error::other("timed out joining second new-session task"))??;
1505 first_result?;
1506 second_result?;
1507 Ok(())
1508 }
1509
1510 async fn make_recording_runtime() -> TestResult<(
1511 AgentSessionRuntime,
1512 Arc<RecordingFactory>,
1513 Arc<EmitRecordingRunner>,
1514 )> {
1515 let runner = Arc::new(EmitRecordingRunner::new());
1516 let factory = Arc::new(RecordingFactory::new(Arc::clone(&runner)));
1517 let session_manager = SessionManager::in_memory(Some("."), None)?;
1518 let runtime = create_agent_session_runtime(
1519 Arc::clone(&factory) as Arc<dyn CreateAgentSessionRuntimeFactory>,
1520 ".".into(),
1521 ".".into(),
1522 session_manager,
1523 )
1524 .await?;
1525 Ok((runtime, factory, runner))
1526 }
1527
1528 #[tokio::test]
1529 async fn new_session_passes_new_reason_and_emits_typed_shutdown() -> TestResult {
1530 let (runtime, factory, runner) = make_recording_runtime().await?;
1531 runtime.new_session(NewSessionOptions::default()).await?;
1532 assert_eq!(
1533 factory.reasons_clone(),
1534 vec![SessionStartReason::Startup, SessionStartReason::New],
1535 "replacement factory must receive start_reason = New"
1536 );
1537 let log = runner.log_clone();
1538 assert!(
1539 log.iter().any(|e| e == "session_shutdown:new:-"),
1540 "old session must receive typed session_shutdown{{new}} (in-memory: no target), got {log:?}"
1541 );
1542 Ok(())
1543 }
1544
1545 #[tokio::test]
1546 async fn fork_passes_fork_reason_and_emits_typed_shutdown() -> TestResult {
1547 let (runtime, factory, runner) = make_recording_runtime().await?;
1548 let entry_id = {
1549 let session = runtime.session();
1550 let sm = session.session_manager();
1551 let mut sm = sm.lock().await;
1552 sm.append_message(&pi_agent::AgentMessage::Llm(Box::new(
1553 pi_ai::Message::Assistant({
1554 let mut a = pi_ai::AssistantMessage::new(
1555 "test-api",
1556 "test-provider",
1557 "m",
1558 pi_agent::now_millis(),
1559 );
1560 a.stop_reason = pi_ai::StopReason::Stop;
1561 a
1562 }),
1563 )))?
1564 };
1565 runtime.fork(&entry_id, ForkPosition::At).await?;
1566 assert_eq!(
1567 factory.reasons_clone(),
1568 vec![SessionStartReason::Startup, SessionStartReason::Fork],
1569 "fork factory must receive start_reason = Fork"
1570 );
1571 let log = runner.log_clone();
1572 assert!(
1573 log.iter().any(|e| e == "session_shutdown:fork:-"),
1574 "old session must receive typed session_shutdown{{fork}}, got {log:?}"
1575 );
1576 Ok(())
1577 }
1578
1579 #[tokio::test]
1580 async fn switch_session_emits_shutdown_with_target_session_file() -> TestResult {
1581 let (runtime, factory, runner) = make_recording_runtime().await?;
1582 let tmp = tempfile::tempdir()?;
1583 let path = tmp.path().join("switch-target.jsonl");
1584 let path_str = path.to_string_lossy().into_owned();
1585 runtime
1586 .switch_session(&path_str, SwitchSessionOptions::default())
1587 .await?;
1588 assert_eq!(
1589 factory.reasons_clone(),
1590 vec![SessionStartReason::Startup, SessionStartReason::Resume],
1591 );
1592 let expected = format!("session_shutdown:resume:{path_str}");
1593 let log = runner.log_clone();
1594 assert!(
1595 log.contains(&expected),
1596 "switch must carry the new session file as targetSessionFile: want {expected}, got {log:?}"
1597 );
1598 Ok(())
1599 }
1600
1601 #[tokio::test]
1602 async fn dispose_emits_quit_shutdown_without_target() -> TestResult {
1603 let (runtime, _factory, runner) = make_recording_runtime().await?;
1604 runtime.dispose().await;
1605 let log = runner.log_clone();
1606 assert!(
1607 log.iter().any(|e| e == "session_shutdown:quit:-"),
1608 "dispose must emit typed session_shutdown{{quit}} with no target, got {log:?}"
1609 );
1610 Ok(())
1611 }
1612}