//! `/api/state` + `/api/dashboard-state` snapshot builders. use std::sync::atomic::{AtomicBool, Ordering}; use axum::extract::State; use serde::Serialize; use crate::login::LoginState; use crate::login_session::drop_if_finished; use super::AppState; pub(super) async fn api_state(State(state): State) -> axum::Json { // Capture seq *before* any reads so the dedupe contract is // "events with seq > snapshot.seq are post-snapshot, never missed." let seq = state.bus.current_seq(); drop_if_finished(&state.session); let login = *state.login.lock().unwrap(); let session_snapshot = state.session.lock().unwrap().clone(); let (status, session_view) = match (login, session_snapshot) { (LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None), (LoginState::Online, _) => ("online", None), (LoginState::NeedsLogin, None) => ("needs_login_idle", None), (LoginState::NeedsLogin, Some(s)) => ( "needs_login_in_progress", Some(SessionView { url: s.url(), output: s.output(), finished: s.finished(), exit_note: s.exit_note(), }), ), }; let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(7000); let inbox = recent_inbox(&state.socket).await; let (turn_state, turn_state_since) = state.bus.state_snapshot(); let model = state.bus.model(); // The alias the operator selected (e.g. "sonnet") vs. what the last // turn actually ran on (e.g. "claude-sonnet-4-5-20260805") — mara: // "i cannot see if sonnet became claude-sonnet-5 or something else". // `None` until the first turn completes (no assistant event to // resolve from yet); same source `serve_common.rs`'s turn-stats // rollup already prefers over the requested alias. let resolved_model = state.bus.last_resolved_model(); let context_window_tokens = state.bus.effective_context_window(&model); let ctx_usage = state.bus.last_ctx_usage(); let cost_usage = state.bus.last_cost_usage(); let pickers = pickers(&state.bus); axum::Json(StateSnapshot { seq, label: state.label.clone(), qualified_label: crate::identity::qualify(&state.label), dashboard_port, status, session: session_view, inbox, turn_state, turn_state_since, model: pickers.model, resolved_model, context_window_tokens, ctx_usage, cost_usage, links: agent_links(&state.label, state.gui_vnc_port.is_some()), forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL") .ok() .filter(|s| !s.is_empty()), hive_name: crate::identity::hive_name(), swarm_name: crate::identity::swarm_name(), available_models: pickers.available_models, effort: pickers.effort, available_efforts: pickers.available_efforts, paused: crate::paths::paused_marker().exists(), }) } /// Lean snapshot of the agent-owned fields that the dashboard card /// needs. Served at `GET /api/dashboard-state` (accessible through the /// gateway at `/agent//api/dashboard-state`). The dashboard /// fetches this once per running agent to get fresh, agent-authoritative /// values instead of relying on hive-c0re's periodic file-reads. /// /// Structural fields (running, `needs_update`, `deployed_sha`, parent, …) /// continue to come from hive-c0re's `/api/state`; this endpoint covers /// only the fields the agent itself is the source of truth for. pub(super) async fn api_dashboard_state( State(state): State, ) -> axum::Json { let (status_text, status_set_at) = read_own_status(); let rate_limited = state.bus.is_rate_limited(); let model = state.bus.model(); let context_window_tokens = state.bus.effective_context_window(&model); // Full context-window size = input + cache-read + cache-creation. Using // raw `input_tokens` here reported only the *uncached* sliver, which is // ~0 once prompt caching kicks in — so every card showed `ctx·0k`. Match // the agent page (which ships the whole `ctx_usage` and sums it) and the // cache-TTL logic in turn.rs, both of which use `context_tokens()`. let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.context_tokens()); axum::Json(DashboardState { status_text, status_set_at, ctx_tokens, context_window_tokens, rate_limited, links: agent_links(&state.label, state.gui_vnc_port.is_some()), }) } #[derive(Serialize)] pub(super) struct StateSnapshot { /// Bus seq at the moment this snapshot was assembled. Clients dedupe /// their buffered SSE traffic against this value: events with /// `seq <= snapshot.seq` are already reflected (or pre-date the /// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on /// harness restart — clients treat reconnect as a fresh world. seq: u64, label: String, /// Hive-qualified long name (`${label}@${hyperhive.domain}`) when /// the host has been configured for a multi-hive swarm; falls back /// to the short label when the hive domain env var is unset. /// The frontend uses this for the page title / agent self-introduction; /// when it equals `label`, the page renders the short form unchanged. qualified_label: String, dashboard_port: u16, /// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`. status: &'static str, /// Present when `status == "needs_login_in_progress"`. session: Option, /// Last N messages addressed to this agent, newest-first. Pulled /// from the broker via the per-agent socket on each render. /// Empty on transport failure. inbox: Vec, /// Authoritative turn-loop state from the harness and the unix /// timestamp the state was entered. The JS computes the age /// client-side off this rather than tracking it from SSE events. turn_state: crate::events::TurnState, turn_state_since: i64, /// Currently-active claude model name. Reflected on the page so /// the operator can see what they just switched to (and what's /// in flight). Mutable at runtime via `POST /api/model`. model: String, /// The concrete model id the most recent completed turn actually /// ran on (e.g. `"claude-sonnet-4-5-20260805"`), resolved from that /// turn's assistant events — `model` above is the alias the /// operator picked, which claude itself maps to a specific dated /// snapshot. `None` before any turn has completed. resolved_model: Option, /// Effective context-window token budget for the current model. /// Primary source: API-reported `modelUsage.*.contextWindow` from /// the last result event (authoritative per-inference active window). /// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000. /// Consumers (e.g. dashboard badge) use this to render ctx-usage %. context_window_tokens: u64, /// Last-inference token usage from the most recent completed /// turn — represents the current context-window size at turn-end. /// `null` until the first turn finishes. ctx_usage: Option, /// Cumulative token usage across the most recent turn's inferences /// (cost signal). `null` until the first turn finishes. cost_usage: Option, /// Navigation links for this agent page. Also served via /// `DashboardState.links` (`GET /api/dashboard-state`) for the /// dashboard card's icon strip. Both are produced by `agent_links()` /// — single source of truth. See [`docs/web-ui/dashboard.md::Container row`] /// for the frontend resolver + which links appear in which conditions. links: Vec, /// Public URL of the forge (e.g. /// `"https://forge.pr1ma.darkest.space"`). Sourced from /// `HIVE_FORGE_PUBLIC_URL` (set from `services.hyperhive.forge. /// publicUrl`); `None` when unset. The frontend uses this to build /// forge nav-strip links, and **hides** the forge link entirely /// when absent rather than guessing `:3000`. forge_public_url: Option, /// Human name of this hive instance (e.g. `"pr1ma"`). Sourced /// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend /// uses this for the page `` and header label so browser /// tabs disambiguate when multiple hives are open in parallel. hive_name: Option<String>, /// Human name of the swarm (e.g. `"constellat1on"`). Sourced from /// `HYPERHIVE_SWARM_NAME`; `None` when unset. swarm_name: Option<String>, /// Ordered list of model short-names the operator has declared as /// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS` /// (comma-separated, set by `services.hyperhive.availableModels`). /// Falls back to `["haiku", "sonnet", "opus"]` when the env var is /// 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, narrowed to this same list /// when at least one entry matches one it offers (see [`pickers`]). available_models: Vec<String>, /// Currently-active claude effort level. Reflected on the page so the /// operator's effort picker shows the live selection. Mutable at /// runtime via `POST /api/effort`; applies on the next session. effort: String, /// Selectable effort levels for the picker, ascending. Fixed set /// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from /// [`crate::harness_state::EFFORT_LEVELS`], not operator-configurable like /// `available_models`. The frontend renders one button per entry. /// On ACP, the levels the session offers instead, empty when it offers /// none, which hides the picker (see [`pickers`]). available_efforts: Vec<String>, /// Whether this agent's turn loop is currently parked (the harness /// keeps serving this page + its MCP daemons but drives no turns). /// Same on-disk marker hive-c0re's `Coordinator::is_paused` checks /// (`crate::paths::paused_marker`) — read directly here rather than /// asking hive-c0re over the socket, since the harness already has /// the file locally. hive-c0re owns the actual pause/resume *writes* /// (via hive-priv, this process runs unprivileged) — the per-agent /// page's own `⋯` menu POSTs to hive-c0re's existing /// `/api/pause/<name>` / `/api/resume/<name>` (same endpoints /// `<hive-agent-menu>` on the dashboard already uses), this field /// only tells the frontend which of the two to show. paused: bool, } #[derive(Serialize)] struct SessionView { /// First `https://…` claude emitted on stdout, if any. url: Option<String>, /// Accumulated stdout + stderr. output: String, finished: bool, exit_note: Option<String>, } /// One navigation link in the agent page header row. The same JSON /// shape appears in both `StateSnapshot.links` (`GET /api/state`, /// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`, /// dashboard card icon strip). `agent_links()` is the single source /// of truth for what links an agent exposes. #[derive(Serialize)] struct AgentLink { /// `kind = Container | Forge` → path; `kind = External` → full URL. /// The frontend prepends the right base before rendering. url: String, icon: String, label: String, kind: AgentLinkKind, } /// Resolution hint for `AgentLink.url`. The agent backend can't know /// which hostname the browser sees (especially when the dashboard /// proxies the call from a different origin), so it labels each link /// and lets the frontend prepend the right base. #[derive(Serialize, Clone, Copy)] #[serde(rename_all = "snake_case")] enum AgentLinkKind { /// `url` is a path on the agent's container web UI (`/stats`, /// `/screen`). Agent page: same-origin path. Dashboard: /// `http://<host>:<container.port><url>`. Container, /// `url` is a path on the local Forgejo (`/<label>`, /// `/agent-configs/<label>`). Both surfaces: /// `http://<host>:3000<url>`. Forge, /// `url` is already a fully-qualified absolute URL — use as-is. /// Agent-declared `services.hyperhive.agent.dashboardLinks` extras arrive here. External, } #[derive(serde::Serialize)] pub(super) struct DashboardState { /// Free-text status set by `set_status`, read directly from the /// `hyperhive-status` file the harness writes. `None` when unset. #[serde(skip_serializing_if = "Option::is_none")] status_text: Option<String>, /// Unix timestamp (seconds) when the status file was last written. /// `None` when no status is set. #[serde(skip_serializing_if = "Option::is_none")] status_set_at: Option<i64>, /// Full context-window size from the most recent completed turn /// (`ctx_usage.context_tokens()` = input + cache-read + cache-creation). /// `None` until the first turn finishes. Drives the `ctx·Nk` card badge. #[serde(skip_serializing_if = "Option::is_none")] ctx_tokens: Option<u64>, /// Effective context-window budget for the current model. Same /// derivation as `StateSnapshot::context_window_tokens`. context_window_tokens: u64, /// True while the harness is parked after a rate-limit response. rate_limited: bool, /// Navigation links for the dashboard card's icon strip. This is /// the authoritative source — includes the screen link (GUI agents) /// which hive-c0re's disk-based fallback cannot determine. links: Vec<AgentLink>, } /// Read the agent's own free-text status and the timestamp when it was /// set, directly from the `hyperhive-status` file in the state dir. /// Mirrors `hive_c0re::container_view::read_agent_status` but runs /// inside the agent container using its own state dir. fn read_own_status() -> (Option<String>, Option<i64>) { let path = crate::paths::state_dir().join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); let text = std::fs::read_to_string(&path) .ok() .as_deref() .map(str::trim) .filter(|t| !t.is_empty()) .map(str::to_owned); let mtime = meta.and_then(|m| { m.modified().ok().and_then(|t| { t.duration_since(std::time::UNIX_EPOCH) .ok() .and_then(|d| i64::try_from(d.as_secs()).ok()) }) }); if text.is_none() { (None, None) } else { (text, mtime) } } /// Build the navigation link list for the agent page header. URLs /// are paths (relative) for `Container`/`Forge` targets and absolute /// for `External`; the frontend resolves each against its `kind` /// against the right base so the backend never has to guess the /// operator's browser host. See /// `docs/web-ui/dashboard.md::Container row` for /// the resolver + how `deployed:<sha>` ships alongside. /// /// Icons are real emoji, not unicode arrow/geometric glyphs — mara: /// operator-declared `dashboardLinks` extras are already free-choice /// emoji, so the built-ins should read as the same family rather than /// mixing in plain text glyphs (which also don't reliably render as a /// hexagon/arrow across fonts to begin with). `config` went through two /// rounds on this: gear (`⚙`, with and without a variation selector) /// still rendered broken in mara's browser either way — same /// font-unreliable codepoint `icons.tsx` already documents avoiding. /// Landed on the wrench (`🔧`) instead: a supplementary-plane pictograph /// with `Emoji_Presentation=Yes`, same category as every other icon /// here (`📊`/`🖥`/`🔨`/`🧭`), so it doesn't need a variation selector to /// render as color emoji in the first place. fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> { let mut links = Vec::new(); links.push(AgentLink { url: "stats.html".to_owned(), icon: "📊".to_owned(), label: "stats".to_owned(), kind: AgentLinkKind::Container, }); if gui_enabled { links.push(AgentLink { url: "screen.html".to_owned(), icon: "🖥".to_owned(), label: "screen".to_owned(), kind: AgentLinkKind::Container, }); } // Either copy of the forge token: the one fetched from the swarm secret // store (`HIVE_FORGE_TOKEN_FILE`), or the state-dir file the hive used // to write. let fetched_token = std::env::var_os("HIVE_FORGE_TOKEN_FILE") .is_some_and(|p| std::path::Path::new(&p).is_file()); if fetched_token || crate::paths::state_dir().join("forge-token").is_file() { links.push(AgentLink { url: format!("/{label}"), icon: "🔨".to_owned(), label: "forge".to_owned(), kind: AgentLinkKind::Forge, }); links.push(AgentLink { url: format!("/agent-configs/{label}"), icon: "🔧".to_owned(), label: "config".to_owned(), kind: AgentLinkKind::Forge, }); } // Agent-declared extras (`services.hyperhive.agent.dashboardLinks` → the // `hive-dashboard-links` NixOS oneshot writes them to // `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk // is `{label, icon, url}` with absolute URLs — those become // `kind = External` links, passed through verbatim. let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json"); if let Ok(text) = std::fs::read_to_string(&extras_path) && !text.trim().is_empty() && let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text) { for e in extras { links.push(AgentLink { url: e.url, icon: e.icon, label: e.label, kind: AgentLinkKind::External, }); } } links } /// On-disk shape of `hyperhive-dashboard-links.json` (the /// `hive-dashboard-links` NixOS oneshot's output). Mapped to /// `AgentLink { kind: External }` inside `agent_links`. #[derive(serde::Deserialize)] struct ExtraLink { label: String, #[serde(default)] icon: String, url: String, } /// Best-effort: pull the last 30 messages addressed to us via the /// per-agent / manager socket. Empty list on any transport / decode /// failure — the inbox section is decorative, not authoritative. async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::inbox::InboxRow> { const LIMIT: u64 = 30; // Deadline-bounded (via `broker_request`): `/api/state` must render even // when hive-c0re is busy — an empty inbox section beats a hung snapshot. match super::broker_request( socket, &hive_core_agent_sock::Request::Recent { limit: LIMIT }, ) .await { Ok(hive_core_agent_sock::Response::Recent { rows }) => rows, _ => Vec::new(), } } /// 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<String> { std::env::var("HIVE_AVAILABLE_MODELS") .unwrap_or_default() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .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<String> { const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"]; let models = parse_available_models_env(); if models.is_empty() { DEFAULT.iter().map(ToString::to_string).collect() } else { models } } /// 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<Vec<String>> { let models = parse_available_models_env(); (!models.is_empty()).then_some(models) } /// Set the first time [`filter_offered_models`] logs its "no match" warning. /// `pickers()` runs on every `/api/state` poll (every 4s per open dashboard /// tab) and on every `post_set_model`/`post_set_effort` call, and all of them /// funnel through the one [`filter_offered_models`] call site in /// [`offered_pickers`], so one flag guards every caller. static MODEL_MISMATCH_WARNED: AtomicBool = AtomicBool::new(false); /// 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. `warned` gates the mismatch warning /// to once per process; tests pass their own instead of [`MODEL_MISMATCH_WARNED`] /// so they don't observe each other's state. fn filter_offered_models( offered: Vec<String>, configured: Option<&[String]>, warned: &AtomicBool, ) -> Vec<String> { let Some(configured) = configured else { return offered; }; let matching: Vec<String> = offered .iter() .filter(|model| configured.iter().any(|c| c == *model)) .cloned() .collect(); if matching.is_empty() { if !warned.swap(true, Ordering::Relaxed) { 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)] pub(super) struct Pickers { pub(super) model: String, pub(super) available_models: Vec<String>, pub(super) effort: String, pub(super) available_efforts: Vec<String>, } /// On claude, the requested model and effort, the configured models and /// [`crate::harness_state::EFFORT_LEVELS`]. On ACP, what the session offers /// (see [`offered_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(), configured_models().as_deref()) } None => Pickers { model, available_models: available_models(), effort, available_efforts: crate::harness_state::EFFORT_LEVELS .iter() .map(ToString::to_string) .collect(), }, } } /// Pickers for what an ACP session offers. The runtime sets a requested value /// on the next turn, and only if the session offers it, so each picker shows /// the requested value when offered and not refused by the agent, 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. `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 settable = |wanted: &String, c: &hive_runtime::Choice| { c.values.contains(wanted) && c.refused.as_ref() != Some(wanted) }; let shown = |wanted: String, choice: Option<&hive_runtime::Choice>| match choice { Some(c) if !settable(&wanted, c) => c.current.clone(), _ => wanted, }; let model_pending = offered .model .as_ref() .is_some_and(|c| c.current != model && settable(&model, c)); let effort_choice = offered.effort.filter(|_| !model_pending); Pickers { model: shown(model, offered.model.as_ref()), effort: shown(effort, effort_choice.as_ref()), available_models: offered .model .map(|c| filter_offered_models(c.values, configured, &MODEL_MISMATCH_WARNED)) .unwrap_or_default(), available_efforts: effort_choice.map(|c| c.values).unwrap_or_default(), } } #[cfg(test)] mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use hive_runtime::{Choice, SessionChoices}; use super::{Pickers, filter_offered_models, offered_pickers}; fn choice(current: &str, values: &[&str]) -> Choice { Choice { current: current.to_owned(), values: values.iter().map(|v| (*v).to_owned()).collect(), refused: None, } } fn strings(values: &[&str]) -> Vec<String> { values.iter().map(|v| (*v).to_owned()).collect() } #[test] fn acp_pickers_list_what_the_session_offers() { let offered = SessionChoices { model: Some(choice("m/think", &["m/think", "m/plain"])), effort: Some(choice("low", &["low", "high"])), }; // `haiku` and `max` are not on offer: the session's own values show. assert_eq!( offered_pickers("haiku".into(), "max".into(), offered.clone(), None), Pickers { model: "m/think".into(), available_models: strings(&["m/think", "m/plain"]), effort: "low".into(), available_efforts: strings(&["low", "high"]), } ); let picked = offered_pickers("m/think".into(), "high".into(), offered, None); assert_eq!(picked.effort, "high"); assert_eq!(picked.available_efforts, strings(&["low", "high"])); } #[test] fn acp_effort_picker_hides_without_effort_or_while_a_model_is_pending() { let plain = SessionChoices { model: Some(choice("m/plain", &["m/think", "m/plain"])), effort: None, }; let shown = offered_pickers("m/plain".into(), "high".into(), plain, None); assert_eq!(shown.model, "m/plain"); assert!(shown.available_efforts.is_empty()); let think = SessionChoices { 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, None); assert_eq!(pending.model, "m/plain"); assert!(pending.available_efforts.is_empty()); } #[test] fn acp_pickers_show_the_session_s_model_once_the_agent_refuses_the_wanted_one() { let refused = SessionChoices { model: Some(Choice { refused: Some("m/plain".into()), ..choice("m/think", &["m/think", "m/plain"]) }), effort: Some(choice("low", &["low", "high"])), }; assert_eq!( offered_pickers("m/plain".into(), "high".into(), refused, None), Pickers { model: "m/think".into(), available_models: strings(&["m/think", "m/plain"]), effort: "high".into(), available_efforts: strings(&["low", "high"]), } ); } #[test] fn acp_pickers_are_empty_before_a_session_offers_anything() { 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() { let warned = AtomicBool::new(false); assert_eq!( filter_offered_models(strings(&["m/think", "m/plain"]), None, &warned), 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"]); let warned = AtomicBool::new(false); assert_eq!( filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned), 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"]); let warned = AtomicBool::new(false); assert_eq!( filter_offered_models(strings(&["m/think", "m/plain"]), Some(&configured), &warned), strings(&["m/think", "m/plain"]) ); assert!( warned.load(Ordering::Relaxed), "the mismatch warning should have fired once" ); } #[test] fn a_second_no_match_call_does_not_warn_again() { // `tracing::warn!` output isn't captured in this crate's tests (no // subscriber fixture exists), so this pins the guard itself: once // `warned` is set, a second no-match call leaves it set rather than // toggling or re-arming, which is what keeps the log line from // repeating on every `/api/state` poll. let configured = strings(&["haiku"]); let warned = AtomicBool::new(false); filter_offered_models(strings(&["m/think"]), Some(&configured), &warned); assert!(warned.load(Ordering::Relaxed)); let second = filter_offered_models(strings(&["m/think"]), Some(&configured), &warned); assert_eq!(second, strings(&["m/think"])); assert!(warned.load(Ordering::Relaxed)); } #[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"])); } }