Skip to main content

pi/core/agent_session/
model.rs

1//! Model set / cycle / scope / thinking-clamp impls.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/agent-session.ts`
4//! `setModel`, `cycleModel`, `setThinkingLevel`, `cycleThinkingLevel`,
5//! `getAvailableThinkingLevels`, `supportsThinking`, plus the private helpers
6//! `_cycleScopedModel`, `_cycleAvailableModel`, `_getThinkingLevelForModelSwitch`,
7//! `_clampThinkingLevel`, and `_emitModelSelect`.
8//!
9//! Behaviour preserved from the TypeScript contract:
10//! - Scoped cycling (`--models`) filters by configured auth and falls back to
11//!   the first scoped model when the current model is not in the scoped set.
12//! - Available cycling uses the model-runtime auth-configured snapshot.
13//! - Both call `set_thinking_level`, which clamps to the new model's supported
14//!   set and only persists/emits when the effective level actually changes.
15//! - Auth checks are skipped when no model runtime is attached (tests /
16//!   pre-runtime builds).
17//!
18//! Lock order: never hold `AgentSessionInner` across `.await`. The session
19//! manager async mutex is acquired for append-only persistence and released
20//! before extension emits.
21
22use std::sync::Arc;
23
24use pi_ai::{Model, ModelThinkingLevel, ThinkingLevelMap};
25
26use crate::core::model_runtime::ModelRuntime;
27use crate::core::sessions::SessionError;
28
29use super::events::{AgentSessionEvent, ModelSelectSource};
30use super::{AgentSession, ScopedModel};
31
32/// Result of [`AgentSession::cycle_model`].
33#[derive(Clone, Debug, PartialEq)]
34pub struct ModelCycleResult {
35    /// Model now active on the agent.
36    pub model: Model,
37    /// Effective thinking level after clamping to model capabilities.
38    pub thinking_level: ModelThinkingLevel,
39    /// Whether cycling happened across scoped (`--models`) entries or all
40    /// available models.
41    pub is_scoped: bool,
42}
43
44/// Direction of model cycling (TypeScript `"forward" | "backward"`).
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub enum CycleDirection {
47    /// Advance to the next entry.
48    Forward,
49    /// Step back to the previous entry.
50    Backward,
51}
52
53/// Errors returned by [`AgentSession::set_model`].
54#[derive(Debug, thiserror::Error)]
55pub enum ModelError {
56    /// No credential / OAuth configured for the provider.
57    #[error("No API key for {0}/{1}")]
58    NoAuth(String, String),
59    /// Session persistence failed while appending the model-change entry.
60    #[error(transparent)]
61    Session(#[from] SessionError),
62}
63
64/// Canonical thinking-level ordering used by `clampThinkingLevel`
65/// (TypeScript `EXTENDED_THINKING_LEVELS`).
66const EXTENDED_THINKING_LEVELS: [ModelThinkingLevel; 7] = [
67    ModelThinkingLevel::Off,
68    ModelThinkingLevel::Minimal,
69    ModelThinkingLevel::Low,
70    ModelThinkingLevel::Medium,
71    ModelThinkingLevel::High,
72    ModelThinkingLevel::Xhigh,
73    ModelThinkingLevel::Max,
74];
75
76/// Index of `level` in [`EXTENDED_THINKING_LEVELS`], or `None` when unknown.
77fn extended_level_index(level: ModelThinkingLevel) -> Option<usize> {
78    EXTENDED_THINKING_LEVELS
79        .iter()
80        .position(|candidate| *candidate == level)
81}
82
83/// Equality by `id` and `provider` (TypeScript `modelsAreEqual`).
84fn models_are_equal(a: &Model, b: &Model) -> bool {
85    a.id == b.id && a.provider == b.provider
86}
87
88/// Levels supported by `model` (TypeScript `getSupportedThinkingLevels`).
89///
90/// Non-reasoning models support only `Off`. Otherwise levels come from
91/// [`Model::thinking_level_map`]; an explicit `None` value marks a level
92/// unsupported, while `xhigh` and `max` are included only when explicitly
93/// mapped (mirrors the TypeScript filter exactly).
94pub(super) fn supported_thinking_levels(model: &Model) -> Vec<ModelThinkingLevel> {
95    if !model.reasoning {
96        return vec![ModelThinkingLevel::Off];
97    }
98    let Some(map) = model.thinking_level_map.as_ref() else {
99        return EXTENDED_THINKING_LEVELS.to_vec();
100    };
101    EXTENDED_THINKING_LEVELS
102        .iter()
103        .filter(|level| level_supported_by_map(**level, map))
104        .copied()
105        .collect()
106}
107
108fn level_supported_by_map(level: ModelThinkingLevel, map: &ThinkingLevelMap) -> bool {
109    match map.get(&level) {
110        // Explicit null → unsupported.
111        Some(None) => false,
112        // Explicit value → supported.
113        Some(Some(_)) => true,
114        // Absent: supported except for xhigh/max which require an explicit entry.
115        None => !matches!(level, ModelThinkingLevel::Xhigh | ModelThinkingLevel::Max),
116    }
117}
118
119/// Clamp `level` to a level supported by `model`.
120///
121/// Walks forward first, then backward through [`EXTENDED_THINKING_LEVELS`]
122/// (TypeScript `clampThinkingLevel`).
123pub(super) fn clamp_thinking_level(model: &Model, level: ModelThinkingLevel) -> ModelThinkingLevel {
124    let available = supported_thinking_levels(model);
125    let has_mapped_candidates = model
126        .thinking_level_map
127        .as_ref()
128        .is_some_and(|map| map.values().any(Option::is_some));
129    let is_clamp_candidate = |candidate: &ModelThinkingLevel| {
130        if !available.contains(candidate) {
131            return false;
132        }
133        !has_mapped_candidates
134            || model
135                .thinking_level_map
136                .as_ref()
137                .is_some_and(|map| map.get(candidate).is_some_and(Option::is_some))
138    };
139
140    if is_clamp_candidate(&level) {
141        return level;
142    }
143    let Some(requested_index) = extended_level_index(level) else {
144        return available
145            .first()
146            .copied()
147            .unwrap_or(ModelThinkingLevel::Off);
148    };
149    for candidate in &EXTENDED_THINKING_LEVELS[requested_index..] {
150        if is_clamp_candidate(candidate) {
151            return *candidate;
152        }
153    }
154    for candidate in EXTENDED_THINKING_LEVELS[..requested_index].iter().rev() {
155        if is_clamp_candidate(candidate) {
156            return *candidate;
157        }
158    }
159    available
160        .first()
161        .copied()
162        .unwrap_or(ModelThinkingLevel::Off)
163}
164
165impl AgentSession {
166    /// Clone the typed model-runtime handle when this session has one.
167    ///
168    /// Pre-built-agent tests may omit the runtime; model set / cycle methods
169    /// treat that as "auth checks skipped".
170    pub(super) fn model_runtime(&self) -> Option<Arc<ModelRuntime>> {
171        self.model_runtime_handle()
172    }
173
174    /// Set the current model.
175    ///
176    /// Validates auth via the attached runtime (when present), updates agent
177    /// state, appends a `model_change` session entry, mutates settings, and
178    /// re-clamps the thinking level to the new model's capabilities.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`ModelError::NoAuth`] when the runtime reports no configured
183    /// credential for the model's provider, or [`ModelError::Session`] when
184    /// persistence fails (live agent state is left unchanged in that case).
185    pub async fn set_model(&self, model: Model) -> Result<(), ModelError> {
186        if let Some(runtime) = self.model_runtime()
187            && runtime.check_auth(&model.provider).await.is_none()
188        {
189            return Err(ModelError::NoAuth(model.provider.clone(), model.id.clone()));
190        }
191        let previous = self.model();
192        if models_are_equal(&previous, &model) {
193            return Ok(());
194        }
195        let thinking = self.thinking_level_for_model_switch(None);
196        // Durable append first: live state, settings, and events publish only
197        // model changes the session file actually holds.
198        {
199            let mut manager = self.session_manager.lock().await;
200            manager.append_model_change(&model.provider, &model.id)?;
201        }
202        self.agent.set_model(model.clone());
203        self.lock_settings()
204            .set_default_model_and_provider(&model.provider, &model.id);
205        // Model-switch path: the thinking append may fail independently; the
206        // live level (reported by callers via `thinking_level()`) stays honest.
207        let _committed = self.set_thinking_level(thinking).await;
208        self.emit_model_select(&model, Some(&previous), ModelSelectSource::Set)
209            .await;
210        Ok(())
211    }
212
213    /// Cycle to the next or previous model.
214    ///
215    /// Uses scoped models (`--models`) when present, otherwise cycles across
216    /// all auth-configured models from the runtime. Returns `None` when only
217    /// one candidate is available.
218    pub async fn cycle_model(&self, direction: CycleDirection) -> Option<ModelCycleResult> {
219        if self.scoped_models().is_empty() {
220            self.cycle_available_model(direction).await
221        } else {
222            self.cycle_scoped_model(direction).await
223        }
224    }
225
226    async fn cycle_scoped_model(&self, direction: CycleDirection) -> Option<ModelCycleResult> {
227        let scoped = self.scoped_models();
228        let filtered = self.filter_scoped_by_auth(scoped).await;
229        if filtered.len() <= 1 {
230            return None;
231        }
232        let current = self.model();
233        let current_index = filtered
234            .iter()
235            .position(|scoped| models_are_equal(&scoped.model, &current))
236            .unwrap_or(0);
237        let len = filtered.len();
238        let next_index = match direction {
239            CycleDirection::Forward => (current_index + 1) % len,
240            CycleDirection::Backward => (current_index + len - 1) % len,
241        };
242        let next = &filtered[next_index];
243        let thinking = self.thinking_level_for_model_switch(next.thinking_level);
244        // Durable append first; a persistence failure cycles nothing.
245        {
246            let mut manager = self.session_manager.lock().await;
247            if manager
248                .append_model_change(&next.model.provider, &next.model.id)
249                .is_err()
250            {
251                return None;
252            }
253        }
254        self.agent.set_model(next.model.clone());
255        self.lock_settings()
256            .set_default_model_and_provider(&next.model.provider, &next.model.id);
257        // Cycle result reports the actual live level below; bind-and-ignore.
258        let _committed = self.set_thinking_level(thinking).await;
259        self.emit_model_select(&next.model, Some(&current), ModelSelectSource::Cycle)
260            .await;
261        Some(ModelCycleResult {
262            model: next.model.clone(),
263            thinking_level: self.thinking_level(),
264            is_scoped: true,
265        })
266    }
267
268    async fn cycle_available_model(&self, direction: CycleDirection) -> Option<ModelCycleResult> {
269        let runtime = self.model_runtime()?;
270        let available = runtime.get_available_snapshot();
271        if available.len() <= 1 {
272            return None;
273        }
274        let current = self.model();
275        let current_index = available
276            .iter()
277            .position(|model| models_are_equal(model, &current))
278            .unwrap_or(0);
279        let len = available.len();
280        let next_index = match direction {
281            CycleDirection::Forward => (current_index + 1) % len,
282            CycleDirection::Backward => (current_index + len - 1) % len,
283        };
284        let next_model = available[next_index].clone();
285        let thinking = self.thinking_level_for_model_switch(None);
286        // Durable append first; a persistence failure cycles nothing.
287        {
288            let mut manager = self.session_manager.lock().await;
289            if manager
290                .append_model_change(&next_model.provider, &next_model.id)
291                .is_err()
292            {
293                return None;
294            }
295        }
296        self.agent.set_model(next_model.clone());
297        self.lock_settings()
298            .set_default_model_and_provider(&next_model.provider, &next_model.id);
299        // Cycle result reports the actual live level below; bind-and-ignore.
300        let _committed = self.set_thinking_level(thinking).await;
301        self.emit_model_select(&next_model, Some(&current), ModelSelectSource::Cycle)
302            .await;
303        Some(ModelCycleResult {
304            model: next_model,
305            thinking_level: self.thinking_level(),
306            is_scoped: false,
307        })
308    }
309
310    /// Filter scoped models by configured auth (TypeScript `_cycleScopedModel`
311    /// `auth !== undefined` filter).
312    async fn filter_scoped_by_auth(&self, scoped: Vec<ScopedModel>) -> Vec<ScopedModel> {
313        let Some(runtime) = self.model_runtime() else {
314            return scoped;
315        };
316        let mut keep = Vec::with_capacity(scoped.len());
317        for entry in scoped {
318            if runtime.check_auth(&entry.model.provider).await.is_some() {
319                keep.push(entry);
320            }
321        }
322        keep
323    }
324
325    /// Set the thinking level.
326    ///
327    /// Clamps to the current model's supported levels. Only persists / emits
328    /// when the effective level actually changes. The reference is synchronous
329    /// because JavaScript is single-threaded; this Rust port is `async` so it
330    /// can append to the session manager (held under a `tokio::Mutex`) and
331    /// await extension emits without spawning.
332    ///
333    /// Returns whether the live level now equals the requested effective
334    /// level: `true` on commit or when no change was needed, `false` when the
335    /// durable append failed (nothing was mutated or published).
336    #[must_use = "a false return means the level change was not durably committed"]
337    pub async fn set_thinking_level(&self, level: ModelThinkingLevel) -> bool {
338        let available = self.available_thinking_levels();
339        let effective = if available.contains(&level) {
340            level
341        } else {
342            clamp_thinking_level(&self.model(), level)
343        };
344        let previous = self.thinking_level();
345        if effective == previous {
346            return true;
347        }
348        // Durable append first: live level, settings, and events publish only
349        // changes the session file actually holds.
350        {
351            let mut manager = self.session_manager.lock().await;
352            if manager
353                .append_thinking_level_change(level_str(effective))
354                .is_err()
355            {
356                return false;
357            }
358        }
359        self.agent.set_thinking_level(effective);
360        // Persist as the default only when the model supports reasoning, or the
361        // new level is not "off" (TypeScript `supportsThinking() ||
362        // effectiveLevel !== "off"`).
363        if self.supports_thinking() || effective != ModelThinkingLevel::Off {
364            self.lock_settings().set_default_thinking_level(effective);
365        }
366        self.emit_public(super::events::AgentSessionEvent::ThinkingLevelChanged {
367            level: effective,
368        });
369        let runner = self.hooks.runner();
370        let _ = runner
371            .emit(super::events::AgentSessionEvent::ThinkingLevelChanged { level: effective })
372            .await;
373        true
374    }
375
376    /// Cycle to the next supported thinking level.
377    ///
378    /// Returns the new level, `None` when the current model does not support
379    /// reasoning, or `None` when the durable level-change append failed.
380    pub async fn cycle_thinking_level(&self) -> Option<ModelThinkingLevel> {
381        if !self.supports_thinking() {
382            return None;
383        }
384        let levels = self.available_thinking_levels();
385        if levels.is_empty() {
386            return None;
387        }
388        let current = self.thinking_level();
389        let current_index = levels
390            .iter()
391            .position(|level| *level == current)
392            .unwrap_or(0);
393        let len = levels.len();
394        let next_index = (current_index + 1) % len;
395        let next_level = levels[next_index];
396        if self.set_thinking_level(next_level).await {
397            Some(next_level)
398        } else {
399            None
400        }
401    }
402
403    /// Supported thinking levels for the current model.
404    #[must_use]
405    pub fn available_thinking_levels(&self) -> Vec<ModelThinkingLevel> {
406        supported_thinking_levels(&self.model())
407    }
408
409    /// Whether the current model supports reasoning.
410    #[must_use]
411    pub fn supports_thinking(&self) -> bool {
412        self.model().reasoning
413    }
414
415    /// Resolve the thinking level to apply when switching models.
416    ///
417    /// - Explicit `scoped_level` (from `--models`) always wins.
418    /// - Otherwise, when the current model does not support reasoning, fall
419    ///   back to the settings default then `Medium` (TypeScript
420    ///   `DEFAULT_THINKING_LEVEL`).
421    /// - Otherwise inherit the current session thinking level.
422    pub(super) fn thinking_level_for_model_switch(
423        &self,
424        scoped_level: Option<ModelThinkingLevel>,
425    ) -> ModelThinkingLevel {
426        if let Some(explicit) = scoped_level {
427            return explicit;
428        }
429        if !self.supports_thinking() {
430            return self
431                .lock_settings()
432                .get_default_thinking_level()
433                .unwrap_or(ModelThinkingLevel::Medium);
434        }
435        self.thinking_level()
436    }
437
438    /// Emit `model_select` to extensions when the model actually changed.
439    async fn emit_model_select(
440        &self,
441        next_model: &Model,
442        previous_model: Option<&Model>,
443        source: ModelSelectSource,
444    ) {
445        if matches!(previous_model, Some(prev) if models_are_equal(prev, next_model)) {
446            return;
447        }
448        let runner = self.hooks.runner();
449        if !runner.has_handlers("model_select") {
450            return;
451        }
452        if let Err(error) = runner
453            .emit(AgentSessionEvent::ModelSelect {
454                model: Box::new(next_model.clone()),
455                previous_model: previous_model.map(|model| Box::new(model.clone())),
456                source,
457            })
458            .await
459        {
460            runner.emit_error(error.to_string());
461        }
462    }
463}
464
465/// Wire string for a thinking level (matches TypeScript `ThinkingLevel` union).
466fn level_str(level: ModelThinkingLevel) -> &'static str {
467    match level {
468        ModelThinkingLevel::Off => "off",
469        ModelThinkingLevel::Minimal => "minimal",
470        ModelThinkingLevel::Low => "low",
471        ModelThinkingLevel::Medium => "medium",
472        ModelThinkingLevel::High => "high",
473        ModelThinkingLevel::Xhigh => "xhigh",
474        ModelThinkingLevel::Max => "max",
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    fn model(id: &str, provider: &str, reasoning: bool) -> Model {
483        Model {
484            id: id.to_owned(),
485            name: id.to_owned(),
486            api: "test-api".to_owned(),
487            provider: provider.to_owned(),
488            base_url: String::new(),
489            reasoning,
490            thinking_level_map: None,
491            input: vec![pi_ai::ModelInput::Text],
492            cost: pi_ai::ModelCost::default(),
493            context_window: 8_192,
494            max_tokens: 1_024,
495            headers: None,
496            compat: None,
497            extra: std::collections::BTreeMap::new(),
498        }
499    }
500
501    #[test]
502    fn supported_levels_non_reasoning_is_off_only() {
503        let levels = supported_thinking_levels(&model("a", "p", false));
504        assert_eq!(levels, vec![ModelThinkingLevel::Off]);
505    }
506
507    #[test]
508    fn supported_levels_reasoning_without_map_is_full_set() {
509        let levels = supported_thinking_levels(&model("a", "p", true));
510        assert_eq!(levels, EXTENDED_THINKING_LEVELS.to_vec());
511    }
512
513    #[test]
514    fn clamp_high_to_non_reasoning_is_off() {
515        let m = model("a", "p", false);
516        assert_eq!(
517            clamp_thinking_level(&m, ModelThinkingLevel::High),
518            ModelThinkingLevel::Off
519        );
520    }
521
522    #[test]
523    fn clamp_walks_forward_then_backward() {
524        // Map: off and high supported; requested medium.
525        // Forward from medium: high is supported → high.
526        let mut m = model("a", "p", true);
527        let mut map = ThinkingLevelMap::new();
528        map.insert(ModelThinkingLevel::Off, None);
529        map.insert(ModelThinkingLevel::High, Some("high".to_owned()));
530        m.thinking_level_map = Some(map);
531        assert_eq!(
532            clamp_thinking_level(&m, ModelThinkingLevel::Medium),
533            ModelThinkingLevel::High
534        );
535    }
536
537    #[test]
538    fn clamp_walks_backward_when_no_higher_supported() {
539        // Map: off and low supported; requested medium.
540        // Forward from medium: high/xhigh/max absent → none.
541        // Backward from medium-1=low: low supported → low.
542        let mut m = model("a", "p", true);
543        let mut map = ThinkingLevelMap::new();
544        map.insert(ModelThinkingLevel::Off, None);
545        map.insert(ModelThinkingLevel::Low, Some("low".to_owned()));
546        m.thinking_level_map = Some(map);
547        assert_eq!(
548            clamp_thinking_level(&m, ModelThinkingLevel::Medium),
549            ModelThinkingLevel::Low
550        );
551    }
552
553    #[test]
554    fn models_are_equal_checks_id_and_provider() {
555        let a = model("a", "p", false);
556        let b = model("a", "p", true);
557        let c = model("a", "q", false);
558        assert!(models_are_equal(&a, &b), "id+provider match");
559        assert!(!models_are_equal(&a, &c), "provider differs");
560    }
561
562    #[test]
563    fn xhigh_max_require_explicit_map_entry() {
564        let mut m = model("a", "p", true);
565        let mut map = ThinkingLevelMap::new();
566        // Off + low present; xhigh absent → not supported.
567        map.insert(ModelThinkingLevel::Off, None);
568        map.insert(ModelThinkingLevel::Low, Some("low".to_owned()));
569        m.thinking_level_map = Some(map);
570        let levels = supported_thinking_levels(&m);
571        assert!(!levels.contains(&ModelThinkingLevel::Xhigh));
572        assert!(!levels.contains(&ModelThinkingLevel::Max));
573        // Add xhigh explicitly → supported.
574        let Some(map_ref) = m.thinking_level_map.as_mut() else {
575            unreachable!();
576        };
577        map_ref.insert(ModelThinkingLevel::Xhigh, Some("xhigh".to_owned()));
578        let levels = supported_thinking_levels(&m);
579        assert!(levels.contains(&ModelThinkingLevel::Xhigh));
580    }
581
582    #[test]
583    fn explicit_null_disables_level() {
584        let mut m = model("a", "p", true);
585        let mut map = ThinkingLevelMap::new();
586        map.insert(ModelThinkingLevel::Off, None);
587        map.insert(ModelThinkingLevel::Medium, None);
588        m.thinking_level_map = Some(map);
589        let levels = supported_thinking_levels(&m);
590        assert!(!levels.contains(&ModelThinkingLevel::Medium));
591    }
592
593    #[test]
594    fn level_str_matches_wire_union() {
595        assert_eq!(level_str(ModelThinkingLevel::Off), "off");
596        assert_eq!(level_str(ModelThinkingLevel::Medium), "medium");
597        assert_eq!(level_str(ModelThinkingLevel::Xhigh), "xhigh");
598        assert_eq!(level_str(ModelThinkingLevel::Max), "max");
599    }
600
601    mod append_failures {
602        use super::*;
603        use crate::core::agent_session::AgentSessionConfig;
604        use futures::stream::{self, BoxStream, StreamExt};
605        use pi_ai::{AssistantMessageEvent, Context, Provider, ProviderError, StreamOptions};
606        use std::sync::Mutex as StdMutex;
607
608        type TestResult<T = ()> = Result<T, String>;
609
610        #[derive(Clone)]
611        struct StubProvider;
612
613        impl Provider for StubProvider {
614            fn stream(
615                &self,
616                _model: &Model,
617                _context: Context,
618                _options: StreamOptions,
619            ) -> BoxStream<'static, Result<AssistantMessageEvent, ProviderError>> {
620                stream::empty().boxed()
621            }
622        }
623
624        fn context(error: impl std::fmt::Display, label: &str) -> String {
625            format!("{label}: {error}")
626        }
627
628        /// Session whose every subsequent append fails (file blocked by a dir).
629        fn blocked_append_session(
630            dir: &tempfile::TempDir,
631            session_model: Model,
632            scoped_models: Vec<ScopedModel>,
633        ) -> TestResult<Arc<AgentSession>> {
634            let mut manager = crate::core::sessions::SessionManager::create(
635                dir.path().to_string_lossy().as_ref(),
636                Some(dir.path().to_string_lossy().as_ref()),
637                None,
638            )
639            .map_err(|error| context(error, "session manager"))?;
640            manager
641                .append_message(&pi_agent::user_text("seed", std::iter::empty()))
642                .map_err(|error| context(error, "seed user append"))?;
643            // Persistence is lazy until an assistant entry exists; materialize
644            // the file so the directory blocker makes every later append fail.
645            let mut assistant =
646                pi_ai::AssistantMessage::new("test-api", "p", "m", pi_agent::now_millis());
647            assistant.stop_reason = pi_ai::StopReason::Stop;
648            manager
649                .append_message(&pi_agent::AgentMessage::Llm(Box::new(
650                    pi_ai::Message::Assistant(assistant),
651                )))
652                .map_err(|error| context(error, "seed assistant append"))?;
653            let session_file = std::path::PathBuf::from(
654                manager
655                    .get_session_file()
656                    .ok_or_else(|| "missing session file".to_owned())?,
657            );
658            std::fs::remove_file(&session_file)
659                .map_err(|error| context(error, "remove session file"))?;
660            std::fs::create_dir(&session_file)
661                .map_err(|error| context(error, "block append path"))?;
662            let mut config = AgentSessionConfig::test_config(Arc::new(StubProvider), session_model)
663                .map_err(|error| context(error, "test config"))?;
664            config.session_manager = manager;
665            config.scoped_models = scoped_models;
666            AgentSession::new(config).map_err(|error| context(error, "session"))
667        }
668
669        fn record_events(session: &Arc<AgentSession>) -> Arc<StdMutex<Vec<String>>> {
670            let events = Arc::new(StdMutex::new(Vec::new()));
671            let events_clone = Arc::clone(&events);
672            let _unsub = session.subscribe(move |event| {
673                events_clone
674                    .lock()
675                    .unwrap_or_else(std::sync::PoisonError::into_inner)
676                    .push(event.type_name().to_owned());
677            });
678            events
679        }
680
681        fn recorded(events: &Arc<StdMutex<Vec<String>>>) -> Vec<String> {
682            events
683                .lock()
684                .unwrap_or_else(std::sync::PoisonError::into_inner)
685                .clone()
686        }
687
688        #[tokio::test]
689        async fn set_model_append_failure_returns_error_without_live_drift() -> TestResult {
690            let dir = tempfile::tempdir().map_err(|error| context(error, "tempdir"))?;
691            let session = blocked_append_session(&dir, model("m", "p", false), Vec::new())?;
692            let events = record_events(&session);
693
694            let result = session.set_model(model("b", "p", false)).await;
695            assert!(
696                matches!(result, Err(ModelError::Session(_))),
697                "expected typed session error: {result:?}"
698            );
699            assert_eq!(session.model().id, "m", "live model must not change");
700            assert!(
701                recorded(&events).is_empty(),
702                "failed append must publish no events: {:?}",
703                recorded(&events)
704            );
705            Ok(())
706        }
707
708        #[tokio::test]
709        async fn scoped_cycle_append_failure_cycles_nothing() -> TestResult {
710            let dir = tempfile::tempdir().map_err(|error| context(error, "tempdir"))?;
711            let scoped = vec![
712                ScopedModel {
713                    model: model("m", "p", false),
714                    thinking_level: None,
715                },
716                ScopedModel {
717                    model: model("b", "p", false),
718                    thinking_level: None,
719                },
720            ];
721            let session = blocked_append_session(&dir, model("m", "p", false), scoped)?;
722            let events = record_events(&session);
723
724            let cycled = session.cycle_model(CycleDirection::Forward).await;
725            assert!(cycled.is_none(), "failed append must not report a cycle");
726            assert_eq!(session.model().id, "m", "live model must not change");
727            assert!(
728                recorded(&events).is_empty(),
729                "failed append must publish no events: {:?}",
730                recorded(&events)
731            );
732            Ok(())
733        }
734
735        #[tokio::test]
736        async fn thinking_level_append_failure_publishes_nothing() -> TestResult {
737            let dir = tempfile::tempdir().map_err(|error| context(error, "tempdir"))?;
738            let session = blocked_append_session(&dir, model("m", "p", true), Vec::new())?;
739            let before = session.thinking_level();
740            let next = if before == ModelThinkingLevel::High {
741                ModelThinkingLevel::Low
742            } else {
743                ModelThinkingLevel::High
744            };
745            let events = record_events(&session);
746
747            assert!(
748                !session.set_thinking_level(next).await,
749                "failed append must report an uncommitted level change"
750            );
751            assert_eq!(
752                session.thinking_level(),
753                before,
754                "live thinking level must not change"
755            );
756            assert!(
757                session.cycle_thinking_level().await.is_none(),
758                "failed append must not report a successful cycle"
759            );
760            assert_eq!(
761                session.thinking_level(),
762                before,
763                "failed cycle must not change the live level"
764            );
765            assert!(
766                !recorded(&events)
767                    .iter()
768                    .any(|name| name == "thinking_level_changed"),
769                "failed append must not publish thinking_level_changed: {:?}",
770                recorded(&events)
771            );
772            Ok(())
773        }
774    }
775}