Skip to main content

forge_agent/
session_lifecycle.rs

1//! Model resolution + connect-event + initialize-result mapping.
2//! Mirrors the relevant portions of upstream's
3//! `agent-sdk/src/bridge/session_lifecycle.ts`.
4//!
5//! What lives here:
6//! - `normalize_model_key` + `humanize_model_id`
7//! - `model_keys_are_compatible` / `same_context_suffix` / `same_family_and_version`
8//!   / `has_variant_sibling_conflict`
9//! - `resolve_catalog_model` / `resolve_current_model`
10//! - `current_model_is_authoritative`
11//! - `current_models_equal` / `refresh_current_model`
12//! - `map_available_models` (initialize-response `models` array → typed)
13//!
14//! What does NOT live here (despite upstream parking it in
15//! `session_lifecycle.ts`): mode list logic (see `commands.rs`), MCP
16//! reconciliation (see `mcp.rs`), `AskUserQuestion` sequencing (see
17//! `user_interaction.rs`), the bulk of permission/elicitation routing.
18
19use serde_json::Value;
20
21use forge_primitives::{AvailableModel, CurrentModel, EffortLevel};
22
23const OPUS_MODEL_ALIAS: &str = "opus";
24const MAX_MODEL_VERSION_PARTS: usize = 2;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum ModelFamily {
28    Opus,
29    Sonnet,
30    Haiku,
31    Fable,
32    Unknown,
33}
34
35#[derive(Debug)]
36struct NormalizedModelKey {
37    original: String,
38    family: ModelFamily,
39    version_parts: Vec<u32>,
40    variant_parts: Vec<String>,
41    /// Lowercased; empty when the id has no `[...]` suffix.
42    context_suffix: String,
43}
44
45fn normalize_model_key(id: &str) -> NormalizedModelKey {
46    let original = id.trim().to_owned();
47    if original.is_empty() {
48        return NormalizedModelKey {
49            original,
50            family: ModelFamily::Unknown,
51            version_parts: Vec::new(),
52            variant_parts: Vec::new(),
53            context_suffix: String::new(),
54        };
55    }
56    let lower = original.to_ascii_lowercase();
57    let (without_context, context_suffix) = if let Some(open) = lower.rfind('[')
58        && lower.ends_with(']')
59    {
60        (lower[..open].to_owned(), lower[open + 1..lower.len() - 1].to_owned())
61    } else {
62        (lower.clone(), String::new())
63    };
64    let trimmed_pre = without_context.trim_end_matches('-');
65    let without_prefix = trimmed_pre.strip_prefix("claude-").unwrap_or(trimmed_pre);
66    let mut parts = without_prefix.split('-').filter(|p| !p.is_empty());
67    let family_part = parts.next().unwrap_or("");
68    let family = match family_part {
69        "opus" => ModelFamily::Opus,
70        "sonnet" => ModelFamily::Sonnet,
71        "haiku" => ModelFamily::Haiku,
72        "fable" => ModelFamily::Fable,
73        _ => ModelFamily::Unknown,
74    };
75
76    let mut version_parts: Vec<u32> = Vec::new();
77    let mut variant_parts: Vec<String> = Vec::new();
78
79    if family != ModelFamily::Unknown {
80        for part in parts {
81            if part.chars().all(|c| c.is_ascii_digit()) {
82                if version_parts.len() < MAX_MODEL_VERSION_PARTS {
83                    if let Ok(v) = part.parse::<u32>() {
84                        version_parts.push(v);
85                    }
86                    continue;
87                }
88                // 8-digit `20YYMMDD` release-build token - upstream
89                // tracks these in `build_parts` but never surfaces them
90                // in the user-visible humanizer, so drop.
91                if part.len() == 8
92                    && part.starts_with("20")
93                    && part.chars().all(|c| c.is_ascii_digit())
94                {
95                    continue;
96                }
97            }
98            variant_parts.push(part.to_owned());
99        }
100    }
101
102    NormalizedModelKey { original, family, version_parts, variant_parts, context_suffix }
103}
104
105fn family_label(family: ModelFamily) -> Option<&'static str> {
106    match family {
107        ModelFamily::Opus => Some("Opus"),
108        ModelFamily::Sonnet => Some("Sonnet"),
109        ModelFamily::Haiku => Some("Haiku"),
110        ModelFamily::Fable => Some("Fable"),
111        ModelFamily::Unknown => None,
112    }
113}
114
115fn format_humanized(key: &NormalizedModelKey) -> String {
116    let Some(family_lbl) = family_label(key.family) else {
117        return key.original.clone();
118    };
119    let version_lbl = if key.version_parts.is_empty() {
120        String::new()
121    } else {
122        format!(" {}", key.version_parts.iter().map(u32::to_string).collect::<Vec<_>>().join("."))
123    };
124    let context_lbl = match key.context_suffix.as_str() {
125        "" => String::new(),
126        s if s.eq_ignore_ascii_case("1m") => " [1M]".to_owned(),
127        s => format!(" [{s}]"),
128    };
129    format!("{family_lbl}{version_lbl}{context_lbl}")
130}
131
132fn humanize_model_id(id: &str) -> String {
133    format_humanized(&normalize_model_key(id))
134}
135
136fn model_keys_are_compatible(left_id: &str, right_id: &str) -> bool {
137    let left = normalize_model_key(left_id);
138    let right = normalize_model_key(right_id);
139    if left.family == ModelFamily::Unknown || right.family == ModelFamily::Unknown {
140        return left.original.eq_ignore_ascii_case(&right.original);
141    }
142    if left.family != right.family {
143        return false;
144    }
145    if left.variant_parts.join(".") != right.variant_parts.join(".") {
146        return false;
147    }
148    if left.version_parts.is_empty() || right.version_parts.is_empty() {
149        return true;
150    }
151    left.version_parts == right.version_parts
152}
153
154fn same_context_suffix(left_id: &str, right_id: &str) -> bool {
155    normalize_model_key(left_id).context_suffix == normalize_model_key(right_id).context_suffix
156}
157
158fn same_family_and_version(left_id: &str, right_id: &str) -> bool {
159    let left = normalize_model_key(left_id);
160    let right = normalize_model_key(right_id);
161    if left.family == ModelFamily::Unknown || right.family == ModelFamily::Unknown {
162        return left.original.eq_ignore_ascii_case(&right.original);
163    }
164    if left.family != right.family {
165        return false;
166    }
167    if left.version_parts.is_empty() || right.version_parts.is_empty() {
168        return left.version_parts.len() == right.version_parts.len();
169    }
170    left.version_parts == right.version_parts
171}
172
173fn has_variant_sibling_conflict(
174    available_models: &[AvailableModel],
175    candidate_id: &str,
176    resolved_id: &str,
177) -> bool {
178    if same_context_suffix(candidate_id, resolved_id) {
179        return false;
180    }
181    let resolved_context = normalize_model_key(resolved_id).context_suffix;
182    if resolved_context.is_empty() {
183        return false;
184    }
185    available_models.iter().any(|entry| {
186        if entry.id == candidate_id {
187            return false;
188        }
189        if !same_family_and_version(&entry.id, resolved_id) {
190            return false;
191        }
192        normalize_model_key(&entry.id).context_suffix == resolved_context
193    })
194}
195
196fn current_model_is_authoritative(resolved_id: &str, requested_id: Option<&str>) -> bool {
197    let resolved = resolved_id.trim();
198    if resolved.is_empty() || resolved == "Connecting..." {
199        return requested_id.is_some_and(|s| !s.trim().is_empty());
200    }
201    true
202}
203
204fn resolve_catalog_model<'a>(
205    available_models: &'a [AvailableModel],
206    resolved_id: &str,
207    requested_id: Option<&str>,
208) -> Option<&'a AvailableModel> {
209    if let Some(exact) = available_models.iter().find(|m| m.id == resolved_id) {
210        return Some(exact);
211    }
212    if let Some(req) = requested_id
213        && let Some(exact_requested) = available_models.iter().find(|m| m.id == req)
214        && model_keys_are_compatible(&exact_requested.id, resolved_id)
215        && !has_variant_sibling_conflict(available_models, &exact_requested.id, resolved_id)
216    {
217        return Some(exact_requested);
218    }
219    let compatible: Vec<&AvailableModel> = available_models
220        .iter()
221        .filter(|m| {
222            model_keys_are_compatible(&m.id, resolved_id)
223                && !has_variant_sibling_conflict(available_models, &m.id, resolved_id)
224        })
225        .collect();
226    if compatible.len() == 1 {
227        return Some(compatible[0]);
228    }
229    None
230}
231
232/// Mirrors upstream's `resolveCurrentModel`. Pure function on the
233/// session's resolved/runtime/requested model strings + the
234/// `available_models` catalogue. Primitive-arg form - callers pass
235/// what they have; no session struct dependency.
236pub fn resolve_current_model_from_inputs(
237    model_id: &str,
238    requested_model_id: Option<&str>,
239    resolved_runtime_model_id: Option<&str>,
240    available_models: &[AvailableModel],
241) -> CurrentModel {
242    let requested_id = requested_model_id.map(str::trim).filter(|s| !s.is_empty());
243    let resolved_id =
244        resolved_runtime_model_id.map(str::trim).filter(|s| !s.is_empty()).map_or_else(
245            || {
246                if model_id.trim().is_empty() {
247                    requested_id.unwrap_or(OPUS_MODEL_ALIAS).to_owned()
248                } else {
249                    model_id.trim().to_owned()
250                }
251            },
252            str::to_owned,
253        );
254
255    let catalog = resolve_catalog_model(available_models, &resolved_id, requested_id);
256    let runtime_display_id = if resolved_id.is_empty() {
257        requested_id.unwrap_or(OPUS_MODEL_ALIAS)
258    } else {
259        resolved_id.as_str()
260    };
261
262    // Prefer the catalogue's `display_name` when it carries version
263    // info (e.g. "Claude Opus 4.7"). Recent claude CLI builds ship
264    // a short displayName ("Opus") without a version number, so when
265    // the catalogue value lacks any digit we fall back to
266    // humanize_model_id which parses the version off the resolved id
267    // (e.g. "claude-opus-4-7-20251022" -> "Opus 4.7").
268    let catalog_name = catalog.map(|m| m.display_name.clone()).filter(|s| !s.is_empty());
269    let catalog_has_version =
270        catalog_name.as_deref().is_some_and(|s| s.chars().any(|c| c.is_ascii_digit()));
271    let display_name = if catalog_has_version {
272        catalog_name.unwrap_or_else(|| humanize_model_id(runtime_display_id))
273    } else {
274        let humanized = humanize_model_id(runtime_display_id);
275        let humanized_has_version = humanized.chars().any(|c| c.is_ascii_digit());
276        if humanized_has_version { humanized } else { catalog_name.unwrap_or(humanized) }
277    };
278
279    // The panel names the model the session asked for: the resolved id
280    // is the CLI's own spelling, carrying the `[1m]` marker and
281    // possibly a model the session never asked for.
282    let display_name = requested_id.unwrap_or(display_name.as_str()).to_owned();
283
284    CurrentModel {
285        requested_id: requested_id.map(str::to_owned),
286        resolved_id: resolved_id.clone(),
287        display_name_short: display_name.clone(),
288        display_name_long: display_name,
289        catalog_id: catalog.map(|m| m.id.clone()),
290        supports_effort: catalog.is_some_and(|m| m.supports_effort),
291        supported_effort_levels: catalog
292            .map_or_else(Vec::new, |m| m.supported_effort_levels.clone()),
293        supports_auto_mode: catalog.and_then(|m| m.supports_auto_mode),
294        supports_adaptive_thinking: catalog.and_then(|m| m.supports_adaptive_thinking),
295        is_authoritative: current_model_is_authoritative(&resolved_id, requested_id),
296    }
297}
298
299/// Mirrors `mapAvailableModels(models)` - initialize-response `models`
300/// array → typed `AvailableModel`. Drops entries lacking a non-empty
301/// `value` or `displayName`.
302pub fn map_available_models(models: Option<&Value>) -> Vec<AvailableModel> {
303    let Some(arr) = models.and_then(Value::as_array) else {
304        return Vec::new();
305    };
306    arr.iter()
307        .filter_map(|entry| {
308            let r = entry.as_object()?;
309            let id = r.get("value").and_then(Value::as_str)?.trim().to_owned();
310            if id.is_empty() {
311                return None;
312            }
313            let display_name = r.get("displayName").and_then(Value::as_str)?.trim().to_owned();
314            if display_name.is_empty() {
315                return None;
316            }
317            let supported_effort_levels: Vec<EffortLevel> = r
318                .get("supportedEffortLevels")
319                .and_then(Value::as_array)
320                .map(|levels| {
321                    levels
322                        .iter()
323                        .filter_map(|l| match l.as_str()? {
324                            "low" => Some(EffortLevel::Low),
325                            "medium" => Some(EffortLevel::Medium),
326                            "high" => Some(EffortLevel::High),
327                            "xhigh" => Some(EffortLevel::Xhigh),
328                            "max" => Some(EffortLevel::Max),
329                            _ => None,
330                        })
331                        .collect()
332                })
333                .unwrap_or_default();
334            Some(AvailableModel {
335                id,
336                display_name,
337                description: r.get("description").and_then(Value::as_str).map(str::to_owned),
338                supports_effort: r.get("supportsEffort").and_then(Value::as_bool).unwrap_or(false),
339                supported_effort_levels,
340                supports_adaptive_thinking: r
341                    .get("supportsAdaptiveThinking")
342                    .and_then(Value::as_bool),
343                supports_auto_mode: r.get("supportsAutoMode").and_then(Value::as_bool),
344            })
345        })
346        .collect()
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn humanize_known_families() {
355        assert_eq!(humanize_model_id("claude-sonnet-4-6"), "Sonnet 4.6");
356        assert_eq!(humanize_model_id("claude-opus-4-7"), "Opus 4.7");
357        assert_eq!(humanize_model_id("claude-haiku-4-5"), "Haiku 4.5");
358    }
359
360    #[test]
361    fn humanize_fable_family() {
362        assert_eq!(humanize_model_id("claude-fable-5"), "Fable 5");
363        assert_eq!(humanize_model_id("claude-fable-5[1m]"), "Fable 5 [1M]");
364    }
365
366    #[test]
367    fn humanize_with_context_suffix() {
368        assert_eq!(humanize_model_id("claude-sonnet-4-6[1m]"), "Sonnet 4.6 [1M]");
369        assert_eq!(humanize_model_id("claude-sonnet-4-6[8k]"), "Sonnet 4.6 [8k]");
370    }
371
372    #[test]
373    fn humanize_unknown_falls_through() {
374        assert_eq!(humanize_model_id("custom-model-1"), "custom-model-1");
375        assert_eq!(humanize_model_id(""), "");
376    }
377
378    #[test]
379    fn humanize_strips_release_build_tokens() {
380        // build tokens are dropped from version_parts after MAX=2
381        assert_eq!(humanize_model_id("claude-sonnet-4-6-20260101"), "Sonnet 4.6");
382    }
383
384    #[test]
385    fn map_models_filters_empty_and_invalid() {
386        let v = serde_json::json!([
387            { "value": "a", "displayName": "A", "supportsEffort": true,
388              "supportedEffortLevels": ["low", "medium", "wrong"] },
389            { "value": "  ", "displayName": "skip" },
390            { "value": "b", "displayName": "  " },
391            { "value": "c", "displayName": "C" },
392        ]);
393        let m = map_available_models(Some(&v));
394        assert_eq!(m.len(), 2);
395        assert_eq!(m[0].id, "a");
396        assert_eq!(m[0].supported_effort_levels.len(), 2);
397        assert_eq!(m[1].id, "c");
398    }
399
400    #[test]
401    fn resolve_current_model_falls_back_to_opus() {
402        let cm = resolve_current_model_from_inputs("", None, None, &[]);
403        assert_eq!(cm.resolved_id, "opus");
404        // Matches upstream: non-empty resolved id (even the "opus"
405        // alias) is treated as authoritative.
406        assert!(cm.is_authoritative);
407
408        let cm = resolve_current_model_from_inputs("claude-sonnet-4-6", None, None, &[]);
409        assert_eq!(cm.resolved_id, "claude-sonnet-4-6");
410        assert_eq!(cm.display_name_short, "Sonnet 4.6");
411        assert!(cm.is_authoritative);
412    }
413
414    /// The panel shows the canonical model name exactly as forge.toml
415    /// declares it (Ved, 2026-09-15): the name the session asked for,
416    /// never a humanised label or the id the CLI resolved. A known
417    /// family is the discriminating case - without the rule it renders
418    /// as "Opus 5".
419    #[test]
420    fn the_panel_shows_the_canonical_model_name_verbatim() {
421        let cm =
422            resolve_current_model_from_inputs("claude-opus-5", Some("claude-opus-5"), None, &[]);
423        assert_eq!(
424            cm.display_name_long, "claude-opus-5",
425            "the panel shows the canonical name, not the humanised label"
426        );
427    }
428
429    /// Captured live (2026-09-15 trial): a forge-project session whose
430    /// wire carried deepseek resolved its init to claude-opus-5[1m],
431    /// and the account panel named a model the session never asked for.
432    #[test]
433    fn a_resolved_model_the_session_never_asked_for_does_not_name_the_row() {
434        let cm = resolve_current_model_from_inputs(
435            "claude-opus-5[1m]",
436            Some("deepseek-v4.1-flash"),
437            None,
438            &[],
439        );
440        assert_eq!(
441            cm.display_name_long, "deepseek-v4.1-flash",
442            "the row names the model the session runs on, not the CLI's stray resolution",
443        );
444    }
445
446    /// The canonical name is shown verbatim even when a catalogue entry
447    /// exists with a prettier display name, and a `[1m]` marker the
448    /// user's canonical name never carried does not appear.
449    #[test]
450    fn a_catalogue_display_name_does_not_rename_the_canonical_model() {
451        let catalog = vec![AvailableModel {
452            id: "deepseek-v4.1-flash[1m]".to_owned(),
453            display_name: "DeepSeek Flash".to_owned(),
454            description: None,
455            supports_effort: false,
456            supported_effort_levels: vec![],
457            supports_adaptive_thinking: None,
458            supports_auto_mode: None,
459        }];
460        let cm = resolve_current_model_from_inputs(
461            "deepseek-v4.1-flash[1m]",
462            Some("deepseek-v4.1-flash"),
463            None,
464            &catalog,
465        );
466        assert_eq!(
467            cm.display_name_long, "deepseek-v4.1-flash",
468            "the canonical name is not renamed by the catalogue or the context marker",
469        );
470    }
471
472    #[test]
473    fn catalog_short_name_augmented_with_version_from_resolved_id() {
474        // Recent claude CLI builds ship `displayName: "Opus"` without a
475        // version number. When the resolved id carries version info,
476        // prefer humanize_model_id so the display name renders as
477        // "Opus 4.7" rather than the bare family name.
478        let catalog = vec![AvailableModel {
479            id: "claude-opus-4-7-20251022".to_owned(),
480            display_name: "Opus".to_owned(),
481            description: None,
482            supports_effort: false,
483            supported_effort_levels: vec![],
484            supports_adaptive_thinking: None,
485            supports_auto_mode: None,
486        }];
487        let cm =
488            resolve_current_model_from_inputs("claude-opus-4-7-20251022", None, None, &catalog);
489        assert_eq!(cm.display_name_short, "Opus 4.7");
490        assert_eq!(cm.display_name_long, "Opus 4.7");
491    }
492
493    #[test]
494    fn catalog_versioned_name_kept_as_is() {
495        // When the catalog entry already has a version, keep it
496        // verbatim - don't second-guess the CLI.
497        let catalog = vec![AvailableModel {
498            id: "claude-opus-4-7-20251022".to_owned(),
499            display_name: "Claude Opus 4.7".to_owned(),
500            description: None,
501            supports_effort: false,
502            supported_effort_levels: vec![],
503            supports_adaptive_thinking: None,
504            supports_auto_mode: None,
505        }];
506        let cm =
507            resolve_current_model_from_inputs("claude-opus-4-7-20251022", None, None, &catalog);
508        assert_eq!(cm.display_name_short, "Claude Opus 4.7");
509    }
510
511    #[test]
512    fn catalog_match_populates_supports_effort() {
513        let catalog = vec![AvailableModel {
514            id: "claude-sonnet-4-6".to_owned(),
515            display_name: "Sonnet 4.6".to_owned(),
516            description: None,
517            supports_effort: true,
518            supported_effort_levels: vec![EffortLevel::Medium, EffortLevel::High],
519            supports_adaptive_thinking: Some(true),
520            supports_auto_mode: Some(true),
521        }];
522        let cm = resolve_current_model_from_inputs("claude-sonnet-4-6", None, None, &catalog);
523        assert!(cm.supports_effort);
524        assert_eq!(cm.supported_effort_levels.len(), 2);
525        assert_eq!(cm.catalog_id.as_deref(), Some("claude-sonnet-4-6"));
526        assert_eq!(cm.supports_auto_mode, Some(true));
527    }
528}