diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index 91ecd33b..d6e058af 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -187,7 +187,8 @@ pub(super) struct StateSnapshot { /// absent or empty. The frontend model quick-picker renders one button /// per entry in this list, so operators can add new models or drop /// ones they don't want without touching the frontend code. - /// On ACP, the models the session offers instead (see [`pickers`]). + /// On ACP, the models the session offers, narrowed to this same list + /// when at least one entry matches one it offers (see [`pickers`]). available_models: Vec, /// Currently-active claude effort level. Reflected on the page so the /// operator's effort picker shows the live selection. Mutable at @@ -423,20 +424,26 @@ async fn recent_inbox(socket: &std::path::Path) -> Vec Vec { - const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"]; - // Absent / empty / all-whitespace env all funnel to the single - // emptiness check below — no separate up-front guard needed. - let models: Vec = std::env::var("HIVE_AVAILABLE_MODELS") +/// Parse `HIVE_AVAILABLE_MODELS` (comma-separated, injected by +/// `services.hyperhive.agent.availableModels`) into its entries. Empty +/// (absent env, or nothing left after trimming) means different things to +/// the two callers below, so neither the empty check nor a fallback lives +/// here. +fn parse_available_models_env() -> Vec { + std::env::var("HIVE_AVAILABLE_MODELS") .unwrap_or_default() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) - .collect(); + .collect() +} + +/// The claude picker's model list. Falls back to `["haiku", "sonnet", +/// "opus"]` when the env var is absent or resolves to an empty list after +/// trimming. +fn available_models() -> Vec { + const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"]; + let models = parse_available_models_env(); if models.is_empty() { DEFAULT.iter().map(ToString::to_string).collect() } else { @@ -444,6 +451,43 @@ fn available_models() -> Vec { } } +/// The operator's configured model list, for filtering the ACP picker. +/// `None` when the env is absent or empty, so [`filter_offered_models`] can +/// tell "nothing configured" apart from a real (if unmatched) list — unlike +/// [`available_models`], which folds both into its claude-only fallback. +fn configured_models() -> Option> { + let models = parse_available_models_env(); + (!models.is_empty()).then_some(models) +} + +/// Keep only the entries of `offered` (an ACP session's own model list, in +/// its own order) that `configured` names. `configured: None` (the env is +/// unset) or a `configured` list matching none of `offered` both leave +/// `offered` unfiltered — the latter is what an ACP agent that has never +/// set `services.hyperhive.agent.availableModels` hits, since the option's +/// default is the claude names, so it must show every model the session +/// offers rather than an empty picker; a warning names the mismatch once. +fn filter_offered_models(offered: Vec, configured: Option<&[String]>) -> Vec { + let Some(configured) = configured else { + return offered; + }; + let matching: Vec = offered + .iter() + .filter(|model| configured.iter().any(|c| c == *model)) + .cloned() + .collect(); + if matching.is_empty() { + tracing::warn!( + configured = configured.join(", "), + "HIVE_AVAILABLE_MODELS matches none of the models this ACP session offers; \ + showing every model it offers instead" + ); + offered + } else { + matching + } +} + /// The model and effort pickers: the value each shows and the values it /// offers. #[derive(Debug, PartialEq, Eq)] @@ -460,7 +504,9 @@ pub(super) struct Pickers { pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers { let (model, effort) = (bus.model(), bus.effort()); match bus.session_choices() { - Some(choices) => offered_pickers(model, effort, choices.get()), + Some(choices) => { + offered_pickers(model, effort, choices.get(), configured_models().as_deref()) + } None => Pickers { model, available_models: available_models(), @@ -477,11 +523,14 @@ pub(super) fn pickers(bus: &crate::events::Bus) -> Pickers { /// on the next turn, and only if the session offers it, so each picker shows /// the requested value when offered and the session's own otherwise. The /// effort levels on offer come with the model, so while a new model waits -/// for the next turn the effort picker is empty. +/// for the next turn the effort picker is empty. `configured` narrows the +/// model list per [`filter_offered_models`]; it never narrows which value is +/// shown as current, only which values the picker offers alongside it. fn offered_pickers( model: String, effort: String, offered: hive_runtime::SessionChoices, + configured: Option<&[String]>, ) -> Pickers { let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice { Some(c) if !c.values.contains(&wanted) => c.current.clone(), @@ -495,7 +544,10 @@ fn offered_pickers( Pickers { model: shown(model, offered.model.as_ref()), effort: shown(effort, effort_choice.as_ref()), - available_models: offered.model.map(|c| c.values).unwrap_or_default(), + available_models: offered + .model + .map(|c| filter_offered_models(c.values, configured)) + .unwrap_or_default(), available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(), } } @@ -504,7 +556,7 @@ fn offered_pickers( mod tests { use hive_runtime::{Choice, SessionChoices}; - use super::{Pickers, offered_pickers}; + use super::{Pickers, filter_offered_models, offered_pickers}; fn choice(current: &str, values: &[&str]) -> Choice { Choice { @@ -525,7 +577,7 @@ mod tests { }; // `haiku` and `max` are not on offer: the session's own values show. assert_eq!( - offered_pickers("haiku".into(), "max".into(), offered.clone()), + offered_pickers("haiku".into(), "max".into(), offered.clone(), None), Pickers { model: "m/think".into(), available_models: strings(&["m/think", "m/plain"]), @@ -533,7 +585,7 @@ mod tests { available_efforts: strings(&["low", "high"]), } ); - let picked = offered_pickers("m/think".into(), "high".into(), offered); + let picked = offered_pickers("m/think".into(), "high".into(), offered, None); assert_eq!(picked.effort, "high"); assert_eq!(picked.available_efforts, strings(&["low", "high"])); } @@ -544,7 +596,7 @@ mod tests { model: Some(choice("m/plain", &["m/think", "m/plain"])), effort: None, }; - let shown = offered_pickers("m/plain".into(), "high".into(), plain); + let shown = offered_pickers("m/plain".into(), "high".into(), plain, None); assert_eq!(shown.model, "m/plain"); assert!(shown.available_efforts.is_empty()); @@ -552,14 +604,57 @@ mod tests { model: Some(choice("m/think", &["m/think", "m/plain"])), effort: Some(choice("low", &["low", "high"])), }; - let pending = offered_pickers("m/plain".into(), "high".into(), think); + let pending = offered_pickers("m/plain".into(), "high".into(), think, None); assert_eq!(pending.model, "m/plain"); assert!(pending.available_efforts.is_empty()); } #[test] fn acp_pickers_are_empty_before_a_session_offers_anything() { - let none = offered_pickers("haiku".into(), "high".into(), SessionChoices::default()); + let none = offered_pickers( + "haiku".into(), + "high".into(), + SessionChoices::default(), + None, + ); assert!(none.available_models.is_empty() && none.available_efforts.is_empty()); } + + #[test] + fn unconfigured_model_list_leaves_the_session_offer_untouched() { + assert_eq!( + filter_offered_models(strings(&["m/think", "m/plain"]), None), + strings(&["m/think", "m/plain"]) + ); + } + + #[test] + fn configured_model_list_keeps_only_the_matches_in_the_session_s_order() { + let configured = strings(&["m/plain", "m/other"]); + assert_eq!( + filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured)), + strings(&["m/plain"]) + ); + } + + #[test] + fn configured_model_list_matching_nothing_falls_back_to_every_offered_model() { + // The claude names on an ACP agent that never set `availableModels`. + let configured = strings(&["haiku", "sonnet", "opus"]); + assert_eq!( + filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured)), + strings(&["m/think", "m/plain"]) + ); + } + + #[test] + fn offered_pickers_applies_the_configured_model_filter() { + let offered = SessionChoices { + model: Some(choice("m/think", &["m/think", "m/plain"])), + effort: None, + }; + let configured = strings(&["m/plain"]); + let filtered = offered_pickers("m/think".into(), "high".into(), offered, Some(&configured)); + assert_eq!(filtered.available_models, strings(&["m/plain"])); + } } diff --git a/nix/agent-modules/agent-service.nix b/nix/agent-modules/agent-service.nix index c3b76330..5ff30300 100644 --- a/nix/agent-modules/agent-service.nix +++ b/nix/agent-modules/agent-service.nix @@ -103,7 +103,10 @@ in UI; this option only controls which choices the picker presents. Values are the short model names that `claude --model` accepts: - `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). + `"haiku"`, `"sonnet"`, `"opus"` (or any future identifier). On an ACP + agent this same list also filters the model picker, matched against + the model ids opencode reports (`/`); a list + that matches none of them shows every model the session offers. ''; };