Skip to main content

pi/core/
agent_session_runtime.rs

1//! `AgentSessionRuntime` — owns the current [`AgentSession`] and drives the
2//! new / switch / fork / import replacement pipeline.
3//!
4//! Ports `coding-agent/src/core/agent-session-runtime.ts`. The runtime is
5//! single-owner: each replacement operation (under a serializing async mutex)
6//! creates the replacement session, tears down the current one, atomically
7//! swaps, and rebinds listeners on the new session.
8//!
9//! Replacement order (matches TS regression 2860: `withSession` runs AFTER
10//! rebind on the NEW session):
11//! 1. `emit_before_switch` / `emit_before_fork` (cancellable).
12//! 2. Build the new session manager.
13//! 3. Call the factory → new session + services + diagnostics.
14//! 4. `teardown_current`: typed `session_shutdown{reason, targetSessionFile}`
15//!    emit, then `before_session_invalidate`, then `session.dispose()`.
16//! 5. `apply(result)`: swap session + services + diagnostics.
17//! 6. `finish_session_replacement`: `rebind_session(new_session)` then
18//!    optional `with_session(ctx)`. The mode's rebind calls `bind_extensions`
19//!    on the new session, which emits the stored
20//!    `session_start{new|resume|fork}` after the old host received its
21//!    `session_shutdown` in step 4.
22
23use 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// ---------------------------------------------------------------------------
41// Services carried by the runtime
42// ---------------------------------------------------------------------------
43
44/// Cwd-bound service handles the runtime needs to drive replacements.
45///
46/// This is a intentionally narrow view of [`crate::core::agent_session_services::AgentSessionServices`]:
47/// the runtime only needs `cwd`, `agent_dir`, and accumulated diagnostics.
48/// The full services object (with `ModelRuntime`, `DefaultResourceLoader`)
49/// is owned by the app layer; the factory extracts what the runtime needs.
50#[derive(Clone, Debug)]
51pub struct AgentSessionRuntimeServices {
52    /// Effective working directory.
53    pub cwd: PathBuf,
54    /// Agent directory (preserved across replacements).
55    pub agent_dir: PathBuf,
56}
57
58// ---------------------------------------------------------------------------
59// Factory
60// ---------------------------------------------------------------------------
61
62/// Inputs to [`CreateAgentSessionRuntimeFactory::create`].
63#[derive(Debug)]
64pub struct CreateAgentSessionRuntimeOptions {
65    /// Effective cwd for the new runtime.
66    pub cwd: String,
67    /// Agent directory (preserved across replacements).
68    pub agent_dir: String,
69    /// Session manager to bind (already constructed for the target session).
70    pub session_manager: SessionManager,
71    /// Replacement reason (carried into `session_start`).
72    pub start_reason: SessionStartReason,
73    /// Previous session file (for `session_start.previousSessionFile`).
74    pub previous_session_file: Option<String>,
75}
76
77/// Result returned by [`CreateAgentSessionRuntimeFactory::create`].
78pub struct CreateAgentSessionRuntimeResult {
79    /// New session.
80    pub session: Arc<AgentSession>,
81    /// Services snapshot.
82    pub services: AgentSessionRuntimeServices,
83    /// Diagnostics collected during creation.
84    pub diagnostics: Vec<crate::core::agent_session_services::AgentSessionRuntimeDiagnostic>,
85    /// Optional model-fallback warning.
86    pub model_fallback_message: Option<String>,
87}
88
89/// Factory that builds a fresh session + services from a session manager.
90///
91/// Implemented by the real services factory (which composes
92/// `create_agent_session_services` + `create_agent_session_from_services`)
93/// and fake test factories.
94pub trait CreateAgentSessionRuntimeFactory: Send + Sync {
95    /// Build a new session + services from the given options.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`AgentSessionRuntimeError`] when services construction or
100    /// session creation fails.
101    fn create(
102        &self,
103        options: CreateAgentSessionRuntimeOptions,
104    ) -> BoxFuture<'_, Result<CreateAgentSessionRuntimeResult, AgentSessionRuntimeError>>;
105}
106
107// ---------------------------------------------------------------------------
108// Callbacks
109// ---------------------------------------------------------------------------
110
111/// Async callback invoked on the new session after teardown + apply, before
112/// `with_session`. Re-binds listeners / event subscriptions.
113pub type RebindSessionCallback =
114    Arc<dyn Fn(Arc<AgentSession>) -> BoxFuture<'static, ()> + Send + Sync>;
115
116/// Synchronous callback invoked after `session_shutdown` handlers finish but
117/// before the old session is invalidated (host UI teardown that must not
118/// yield to the event loop).
119pub type BeforeSessionInvalidateCallback = Arc<dyn Fn() + Send + Sync>;
120
121// ---------------------------------------------------------------------------
122// Outcomes / options
123// ---------------------------------------------------------------------------
124
125/// Outcome of `new_session` / `switch_session` / `import_from_jsonl`.
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub struct SwitchOutcome {
128    /// True when cancelled by a `before_switch` extension hook.
129    pub cancelled: bool,
130}
131
132/// Outcome of `fork`.
133#[derive(Clone, Debug, Default)]
134pub struct ForkOutcome {
135    /// True when cancelled by a `before_fork` extension hook.
136    pub cancelled: bool,
137    /// User-message text when forking before a user message (else `None`).
138    pub selected_text: Option<String>,
139}
140
141/// Options for [`AgentSessionRuntime::new_session`].
142#[derive(Clone, Debug, Default)]
143pub struct NewSessionOptions {
144    /// Optional parent session file (chained into the new session header).
145    pub parent_session: Option<String>,
146}
147
148/// Options for [`AgentSessionRuntime::fork`].
149#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
150pub enum ForkPosition {
151    /// Fork from the parent of the selected user message (default).
152    #[default]
153    Before,
154    /// Fork at the selected entry (clone its subtree).
155    At,
156}
157
158/// Options for [`AgentSessionRuntime::switch_session`].
159#[derive(Clone, Debug, Default)]
160pub struct SwitchSessionOptions {
161    /// Optional cwd override when the imported session's stored cwd is missing.
162    pub cwd_override: Option<String>,
163}
164
165// ---------------------------------------------------------------------------
166// Errors
167// ---------------------------------------------------------------------------
168
169/// Errors from runtime operations.
170#[derive(Debug, thiserror::Error)]
171pub enum AgentSessionRuntimeError {
172    /// Session I/O error.
173    #[error(transparent)]
174    Session(#[from] SessionError),
175    /// Imported session cwd does not exist.
176    #[error("Stored session working directory does not exist")]
177    MissingSessionCwd,
178    /// Imported file not found.
179    #[error(transparent)]
180    ImportNotFound(#[from] SessionImportFileNotFoundError),
181    /// Persisted session missing its file path.
182    #[error("Persisted session is missing a session file")]
183    MissingSessionFile,
184    /// Fork target is invalid (not a user message when `position = before`).
185    #[error("Invalid entry ID for forking")]
186    InvalidForkEntry,
187    /// Unflushed persisted session cannot be forked.
188    #[error(
189        "This session has not been saved yet. Wait for the first assistant response before cloning or forking it."
190    )]
191    UnflushedSession,
192    /// File transfer error during import.
193    #[error("{0}")]
194    Transfer(String),
195    /// Factory failed to build the replacement runtime.
196    #[error("runtime replacement failed: {0}")]
197    Factory(String),
198}
199
200// ---------------------------------------------------------------------------
201// AgentSessionRuntime
202// ---------------------------------------------------------------------------
203
204/// Owns the current [`AgentSession`] plus cwd-bound services, and drives the
205/// replacement pipeline for new / switch / fork / import.
206///
207/// Not `Clone`. Callers hold it behind `Arc<AgentSessionRuntime>`; interior
208/// mutability covers session + services so replacement operations can run
209/// through a shared reference. A serializing async mutex prevents concurrent
210/// replacement operations from interleaving teardown / apply / rebind.
211pub 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    /// Serializes all replacement operations (new/switch/fork/import/dispose).
220    replacement_lock: AsyncMutex<()>,
221}
222
223impl AgentSessionRuntime {
224    /// Construct the runtime from an initial session + services + factory.
225    #[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    /// Snapshot of the current session (cheap Arc clone).
246    #[must_use]
247    pub fn session(&self) -> Arc<AgentSession> {
248        self.read_session()
249    }
250
251    /// Effective cwd.
252    #[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    /// Agent directory (preserved across replacements).
261    #[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    /// Snapshot of diagnostics.
270    #[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    /// Snapshot of the model-fallback warning.
281    #[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    /// Set the rebind callback invoked after each replacement.
290    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    /// Set the synchronous pre-invalidate callback invoked during teardown.
297    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    // ----- Replacement operations ----------------------------------------
304
305    /// Switch to a different session file (resume).
306    ///
307    /// # Errors
308    ///
309    /// See [`AgentSessionRuntimeError`].
310    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    /// Start a new session in the current cwd.
350    ///
351    /// # Errors
352    ///
353    /// See [`AgentSessionRuntimeError`].
354    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    /// Fork the session at `entry_id`.
409    ///
410    /// `position = Before` (default): the entry must be a user message; the
411    /// fork point is that message's parent and `selected_text` is returned.
412    /// `position = At`: the fork point is the entry itself (clone subtree).
413    ///
414    /// For **persisted** sessions, the forked branch is written to a new file
415    /// under the session directory. For **in-memory** sessions, a fresh
416    /// in-memory manager is constructed (the mutated branch on the old
417    /// manager is not carried over — until `AgentSession` accepts a shared
418    /// `Arc<AsyncMutex<SessionManager>>`, matching the TS single-reference
419    /// pattern).
420    ///
421    /// # Errors
422    ///
423    /// See [`AgentSessionRuntimeError`].
424    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        // Build the new session manager.
467        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(&current_session_file).exists() {
487                        return Err(AgentSessionRuntimeError::UnflushedSession);
488                    }
489                    let mut reopened =
490                        SessionManager::open(&current_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            // In-memory: cannot extract the mutated manager from the live Arc.
500            // Fall back to a fresh in-memory manager with parent linkage.
501            let opts = match target_leaf_id.as_deref() {
502                Some(_) => None, // branch state lost for in-memory (documented)
503                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    /// Import a JSONL session file and switch to it.
536    ///
537    /// # Errors
538    ///
539    /// Returns [`AgentSessionRuntimeError::ImportNotFound`] when the input
540    /// path does not exist.
541    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    /// Dispose the current session and the runtime.
622    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    // ----- Internal helpers ----------------------------------------------
629
630    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    /// Emit `session_before_switch` (cancellable) when handlers are registered.
642    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    /// Emit `session_before_fork` (cancellable) when handlers are registered.
674    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    /// Tear down the current session: typed `session_shutdown` emit →
701    /// pre-invalidate → dispose.
702    ///
703    /// The emit self-gates on `session_shutdown` handler presence and host
704    /// errors are isolated. Host process reap is always performed exactly
705    /// once inside [`AgentSession::dispose`] when a concrete host is present,
706    /// even without `session_shutdown` handlers.
707    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        // Pre-invalidate callback (host UI teardown, sync). Falls back to the
721        // session-bound shutdown handler when no runtime-level callback exists.
722        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        // dispose always awaits host process reap exactly once when bound.
733        session.dispose().await;
734    }
735
736    /// Apply the factory result: swap session + services + diagnostics.
737    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    /// Invoke the rebind callback on the new session + optional `with_session`.
753    ///
754    /// Runs AFTER apply, so `self.session()` is the new session (regression 2860).
755    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    /// Assert the session cwd exists; fall back to the runtime cwd.
773    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
779// ---------------------------------------------------------------------------
780// Helpers
781// ---------------------------------------------------------------------------
782
783/// Extract concatenated text from a user-message entry (for `fork` `selected_text`).
784fn 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
795/// Create the initial runtime from a factory and initial session manager.
796///
797/// # Errors
798///
799/// Returns [`AgentSessionRuntimeError`] when the factory fails.
800pub 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
824// ---------------------------------------------------------------------------
825// Re-exports
826// ---------------------------------------------------------------------------
827
828pub use crate::core::session_transfer::SessionImportFileNotFoundError as SessionImportFileNotFound;
829
830// ---------------------------------------------------------------------------
831// Tests
832// ---------------------------------------------------------------------------
833
834#[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    /// Factory that produces a fresh in-memory session per call.
887    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    /// Extension runner recording lifecycle `emit` calls (shared across the
925    /// sessions a recording factory creates).
926    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    /// Factory recording every `start_reason` and installing a shared
1102    /// recording runner on each created session.
1103    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        // Regression 2860: withSession must run on the NEW session.
1394        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}