diff --git a/docs/auto-model-routing.md b/docs/auto-model-routing.md new file mode 100644 index 000000000..4dc54e30d --- /dev/null +++ b/docs/auto-model-routing.md @@ -0,0 +1,173 @@ +# Auto model routing + +A distribution can expose named Auto modes in the model picker with +`goose.autoRouting` in its runtime config. Each mode uses Goose and declares an +actual Goose `modelProviderId`, candidate models, and a concrete fallback. No +router runs until the user selects an Auto mode and sends a message. + +The router chooses a candidate for each queued turn before the prompt starts. +Berd applies the chosen provider/model and a supported reasoning effort through +ACP. If routing fails, times out, or returns an invalid candidate, Berd uses the +mode's fallback. Explicit model selection leaves Auto mode. + +`modelId` is the provider's served endpoint ID. `routerModel` is the name understood +by the router; they can differ. `harness` defaults to `codex` and describes the +router candidate's benchmark harness, not a change to Berd's Goose backend. +Responses may omit the `system.ai.` prefix; numeric versions such as `6.1` and +`6-1` are equivalent when matching router names. The response must identify one +configured model and its configured harness. + +Efforts are ordered `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. +`minEffort` and `maxEffort` bound each candidate. An omitted effort defaults to +`medium`, clamped into those bounds. A returned effort is also clamped. A mode's +fallback must reference a candidate and specify an effort within its bounds. +Only effort choices actually supported by the session are applied. + +The default timeout is 5 seconds; `timeoutMs` accepts 1–120000 milliseconds. +`defaultModeId` optionally selects an initial mode. Omit it to retain the +distribution's ordinary initial model. All mode IDs and router model/harness +pairs must be unique within their containing lists. A provider can discover +candidate model IDs dynamically; candidate IDs need not appear in its optional +runtime model metadata. + +## Refreshing config for new chats + +Before resolving a new chat's Auto mode and initial model, Berd refreshes runtime +config through `refresh_runtime_config_for_new_chat`. UI and CLI creation paths +share this boundary, including reuse of an empty draft. Opening an existing +conversation does not refresh config. The first prompt waits for creation to +finish, so it cannot dispatch using defaults captured before the refresh. + +The native resolver reads the distribution's existing config source and bounds +that read to five seconds. Failed, invalid, or timed-out reads retain the last +valid source config. Without a valid source result, config remains unavailable; +a transient failure is never evidence that a saved Auto mode was removed. +Concurrent new-chat requests share a refresh, and older responses cannot replace +newer config. An explicit model or persona selection remains authoritative. +Routing uses one effective config snapshot per attempt. If routing settings +change during preparation, the prompt stays uncommitted for retry; a refresh +with unchanged routing settings does not interrupt it. + +`autoRouting` remains in `runtime-config.json`. No LaunchDarkly flag is enabled +by this change. A future distribution integration can evaluate a JSON flag for +selected sections, merge it over the bundled config, validate the effective +config, and return it through this same native resolution boundary. The picker +and native router must receive the same effective config; updating only the +renderer store is not sufficient. Flag identity, keys, and targeting belong to +the distribution. Refresh evaluates the current source value for each new chat; +a retained streaming flag client need not make a new network request each time. + +## Gateway example + +The following configuration illustrates a deployment-owned Databricks gateway. +Replace the example host, path, provider model IDs, and selector with values +supported by your gateway. This is a protocol example; gateways do not all expose +this routing endpoint. + +```json +{ + "schemaVersion": 1, + "goose": { + "defaultModelProviderId": "databricks_v2", + "defaultModelId": "team-gpt-6-1-sol", + "modelProviders": [{ + "id": "databricks_v2", + "displayName": "Databricks", + "models": [{"id": "team-gpt-6-1-sol", "name": "GPT Sol"}] + }], + "autoRouting": { + "url": "https://example.cloud.databricks.com/ai-gateway/routing/v1/routes:select", + "timeoutMs": 5000, + "bearerTokenEnv": "DATABRICKS_TOKEN", + "routeSelector": { + "router_name": "task_v3", + "config": {"model": "system.ai.gpt-6-luna"} + }, + "modes": [{ + "id": "auto-balanced", + "displayName": "Auto (balanced)", + "modelProviderId": "databricks_v2", + "fallback": {"modelId": "team-gpt-6-1-sol", "effort": "high"}, + "models": [{ + "modelId": "team-gpt-6-1-sol", + "routerModel": "system.ai.gpt-6-1-sol", + "minEffort": "high", + "maxEffort": "max" + }, { + "modelId": "team-claude-opus", + "routerModel": "system.ai.claude-opus", + "maxEffort": "high" + }] + }, { + "id": "auto-premium", + "displayName": "Auto (premium)", + "modelProviderId": "databricks_v2", + "fallback": {"modelId": "team-claude-opus", "effort": "high"}, + "models": [{ + "modelId": "team-claude-opus", + "routerModel": "system.ai.claude-opus", + "minEffort": "medium", + "maxEffort": "high" + }] + }] + } + } +} +``` + +Set `DATABRICKS_TOKEN` in the environment of the Berd desktop process when the +router requires a bearer token, using your existing credential tooling. A macOS +app launched from Finder does not automatically inherit a shell's environment. +`bearerTokenEnv` names the environment variable; never put a token into runtime +config. This routing credential is independent of Goose's inference-provider +sign-in. Berd does not extract Goose OAuth tokens. Omit `bearerTokenEnv` for a +gateway that authenticates through its own network boundary. Omit +`routeSelector` when the gateway selects the router itself. Runtime config +rejects URL credentials/query parameters and secret-looking selector keys; +arbitrary authentication headers are not supported. + +## Router protocol and diagnostics + +Each fresh prompt includes task text, loaded skill instructions, and recent +conversation context, with up to 24,000 characters for each source. Metadata +and text previews for up to four explicitly attached local files contribute +up to 24,000 characters per file. The complete routing context is capped at +176,000 characters, leaving room for all seven sections and their metadata. Images +and directories contribute metadata; remote paths are not read locally. Steering +an in-progress turn keeps its current model and does not call the router. + +The native backend posts this JSON to the configured URL, avoiding browser CORS: + +```json +{ + "route_options": [{"model": "system.ai.gpt-6-1-sol", "harness": "codex", "min_effort": "high", "max_effort": "max"}], + "task": {"prompt": "Bounded context for the turn"}, + "route_selector": {"router_name": "task_v3", "config": {"model": "system.ai.gpt-6-luna"}} +} +``` + +The response identifies exactly one route: + +```json +{ + "route_selection": [{ + "route_option": {"model": "gpt-6-1-sol", "harness": "codex"}, + "params": {"task_tier": "High", "reasoning_effort": "high"} + }], + "rationale": "Selected a candidate for this task." +} +``` + +`params.reasoning_effort`, `params.effort`, or selection-level `effort` can carry +the optional effort. Unknown efforts or candidates fall back. Responses are +limited to 1 MiB and redirects are disabled. Requests cannot override the +configured URL, selector, candidate allowlist, or credentials. + +The complete accepted response remains in the current session's in-memory Auto +details. Rationale and evidence can quote task context, so the response is never +written to the app log, persisted into session history, or sent as telemetry. +Transport logs record only the response byte count. Request prompts and +authentication headers are never logged by the routing transport. Feedback +exports exclude entire older app log files that contain raw router-response +records, including multiline bodies and files whose exported tail starts inside +a response. Transport failures have stable fallback reason codes. diff --git a/src-tauri/crates/berd-call/src/openai_realtime_protocol.rs b/src-tauri/crates/berd-call/src/openai_realtime_protocol.rs index 9a50af003..2bc4afc6f 100644 --- a/src-tauri/crates/berd-call/src/openai_realtime_protocol.rs +++ b/src-tauri/crates/berd-call/src/openai_realtime_protocol.rs @@ -1889,7 +1889,7 @@ impl RealtimeExpertSpokespersonSession { && (handoff.reminder_attempts == 0 || retrying.contains(handoff_id)) }) .collect::>(); - pending.sort_by(|(left, _), (right, _)| left.cmp(right)); + pending.sort_by_key(|(handoff_id, _)| *handoff_id); if pending.is_empty() { return RealtimeHandoffReminder::None; } diff --git a/src-tauri/src/commands/auto_routing.rs b/src-tauri/src/commands/auto_routing.rs new file mode 100644 index 000000000..209a47074 --- /dev/null +++ b/src-tauri/src/commands/auto_routing.rs @@ -0,0 +1,351 @@ +//! Runtime-configured routing transport. Requests and credentials are never logged. +//! Full responses stay in session memory; logs contain transport metadata only. +use crate::commands::runtime_config::{ + auto_routing_revision, RuntimeAutoRoutingConfig, RuntimeAutoRoutingMode, RuntimeConfigState, +}; +use crate::services::distro_bundle::DistroBundleState; +use serde_json::{json, Value}; +use std::time::Duration; +use tauri::State; + +const AUTO_ROUTING_CONFIG_CHANGED: &str = "AUTO_ROUTING_CONFIG_CHANGED"; + +fn checked_routing_config<'a>( + current: Option<&'a RuntimeAutoRoutingConfig>, + expected_revision: Option<&str>, +) -> Result<&'a RuntimeAutoRoutingConfig, String> { + let current = current.ok_or_else(|| AUTO_ROUTING_CONFIG_CHANGED.to_string())?; + let revision = + auto_routing_revision(current).map_err(|_| AUTO_ROUTING_CONFIG_CHANGED.to_string())?; + if Some(revision.as_str()) != expected_revision { + return Err(AUTO_ROUTING_CONFIG_CHANGED.to_string()); + } + Ok(current) +} + +fn build_request( + config: &RuntimeAutoRoutingConfig, + mode: &RuntimeAutoRoutingMode, + prompt: &str, +) -> Value { + let options: Vec = mode.models.iter().map(|model| { + let mut option = json!({"model": model.router_model, "harness": model.harness.as_deref().unwrap_or("codex")}); + if let Some(effort) = &model.min_effort { option["min_effort"] = json!(effort); } + if let Some(effort) = &model.max_effort { option["max_effort"] = json!(effort); } + option + }).collect(); + let mut body = json!({"task": {"prompt": prompt}, "route_options": options}); + if let Some(selector) = &config.route_selector { + body["route_selector"] = json!(selector); + } + body +} + +#[tauri::command] +pub async fn route_auto_model( + mode_id: String, + prompt: String, + expected_auto_routing_revision: Option, + runtime_config: State<'_, RuntimeConfigState>, + distro_bundle: State<'_, DistroBundleState>, +) -> Result { + // Allow four UTF-8 bytes per character in the 176k renderer context budget. + if prompt.len() > 704_000 { + return Err("Routing context exceeds limit".to_string()); + } + route_with_current_config( + &mode_id, + &prompt, + expected_auto_routing_revision.as_deref(), + runtime_config.inner(), + distro_bundle.inner(), + ) + .await +} + +async fn route_with_current_config( + mode_id: &str, + prompt: &str, + expected_revision: Option<&str>, + runtime_config: &RuntimeConfigState, + distro_bundle: &DistroBundleState, +) -> Result { + let runtime = runtime_config.ready_config(distro_bundle).await?; + // Echo the native-issued revision, never re-encoded renderer config. + // Native JSON numbers retain their original precision and representation. + let config = checked_routing_config(runtime.goose.auto_routing.as_ref(), expected_revision)?; + let mode = config + .modes + .iter() + .find(|mode| mode.id == mode_id) + .ok_or("Auto routing mode is not configured")?; + let result = request_route(config, mode, prompt).await; + // A different window may refresh native state while HTTP is pending. + // Reject both obsolete decisions and obsolete errors (which would otherwise + // apply the renderer snapshot's old fallback) before releasing the result. + let current = runtime_config + .ready_config(distro_bundle) + .await + .map_err(|_| AUTO_ROUTING_CONFIG_CHANGED.to_string())?; + checked_routing_config(current.goose.auto_routing.as_ref(), expected_revision)?; + result +} + +async fn request_route( + config: &RuntimeAutoRoutingConfig, + mode: &RuntimeAutoRoutingMode, + prompt: &str, +) -> Result { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_millis(config.timeout_ms.unwrap_or(5_000))) + .build() + .map_err(|_| "Routing client unavailable".to_string())?; + let mut request = client + .post(&config.url) + .json(&build_request(config, mode, prompt)); + if let Some(name) = &config.bearer_token_env { + let token = + std::env::var(name).map_err(|_| "Routing credential unavailable".to_string())?; + if token.trim().is_empty() { + return Err("Routing credential unavailable".to_string()); + } + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|_| "Routing request failed".to_string())?; + if !response.status().is_success() { + return Err(format!( + "Routing request returned HTTP {}", + response.status().as_u16() + )); + } + // Keep the opaque response for session-local inspection, with a bounded body. + let mut response = response; + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "Routing response read failed".to_string())? + { + if bytes.len() + chunk.len() > 1_048_576 { + return Err("Routing response exceeds limit".to_string()); + } + bytes.extend_from_slice(&chunk); + } + log::info!(target: "auto_routing", "Routing response received ({} bytes)", bytes.len()); + serde_json::from_slice(&bytes).map_err(|_| "Routing response is not JSON".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + fn config(url: String) -> RuntimeAutoRoutingConfig { + serde_json::from_value(json!({"url":url,"timeoutMs":1000,"modes":[{ + "id":"auto","displayName":"Auto","modelProviderId":"provider", + "fallback":{"modelId":"served","effort":"high"}, + "models":[{"modelId":"served","routerModel":"system.ai.sol","minEffort":"high"}] + }]})) + .unwrap() + } + #[test] + fn renderer_revision_must_match_native_before_routing() { + let current = config("https://trusted.example.com/route".to_string()); + let revision = auto_routing_revision(¤t).unwrap(); + assert!(std::ptr::eq( + checked_routing_config(Some(¤t), Some(&revision)).unwrap(), + ¤t, + )); + let mut changed = current.clone(); + changed.modes[0].fallback.model_id = "changed".to_string(); + assert_eq!( + checked_routing_config(Some(&changed), Some(&revision)).unwrap_err(), + AUTO_ROUTING_CONFIG_CHANGED, + ); + assert_eq!( + checked_routing_config(None, Some(&revision)).unwrap_err(), + AUTO_ROUTING_CONFIG_CHANGED, + ); + assert_eq!( + checked_routing_config(Some(¤t), None).unwrap_err(), + AUTO_ROUTING_CONFIG_CHANGED, + ); + assert_eq!( + checked_routing_config(Some(¤t), Some("forged")).unwrap_err(), + AUTO_ROUTING_CONFIG_CHANGED, + ); + } + + #[test] + fn native_revision_survives_renderer_numeric_round_trip() { + let mut native = config("https://trusted.example.com/route".to_string()); + native.route_selector = Some( + serde_json::from_str( + r#"{ + "router_name":"task_v3", + "config":{"float":1.0,"large":9007199254740993} + }"#, + ) + .unwrap(), + ); + let revision = auto_routing_revision(&native).unwrap(); + // Emulate JSON.parse -> JSON.stringify in JavaScript: integral floats + // become integers and values beyond MAX_SAFE_INTEGER lose precision. + let mut renderer = serde_json::to_value(&native).unwrap(); + renderer["routeSelector"]["config"]["float"] = json!(1); + renderer["routeSelector"]["config"]["large"] = json!(9007199254740992u64); + let renderer: RuntimeAutoRoutingConfig = serde_json::from_value(renderer).unwrap(); + assert_ne!(native, renderer); + assert_ne!(auto_routing_revision(&renderer).unwrap(), revision); + + let checked = checked_routing_config(Some(&native), Some(&revision)).unwrap(); + let request = build_request(checked, &checked.modes[0], "context"); + let selector = &request["route_selector"]["config"]; + assert!(selector["float"].is_f64()); + assert_eq!(selector["large"].as_u64(), Some(9007199254740993)); + // A real native number change still invalidates the captured revision. + assert_eq!( + checked_routing_config(Some(&renderer), Some(&revision)).unwrap_err(), + AUTO_ROUTING_CONFIG_CHANGED + ); + } + async fn server( + status: u16, + body: String, + delay: Duration, + ) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/route", listener.local_addr().unwrap()); + let handle = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut data = vec![0u8; 16384]; + let size = stream.read(&mut data).await.unwrap(); + let request = String::from_utf8_lossy(&data[..size]).into_owned(); + tokio::time::sleep(delay).await; + let response = format!( + "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + request + }); + (url, handle) + } + #[tokio::test] + async fn refresh_during_http_rejects_obsolete_success_and_fallback_error() { + for status in [200, 503] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/route", listener.local_addr().unwrap()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 16384]; + stream.read(&mut request).await.unwrap(); + started_tx.send(()).unwrap(); + release_rx.await.unwrap(); + let body = r#"{"route_selection":[]}"#; + let response = format!("HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + let dir = tempfile::tempdir().unwrap(); + let state = RuntimeConfigState::new(dir.path().to_owned(), None); + let distro = DistroBundleState::empty_for_tests(); + let mut runtime = crate::commands::runtime_config::default_runtime_config(); + runtime.goose.model_providers.push( + serde_json::from_value(json!({ + "id":"provider", "displayName":"Provider", + "models":[{"id":"served", "name":"Served"}] + })) + .unwrap(), + ); + runtime.goose.default_model_provider_id = Some("provider".to_string()); + runtime.goose.default_model_id = Some("served".to_string()); + runtime.goose.auto_routing = Some(config(url)); + state.set_fake_config(runtime.clone()).unwrap(); + let revision = + auto_routing_revision(runtime.goose.auto_routing.as_ref().unwrap()).unwrap(); + let (result, _) = tokio::join!( + route_with_current_config("auto", "context", Some(&revision), &state, &distro), + async { + started_rx.await.unwrap(); + runtime.goose.auto_routing.as_mut().unwrap().modes[0] + .fallback + .effort = "max".to_string(); + state.set_fake_config(runtime).unwrap(); + release_tx.send(()).unwrap(); + }, + ); + assert_eq!(result.unwrap_err(), AUTO_ROUTING_CONFIG_CHANGED, + "HTTP {status} from an obsolete config must not release its decision or fallback error"); + server.await.unwrap(); + } + } + + #[tokio::test] + async fn transport_posts_configured_candidates_and_keeps_complete_response() { + let raw = json!({"route_selection":[],"rationale":"complete","extra":{"evidence":true}}); + let (url, server) = server(200, raw.to_string(), Duration::ZERO).await; + let config = config(url); + let response = request_route(&config, &config.modes[0], "task context") + .await + .unwrap(); + assert_eq!(response, raw); + let request = server.await.unwrap(); + assert!(request.starts_with("POST /route HTTP/1.1")); + let body: Value = serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["task"]["prompt"], "task context"); + assert_eq!( + body["route_options"][0], + json!({"model":"system.ai.sol","harness":"codex","min_effort":"high"}) + ); + assert!(body.get("route_selector").is_none()); + } + #[tokio::test] + async fn transport_fails_for_http_status_invalid_json_timeouts_and_oversized_responses() { + for (status, body, timeout, delay, expected) in [ + (503, "unavailable".to_string(), 1000, 0, "HTTP 503"), + (200, "not JSON".to_string(), 1000, 0, "not JSON"), + (200, "{}".to_string(), 10, 100, "request failed"), + (200, "x".repeat(1_048_577), 1000, 0, "exceeds limit"), + (302, "{}".to_string(), 1000, 0, "HTTP 302"), + ] { + let (url, handle) = server(status, body, Duration::from_millis(delay)).await; + let mut config = config(url); + config.timeout_ms = Some(timeout); + let error = request_route(&config, &config.modes[0], "task") + .await + .unwrap_err(); + assert!(error.contains(expected), "{error}"); + handle.await.unwrap(); + } + } + #[tokio::test] + async fn missing_credential_fails_before_sending() { + let mut config = config("http://127.0.0.1:1/route".to_string()); + config.bearer_token_env = + Some("BERD_AUTO_ROUTING_TEST_MISSING_CREDENTIAL_18A854".to_string()); + let error = request_route(&config, &config.modes[0], "task") + .await + .unwrap_err(); + assert_eq!(error, "Routing credential unavailable"); + } + #[test] + fn selector_and_bounds_are_forwarded_without_concrete_model_id() { + let mut config = config("https://gateway.example.com/route".to_string()); + config.route_selector = Some( + serde_json::from_value( + json!({"router_name":"task_v3","config":{"model":"system.ai.luna"}}), + ) + .unwrap(), + ); + let body = build_request(&config, &config.modes[0], "task"); + assert_eq!(body["route_selector"]["router_name"], "task_v3"); + assert!(!body.to_string().contains("served")); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 077b94108..65d484762 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod agents; pub mod artifacts; #[cfg_attr(not(feature = "block-builderbot"), allow(dead_code))] pub mod auth; +pub mod auto_routing; #[cfg(feature = "block-automations")] pub mod automations; pub mod avatars; diff --git a/src-tauri/src/commands/runtime_config.rs b/src-tauri/src/commands/runtime_config.rs index 46b6061cb..d690b654e 100644 --- a/src-tauri/src/commands/runtime_config.rs +++ b/src-tauri/src/commands/runtime_config.rs @@ -4,13 +4,16 @@ use crate::services::kgoose; #[cfg(any(feature = "admin-runtime-config", test))] use chrono::Utc; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; +use std::future::Future; use std::path::{Path, PathBuf}; use std::sync::Mutex; -#[cfg(feature = "admin-runtime-config")] use std::time::Duration; use tauri::State; +const NEW_CHAT_RUNTIME_CONFIG_TIMEOUT: Duration = Duration::from_secs(5); + const RUNTIME_CONFIG_DIR_NAME: &str = "runtime-config"; const FAKE_RUNTIME_CONFIG_FILE_NAME: &str = "fake-endpoint.json"; const ADMIN_RUNTIME_CONFIG_CACHE_FILE_NAME: &str = "admin-cache.json"; @@ -76,6 +79,57 @@ pub struct RuntimeGooseConfig { #[serde(skip_serializing_if = "Option::is_none", default)] pub default_model_id: Option, pub model_providers: Vec, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub auto_routing: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeAutoRoutingConfig { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub bearer_token_env: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub route_selector: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub default_mode_id: Option, + pub modes: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeRouteSelector { + pub router_name: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub config: Option>, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeAutoRoutingMode { + pub id: String, + pub display_name: String, + pub model_provider_id: String, + pub fallback: RuntimeAutoRoutingFallback, + pub models: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeAutoRoutingFallback { + pub model_id: String, + pub effort: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeAutoRoutingModel { + pub model_id: String, + pub router_model: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub harness: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub min_effort: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub max_effort: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -218,6 +272,14 @@ pub enum RuntimeConfigLoadResult { Ready { source: RuntimeConfigSource, config: Box, + /// Opaque native fingerprint. Callers echo it without re-encoding the + /// configuration, whose free JSON numbers can change in JavaScript. + #[serde( + rename = "autoRoutingRevision", + skip_serializing_if = "Option::is_none", + default + )] + auto_routing_revision: Option, }, Unavailable { source: RuntimeConfigSource, @@ -226,11 +288,51 @@ pub enum RuntimeConfigLoadResult { }, } +impl RuntimeConfigLoadResult { + fn ready(source: RuntimeConfigSource, config: RuntimeConfig) -> Self { + let revision = config + .goose + .auto_routing + .as_ref() + .map(auto_routing_revision) + .transpose(); + match revision { + Ok(auto_routing_revision) => Self::Ready { + source, + config: Box::new(config), + auto_routing_revision, + }, + Err(message) => Self::Unavailable { + source, + reason: RuntimeConfigUnavailableReason::Invalid, + message, + }, + } + } +} + +/// Hash only native-owned data. In particular, never recreate this revision +/// from renderer JSON: JavaScript rounds integers and normalizes float types. +pub(crate) fn auto_routing_revision(config: &RuntimeAutoRoutingConfig) -> Result { + let encoded = serde_json::to_vec(config) + .map_err(|_| "Failed to encode Auto routing revision".to_string())?; + Ok(hex::encode(Sha256::digest(encoded))) +} + pub struct RuntimeConfigState { fake_config_path: PathBuf, admin_cache_path: PathBuf, bundled_config_path: Option, - cached: Mutex>, + cached: Mutex, +} + +#[derive(Default)] +struct RuntimeConfigCache { + generation: u64, + current: Option, + // Compiled defaults are a development fallback, not evidence that a + // configured routing mode was deliberately removed from the source. + last_valid: Option, } impl RuntimeConfigState { @@ -239,7 +341,7 @@ impl RuntimeConfigState { fake_config_path: fake_runtime_config_path(&app_data_dir), admin_cache_path: admin_cache_path(&app_data_dir), bundled_config_path, - cached: Mutex::new(None), + cached: Mutex::new(RuntimeConfigCache::default()), } } @@ -247,22 +349,26 @@ impl RuntimeConfigState { &self, distro_state: &DistroBundleState, ) -> Result { - if let Some(result) = self - .cached - .lock() - .map_err(|_| "Runtime config cache lock poisoned".to_string())? - .clone() - { - return Ok(result); - } + let generation = { + let mut cached = self + .cached + .lock() + .map_err(|_| "Runtime config cache lock poisoned".to_string())?; + if let Some(result) = &cached.current { + return Ok(result.clone()); + } + cached.generation += 1; + cached.generation + }; let result = load_runtime_config_from_source( &self.fake_config_path, &self.admin_cache_path, self.bundled_config_path.as_deref(), distro_state, + true, ) .await; - self.replace_cache(result) + self.finish_load(generation, result, false) } pub async fn ready_config( @@ -283,14 +389,16 @@ impl RuntimeConfigState { &self, distro_state: &DistroBundleState, ) -> Result { + let generation = self.begin_load()?; let result = load_runtime_config_from_source( &self.fake_config_path, &self.admin_cache_path, self.bundled_config_path.as_deref(), distro_state, + true, ) .await; - self.replace_cache(result) + self.finish_load(generation, result, false) } #[cfg(debug_assertions)] @@ -299,11 +407,20 @@ impl RuntimeConfigState { config: RuntimeConfig, ) -> Result { validate_runtime_config(&config)?; - write_fake_runtime_config_to_path(&self.fake_config_path, &config)?; - self.replace_cache(RuntimeConfigLoadResult::Ready { - source: RuntimeConfigSource::FakeEndpoint, - config: Box::new(config), - }) + let generation = { + let mut cached = self + .cached + .lock() + .map_err(|_| "Runtime config cache lock poisoned".to_string())?; + write_fake_runtime_config_to_path(&self.fake_config_path, &config)?; + cached.generation += 1; + cached.generation + }; + self.finish_load( + generation, + RuntimeConfigLoadResult::ready(RuntimeConfigSource::FakeEndpoint, config), + false, + ) } #[cfg(not(debug_assertions))] @@ -319,24 +436,42 @@ impl RuntimeConfigState { &self, distro_state: &DistroBundleState, ) -> Result { - match std::fs::remove_file(&self.fake_config_path) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "Failed to remove fake runtime config '{}': {error}", - self.fake_config_path.display() - )) + let generation = { + let mut cached = self + .cached + .lock() + .map_err(|_| "Runtime config cache lock poisoned".to_string())?; + match std::fs::remove_file(&self.fake_config_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Failed to remove fake runtime config '{}': {error}", + self.fake_config_path.display() + )) + } } - } + if matches!( + &cached.last_valid, + Some(RuntimeConfigLoadResult::Ready { + source: RuntimeConfigSource::FakeEndpoint, + .. + }) + ) { + cached.last_valid = None; + } + cached.generation += 1; + cached.generation + }; let result = load_runtime_config_from_source( &self.fake_config_path, &self.admin_cache_path, self.bundled_config_path.as_deref(), distro_state, + true, ) .await; - self.replace_cache(result) + self.finish_load(generation, result, false) } #[cfg(not(debug_assertions))] @@ -347,16 +482,92 @@ impl RuntimeConfigState { Ok(unsupported_fake_runtime_config()) } - fn replace_cache( + /// Refresh the source for a new chat without discarding a working config + /// when the source is temporarily unavailable. The returned value is also + /// the effective config read by native routing commands. + pub async fn refresh_for_new_chat( &self, + distro_state: &DistroBundleState, + ) -> Result { + self.refresh_for_new_chat_with( + load_runtime_config_from_source( + &self.fake_config_path, + &self.admin_cache_path, + self.bundled_config_path.as_deref(), + distro_state, + false, + ), + NEW_CHAT_RUNTIME_CONFIG_TIMEOUT, + ) + .await + } + + async fn refresh_for_new_chat_with( + &self, + load: impl Future, + timeout: Duration, + ) -> Result { + let generation = self.begin_load()?; + let result = tokio::time::timeout(timeout, load) + .await + .unwrap_or_else(|_| { + runtime_config_refresh_unavailable("Runtime config refresh timed out".to_string()) + }); + self.finish_load(generation, result, true) + } + + fn begin_load(&self) -> Result { + let mut cached = self + .cached + .lock() + .map_err(|_| "Runtime config cache lock poisoned".to_string())?; + cached.generation += 1; + Ok(cached.generation) + } + + fn finish_load( + &self, + generation: u64, result: RuntimeConfigLoadResult, + retain_last_valid: bool, ) -> Result { let result = runtime_config_load_result_for_local_byo_dev(result); let mut cached = self .cached .lock() .map_err(|_| "Runtime config cache lock poisoned".to_string())?; - *cached = Some(result.clone()); + if generation != cached.generation { + // The newer load owns the cache, but it may not have committed yet. + // An older successful read remains usable by its caller in that gap. + return Ok(cached.current.clone().unwrap_or(result)); + } + let result = + if retain_last_valid && matches!(result, RuntimeConfigLoadResult::Unavailable { .. }) { + cached + .last_valid + .clone() + .or_else(|| { + // Defaults still support unrelated native operations. Their + // AppDefault source remains non-authoritative for Auto intent. + cached.current.clone().filter(|current| { + matches!( + current, + RuntimeConfigLoadResult::Ready { + source: RuntimeConfigSource::AppDefault, + .. + } + ) + }) + }) + .unwrap_or(result) + } else { + result + }; + if matches!(&result, RuntimeConfigLoadResult::Ready { source, .. } if *source != RuntimeConfigSource::AppDefault) + { + cached.last_valid = Some(result.clone()); + } + cached.current = Some(result.clone()); Ok(result) } } @@ -377,10 +588,21 @@ async fn load_runtime_config_from_source( cache_path: &Path, bundled_config_path: Option<&Path>, distro_state: &DistroBundleState, + allow_default_fallback: bool, ) -> RuntimeConfigLoadResult { #[cfg(debug_assertions)] - if fake_config_path.exists() { - return read_fake_runtime_config_from_path(fake_config_path); + { + let path = fake_config_path.to_owned(); + match tokio::task::spawn_blocking(move || { + path.exists() + .then(|| read_fake_runtime_config_from_path(&path)) + }) + .await + { + Ok(Some(result)) => return result, + Ok(None) => {} + Err(error) => return runtime_config_refresh_unavailable(error.to_string()), + } } #[cfg(not(debug_assertions))] let _ = fake_config_path; @@ -390,24 +612,33 @@ async fn load_runtime_config_from_source( let _ = bundled_config_path; match fetch_admin_runtime_config(distro_state).await { Ok((config, source_url)) => { - if let Err(error) = write_admin_cache_to_path(cache_path, &config, &source_url) { - log::warn!("failed to write admin runtime config cache: {error}"); + let path = cache_path.to_owned(); + let cache_config = config.clone(); + match tokio::task::spawn_blocking(move || { + write_admin_cache_to_path(&path, &cache_config, &source_url) + }) + .await + { + Ok(Ok(())) => {} + error => log::warn!("failed to write admin runtime config cache: {error:?}"), + } + return RuntimeConfigLoadResult::ready(RuntimeConfigSource::Endpoint, config); + } + Err(error) => { + log::warn!("failed to fetch admin runtime config: {error}"); + if !allow_default_fallback { + return runtime_config_refresh_unavailable(error); } - return RuntimeConfigLoadResult::Ready { - source: RuntimeConfigSource::Endpoint, - config: Box::new(config), - }; } - Err(error) => log::warn!("failed to fetch admin runtime config: {error}"), } match read_admin_cache_from_path(cache_path) { Ok(cache) => { log::info!("using cached admin runtime config"); - return RuntimeConfigLoadResult::Ready { - source: RuntimeConfigSource::CachedEndpoint, - config: Box::new(cache.config), - }; + return RuntimeConfigLoadResult::ready( + RuntimeConfigSource::CachedEndpoint, + cache.config, + ); } Err(error) => log::warn!("failed to read cached admin runtime config: {error}"), } @@ -421,10 +652,31 @@ async fn load_runtime_config_from_source( // runtime-config.json is the source of truth. A stale admin cache must // not shadow it, so the cache is intentionally ignored in this mode. let _ = (cache_path, distro_state); - load_bundled_runtime_config_from_source( - bundled_config_path, - allow_bundled_runtime_config_default_fallback(), - ) + let path = bundled_config_path.map(Path::to_owned); + tokio::task::spawn_blocking(move || { + load_bundled_runtime_config_from_source( + path.as_deref(), + allow_default_fallback && allow_bundled_runtime_config_default_fallback(), + ) + }) + .await + .unwrap_or_else(|error| runtime_config_refresh_unavailable(error.to_string())) + } +} + +fn runtime_config_refresh_unavailable(message: String) -> RuntimeConfigLoadResult { + RuntimeConfigLoadResult::Unavailable { + source: if cfg!(feature = "admin-runtime-config") { + RuntimeConfigSource::Endpoint + } else { + RuntimeConfigSource::BundledFile + }, + reason: if cfg!(feature = "admin-runtime-config") { + RuntimeConfigUnavailableReason::EndpointUnavailable + } else { + RuntimeConfigUnavailableReason::ReadFailed + }, + message, } } @@ -454,10 +706,7 @@ fn load_bundled_runtime_config_from_source( Some(path) if path.exists() => match read_bundled_runtime_config_from_path(path) { Ok(config) => { log::info!("using bundled runtime config '{}'", path.display()); - RuntimeConfigLoadResult::Ready { - source: RuntimeConfigSource::BundledFile, - config: Box::new(config), - } + RuntimeConfigLoadResult::ready(RuntimeConfigSource::BundledFile, config) } Err((reason, message)) => bundled_runtime_config_unavailable_or_default( reason, @@ -570,10 +819,7 @@ fn read_fake_runtime_config_from_path(path: &Path) -> RuntimeConfigLoadResult { path.display() )); } - RuntimeConfigLoadResult::Ready { - source: RuntimeConfigSource::FakeEndpoint, - config: Box::new(config), - } + RuntimeConfigLoadResult::ready(RuntimeConfigSource::FakeEndpoint, config) } #[cfg(any(feature = "admin-runtime-config", test))] @@ -636,7 +882,7 @@ fn runtime_config_load_result_for_local_byo_dev( #[cfg(debug_assertions)] if local_byo_key_providers_enabled() { return match result { - RuntimeConfigLoadResult::Ready { source, config } + RuntimeConfigLoadResult::Ready { source, config, .. } if matches!( source, RuntimeConfigSource::AppDefault | RuntimeConfigSource::BundledFile @@ -644,10 +890,7 @@ fn runtime_config_load_result_for_local_byo_dev( { let mut config = *config; clear_default_databricks_distribution_config(&mut config); - RuntimeConfigLoadResult::Ready { - source, - config: Box::new(config), - } + RuntimeConfigLoadResult::ready(source, config) } other => other, }; @@ -694,10 +937,7 @@ fn default_runtime_config_result(source: RuntimeConfigSource) -> RuntimeConfigLo message: format!("Default runtime config failed validation: {error}"), }; } - RuntimeConfigLoadResult::Ready { - source, - config: Box::new(config), - } + RuntimeConfigLoadResult::ready(source, config) } pub(crate) fn default_runtime_config() -> RuntimeConfig { @@ -717,6 +957,7 @@ pub(crate) fn default_goose_config() -> RuntimeGooseConfig { RuntimeGooseConfig { default_model_provider_id: None, default_model_id: None, + auto_routing: None, model_providers: vec![], } } @@ -790,6 +1031,9 @@ fn validate_runtime_config(config: &RuntimeConfig) -> Result<(), String> { } fn validate_goose_config(goose: &RuntimeGooseConfig) -> Result<(), String> { + if let Some(config) = &goose.auto_routing { + validate_auto_routing(config, &goose.model_providers)?; + } validate_optional_runtime_id( goose.default_model_provider_id.as_deref(), "goose.defaultModelProviderId", @@ -908,6 +1152,183 @@ fn validate_goose_config(goose: &RuntimeGooseConfig) -> Result<(), String> { Ok(()) } +fn normalized_router_model(value: &str) -> String { + let chars: Vec = value + .strip_prefix("system.ai.") + .unwrap_or(value) + .chars() + .collect(); + chars + .iter() + .enumerate() + .map(|(index, c)| { + if *c == '.' + && index > 0 + && chars[index - 1].is_ascii_digit() + && chars.get(index + 1).is_some_and(char::is_ascii_digit) + { + '-' + } else { + *c + } + }) + .collect() +} + +fn effort_rank(value: &str) -> Result { + ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + .iter() + .position(|item| *item == value) + .ok_or_else(|| "autoRouting effort has unsupported value".to_string()) +} +fn validate_router_json(value: &serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::Object(map) => { + for (key, item) in map { + if [ + "secret", + "token", + "apikey", + "api-key", + "api_key", + "password", + "authorization", + "bearer", + ] + .iter() + .any(|word| key.to_lowercase().contains(word)) + { + return Err("autoRouting routeSelector must not contain secrets".to_string()); + } + validate_router_json(item)?; + } + } + serde_json::Value::Array(items) => { + for item in items { + validate_router_json(item)?; + } + } + serde_json::Value::String(item) + if item.to_lowercase().contains("bearer ") + || (item.contains("-----BEGIN") && item.contains("PRIVATE KEY")) => + { + return Err("autoRouting routeSelector must not contain secrets".to_string()) + } + _ => (), + } + Ok(()) +} +fn validate_auto_routing( + config: &RuntimeAutoRoutingConfig, + providers: &[RuntimeGooseModelProvider], +) -> Result<(), String> { + validate_runtime_id(&config.url, "autoRouting.url")?; + let url = reqwest::Url::parse(&config.url) + .map_err(|_| "autoRouting url must be valid".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err( + "autoRouting url must be http or https without credentials, query or fragment" + .to_string(), + ); + } + if let Some(name) = &config.bearer_token_env { + let mut chars = name.chars(); + if !chars + .next() + .is_some_and(|c| c.is_ascii_uppercase() || c == '_') + || !chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + { + return Err( + "autoRouting bearerTokenEnv must be an environment variable name".to_string(), + ); + } + } + if matches!(config.timeout_ms, Some(0 | 120001..)) { + return Err("autoRouting timeoutMs must be between 1 and 120000".to_string()); + } + if let Some(selector) = &config.route_selector { + validate_runtime_id( + &selector.router_name, + "autoRouting.routeSelector.router_name", + )?; + validate_router_json( + &serde_json::to_value(selector).map_err(|_| "Invalid routeSelector".to_string())?, + )?; + } + if config.modes.is_empty() { + return Err("autoRouting modes must not be empty".to_string()); + } + let mut ids = HashSet::new(); + for mode in &config.modes { + validate_runtime_id(&mode.id, "autoRouting.mode.id")?; + validate_non_empty(&mode.display_name, "autoRouting.mode.displayName")?; + validate_runtime_id(&mode.model_provider_id, "autoRouting.mode.modelProviderId")?; + if !ids.insert(&mode.id) { + return Err("autoRouting modes must have unique ids".to_string()); + } + if !providers + .iter() + .any(|provider| provider.id == mode.model_provider_id) + { + return Err("autoRouting modelProviderId must reference modelProviders".to_string()); + } + if mode.models.is_empty() { + return Err("autoRouting models must not be empty".to_string()); + } + let mut models = HashSet::new(); + let mut routes = HashSet::new(); + for model in &mode.models { + validate_runtime_id(&model.model_id, "autoRouting.modelId")?; + validate_runtime_id(&model.router_model, "autoRouting.routerModel")?; + validate_optional_runtime_id(model.harness.as_deref(), "autoRouting.harness")?; + if !models.insert(&model.model_id) + || !routes.insert(( + normalized_router_model(&model.router_model), + model.harness.as_deref().unwrap_or("codex"), + )) + { + return Err( + "autoRouting models must have unique modelIds and router model/harness pairs" + .to_string(), + ); + } + if effort_rank(model.min_effort.as_deref().unwrap_or("none"))? + > effort_rank(model.max_effort.as_deref().unwrap_or("max"))? + { + return Err("autoRouting minEffort must not exceed maxEffort".to_string()); + } + } + let fallback = mode + .models + .iter() + .find(|model| model.model_id == mode.fallback.model_id) + .ok_or("autoRouting fallback must reference a candidate model")?; + let effort = effort_rank(&mode.fallback.effort)?; + if effort < effort_rank(fallback.min_effort.as_deref().unwrap_or("none"))? + || effort > effort_rank(fallback.max_effort.as_deref().unwrap_or("max"))? + { + return Err("autoRouting fallback effort must be within candidate bounds".to_string()); + } + } + validate_optional_runtime_id( + config.default_mode_id.as_deref(), + "autoRouting.defaultModeId", + )?; + if config + .default_mode_id + .as_ref() + .is_some_and(|id| !ids.contains(id)) + { + return Err("autoRouting defaultModeId must reference a mode".to_string()); + } + Ok(()) +} + fn validate_custom_provider( provider: &RuntimeCustomProvider, parent_provider_id: &str, @@ -1130,6 +1551,14 @@ pub async fn refresh_runtime_config( state.refresh(distro_state.inner()).await } +#[tauri::command] +pub async fn refresh_runtime_config_for_new_chat( + state: State<'_, RuntimeConfigState>, + distro_state: State<'_, DistroBundleState>, +) -> Result { + state.refresh_for_new_chat(distro_state.inner()).await +} + #[tauri::command] pub fn set_fake_runtime_config( state: State<'_, RuntimeConfigState>, @@ -1157,6 +1586,7 @@ mod tests { fn managed_goose_config() -> RuntimeGooseConfig { RuntimeGooseConfig { + auto_routing: None, default_model_provider_id: Some(TEST_RUNTIME_PROVIDER_ID.to_string()), default_model_id: Some(TEST_RUNTIME_MODEL_ID.to_string()), model_providers: vec![RuntimeGooseModelProvider { @@ -1285,7 +1715,7 @@ mod tests { fn expect_ready(result: RuntimeConfigLoadResult) -> (RuntimeConfigSource, RuntimeConfig) { match result { - RuntimeConfigLoadResult::Ready { source, config } => (source, *config), + RuntimeConfigLoadResult::Ready { source, config, .. } => (source, *config), other => panic!("expected ready result, got {other:?}"), } } @@ -1445,6 +1875,296 @@ mod tests { assert!(message.contains("Failed to read fake runtime config")); } + #[test] + fn ready_wire_result_exposes_native_auto_revision_without_renderer_reencoding() { + let mut config = valid_config(); + let absent = serde_json::to_value(RuntimeConfigLoadResult::ready( + RuntimeConfigSource::BundledFile, + config.clone(), + )) + .unwrap(); + assert!(absent.get("autoRoutingRevision").is_none()); + config.goose = managed_goose_config(); + config.goose.auto_routing = Some(serde_json::from_value(serde_json::json!({ + "url":"https://gateway.example.com/route", + "routeSelector":{"router_name":"task_v3","config":{"float":1.0,"large":9007199254740993u64}}, + "modes":[{"id":"auto","displayName":"Auto","modelProviderId":TEST_RUNTIME_PROVIDER_ID, + "fallback":{"modelId":TEST_RUNTIME_MODEL_ID,"effort":"high"}, + "models":[{"modelId":TEST_RUNTIME_MODEL_ID,"routerModel":"system.ai.sol"}]}] + })).unwrap()); + validate_runtime_config(&config).unwrap(); + let revision = auto_routing_revision(config.goose.auto_routing.as_ref().unwrap()).unwrap(); + let mut wire = serde_json::to_value(RuntimeConfigLoadResult::ready( + RuntimeConfigSource::BundledFile, + config, + )) + .unwrap(); + assert_eq!(wire["autoRoutingRevision"], revision); + assert_eq!(revision.len(), 64); + wire["config"]["goose"]["autoRouting"]["routeSelector"]["config"]["float"] = + serde_json::json!(1); + wire["config"]["goose"]["autoRouting"]["routeSelector"]["config"]["large"] = + serde_json::json!(9007199254740992u64); + let echoed: RuntimeConfigLoadResult = serde_json::from_value(wire).unwrap(); + match echoed { + RuntimeConfigLoadResult::Ready { + config, + auto_routing_revision: Some(echoed_revision), + .. + } => { + assert_eq!(echoed_revision, revision); + assert_ne!( + auto_routing_revision(config.goose.auto_routing.as_ref().unwrap()).unwrap(), + revision + ); + } + other => panic!("Expected Ready with native revision, got {other:?}"), + } + } + + #[tokio::test] + async fn new_chat_refresh_retains_last_valid_config_on_source_failure() { + let (_dir, state) = temp_state(); + let distro = empty_distro_state(); + let expected = state.set_fake_config(valid_config()).unwrap(); + std::fs::write(&state.fake_config_path, "invalid JSON").unwrap(); + assert_eq!(state.refresh_for_new_chat(&distro).await.unwrap(), expected); + assert_eq!(state.get(&distro).await.unwrap(), expected); + } + + #[cfg(not(feature = "admin-runtime-config"))] + #[tokio::test] + async fn new_chat_refresh_reloads_bundled_source_and_retains_it_when_deleted() { + let (_dir, state) = temp_state_with_bundled(Some(&valid_config())); + let distro = empty_distro_state(); + state.get(&distro).await.unwrap(); + let mut updated = valid_config(); + updated.feature_toggles = Some(HashMap::from([("updated".to_string(), true)])); + let path = state.bundled_config_path.as_ref().unwrap(); + std::fs::write(path, serde_json::to_vec(&updated).unwrap()).unwrap(); + let expected = state.refresh_for_new_chat(&distro).await.unwrap(); + assert_eq!(expect_ready(expected.clone()).1, updated); + std::fs::remove_file(path).unwrap(); + assert_eq!(state.refresh_for_new_chat(&distro).await.unwrap(), expected); + } + + #[cfg(not(feature = "admin-runtime-config"))] + #[tokio::test] + async fn new_chat_source_failure_preserves_operational_app_default() { + let (_dir, state) = temp_state(); + let distro = empty_distro_state(); + let initial = state.get(&distro).await.unwrap(); + assert_eq!( + expect_ready(initial.clone()).0, + RuntimeConfigSource::AppDefault + ); + let result = state.refresh_for_new_chat(&distro).await.unwrap(); + assert_eq!(result, initial); + assert_eq!(state.get(&distro).await.unwrap(), result); + assert_eq!( + state.ready_config(&distro).await.unwrap(), + default_runtime_config() + ); + // Ready defaults keep native operations usable without claiming that + // the source successfully removed any previously configured Auto mode. + assert!(matches!( + result, + RuntimeConfigLoadResult::Ready { + source: RuntimeConfigSource::AppDefault, + .. + } + )); + } + + #[cfg(not(feature = "admin-runtime-config"))] + #[tokio::test] + async fn new_chat_timeout_preserves_operational_app_default() { + let (_dir, state) = temp_state(); + let distro = empty_distro_state(); + let initial = state.get(&distro).await.unwrap(); + assert_eq!( + expect_ready(initial.clone()).0, + RuntimeConfigSource::AppDefault + ); + let result = state + .refresh_for_new_chat_with(std::future::pending(), Duration::from_millis(1)) + .await + .unwrap(); + assert_eq!(result, initial); + assert_eq!( + state.ready_config(&distro).await.unwrap(), + default_runtime_config() + ); + } + + #[tokio::test] + async fn new_chat_refresh_timeout_keeps_native_and_returned_config_equal() { + let (_dir, state) = temp_state(); + let distro = empty_distro_state(); + let expected = state.set_fake_config(valid_config()).unwrap(); + let result = state + .refresh_for_new_chat_with(std::future::pending(), Duration::from_millis(1)) + .await + .unwrap(); + assert_eq!(result, expected); + assert_eq!(state.get(&distro).await.unwrap(), result); + } + + #[cfg(not(feature = "admin-runtime-config"))] + #[tokio::test] + async fn new_chat_source_failure_does_not_restore_cleared_fake_override() { + let (_dir, state) = temp_state(); + let distro = empty_distro_state(); + state.set_fake_config(valid_config()).unwrap(); + state.clear_fake_config(&distro).await.unwrap(); + let result = state.refresh_for_new_chat(&distro).await.unwrap(); + let (source, config) = expect_ready(result); + assert_eq!(source, RuntimeConfigSource::AppDefault); + assert_eq!(config, default_runtime_config()); + assert_eq!(state.ready_config(&distro).await.unwrap(), config); + } + + #[cfg(not(feature = "admin-runtime-config"))] + #[tokio::test] + async fn concurrent_new_chat_failure_does_not_restore_cleared_fake_override() { + let (_dir, state) = temp_state(); + let distro = empty_distro_state(); + state.set_fake_config(valid_config()).unwrap(); + let (_, refreshed) = tokio::join!( + state.clear_fake_config(&distro), + state.refresh_for_new_chat(&distro), + ); + let result = refreshed.unwrap(); + assert!(!matches!( + result, + RuntimeConfigLoadResult::Ready { + source: RuntimeConfigSource::FakeEndpoint, + .. + } + )); + assert_eq!(state.get(&distro).await.unwrap(), result); + } + + #[tokio::test] + async fn new_chat_initial_timeout_stays_unavailable() { + let (_dir, state) = temp_state(); + let result = state + .refresh_for_new_chat_with(std::future::pending(), Duration::from_millis(1)) + .await + .unwrap(); + assert!(matches!( + result, + RuntimeConfigLoadResult::Unavailable { .. } + )); + assert_eq!(state.get(&empty_distro_state()).await.unwrap(), result); + } + + #[tokio::test] + async fn older_refresh_cannot_overwrite_new_fake_override() { + let (_dir, state) = temp_state(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (finish_tx, finish_rx) = tokio::sync::oneshot::channel(); + let stale = RuntimeConfigLoadResult::ready( + RuntimeConfigSource::BundledFile, + default_runtime_config(), + ); + let (result, expected) = tokio::join!( + state.refresh_for_new_chat_with( + async { + started_tx.send(()).unwrap(); + finish_rx.await.unwrap(); + stale + }, + Duration::from_secs(5) + ), + async { + started_rx.await.unwrap(); + let expected = state.set_fake_config(valid_config()).unwrap(); + finish_tx.send(()).unwrap(); + expected + }, + ); + assert_eq!(result.unwrap(), expected); + assert_eq!(state.get(&empty_distro_state()).await.unwrap(), expected); + } + + #[tokio::test] + async fn older_success_remains_usable_while_newer_initial_load_is_pending() { + let (_dir, state) = temp_state(); + let older = + RuntimeConfigLoadResult::ready(RuntimeConfigSource::BundledFile, valid_config()); + let mut newer_config = valid_config(); + newer_config.feature_toggles = Some(HashMap::from([("newer".to_string(), true)])); + let newer = RuntimeConfigLoadResult::ready(RuntimeConfigSource::BundledFile, newer_config); + let (newer_started_tx, newer_started_rx) = tokio::sync::oneshot::channel(); + let (older_finished_tx, older_finished_rx) = tokio::sync::oneshot::channel(); + let (older_result, newer_result) = tokio::join!( + biased; + async { + let result = state + .refresh_for_new_chat_with( + async { + newer_started_rx.await.unwrap(); + older.clone() + }, + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!(result, older); + { + let cached = state.cached.lock().unwrap(); + assert_eq!(cached.generation, 2); + assert!(cached.current.is_none()); + assert!(cached.last_valid.is_none()); + } + older_finished_tx.send(()).unwrap(); + result + }, + state.refresh_for_new_chat_with( + async { + newer_started_tx.send(()).unwrap(); + older_finished_rx.await.unwrap(); + newer.clone() + }, + Duration::from_secs(5), + ), + ); + assert_eq!(older_result, older); + assert_eq!(newer_result.unwrap(), newer); + assert_eq!(state.get(&empty_distro_state()).await.unwrap(), newer); + assert_eq!( + state.cached.lock().unwrap().last_valid.as_ref(), + Some(&newer) + ); + } + + #[tokio::test] + async fn older_startup_load_cannot_replace_new_chat_refresh() { + let (_dir, state) = temp_state(); + // The startup get/explicit refresh use this same reservation/commit + // pair around their source await. + let startup_generation = state.begin_load().unwrap(); + let expected = state + .refresh_for_new_chat_with( + async { + RuntimeConfigLoadResult::ready(RuntimeConfigSource::BundledFile, valid_config()) + }, + Duration::from_secs(5), + ) + .await + .unwrap(); + let result = state + .finish_load( + startup_generation, + default_runtime_config_result(RuntimeConfigSource::AppDefault), + false, + ) + .unwrap(); + assert_eq!(result, expected); + assert_eq!(state.get(&empty_distro_state()).await.unwrap(), expected); + } + #[tokio::test] async fn runtime_config_state_get_caches_until_refresh() { let (_dir, state) = temp_state(); @@ -1673,6 +2393,71 @@ mod tests { assert_eq!(config, default_runtime_config()); } + #[test] + fn auto_routing_validation_matches_frontend_contract() { + let valid = serde_json::json!({ + "url":"https://gateway.example.com/route", "bearerTokenEnv":"DATABRICKS_TOKEN", + "modes":[{"id":"auto","displayName":"Auto","modelProviderId":TEST_RUNTIME_PROVIDER_ID, + "fallback":{"modelId":"served","effort":"high"}, + "models":[{"modelId":"served","routerModel":"system.ai.sol","minEffort":"high","maxEffort":"max"}]}] + }); + let providers = managed_goose_config().model_providers; + let parse = |value: serde_json::Value| { + serde_json::from_value::(value).unwrap() + }; + validate_auto_routing(&parse(valid.clone()), &providers).unwrap(); + let mut invalid = Vec::new(); + for url in [ + "file:///tmp/route", + "https://user:secret@example.com/route", + "https://example.com/route?token=value", + "https://example.com/route#fragment", + ] { + let mut value = valid.clone(); + value["url"] = serde_json::json!(url); + invalid.push(value); + } + for env in ["", "token-value", "lowercase", "1TOKEN"] { + let mut value = valid.clone(); + value["bearerTokenEnv"] = serde_json::json!(env); + invalid.push(value); + } + let mut value = valid.clone(); + value["timeoutMs"] = serde_json::json!(0); + invalid.push(value); + let mut value = valid.clone(); + value["defaultModeId"] = serde_json::json!("missing"); + invalid.push(value); + let mut value = valid.clone(); + value["modes"][0]["modelProviderId"] = serde_json::json!("missing"); + invalid.push(value); + let mut value = valid.clone(); + value["modes"][0]["fallback"]["modelId"] = serde_json::json!("missing"); + invalid.push(value); + let mut value = valid.clone(); + value["modes"][0]["fallback"]["effort"] = serde_json::json!("low"); + invalid.push(value); + let mut value = valid.clone(); + value["modes"][0]["models"][0]["minEffort"] = serde_json::json!("invented"); + invalid.push(value); + let mut value = valid.clone(); + value["modes"][0]["models"][0]["maxEffort"] = serde_json::json!("low"); + invalid.push(value); + let mut value = valid.clone(); + value["routeSelector"] = + serde_json::json!({"router_name":"task_v3","config":{"nested":{"api_key":"secret"}}}); + invalid.push(value); + let mut value = valid.clone(); + value["modes"] = serde_json::json!([valid["modes"][0], valid["modes"][0]]); + invalid.push(value); + for value in invalid { + assert!( + validate_auto_routing(&parse(value.clone()), &providers).is_err(), + "unexpected valid config: {value}" + ); + } + } + #[test] fn validate_goose_config_rejects_invalid_shapes() { validate_goose_config(&default_goose_config()).unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32d22b38c..78f44898d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -637,10 +637,12 @@ pub fn run() { commands::diagnostics::probe_kgoose_connectivity, commands::diagnostics::write_diagnostic_event, commands::distro::get_distro_bundle, + commands::auto_routing::route_auto_model, commands::runtime_config::get_runtime_config, commands::runtime_config::set_fake_runtime_config, commands::runtime_config::clear_fake_runtime_config, commands::runtime_config::refresh_runtime_config, + commands::runtime_config::refresh_runtime_config_for_new_chat, commands::security_threshold::get_security_threshold, commands::security_threshold::set_security_threshold, commands::system::get_home_dir, diff --git a/src-tauri/src/services/log_export.rs b/src-tauri/src/services/log_export.rs index 286f1549c..e08641a36 100644 --- a/src-tauri/src/services/log_export.rs +++ b/src-tauri/src/services/log_export.rs @@ -118,7 +118,10 @@ pub(crate) fn build_logs_zip( let Some(filename) = file_name_str(&path) else { continue; }; - let raw = read_tail(&path, &mut budget)?; + // Older Auto builds logged opaque router bodies, including arbitrary + // multiline task content. Exclude the entire file: a tail may start + // inside that body, and response text can imitate a log header. + let raw = read_app_log_tail(&path, &mut budget)?; let sanitized = sanitize_app_log(&raw); let filtered = filter_by_recency(&sanitized, ParserKind::ShellBracketed, cutoff); if filtered.is_empty() { @@ -329,23 +332,46 @@ fn is_hard_excluded(name: &str) -> bool { /// `budget`) from the END of the file, decrementing `budget` by what was read. /// When the read starts mid-file, the leading partial line is trimmed. fn read_tail(path: &Path, budget: &mut u64) -> Result, String> { + let file = std::fs::File::open(path) + .map_err(|error| format!("Failed to open log {}: {error}", path.display()))?; + let total = file + .metadata() + .map_err(|error| format!("Failed to stat log {}: {error}", path.display()))? + .len(); + read_tail_snapshot(file, path, total, budget) +} + +fn read_app_log_tail(path: &Path, budget: &mut u64) -> Result, String> { let mut file = std::fs::File::open(path) .map_err(|error| format!("Failed to open log {}: {error}", path.display()))?; let total = file .metadata() .map_err(|error| format!("Failed to stat log {}: {error}", path.display()))? .len(); + if contains_legacy_router_response(&mut std::io::Read::by_ref(&mut file).take(total)) + .map_err(|error| format!("Failed to inspect log {}: {error}", path.display()))? + { + return Ok(Vec::new()); + } + // Use the scanned handle and length, even if rotation replaces the path or + // another app appends a legacy record before this read completes. + read_tail_snapshot(file, path, total, budget) +} +fn read_tail_snapshot( + mut file: std::fs::File, + path: &Path, + total: u64, + budget: &mut u64, +) -> Result, String> { let read_len = total.min(PER_FILE_TAIL_BYTES).min(*budget); if read_len == 0 { return Ok(Vec::new()); } let start = total - read_len; - if start > 0 { - file.seek(SeekFrom::Start(start)) - .map_err(|error| format!("Failed to seek log {}: {error}", path.display()))?; - } + file.seek(SeekFrom::Start(start)) + .map_err(|error| format!("Failed to seek log {}: {error}", path.display()))?; let mut buffer = Vec::with_capacity(read_len as usize); file.take(read_len) @@ -364,6 +390,29 @@ fn read_tail(path: &Path, budget: &mut u64) -> Result, String> { Ok(buffer) } +/// Scan the whole file with bounded memory before tailing. The marker can sit +/// outside the exported tail or span a read boundary. +fn contains_legacy_router_response(reader: &mut impl Read) -> std::io::Result { + const MARKER: &[u8] = b"Router response for mode "; + let mut buffer = [0_u8; 8192]; + let mut retained = 0; + loop { + let count = reader.read(&mut buffer[retained..])?; + if count == 0 { + return Ok(false); + } + let end = retained + count; + if buffer[..end] + .windows(MARKER.len()) + .any(|bytes| bytes == MARKER) + { + return Ok(true); + } + retained = end.min(MARKER.len() - 1); + buffer.copy_within(end - retained..end, 0); + } +} + /// Apply [`sanitize_app_log_line`] to every line of a shell-log chunk, dropping /// captured sidecar lines and redacting secrets from the rest. fn sanitize_app_log(raw: &[u8]) -> Vec { @@ -581,6 +630,73 @@ mod tests { assert!(out.contains("[INFO] ready")); } + #[test] + fn feedback_excludes_legacy_router_files_even_when_header_is_outside_tail() { + let temp = tempfile::tempdir().unwrap(); + let app_log_dir = temp.path().join("app"); + let goose_state_logs_dir = temp.path().join("goose"); + fs::create_dir_all(&app_log_dir).unwrap(); + let unsafe_log = app_log_dir.join("berd.log"); + let mut contents = "x".repeat(8180); + contents.push_str("Router response for mode auto: {\n"); + contents.push_str(&" ".repeat(PER_FILE_TAIL_BYTES as usize)); + contents.push_str("[2099-01-01][00:00:00][INFO] quoted task contents\n}\n"); + fs::write(&unsafe_log, contents).unwrap(); + let mut budget = MAX_TOTAL_RAW_BYTES; + let tail = read_tail(&unsafe_log, &mut budget).unwrap(); + assert!(!String::from_utf8_lossy(&tail).contains("Router response for mode")); + assert!(read_app_log_tail(&unsafe_log, &mut budget) + .unwrap() + .is_empty()); + fs::write( + app_log_dir.join("berd.1.log"), + "[2099-01-01][00:00:00][INFO] safe transport metadata\n", + ) + .unwrap(); + let zip = build_logs_zip( + Some(&LogDirs { + app_log_dir, + goose_state_logs_dir, + }), + None, + ) + .unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).unwrap(); + assert!(archive.by_name("app-shell/berd.log").is_err()); + assert!(archive.by_name("app-shell/berd.1.log").is_ok()); + } + + #[test] + fn app_log_tail_uses_scanned_handle_and_length_across_append_and_rotation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("berd.log"); + let safe = b"[2099-01-01][00:00:00][INFO] safe metadata\n"; + fs::write(&path, safe).unwrap(); + let mut file = fs::File::open(&path).unwrap(); + let length = file.metadata().unwrap().len(); + assert!(!contains_legacy_router_response( + &mut std::io::Read::by_ref(&mut file).take(length) + ) + .unwrap()); + fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap() + .write_all(b"Router response for mode auto: private task\n") + .unwrap(); + fs::rename(&path, temp.path().join("berd.1.log")).unwrap(); + fs::write( + &path, + b"Router response for mode auto: another private task\n", + ) + .unwrap(); + let mut budget = MAX_TOTAL_RAW_BYTES; + assert_eq!( + read_tail_snapshot(file, &path, length, &mut budget).unwrap(), + safe + ); + } + #[test] fn read_tail_returns_recent_bytes_on_line_boundary() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index f848e790b..190dec512 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -1,3 +1,4 @@ +import { usePendingAutoModeStore } from "@/features/chat/lib/autoModelSelection"; import * as remoteSession from "@/features/chat/lib/remoteSession"; import { REMOTE_SSH_SESSIONS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; import { getModelSelectionIntent } from "@/features/chat/model-selection/modelSelectionIntent"; @@ -93,6 +94,11 @@ import { import type { NavigationPanesViewProps } from "@/app/views/NavigationPanesView"; import type { AppShellContent as AppShellContentType } from "./ui/AppShellContent"; +const mockNewChatRefresh = vi.hoisted(() => vi.fn()); +vi.mock("@/shared/runtime-config/newChatRuntimeConfig", () => ({ + refreshRuntimeConfigForNewChat: () => mockNewChatRefresh(), +})); + const mockAcpCreateSession = vi.hoisted(() => vi.fn()); const mockAcpPrepareSession = vi.hoisted(() => vi.fn()); const mockAcpSetSessionConfigOption = vi.hoisted(() => vi.fn()); @@ -974,6 +980,10 @@ describe("AppShell global navigation", () => { afterEach(cleanup); beforeEach(() => { + usePendingAutoModeStore.setState({ modeId: undefined }); + mockNewChatRefresh + .mockReset() + .mockImplementation(async () => useRuntimeConfigStore.getState().result); dispatchOnboarding({ type: "complete" }); resetHomeWidgetStoreForTests(); resetStarterWidgetPickerRequestForTests(); @@ -1163,6 +1173,77 @@ describe("AppShell global navigation", () => { setReadyRuntimeConfig(); }); + it("awaits fresh Auto defaults for each new chat and refreshes a reused draft", async () => { + const configFor = (modeId: string): RuntimeConfig => ({ + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + defaultModeId: modeId, + modes: [ + { + id: modeId, + displayName: modeId, + modelProviderId: "openai", + fallback: { modelId: `${modeId}-model`, effort: "medium" }, + models: [{ modelId: `${modeId}-model`, routerModel: modeId }], + }, + ], + }, + }, + }); + mockCheckAllProviderStatus.mockResolvedValue([ + { providerId: "openai", isConfigured: true }, + ]); + setReadyRuntimeConfig(configFor("old")); + const refresh = deferred(); + mockNewChatRefresh.mockImplementationOnce(async () => { + await refresh.promise; + setReadyRuntimeConfig(configFor("fresh")); + return useRuntimeConfigStore.getState().result; + }); + const user = userEvent.setup(); + renderAppShell(); + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + expect(mockNewChatRefresh).toHaveBeenCalledTimes(1); + expect(mockAcpCreateSession).not.toHaveBeenCalled(); + expect(useChatSessionStore.getState().sessions).toHaveLength(0); + await act(async () => refresh.resolve()); + await waitFor(() => + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "openai", + expect.any(String), + expect.objectContaining({ modelId: "fresh-model" }), + ), + ); + await waitFor(() => + expect(useChatSessionStore.getState().activeSessionId).toBe( + "created-session", + ), + ); + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + expect(mockNewChatRefresh).toHaveBeenCalledTimes(2); + expect(mockAcpCreateSession).toHaveBeenCalledTimes(1); + mockNewChatRefresh.mockImplementationOnce(async () => { + setReadyRuntimeConfig(configFor("next")); + return useRuntimeConfigStore.getState().result; + }); + mockAcpCreateSession.mockResolvedValueOnce({ sessionId: "next-session" }); + await user.click(screen.getByRole("button", { name: "Sidebar new chat" })); + await waitFor(() => + expect(mockAcpCreateSession).toHaveBeenCalledWith( + "openai", + expect.any(String), + expect.objectContaining({ modelId: "next-model" }), + ), + ); + expect(mockNewChatRefresh).toHaveBeenCalledTimes(3); + expect( + useChatSessionStore.getState().getSession("created-session")?.autoModeId, + ).toBe("fresh"); + }); + it("starts a full blank chat from the sidebar new chat action", async () => { const user = userEvent.setup(); const { container } = renderAppShell(); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index ad0afaf8c..ca217468f 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1,3 +1,9 @@ +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { refreshRuntimeConfigForNewChat } from "@/shared/runtime-config/newChatRuntimeConfig"; +import { + getSelectedAutoModeId, + getAutoModeExecutionTarget, +} from "@/features/chat/lib/autoModelSelection"; import { projectEnvironment } from "@/features/projects/lib/projectEnvironment"; import { useCallback, @@ -319,6 +325,8 @@ type DraftSessionCreationReady = { >["configOptionsSnapshot"]; }; type ProjectChatDraftOptions = { + autoModeId?: string | null; + useDefaultAutoMode?: boolean; remoteHost?: string; remoteWorkingDir?: string; executionTarget?: SessionExecutionTarget; @@ -2272,6 +2280,8 @@ export function AppShell({ options: { activate?: boolean; reuseExistingDraft?: boolean; + autoModeId?: string | null; + useDefaultAutoMode?: boolean; executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; /** SSH host to run the session's backend on. */ @@ -2300,8 +2310,20 @@ export function AppShell({ perfLog( `[perf:newtab] createNewTab start (project=${sessionProject?.id ?? "none"})`, ); - const sessionExecutionTarget = - await resolveSessionCreationTarget(options); + await refreshRuntimeConfigForNewChat(); + const autoModeId = options.useDefaultAutoMode + ? useRuntimeConfigStore.getState().config.goose.autoRouting + ?.defaultModeId + : options.autoModeId !== undefined + ? options.autoModeId + : options.executionTarget + ? null + : getSelectedAutoModeId(); + const sessionExecutionTarget = await resolveSessionCreationTarget({ + ...options, + executionTarget: + getAutoModeExecutionTarget(autoModeId) ?? options.executionTarget, + }); if (!sessionExecutionTarget) return undefined; const sessionState = useChatSessionStore.getState(); const chatState = useChatStore.getState(); @@ -2317,6 +2339,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, reasoningEffortValue: options.reasoningEffort?.value, remoteHost, }, @@ -2358,6 +2381,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir, remoteHost, }); @@ -2377,6 +2401,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir: optimisticWorkingDir, remoteHost, }); @@ -2519,8 +2544,20 @@ export function AppShell({ perfLog( `[perf:newtab] createNewProjectDraft start (project=${project.id})`, ); - const sessionExecutionTarget = - await resolveSessionCreationTarget(options); + await refreshRuntimeConfigForNewChat(); + const autoModeId = options.useDefaultAutoMode + ? useRuntimeConfigStore.getState().config.goose.autoRouting + ?.defaultModeId + : options.autoModeId !== undefined + ? options.autoModeId + : options.executionTarget + ? null + : getSelectedAutoModeId(); + const sessionExecutionTarget = await resolveSessionCreationTarget({ + ...options, + executionTarget: + getAutoModeExecutionTarget(autoModeId) ?? options.executionTarget, + }); if (!sessionExecutionTarget) return undefined; const sessionState = useChatSessionStore.getState(); const chatState = useChatStore.getState(); @@ -2539,6 +2576,7 @@ export function AppShell({ title, projectId: project.id, executionTarget: sessionExecutionTarget, + autoModeId, reasoningEffortValue: options.reasoningEffort?.value, }, allowDraftReuse: options.reuseExistingDraft !== false && !needsStartup, @@ -2561,6 +2599,7 @@ export function AppShell({ title, projectId: project.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir: getOptimisticSessionCwd(project), workspaceAttachments: needsStartup ? asIs?.workspaceAttachments.filter( @@ -2603,6 +2642,8 @@ export function AppShell({ title = DEFAULT_CHAT_TITLE, project?: ProjectInfo, options: { + autoModeId?: string | null; + useDefaultAutoMode?: boolean; executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; remoteHost?: string; @@ -2626,8 +2667,20 @@ export function AppShell({ perfLog( `[perf:newtab] createBackgroundDraftChat start (project=${sessionProject?.id ?? "none"})`, ); - const sessionExecutionTarget = - await resolveSessionCreationTarget(options); + await refreshRuntimeConfigForNewChat(); + const autoModeId = options.useDefaultAutoMode + ? useRuntimeConfigStore.getState().config.goose.autoRouting + ?.defaultModeId + : options.autoModeId !== undefined + ? options.autoModeId + : options.executionTarget + ? null + : getSelectedAutoModeId(); + const sessionExecutionTarget = await resolveSessionCreationTarget({ + ...options, + executionTarget: + getAutoModeExecutionTarget(autoModeId) ?? options.executionTarget, + }); if (!sessionExecutionTarget) return undefined; const sessionState = useChatSessionStore.getState(); const chatState = useChatStore.getState(); @@ -2641,6 +2694,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, reasoningEffortValue: options.reasoningEffort?.value, remoteHost, }, @@ -2669,6 +2723,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir: optimisticWorkingDir, remoteHost, }); @@ -3078,6 +3133,8 @@ export function AppShell({ } const chatOptions = { + autoModeId: options?.autoModeId, + useDefaultAutoMode: options?.useDefaultAutoMode, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, remoteHost: options?.remoteHost, @@ -3319,6 +3376,8 @@ export function AppShell({ ? projects.find((candidate) => candidate.id === options.projectId) : undefined; const chatOptions = { + autoModeId: options?.autoModeId, + useDefaultAutoMode: options?.useDefaultAutoMode, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, remoteHost: options?.remoteHost, @@ -3432,6 +3491,8 @@ export function AppShell({ // The realtime runtime follows the draft id through promotion. activate: realtimeMode, reuseExistingDraft: false, + autoModeId: options?.autoModeId, + useDefaultAutoMode: options?.useDefaultAutoMode, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, remoteHost: options?.remoteHost, diff --git a/src/features/berdctl/__tests__/commands/commands.test.ts b/src/features/berdctl/__tests__/commands/commands.test.ts index b0cb3eb25..fa81e6af1 100644 --- a/src/features/berdctl/__tests__/commands/commands.test.ts +++ b/src/features/berdctl/__tests__/commands/commands.test.ts @@ -1,3 +1,6 @@ +import { usePendingAutoModeStore } from "@/features/chat/lib/autoModelSelection"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -40,6 +43,7 @@ import type { AcpSessionInfo, AcpSessionsPage } from "@/shared/api/acp"; import { createUserMessage, getTextContent } from "@/shared/types/messages"; const mocks = vi.hoisted(() => ({ + refreshNewChat: vi.fn(), acpCreateSession: vi.fn(), acpDuplicateSession: vi.fn(), acpGetSessionInfo: vi.fn(), @@ -77,6 +81,10 @@ const mocks = vi.hoisted(() => ({ getVoiceConversationStatus: vi.fn(), })); +vi.mock("@/shared/runtime-config/newChatRuntimeConfig", () => ({ + refreshRuntimeConfigForNewChat: () => mocks.refreshNewChat(), +})); + vi.mock("@/shared/api/acp", () => ({ acpCreateSession: (...args: unknown[]) => mocks.acpCreateSession(...args), acpDuplicateSession: (...args: unknown[]) => @@ -354,6 +362,9 @@ async function expectCommandError( } beforeEach(() => { + usePendingAutoModeStore.setState({ modeId: undefined }); + useRuntimeConfigStore.setState({ config: DEFAULT_RUNTIME_CONFIG }); + mocks.refreshNewChat.mockReset().mockResolvedValue(undefined); resetSessionTargetCoordinatorsForTests(); localStorage.removeItem("goose:chat-workspace-metadata"); useChatSessionStore.setState({ @@ -700,6 +711,117 @@ describe("command safety metadata", () => { }); describe("sessions.create", () => { + it("preserves an explicit persona instead of applying the refreshed Auto default", async () => { + mocks.listPersonas.mockResolvedValue([ + { + id: "agent-7", + displayName: "Reviewer", + systemPrompt: "Review carefully.", + provider: "goose", + modelProviderId: "openai", + model: "persona-model", + isBuiltin: false, + writable: true, + }, + ]); + mocks.refreshNewChat.mockImplementationOnce(async () => { + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + defaultModeId: "fresh", + modes: [ + { + id: "fresh", + displayName: "Fresh", + modelProviderId: "openai", + fallback: { modelId: "auto-model", effort: "medium" }, + models: [{ modelId: "auto-model", routerModel: "auto" }], + }, + ], + }, + }, + }, + }); + }); + await dispatchCommand( + "sessions", + { action: "create", prompt: "Review this", agent_id: "agent-7" }, + ctx, + ); + expect(mocks.refreshNewChat).toHaveBeenCalledOnce(); + expect(mocks.acpCreateSession).toHaveBeenCalledWith( + "goose", + "/resolved/cwd", + expect.objectContaining({ personaId: "agent-7", modelId: undefined }), + ); + expect( + useChatSessionStore.getState().getSession("session-new")?.autoModeId, + ).toBeNull(); + }); + + it.each([ + null, + "unrelated-ui-mode", + ])("waits for refreshed defaults and ignores pending UI mode %s before its first prompt", async (pendingUiMode) => { + usePendingAutoModeStore.setState({ modeId: pendingUiMode }); + let releaseRefresh = () => {}; + mocks.refreshNewChat.mockImplementationOnce(async () => { + await new Promise((resolve) => { + releaseRefresh = resolve; + }); + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + defaultModeId: "fresh", + modes: [ + { + id: "fresh", + displayName: "Fresh", + modelProviderId: "openai", + fallback: { modelId: "fresh-model", effort: "medium" }, + models: [{ modelId: "fresh-model", routerModel: "fresh" }], + }, + ], + }, + }, + }, + }); + }); + const pending = dispatchCommand( + "sessions", + { action: "create", prompt: "first prompt" }, + ctx, + ); + await vi.waitFor(() => + expect(mocks.refreshNewChat).toHaveBeenCalledTimes(1), + ); + expect(mocks.acpCreateSession).not.toHaveBeenCalled(); + expect( + useChatStore.getState().queuedMessageBySession["session-new"], + ).toBeUndefined(); + releaseRefresh(); + await pending; + expect(mocks.acpCreateSession).toHaveBeenCalledWith( + "openai", + "/resolved/cwd", + expect.objectContaining({ modelId: "fresh-model" }), + ); + expect( + useChatSessionStore.getState().getSession("session-new")?.autoModeId, + ).toBe("fresh"); + expect( + useChatStore.getState().queuedMessageBySession["session-new"]?.[0], + ).toMatchObject({ payload: { text: "first prompt" } }); + }); + it("creates the session and leaves the accepted first prompt in the shared queue", async () => { seedModelCache("databricks_v2", ["model-9"]); mocks.listPersonas.mockResolvedValue([ diff --git a/src/features/berdctl/commands/impl/createSession.ts b/src/features/berdctl/commands/impl/createSession.ts index dfebbf343..c1ebab2fb 100644 --- a/src/features/berdctl/commands/impl/createSession.ts +++ b/src/features/berdctl/commands/impl/createSession.ts @@ -95,6 +95,9 @@ Result: bridgeTimeoutMs: 900_000, execute: async (args, ctx): Promise => { const [ + { refreshRuntimeConfigForNewChat }, + { getAutoModeExecutionTarget }, + { useRuntimeConfigStore }, { acceptFirstSend }, { useChatSessionStore }, { resolveSessionCwd }, @@ -111,6 +114,9 @@ Result: { findProjectOrThrow }, { findReadyHarnessOrThrow, gooseModelOptions, harnessModelOptions }, ] = await Promise.all([ + import("@/shared/runtime-config/newChatRuntimeConfig"), + import("@/features/chat/lib/autoModelSelection"), + import("@/shared/runtime-config/runtimeConfigStore"), import("@/features/chat/lib/firstWorkspaceSend"), import("@/features/chat/stores/chatSessionStore"), import("@/features/projects/lib/sessionCwdSelection"), @@ -122,6 +128,12 @@ Result: import("../runtime/projects"), import("../runtime/providers"), ]); + await refreshRuntimeConfigForNewChat(); + const autoModeId = + args.harness_id || args.model_id || args.agent_id + ? null + : useRuntimeConfigStore.getState().config.goose.autoRouting + ?.defaultModeId; const harnessId = args.harness_id ?? GOOSE_PROVIDER_ID; // The validation legs are independent I/O; overlap them. const [project, , models, persona] = await Promise.all([ @@ -164,7 +176,8 @@ Result: modelId: args.model_id, modelName: args.model_id, }) - : normalizeSessionExecutionTarget({ harnessId }); + : (getAutoModeExecutionTarget(autoModeId) ?? + normalizeSessionExecutionTarget({ harnessId })); const requiresStartupName = Boolean( project && projectRequiresStartupWorkspaceName(project), ); @@ -208,6 +221,7 @@ Result: workingDir, projectId: args.project_id, executionTarget, + autoModeId, personaId: persona?.id, workspaceAttachments: workspacePlan?.workspaceAttachments, deferProviderSetup: false, diff --git a/src/features/berdctl/commands/runtime/sessionSend.test.ts b/src/features/berdctl/commands/runtime/sessionSend.test.ts index 864ca273e..8a2b3a4e0 100644 --- a/src/features/berdctl/commands/runtime/sessionSend.test.ts +++ b/src/features/berdctl/commands/runtime/sessionSend.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { routeAutoModel } from "@/shared/api/autoRouting"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; import { clearMessageTracking, @@ -36,7 +38,13 @@ const mocks = vi.hoisted(() => ({ listSkills: vi.fn(), })); +vi.mock("@/shared/api/autoRouting", async (importOriginal) => ({ + ...(await importOriginal()), + routeAutoModel: vi.fn(), +})); + vi.mock("@/shared/api/acp", () => ({ + acpGetPreparedWorkingDir: () => "/tmp/project", acpGetSessionInfo: (...args: unknown[]) => mocks.acpGetSessionInfo(...args), acpLoadSession: (...args: unknown[]) => mocks.acpLoadSession(...args), acpPrepareSession: (...args: unknown[]) => { @@ -181,6 +189,60 @@ describe("sendPromptToExistingSessionInBackground", () => { mocks.listSkills.mockResolvedValue([]); }); + it("routes a berdctl Auto send under its own lease and releases it after dispatch", async () => { + useChatSessionStore + .getState() + .patchSession(SESSION_ID, { autoModeId: "auto" }); + useRuntimeConfigStore.setState({ + config: { + schemaVersion: 1, + goose: { + modelProviders: [], + autoRouting: { + url: "https://example.com/route", + modes: [ + { + id: "auto", + displayName: "Auto", + modelProviderId: "databricks_v2", + fallback: { modelId: UPDATED_TARGET.modelId, effort: "high" }, + models: [ + { + modelId: UPDATED_TARGET.modelId, + routerModel: "router-model", + }, + ], + }, + ], + }, + }, + }, + }); + vi.mocked(routeAutoModel).mockResolvedValue({ + modeId: "auto", + source: "router", + modelProviderId: "databricks_v2", + modelId: UPDATED_TARGET.modelId, + effort: "high", + }); + try { + await sendPromptToExistingSessionInBackground(SESSION_ID, "auto task"); + expect(routeAutoModel).toHaveBeenCalledTimes(1); + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + expect( + useChatSessionStore.getState().getSession(SESSION_ID) + ?.autoRoutingDecision?.modelId, + ).toBe(UPDATED_TARGET.modelId); + const nextLease = acquireSessionDispatchTarget(SESSION_ID); + expect(nextLease.status).toBe("acquired"); + if (nextLease.status === "acquired") nextLease.release(); + } finally { + useRuntimeConfigStore.setState({ + config: { schemaVersion: 1, goose: { modelProviders: [] } }, + }); + } + }, 1500); + it("buffers a first-load ACP replay before appending a berd-monitor prompt", async () => { let sessionWasLoaded = false; const replayLoadingStates: boolean[] = []; diff --git a/src/features/berdctl/commands/runtime/sessionSend.ts b/src/features/berdctl/commands/runtime/sessionSend.ts index 7a1e7f4b9..8829e0ec7 100644 --- a/src/features/berdctl/commands/runtime/sessionSend.ts +++ b/src/features/berdctl/commands/runtime/sessionSend.ts @@ -158,6 +158,7 @@ export async function sendPromptToExistingSessionInBackground( persona, { ...(options.sendOptions ?? berdctlCrossSessionSendOptions()), + sessionSelectionToken: targetLease.token, systemPrompt: session ? formatIncludedWorkspacesPrompt(session) : undefined, diff --git a/src/features/chat/capabilities/ConversationComposerCapability.tsx b/src/features/chat/capabilities/ConversationComposerCapability.tsx index d993bd985..cd88aacd0 100644 --- a/src/features/chat/capabilities/ConversationComposerCapability.tsx +++ b/src/features/chat/capabilities/ConversationComposerCapability.tsx @@ -375,6 +375,7 @@ export function ConversationComposerCapability({ onPersonaChange: controller.handlePersonaChange, }} agentModelPicker={{ + autoRouting: controller.autoRouting, providers: controller.pickerAgents, providersLoading: controller.providersLoading, selectedProvider: controller.selectedProvider, diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index b3811d6a1..6242a1a95 100644 --- a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts +++ b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts @@ -4877,6 +4877,23 @@ describe("useChatSessionController", () => { expect(result.current.currentModelName).toBe("Claude Sonnet 4"); }); + it.each([ + { provider: "goose", modelProviderId: "openai", model: "gpt-4o" }, + { provider: "codex-acp", model: undefined }, + ])("exits Auto when a persona pins its execution target: %j", (target) => { + useChatSessionStore + .getState() + .patchSession("session-1", { autoModeId: "balanced" }); + useAgentStore.setState({ personas: [personaFixture(target)] }); + const { result } = renderHook(() => + useChatSessionController({ sessionId: "session-1" }), + ); + act(() => result.current.handlePersonaChange("persona-1")); + expect( + useChatSessionStore.getState().getSession("session-1")?.autoModeId, + ).toBeNull(); + }); + it("applies a persona's provider-only target exactly", async () => { useProviderCatalogStore.getState().mergeEntries([ { @@ -5297,6 +5314,9 @@ describe("useChatSessionController", () => { }); it("leaves the current target alone when a legacy persona model cannot resolve", async () => { + useChatSessionStore + .getState() + .patchSession("session-1", { autoModeId: "balanced" }); useAgentStore.setState({ personas: [ personaFixture({ @@ -5328,6 +5348,7 @@ describe("useChatSessionController", () => { useChatSessionStore.getState().getSession("session-1"), ).toMatchObject({ personaId: "persona-1", + autoModeId: "balanced", executionTarget: { harnessId: "goose", modelProviderId: "openai", @@ -6901,6 +6922,9 @@ describe("useChatSessionController", () => { first = result.current.handleSend("hello remote"); second = result.current.handleSend("hello remote"); }); + await waitFor(() => + expect(mockEnsureRemoteHostConnected).toHaveBeenCalled(), + ); await act(async () => { releaseConnect(); expect(await first).toBe(true); diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index e462d1aa2..3bc22e672 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -1,3 +1,11 @@ +import { beginModelSelectionIntent } from "../../model-selection/modelSelectionIntent"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; +import { + getAutoModeExecutionTarget, + getSelectedAutoModeId, + usePendingAutoModeStore, +} from "../../lib/autoModelSelection"; import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; @@ -93,6 +101,8 @@ vi.mock("sonner", () => ({ describe("useResolvedAgentModelPicker", () => { beforeEach(() => { vi.clearAllMocks(); + useRuntimeConfigStore.setState({ config: DEFAULT_RUNTIME_CONFIG }); + usePendingAutoModeStore.setState({ modeId: undefined }); mockPrepareSession.mockResolvedValue(undefined); resetSessionTargetCoordinatorsForTests(); window.localStorage.clear(); @@ -163,6 +173,373 @@ describe("useResolvedAgentModelPicker", () => { ); }); + it("switches an auto choice from a foreign harness to the mode's concrete Goose fallback", async () => { + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + modes: [ + { + id: "balanced", + displayName: "Auto Balanced", + modelProviderId: "openai", + fallback: { modelId: "gpt-5", effort: "medium" }, + models: [{ modelId: "gpt-5", routerModel: "gpt-5" }], + }, + ], + }, + }, + }, + }); + const { useAgentModelPickerState } = await vi.importActual< + typeof import("../useAgentModelPickerState") + >("../useAgentModelPickerState"); + mockUseAgentModelPickerState.mockImplementation(useAgentModelPickerState); + mockAgentStatus.mockReturnValue({ + readyAgentIds: new Set(["goose", "codex-acp"]), + agentReadiness: new Map(), + refresh: vi.fn(), + }); + const session = makeSession( + { harnessId: "codex-acp" }, + { creationState: "pending" }, + ); + useChatSessionStore.setState({ sessions: [session] }); + const { result } = renderModelPicker({ + session, + selectedProvider: "codex-acp", + }); + act(() => result.current.autoRouting.onModeChange("balanced")); + expect(useChatSessionStore.getState().getSession(session.id)).toMatchObject( + { + autoModeId: "balanced", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-5", + }, + }, + ); + }); + + it("exits auto when the user explicitly picks the concrete model already in use", async () => { + const { useAgentModelPickerState } = await vi.importActual< + typeof import("../useAgentModelPickerState") + >("../useAgentModelPickerState"); + mockUseAgentModelPickerState.mockImplementation(useAgentModelPickerState); + mockAgentStatus.mockReturnValue({ + readyAgentIds: new Set(["goose"]), + agentReadiness: new Map(), + refresh: vi.fn(), + }); + const session = makeSession( + { + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-5", + modelName: "GPT-5", + }, + { autoModeId: "balanced" }, + ); + useChatSessionStore.setState({ sessions: [session] }); + const applySessionModelSelection = vi.fn(); + const { result } = renderModelPicker({ + session, + applySessionModelSelection, + }); + act(() => + result.current.handleModelChange("gpt-5", { + id: "gpt-5", + name: "GPT-5", + providerId: "openai", + }), + ); + expect( + useChatSessionStore.getState().getSession(session.id)?.autoModeId, + ).toBeNull(); + expect(applySessionModelSelection).not.toHaveBeenCalled(); + }); + + it("supersedes a deferred foreign-harness choice when auto uses the active concrete model", async () => { + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + modes: [ + { + id: "balanced", + displayName: "Auto Balanced", + modelProviderId: "openai", + fallback: { modelId: "gpt-5", effort: "medium" }, + models: [{ modelId: "gpt-5", routerModel: "gpt-5" }], + }, + ], + }, + }, + }, + }); + const { useAgentModelPickerState } = await vi.importActual< + typeof import("../useAgentModelPickerState") + >("../useAgentModelPickerState"); + mockUseAgentModelPickerState.mockImplementation(useAgentModelPickerState); + mockAgentStatus.mockReturnValue({ + readyAgentIds: new Set(["goose", "claude-acp"]), + agentReadiness: new Map(), + refresh: vi.fn(), + }); + const session = makeSession({ + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-5", + modelName: "GPT-5", + }); + useChatSessionStore.setState({ sessions: [session] }); + const lease = acquireSessionDispatchTarget(session.id); + beginModelSelectionIntent(session.id, { + requestId: "foreign-choice", + target: { + harnessId: "claude-acp", + modelProviderId: "anthropic", + modelId: "opus", + modelName: "Opus", + }, + }); + const { result } = renderModelPicker({ + session, + applySessionModelSelection: vi.fn(() => new Promise(() => {})), + }); + act(() => result.current.autoRouting.onModeChange("balanced")); + expect(getSessionTargetSelection(session.id)?.target).toMatchObject({ + harnessId: "goose", + modelId: "gpt-5", + }); + expect( + useChatSessionStore.getState().getSession(session.id)?.autoModeId, + ).toBe("balanced"); + if (lease.status === "acquired") lease.release(); + }); + + it.each([ + undefined, + "balanced", + ])("enables Auto from home when its fallback is already selected (default: %s)", async (defaultModeId) => { + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + defaultModeId, + modes: [ + { + id: "balanced", + displayName: "Auto Balanced", + modelProviderId: "openai", + fallback: { modelId: "gpt-5", effort: "medium" }, + models: [{ modelId: "gpt-5", routerModel: "gpt-5" }], + }, + ], + }, + }, + }, + }); + const { useAgentModelPickerState } = await vi.importActual< + typeof import("../useAgentModelPickerState") + >("../useAgentModelPickerState"); + mockUseAgentModelPickerState.mockImplementation(useAgentModelPickerState); + mockAgentStatus.mockReturnValue({ + readyAgentIds: new Set(["goose"]), + agentReadiness: new Map(), + refresh: vi.fn(), + }); + const { result } = renderModelPicker({ + sessionId: null, + session: undefined, + pendingModelSelection: { + id: "gpt-5", + name: "GPT-5", + modelProviderId: "openai", + source: "explicit", + }, + }); + act(() => result.current.autoRouting.onModeChange("balanced")); + expect(getSelectedAutoModeId()).toBe("balanced"); + }); + + it.each([ + undefined, + "previous-auto", + ])("restores prior Auto intent after fallback activation fails (previous: %s)", async (previousModeId) => { + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + modes: [ + { + id: "balanced", + displayName: "Auto Balanced", + modelProviderId: "openai", + fallback: { modelId: "gpt-5", effort: "medium" }, + models: [{ modelId: "gpt-5", routerModel: "gpt-5" }], + }, + ], + }, + }, + }, + }); + const { useAgentModelPickerState } = await vi.importActual< + typeof import("../useAgentModelPickerState") + >("../useAgentModelPickerState"); + mockUseAgentModelPickerState.mockImplementation(useAgentModelPickerState); + mockAgentStatus.mockReturnValue({ + readyAgentIds: new Set(["goose", "codex-acp"]), + agentReadiness: new Map(), + refresh: vi.fn(), + }); + const session = makeSession( + { harnessId: "codex-acp" }, + { autoModeId: previousModeId }, + ); + useChatSessionStore.setState({ sessions: [session] }); + const applySessionModelSelection = vi + .fn() + .mockRejectedValueOnce(new Error("activation failed")) + .mockResolvedValue(true); + const { result } = renderModelPicker({ + session, + selectedProvider: "codex-acp", + applySessionModelSelection, + }); + act(() => result.current.autoRouting.onModeChange("balanced")); + expect( + useChatSessionStore.getState().getSession(session.id)?.autoModeId, + ).toBe("balanced"); + await waitFor(() => + expect( + useChatSessionStore.getState().getSession(session.id)?.autoModeId, + ).toBe(previousModeId), + ); + expect( + useChatSessionStore.getState().getSession(session.id)?.executionTarget + ?.harnessId, + ).toBe("codex-acp"); + }); + + it("keeps a newer explicit choice when older Auto activation fails", async () => { + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + modes: [ + { + id: "balanced", + displayName: "Auto Balanced", + modelProviderId: "openai", + fallback: { modelId: "gpt-5", effort: "medium" }, + models: [{ modelId: "gpt-5", routerModel: "gpt-5" }], + }, + ], + }, + }, + }, + }); + const { useAgentModelPickerState } = await vi.importActual< + typeof import("../useAgentModelPickerState") + >("../useAgentModelPickerState"); + mockUseAgentModelPickerState.mockImplementation(useAgentModelPickerState); + mockAgentStatus.mockReturnValue({ + readyAgentIds: new Set(["goose", "codex-acp"]), + agentReadiness: new Map(), + refresh: vi.fn(), + }); + const session = makeSession( + { harnessId: "codex-acp" }, + { autoModeId: "previous-auto" }, + ); + useChatSessionStore.setState({ sessions: [session] }); + let rejectActivation!: (reason: Error) => void; + const applySessionModelSelection = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectActivation = reject; + }), + ) + .mockResolvedValue(true); + const { result } = renderModelPicker({ + session, + selectedProvider: "codex-acp", + applySessionModelSelection, + }); + act(() => result.current.autoRouting.onModeChange("balanced")); + act(() => + result.current.handleModelChange( + "manual", + { id: "manual", name: "Manual", providerId: "openai" }, + "goose", + ), + ); + await act(async () => rejectActivation(new Error("activation failed"))); + expect( + useChatSessionStore.getState().getSession(session.id)?.autoModeId, + ).toBeNull(); + expect( + useChatSessionStore.getState().getSession(session.id)?.executionTarget + ?.modelId, + ).toBe("manual"); + }); + + it("uses configured auto defaults only for new chats and preserves explicit opt-out", () => { + expect(getSelectedAutoModeId()).toBeUndefined(); + useRuntimeConfigStore.setState({ + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://router.example", + defaultModeId: "balanced", + modes: [ + { + id: "balanced", + displayName: "Auto Balanced", + modelProviderId: "openai", + fallback: { modelId: "gpt-5", effort: "medium" }, + models: [{ modelId: "gpt-5", routerModel: "gpt-5" }], + }, + ], + }, + }, + }, + }); + expect(getSelectedAutoModeId()).toBe("balanced"); + expect(getAutoModeExecutionTarget(getSelectedAutoModeId())).toMatchObject({ + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-5", + }); + useChatSessionStore.setState({ + sessions: [makeSession({ harnessId: "codex-acp" })], + }); + expect(getSelectedAutoModeId("session-1")).toBeUndefined(); + act(() => usePendingAutoModeStore.getState().setModeId(null)); + expect(getSelectedAutoModeId()).toBeNull(); + }); + it.each([ ["not_ready", "composer"], ["not_installed", "composer"], diff --git a/src/features/chat/hooks/useAutoRoutingPicker.ts b/src/features/chat/hooks/useAutoRoutingPicker.ts new file mode 100644 index 000000000..0d97b22df --- /dev/null +++ b/src/features/chat/hooks/useAutoRoutingPicker.ts @@ -0,0 +1,18 @@ +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { + selectAutoMode, + usePendingAutoModeStore, +} from "../lib/autoModelSelection"; +import type { ChatInputAutoRouting } from "../types"; + +export function useAutoRoutingPicker(): ChatInputAutoRouting { + const config = useRuntimeConfigStore( + (state) => state.config.goose.autoRouting, + ); + const modeId = usePendingAutoModeStore((state) => state.modeId); + return { + modes: config?.modes ?? [], + selectedModeId: modeId === undefined ? config?.defaultModeId : modeId, + onModeChange: (id) => selectAutoMode(null, id), + }; +} diff --git a/src/features/chat/hooks/useChat.ts b/src/features/chat/hooks/useChat.ts index 93a2fabe5..59da52eeb 100644 --- a/src/features/chat/hooks/useChat.ts +++ b/src/features/chat/hooks/useChat.ts @@ -215,6 +215,7 @@ export function useChat( systemPrompt, beforeUserMessageCommitted: sendOptions?.beforeUserMessageCommitted, signal: abort.signal, + dispatchToken: sendOptions?.sessionSelectionToken, prepare: () => ensurePreparedForPrompt( options?.ensurePrepared, diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 5defc6c05..6b820903e 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -1,3 +1,9 @@ +import { refreshRuntimeConfigForNewChat } from "@/shared/runtime-config/newChatRuntimeConfig"; +import { + getSelectedAutoModeId, + getAutoModeExecutionTarget, + selectAutoMode, +} from "../lib/autoModelSelection"; import { projectEnvironment } from "@/features/projects/lib/projectEnvironment"; import { useCallback, @@ -1064,6 +1070,7 @@ export function useChatSessionController({ ? useChatStore.getState().draftAttachmentsBySession[strandedSessionId] : undefined; const created = await store.createSession({ + autoModeId: current?.autoModeId, title: current?.title, projectId: current?.projectId ?? undefined, personaId: current?.personaId, @@ -1171,6 +1178,7 @@ export function useChatSessionController({ handleModelChange, handlePickerOpen, effectiveModelSelection, + autoRouting, } = useResolvedAgentModelPicker({ providers, selectedProvider, @@ -1550,6 +1558,7 @@ export function useChatSessionController({ const personaTarget = persona ? resolvePersonaTarget(persona) : undefined; if (personaTarget) { + selectAutoMode(sessionId, null); const harnessId = personaTarget.harnessId; if (!personaTarget.modelId) { if (!sessionId) { @@ -2653,9 +2662,13 @@ export function useChatSessionController({ : session?.executionTarget; return (async () => { try { + await refreshRuntimeConfigForNewChat(); + const autoModeId = getSelectedAutoModeId(sessionId); await ensureRemoteHostConnected(remoteHost); const created = await useChatSessionStore.getState().createSession({ - executionTarget: remoteExecutionTarget, + executionTarget: + getAutoModeExecutionTarget(autoModeId) ?? remoteExecutionTarget, + autoModeId, personaId: payload.persona.kind === "persona" ? payload.persona.id @@ -3558,6 +3571,7 @@ export function useChatSessionController({ selectedPersona, handlePersonaChange, handleCreatePersona, + autoRouting, pickerAgents, providersLoading, selectedProvider: selectedAgentId, diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index 65856e025..32156cfab 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -1,3 +1,9 @@ +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { + usePendingAutoModeStore, + selectAutoMode, + getAutoModeExecutionTarget, +} from "../lib/autoModelSelection"; import { useEffect, useMemo, useRef, useState } from "react"; import type { AcpProvider } from "@/shared/api/acp"; import { @@ -8,7 +14,10 @@ import { useProviderCatalogStore } from "@/features/providers/stores/providerCat import { useDefaultProviderReadinessStore } from "@/features/providers/stores/defaultProviderReadinessStore"; import { resolveModelProviderId } from "@/features/providers/lib/modelProviderResolution"; import type { ProviderCatalogEntry } from "@/shared/types/providers"; -import type { ChatSession } from "../stores/chatSessionStore"; +import { + type ChatSession, + useChatSessionStore, +} from "../stores/chatSessionStore"; import { recoverStrandedProviderSession } from "../model-selection/strandedProviderRecovery"; import { useAgentModelPickerState } from "./useAgentModelPickerState"; import { @@ -18,6 +27,7 @@ import { } from "../lib/modelPreferences"; import { beginModelSelectionIntent, + getModelSelectionIntent, clearCurrentModelSelectionIntent, createModelSelectionRequestId, rollbackToPreviousModel, @@ -210,10 +220,30 @@ export function useResolvedAgentModelPicker({ applySessionModelSelection, recreateSessionForProvider, }: UseResolvedAgentModelPickerOptions) { + const autoConfig = useRuntimeConfigStore( + (state) => state.config.goose.autoRouting, + ); + const pendingAutoModeId = usePendingAutoModeStore((state) => state.modeId); + const selectedAutoModeId = sessionId + ? session?.autoModeId + : pendingAutoModeId === undefined + ? autoConfig?.defaultModeId + : pendingAutoModeId; const catalogEntries = useProviderCatalogStore((state) => state.entries); const catalogLoaded = useProviderCatalogStore((state) => state.loaded); // A provider or model choice supersedes work started by either callback. const selectionVersionRef = useRef(0); + const autoActivationRef = useRef< + | { + modeId: string; + previousModeId: ChatSession["autoModeId"]; + previousDecision: ChatSession["autoRoutingDecision"]; + } + | undefined + >(undefined); + const autoActivationByRequestRef = useRef( + new Map>(), + ); const [gooseDefaultSelection, setGooseDefaultSelection] = useState(null); @@ -348,6 +378,7 @@ export function useResolvedAgentModelPicker({ providers, selectedProvider, onProviderSelected: (providerId) => { + selectAutoMode(sessionId, null); selectionVersionRef.current += 1; const versionAtSelection = selectionVersionRef.current; const requestedAgentId = resolveAgentProviderCatalogIdStrictFromEntries( @@ -507,6 +538,8 @@ export function useResolvedAgentModelPicker({ }); }, onModelSelected: (model, requestedAgentId) => { + const autoActivation = autoActivationRef.current; + selectAutoMode(sessionId, null); const targetAgentId = requestedAgentId ?? selectedAgentId; const modelId = model.id; const modelName = model.displayName ?? model.name ?? model.id; @@ -565,7 +598,12 @@ export function useResolvedAgentModelPicker({ // original selection that is still correctly configuring the backend. if ( !session || - sameSessionExecutionTarget(session.executionTarget, nextTarget) + (sameSessionExecutionTarget(session.executionTarget, nextTarget) && + (!getModelSelectionIntent(sessionId) || + sameSessionExecutionTarget( + getModelSelectionIntent(sessionId)?.target, + nextTarget, + ))) ) { return false; } @@ -573,6 +611,20 @@ export function useResolvedAgentModelPicker({ selectionVersionRef.current += 1; const versionAtSelection = selectionVersionRef.current; const requestId = createModelSelectionRequestId(); + const restoreAutoActivation = () => { + const activation = + autoActivationByRequestRef.current.get(requestId) ?? autoActivation; + autoActivationByRequestRef.current.delete(requestId); + if (!activation || selectionVersionRef.current !== versionAtSelection) + return; + const store = useChatSessionStore.getState(); + if (store.getSession(sessionId)?.autoModeId !== activation.modeId) + return; + store.patchSession(sessionId, { + autoModeId: activation.previousModeId, + autoRoutingDecision: activation.previousDecision, + }); + }; const previousStoredModelPreference = getStoredModelPreference(targetAgentId); @@ -616,8 +668,10 @@ export function useResolvedAgentModelPicker({ requestId, ); if (!applied || !intentStillMatches) { + restoreAutoActivation(); return; } + autoActivationByRequestRef.current.delete(requestId); if (selectionVersionRef.current !== versionAtSelection) { return; } @@ -633,6 +687,7 @@ export function useResolvedAgentModelPicker({ !intentStillMatches || selectionVersionRef.current !== versionAtSelection ) { + autoActivationByRequestRef.current.delete(requestId); return; } if ( @@ -650,11 +705,14 @@ export function useResolvedAgentModelPicker({ ), ) ) { + autoActivationByRequestRef.current.delete(requestId); return; } if (selectionVersionRef.current !== versionAtSelection) { + autoActivationByRequestRef.current.delete(requestId); return; } + restoreAutoActivation(); console.error("Failed to set model:", error); if (!sessionHasStarted) { if (previousStoredModelPreference) { @@ -825,6 +883,63 @@ export function useResolvedAgentModelPicker({ : (sessionModelSelection ?? fallbackModelSelection); return { + autoRouting: { + modes: autoConfig?.modes ?? [], + selectedModeId: selectedAutoModeId, + decision: session?.autoRoutingDecision, + onModeChange: (modeId: string) => { + const target = getAutoModeExecutionTarget(modeId); + if (!target) return; + const activation = { + modeId, + previousModeId: sessionId + ? useChatSessionStore.getState().getSession(sessionId)?.autoModeId + : selectedAutoModeId, + previousDecision: sessionId + ? useChatSessionStore.getState().getSession(sessionId) + ?.autoRoutingDecision + : undefined, + }; + autoActivationRef.current = activation; + let accepted: ReturnType; + try { + accepted = handleModelChange( + target.modelId, + { + id: target.modelId, + name: target.modelName, + providerId: target.modelProviderId, + }, + "goose", + ); + } finally { + autoActivationRef.current = undefined; + } + const fallbackAlreadySelected = sessionId + ? sameSessionExecutionTarget(session?.executionTarget, target) + : selectedAgentId === target.harnessId && + effectiveModelSelection?.id === target.modelId && + effectiveModelSelection.modelProviderId === target.modelProviderId; + if (accepted === false && !fallbackAlreadySelected) return; + const pendingIntent = sessionId + ? getModelSelectionIntent(sessionId) + : undefined; + if ( + pendingIntent && + session?.creationState !== "pending" && + sameSessionExecutionTarget(pendingIntent.target, target) + ) { + const previousActivation = autoActivationByRequestRef.current.get( + pendingIntent.requestId, + ); + autoActivationByRequestRef.current.set(pendingIntent.requestId, { + ...(previousActivation ?? activation), + modeId, + }); + } + selectAutoMode(sessionId, modeId); + }, + }, selectedAgentId, pickerAgents, availableModels, diff --git a/src/features/chat/lib/acpSessionMapping.ts b/src/features/chat/lib/acpSessionMapping.ts index ea7e40b9f..4b8ceadc7 100644 --- a/src/features/chat/lib/acpSessionMapping.ts +++ b/src/features/chat/lib/acpSessionMapping.ts @@ -1,3 +1,4 @@ +import { loadAutoMode } from "../stores/autoModePersistence"; import type { AcpSessionInfo, AcpSessionsPage } from "@/shared/api/acp"; import type { ArchiveMutationBySessionId, @@ -35,6 +36,7 @@ export function acpSessionToChatSession( ); return withWorkspaceBackfill({ ...chatSessionFromAcpInfo(session, context), + autoModeId: loadAutoMode(session.sessionId), workspaceAttachments: persistedWorkspaceMetadata?.workspaceAttachments, activeWorkspaceId: persistedWorkspaceMetadata?.activeWorkspaceId, }); @@ -131,6 +133,10 @@ function mergeSessionMetadata( executionTarget, executionTargetSource, personaId, + autoModeId: + existing?.autoModeId !== undefined + ? existing.autoModeId + : session.autoModeId, remoteHost, workspaceAttachments: existing?.workspaceAttachments ?? session.workspaceAttachments, diff --git a/src/features/chat/lib/autoModelSelection.ts b/src/features/chat/lib/autoModelSelection.ts new file mode 100644 index 000000000..a4f17ac68 --- /dev/null +++ b/src/features/chat/lib/autoModelSelection.ts @@ -0,0 +1,47 @@ +import type { ModelExecutionTarget } from "./sessionExecutionTarget"; +import { create } from "zustand"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { useChatSessionStore } from "../stores/chatSessionStore"; + +export const usePendingAutoModeStore = create<{ + modeId: string | null | undefined; + setModeId: (modeId: string | null | undefined) => void; +}>((set) => ({ modeId: undefined, setModeId: (modeId) => set({ modeId }) })); + +export function getSelectedAutoModeId( + sessionId?: string | null, +): string | null | undefined { + if (sessionId) + return useChatSessionStore.getState().getSession(sessionId)?.autoModeId; + const pendingModeId = usePendingAutoModeStore.getState().modeId; + return pendingModeId === undefined + ? useRuntimeConfigStore.getState().config.goose.autoRouting?.defaultModeId + : pendingModeId; +} + +export function selectAutoMode( + sessionId: string | null, + modeId: string | null, +): void { + if (sessionId) + useChatSessionStore.getState().patchSession(sessionId, { + autoModeId: modeId, + autoRoutingDecision: undefined, + }); + else usePendingAutoModeStore.getState().setModeId(modeId); +} + +export function getAutoModeExecutionTarget( + modeId: string | null | undefined, +): ModelExecutionTarget | undefined { + const mode = useRuntimeConfigStore + .getState() + .config.goose.autoRouting?.modes.find((mode) => mode.id === modeId); + if (!mode) return undefined; + return { + harnessId: "goose", + modelProviderId: mode.modelProviderId, + modelId: mode.fallback.modelId, + modelName: mode.fallback.modelId, + }; +} diff --git a/src/features/chat/lib/autoRoutingDispatch.test.ts b/src/features/chat/lib/autoRoutingDispatch.test.ts new file mode 100644 index 000000000..24af4f9bf --- /dev/null +++ b/src/features/chat/lib/autoRoutingDispatch.test.ts @@ -0,0 +1,612 @@ +import { PreCommitSendRejectedError } from "./preCommitSendRejection"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + acpGetPreparedWorkingDir, + acpSendMessage, + acpPrepareSession, + acpSetSessionConfigOption, +} from "@/shared/api/acp"; +import { repairManagedGooseModelSelection } from "@/features/providers/lib/managedModelSelectionRepair"; +import { invokeWithStartupRetry } from "@/shared/api/invokeWithStartupRetry"; +import { routeAutoModel } from "@/shared/api/autoRouting"; +import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { useChatSessionStore } from "../stores/chatSessionStore"; +import { useChatStore } from "../stores/chatStore"; +import { prepareAutoRoutedPrompt } from "./autoRoutingDispatch"; +import { dispatchPrompt } from "./sendCore"; +import { acpSessionToChatSession } from "./acpSessionMapping"; +import { loadAutoMode, persistAutoMode } from "../stores/autoModePersistence"; +import { + acquireSessionDispatchTarget, + resetSessionTargetCoordinatorsForTests, +} from "./sessionTargetCoordinator"; + +vi.mock("@/shared/api/invokeWithStartupRetry", () => ({ + invokeWithStartupRetry: vi.fn(), +})); + +vi.mock("@/shared/api/acp", () => ({ + acpGetPreparedWorkingDir: vi.fn(), + acpSendMessage: vi.fn(), + acpExportSession: vi.fn(), + acpPrepareSession: vi.fn(), + acpSetSessionConfigOption: vi.fn(), +})); +vi.mock("@/shared/api/autoRouting", async (importOriginal) => ({ + ...(await importOriginal()), + routeAutoModel: vi.fn(), +})); +vi.mock("@/features/providers/lib/managedModelSelectionRepair", () => ({ + repairManagedGooseModelSelection: vi.fn(), +})); + +const decision = { + modeId: "balanced", + source: "router" as const, + modelProviderId: "openai", + modelId: "chosen", + effort: "max" as const, + rationale: "Complex task", + response: { full: "diagnostic" }, +}; +describe("auto routing dispatch", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.removeItem("goose:chat-auto-modes"); + vi.mocked(acpSendMessage).mockImplementation( + async (_sessionId, _prompt, options) => { + options?.onPromptDispatching?.(); + }, + ); + vi.mocked(repairManagedGooseModelSelection).mockResolvedValue(null); + vi.mocked(acpGetPreparedWorkingDir).mockReturnValue("/work"); + resetSessionTargetCoordinatorsForTests(); + useRuntimeConfigStore.getState().setResult({ + status: "ready", + source: "bundledFile", + autoRoutingRevision: "a".repeat(64), + config: { + ...DEFAULT_RUNTIME_CONFIG, + goose: { + ...DEFAULT_RUNTIME_CONFIG.goose, + autoRouting: { + url: "https://example.com/route", + modes: [ + { + id: "balanced", + displayName: "Auto (balanced)", + modelProviderId: "openai", + fallback: { modelId: "chosen", effort: "high" }, + models: [ + { + modelId: "chosen", + routerModel: "system.ai.chosen", + minEffort: "high", + maxEffort: "max", + }, + ], + }, + ], + }, + }, + }, + }); + useChatSessionStore.setState({ + sessions: [ + { + id: "s", + title: "Chat", + autoModeId: "balanced", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + modelId: "previous", + modelName: "Previous", + }, + workingDir: "/work", + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + messageCount: 0, + }, + ], + }); + useChatStore.setState({ messagesBySession: {} }); + vi.mocked(routeAutoModel).mockImplementation(async () => ({ ...decision })); + vi.mocked(acpPrepareSession).mockResolvedValue({ + model: { modelId: "chosen", modelName: "Chosen" }, + reasoningEffort: { + configId: "thinking", + currentValue: "medium", + options: [ + { id: "medium", name: "Medium" }, + { id: "high", name: "High" }, + ], + }, + }); + vi.mocked(acpSetSessionConfigOption).mockResolvedValue({ + model: null, + reasoningEffort: null, + }); + }); + + it("rejects a native revision change even when JS config numbers look unchanged", async () => { + const pending = prepareAutoRoutedPrompt("s", { text: "Task" }); + const current = useRuntimeConfigStore.getState().result; + if (current.status !== "ready") throw new Error("Expected ready config"); + useRuntimeConfigStore.getState().setResult({ + ...current, + autoRoutingRevision: "b".repeat(64), + }); + await expect(pending).rejects.toBeInstanceOf(PreCommitSendRejectedError); + expect(routeAutoModel).not.toHaveBeenCalled(); + expect(acpSendMessage).not.toHaveBeenCalled(); + }); + + it("rejects a runtime refresh during context preparation before invoking routing", async () => { + const pending = prepareAutoRoutedPrompt("s", { text: "Task" }); + const current = useRuntimeConfigStore.getState(); + current.setResult({ + status: "ready", + source: "bundledFile", + config: { + ...current.config, + goose: { ...current.config.goose, autoRouting: undefined }, + }, + }); + await expect(pending).rejects.toBeInstanceOf(PreCommitSendRejectedError); + expect(routeAutoModel).not.toHaveBeenCalled(); + expect(acpPrepareSession).not.toHaveBeenCalled(); + }); + + it("rejects a runtime refresh while the router request is pending", async () => { + let finish!: (result: typeof decision) => void; + const started = new Promise((resolve) => { + vi.mocked(routeAutoModel).mockImplementationOnce(() => { + resolve(); + return new Promise((done) => { + finish = done; + }); + }); + }); + const pending = prepareAutoRoutedPrompt("s", { text: "Task" }); + await started; + const current = useRuntimeConfigStore.getState(); + current.setResult({ + status: "ready", + source: "bundledFile", + config: { + ...current.config, + goose: { ...current.config.goose, autoRouting: undefined }, + }, + }); + finish({ ...decision }); + await expect(pending).rejects.toBeInstanceOf(PreCommitSendRejectedError); + expect(acpPrepareSession).not.toHaveBeenCalled(); + expect(acpSetSessionConfigOption).not.toHaveBeenCalled(); + expect(useChatSessionStore.getState().getSession("s")?.autoModeId).toBe( + "balanced", + ); + }); + + it("continues an in-flight route after a refresh with unchanged routing", async () => { + vi.mocked(routeAutoModel).mockImplementationOnce(async () => { + const current = useRuntimeConfigStore.getState(); + current.setResult({ + status: "ready", + source: "bundledFile", + autoRoutingRevision: "a".repeat(64), + config: JSON.parse(JSON.stringify(current.config)), + }); + return { ...decision }; + }); + await expect( + prepareAutoRoutedPrompt("s", { text: "Task" }), + ).resolves.toMatchObject({ modelId: "chosen" }); + expect(acpPrepareSession).toHaveBeenCalledOnce(); + }); + + it("routes each new prompt, applies the supported bounded effort, and retains full diagnostics", async () => { + for (const text of ["First task", "Follow-up task"]) { + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + const result = await prepareAutoRoutedPrompt("s", { + text, + assistantPrompt: "Loaded SKILL.md instructions", + dispatchToken: lease.token, + }); + expect(result).toMatchObject({ + modelId: "chosen", + effortApplied: "high", + response: decision.response, + }); + lease.release(); + } + expect(routeAutoModel).toHaveBeenCalledTimes(2); + expect(routeAutoModel).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("Loaded SKILL.md instructions"), + autoRoutingRevision: "a".repeat(64), + }), + ); + expect(acpSetSessionConfigOption).toHaveBeenCalledWith( + "s", + "thinking", + "high", + expect.objectContaining({ modelId: "chosen" }), + ); + expect(useChatSessionStore.getState().getSession("s")).toMatchObject({ + autoModeId: "balanced", + executionTarget: { modelId: "chosen" }, + autoRoutingDecision: { effortApplied: "high" }, + }); + }); + + it("retains the prepared artifact CWD when routing a workspace-free chat", async () => { + useChatSessionStore.getState().patchSession("s", { workingDir: undefined }); + useChatSessionStore.setState({ activeWorkspaceBySession: {} }); + const preparedCwd = "/Users/example/.berd/artifacts/session"; + vi.mocked(acpGetPreparedWorkingDir).mockReturnValue(preparedCwd); + vi.mocked(acpPrepareSession).mockImplementation( + async (_id, _provider, cwd) => { + // Goose rejects the empty raw draft CWD. Model routing must preserve + // the absolute directory resolved by the send's initial preparation. + if (cwd !== preparedCwd) + throw new Error("cwd must be an absolute path"); + return { + model: { modelId: "chosen", modelName: "Chosen" }, + reasoningEffort: null, + }; + }, + ); + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + try { + const result = await prepareAutoRoutedPrompt("s", { + text: "Task", + dispatchToken: lease.token, + }); + expect(result?.modelId).toBe("chosen"); + expect(acpPrepareSession).toHaveBeenCalledWith( + "s", + "openai", + preparedCwd, + expect.objectContaining({ modelId: "chosen" }), + ); + expect( + useChatSessionStore.getState().getSession("s")?.autoRoutingDecision + ?.modelId, + ).toBe("chosen"); + } finally { + lease.release(); + } + }); + + function configureDistinctFallback() { + const config = useRuntimeConfigStore.getState().config; + const mode = config.goose.autoRouting?.modes[0]; + if (!mode) throw new Error("Expected mode"); + const autoRouting = config.goose.autoRouting; + if (!autoRouting) throw new Error("Expected routing config"); + useRuntimeConfigStore.setState({ + config: { + ...config, + goose: { + ...config.goose, + autoRouting: { + ...autoRouting, + modes: [ + { + ...mode, + fallback: { modelId: "fallback", effort: "high" }, + models: [ + ...mode.models, + { + modelId: "fallback", + routerModel: "router-fallback", + minEffort: "high", + }, + ], + }, + ], + }, + }, + }, + }); + vi.mocked(acpPrepareSession).mockImplementation( + async (_id, _provider, _cwd, options) => ({ + model: { + modelId: options?.modelId ?? "", + modelName: options?.modelId ?? "", + }, + reasoningEffort: null, + }), + ); + } + it("retries the configured fallback once after inventory substitutes the routed model", async () => { + configureDistinctFallback(); + vi.mocked(repairManagedGooseModelSelection).mockImplementation( + async (selection) => + selection.modelId === "chosen" + ? { + providerId: selection.providerId ?? "openai", + modelId: "substitute", + } + : null, + ); + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + try { + const result = await prepareAutoRoutedPrompt("s", { + text: "Task", + dispatchToken: lease.token, + }); + expect(result).toMatchObject({ + modelId: "fallback", + source: "fallback", + fallbackReason: "model_unavailable", + response: decision.response, + }); + expect(acpPrepareSession).toHaveBeenCalledTimes(1); + expect( + useChatSessionStore.getState().getSession("s")?.executionTarget + ?.modelId, + ).toBe("fallback"); + } finally { + lease.release(); + } + }); + it("retries the configured fallback after routed model activation fails", async () => { + configureDistinctFallback(); + vi.mocked(acpPrepareSession).mockRejectedValueOnce( + new Error("Model no longer served"), + ); + const result = await prepareAutoRoutedPrompt("s", { text: "Task" }); + expect(result).toMatchObject({ modelId: "fallback", source: "fallback" }); + expect(acpPrepareSession).toHaveBeenCalledTimes(2); + }); + it("stops after the configured fallback is also unavailable without recording a substitute", async () => { + configureDistinctFallback(); + vi.mocked(repairManagedGooseModelSelection).mockImplementation( + async (selection) => ({ + providerId: selection.providerId ?? "openai", + modelId: "substitute", + }), + ); + await expect( + prepareAutoRoutedPrompt("s", { text: "Task" }), + ).rejects.toThrow(); + expect(acpPrepareSession).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("s")?.executionTarget?.modelId, + ).toBe("previous"); + expect( + useChatSessionStore.getState().getSession("s")?.autoRoutingDecision, + ).toBeUndefined(); + }); + + it.each([ + "renamed", + "disabled", + ])("sends a restored chat on its concrete target when Auto is %s", async (change) => { + persistAutoMode("s", "balanced"); + const backendSession = { + sessionId: "s", + title: "Restored chat", + providerId: "openai", + modelId: "previous", + workingDir: "/work", + messageCount: 0, + createdAt: "2026-01-01", + updatedAt: "2026-01-01", + lastMessageAt: null, + archivedAt: null, + userSetName: false, + subtitle: null, + personaId: null, + }; + const restored = acpSessionToChatSession(backendSession); + expect(restored.autoModeId).toBe("balanced"); + useChatSessionStore.setState({ + sessions: [{ ...restored, autoRoutingDecision: { ...decision } }], + }); + const config = useRuntimeConfigStore.getState().config; + useRuntimeConfigStore.setState({ + config: { + ...config, + goose: { + ...config.goose, + autoRouting: + change === "disabled" + ? undefined + : { + ...config.goose.autoRouting!, + modes: config.goose.autoRouting!.modes.map((mode) => ({ + ...mode, + id: "replacement", + })), + }, + }, + }, + }); + const target = useChatSessionStore + .getState() + .getSession("s")?.executionTarget; + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + try { + await dispatchPrompt("s", "Continue my chat", { + providerId: "openai", + dispatchToken: lease.token, + }); + } finally { + lease.release(); + } + expect(routeAutoModel).not.toHaveBeenCalled(); + expect(acpPrepareSession).not.toHaveBeenCalled(); + expect(acpSetSessionConfigOption).not.toHaveBeenCalled(); + expect(acpSendMessage).toHaveBeenCalledOnce(); + expect(useChatSessionStore.getState().getSession("s")).toMatchObject({ + executionTarget: target, + autoModeId: null, + }); + expect( + useChatSessionStore.getState().getSession("s")?.autoRoutingDecision, + ).toBeUndefined(); + expect(loadAutoMode("s")).toBeNull(); + expect( + useChatStore + .getState() + .messagesBySession.s.filter((message) => message.role === "user"), + ).toHaveLength(1); + expect( + acpSessionToChatSession({ ...backendSession, messageCount: 1 }) + .autoModeId, + ).toBeNull(); + }); + + it("retains Auto intent while runtime configuration is still loading", async () => { + useRuntimeConfigStore.setState({ + loaded: false, + config: DEFAULT_RUNTIME_CONFIG, + }); + await expect( + prepareAutoRoutedPrompt("s", { text: "Task" }), + ).rejects.toThrow(PreCommitSendRejectedError); + expect(useChatSessionStore.getState().getSession("s")?.autoModeId).toBe( + "balanced", + ); + expect(routeAutoModel).not.toHaveBeenCalled(); + }); + + it.each([ + "unavailable", + "appDefault", + ] as const)("preserves persisted Auto through %s config and routes after recovery", async (state) => { + const readyConfig = useRuntimeConfigStore.getState().config; + persistAutoMode("s", "balanced"); + const sessionBefore = useChatSessionStore.getState().getSession("s"); + useRuntimeConfigStore.getState().setResult( + state === "appDefault" + ? { + status: "ready", + source: "appDefault", + config: DEFAULT_RUNTIME_CONFIG, + } + : { + status: "unavailable", + source: "endpoint", + reason: "missing", + message: "Temporarily unavailable", + }, + ); + expect(useRuntimeConfigStore.getState().loaded).toBe(true); + const dispatch = async () => { + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + try { + return await dispatchPrompt("s", "Continue my chat", { + providerId: "openai", + dispatchToken: lease.token, + }); + } finally { + lease.release(); + } + }; + await expect(dispatch()).rejects.toThrow(PreCommitSendRejectedError); + expect(useChatSessionStore.getState().getSession("s")).toEqual( + sessionBefore, + ); + expect(loadAutoMode("s")).toBe("balanced"); + expect(routeAutoModel).not.toHaveBeenCalled(); + expect(acpSendMessage).not.toHaveBeenCalled(); + expect(useChatStore.getState().messagesBySession.s ?? []).toHaveLength(0); + + useRuntimeConfigStore.getState().setResult({ + status: "ready", + source: "endpoint", + config: readyConfig, + }); + await dispatch(); + expect(routeAutoModel).toHaveBeenCalledOnce(); + expect(acpSendMessage).toHaveBeenCalledOnce(); + expect(loadAutoMode("s")).toBe("balanced"); + expect(useChatSessionStore.getState().getSession("s")).toMatchObject({ + autoModeId: "balanced", + executionTarget: { modelId: "chosen" }, + autoRoutingDecision: { modeId: "balanced", modelId: "chosen" }, + }); + }); + + it("skips the router after an explicit model choice", async () => { + useChatSessionStore.getState().patchSession("s", { autoModeId: null }); + await prepareAutoRoutedPrompt("s", { text: "Task" }); + expect(routeAutoModel).not.toHaveBeenCalled(); + expect(acpPrepareSession).not.toHaveBeenCalled(); + }); + + it("ignores a late routing response after manual opt-out", async () => { + vi.mocked(routeAutoModel).mockImplementation(async () => { + useChatSessionStore.getState().patchSession("s", { autoModeId: null }); + return { ...decision }; + }); + await expect( + prepareAutoRoutedPrompt("s", { text: "Task" }), + ).rejects.toThrow(PreCommitSendRejectedError); + expect(acpPrepareSession).not.toHaveBeenCalled(); + }); + + it("releases the owning dispatch lease on Stop while native routing stays pending", async () => { + const actual = await vi.importActual< + typeof import("@/shared/api/autoRouting") + >("@/shared/api/autoRouting"); + vi.mocked(routeAutoModel).mockImplementation(actual.routeAutoModel); + let finish!: (value: unknown) => void; + vi.mocked(invokeWithStartupRetry).mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const controller = new AbortController(); + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + const pending = prepareAutoRoutedPrompt("s", { + text: "Task", + signal: controller.signal, + dispatchToken: lease.token, + }).finally(lease.release); + await vi.waitFor(() => + expect(invokeWithStartupRetry).toHaveBeenCalledTimes(1), + ); + const rejection = expect(pending).rejects.toMatchObject({ + name: "AbortError", + }); + controller.abort(); + await rejection; + const next = acquireSessionDispatchTarget("s"); + expect(next.status).toBe("acquired"); + if (next.status === "acquired") next.release(); + finish({ + route_selection: [ + { route_option: { model: "chosen", harness: "codex" } }, + ], + }); + await Promise.resolve(); + expect(acpPrepareSession).not.toHaveBeenCalled(); + expect( + useChatSessionStore.getState().getSession("s")?.autoRoutingDecision, + ).toBeUndefined(); + }); + + it("does not switch models when the prompt is cancelled during routing", async () => { + const controller = new AbortController(); + vi.mocked(routeAutoModel).mockImplementation(async () => { + controller.abort(); + return { ...decision }; + }); + await expect( + prepareAutoRoutedPrompt("s", { text: "Task", signal: controller.signal }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(acpPrepareSession).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/chat/lib/autoRoutingDispatch.ts b/src/features/chat/lib/autoRoutingDispatch.ts new file mode 100644 index 000000000..9765b2118 --- /dev/null +++ b/src/features/chat/lib/autoRoutingDispatch.ts @@ -0,0 +1,209 @@ +import { i18n } from "@/shared/i18n"; +import { + acpGetPreparedWorkingDir, + acpSetSessionConfigOption, +} from "@/shared/api/acp"; +import { + autoRoutingFallback, + routeAutoModel, + type AutoRoutingDecision, +} from "@/shared/api/autoRouting"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { autoRoutingEfforts } from "@/shared/runtime-config/schema"; +import type { ChatAttachmentDraft } from "@/shared/types/messages"; +import { useChatSessionStore } from "../stores/chatSessionStore"; +import { useChatStore } from "../stores/chatStore"; +import { buildAutoRoutingPrompt } from "./autoRoutingPrompt"; +import { selectAutoMode } from "./autoModelSelection"; +import { PreCommitSendRejectedError } from "./preCommitSendRejection"; +import { + transitionSessionDispatchTarget, + transitionSessionTarget, + type SessionTargetOutcome, +} from "./sessionTargetCoordinator"; + +const EFFORTS: readonly string[] = autoRoutingEfforts; + +/** Fresh prompts route once; steering bypasses dispatchPrompt entirely. */ +export async function prepareAutoRoutedPrompt( + sessionId: string, + input: { + text: string; + assistantPrompt?: string; + attachments?: ChatAttachmentDraft[]; + dispatchToken?: symbol; + signal?: AbortSignal; + }, +): Promise { + const session = useChatSessionStore.getState().getSession(sessionId); + const modeId = session?.autoModeId; + if (!modeId) return; + const runtime = useRuntimeConfigStore.getState(); + const mode = runtime.config.goose.autoRouting?.modes.find( + (candidate) => candidate.id === modeId, + ); + if (!mode) { + // Startup and failed loads expose defaults, not an authoritative config. + // Retain intent until a successful load confirms the mode was removed. + if ( + !runtime.loaded || + runtime.result.status !== "ready" || + runtime.result.source === "appDefault" + ) { + throw new PreCommitSendRejectedError( + i18n.t("chat:errors.sessionPreparationSuperseded"), + ); + } + selectAutoMode(sessionId, null); + return; + } + if (session.executionTarget?.harnessId !== "goose") return; + const autoRoutingRevision = + runtime.result.status === "ready" + ? runtime.result.autoRoutingRevision + : undefined; + const routingSnapshot = JSON.stringify(runtime.config.goose.autoRouting); + const assertCurrent = () => { + if (input.signal?.aborted) throw new DOMException("Aborted", "AbortError"); + const currentResult = useRuntimeConfigStore.getState().result; + if ( + (currentResult.status === "ready" + ? currentResult.autoRoutingRevision + : undefined) !== autoRoutingRevision || + JSON.stringify( + useRuntimeConfigStore.getState().config.goose.autoRouting, + ) !== routingSnapshot || + useChatSessionStore.getState().getSession(sessionId)?.autoModeId !== + modeId + ) { + throw new PreCommitSendRejectedError( + i18n.t("chat:errors.sessionPreparationSuperseded"), + ); + } + }; + assertCurrent(); + const prompt = await buildAutoRoutingPrompt({ + ...input, + messages: useChatStore.getState().messagesBySession[sessionId], + remote: Boolean(session.remoteHost), + }); + assertCurrent(); + let decision = await routeAutoModel({ + modeId, + prompt, + signal: input.signal, + config: runtime.config.goose.autoRouting, + autoRoutingRevision, + }); + assertCurrent(); + let fallbackAttempted = decision.modelId === mode.fallback.modelId; + let outcome: Extract; + for (;;) { + const request = { + sessionId, + requireExactTarget: true, + target: { + harnessId: "goose" as const, + modelProviderId: decision.modelProviderId, + modelId: decision.modelId, + modelName: decision.modelId, + }, + // Routing changes only the model; preserve the send's resolved CWD. + workingDir: acpGetPreparedWorkingDir(sessionId), + }; + let attempt: SessionTargetOutcome; + try { + attempt = input.dispatchToken + ? await transitionSessionDispatchTarget({ + ...request, + dispatchToken: input.dispatchToken, + }) + : await transitionSessionTarget(request); + } catch (error) { + assertCurrent(); + if ( + error instanceof PreCommitSendRejectedError || + (error instanceof Error && error.name === "AbortError") || + fallbackAttempted + ) + throw error; + decision = autoRoutingFallback( + mode, + "model_unavailable", + decision.response, + ); + fallbackAttempted = true; + continue; + } + if (!attempt.applied) { + throw new PreCommitSendRejectedError( + i18n.t("chat:errors.sessionPreparationSuperseded"), + ); + } + assertCurrent(); + if ( + attempt.target.modelId === decision.modelId && + attempt.target.modelProviderId === decision.modelProviderId && + attempt.target.harnessId === "goose" + ) { + outcome = attempt; + break; + } + // Inventory repair can substitute a model. Use only the configured + // fallback, once, rather than sending on an unrequested substitute. + if (fallbackAttempted) + throw new Error(i18n.t("chat:errors.autoModelUnavailable")); + decision = autoRoutingFallback( + mode, + "model_unavailable", + decision.response, + ); + fallbackAttempted = true; + } + const reasoning = + outcome.configOptionsSnapshot?.reasoningEffort ?? + useChatSessionStore.getState().getSession(sessionId)?.reasoningEffort; + if (reasoning?.options.length) { + const candidate = mode.models.find( + (model) => model.modelId === decision.modelId, + ); + const lower = candidate?.minEffort + ? EFFORTS.indexOf(candidate.minEffort) + : 0; + const upper = candidate?.maxEffort + ? EFFORTS.indexOf(candidate.maxEffort) + : EFFORTS.length - 1; + const requested = EFFORTS.indexOf(decision.effort); + const supported = reasoning.options + .map((option) => ({ id: option.id, rank: EFFORTS.indexOf(option.id) })) + .filter((option) => option.rank >= lower && option.rank <= upper) + .sort( + (a, b) => + Math.abs(a.rank - requested) - Math.abs(b.rank - requested) || + b.rank - a.rank, + ); + const selected = supported[0]; + if (!selected) throw new Error(i18n.t("chat:errors.autoEffortUnavailable")); + if (selected.id !== reasoning.currentValue) { + await acpSetSessionConfigOption( + sessionId, + reasoning.configId, + selected.id, + { + providerId: decision.modelProviderId, + modelId: decision.modelId, + reasoningEffortValue: selected.id, + }, + ); + assertCurrent(); + } + useChatSessionStore.getState().patchSession(sessionId, { + reasoningEffort: { ...reasoning, currentValue: selected.id }, + }); + decision.effortApplied = selected.id; + } + useChatSessionStore + .getState() + .patchSession(sessionId, { autoRoutingDecision: decision }); + return decision; +} diff --git a/src/features/chat/lib/autoRoutingPrompt.test.ts b/src/features/chat/lib/autoRoutingPrompt.test.ts new file mode 100644 index 000000000..6f6182a3f --- /dev/null +++ b/src/features/chat/lib/autoRoutingPrompt.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from "vitest"; +import { readTextFile } from "@/shared/api/system"; +import type { Message } from "@/shared/types/messages"; +import { buildAutoRoutingPrompt } from "./autoRoutingPrompt"; + +vi.mock("@/shared/api/system", () => ({ readTextFile: vi.fn() })); + +describe("auto routing task context", () => { + it("includes loaded skill content and bounded file contents with metadata", async () => { + vi.mocked(readTextFile).mockResolvedValue({ + contents: "content ".repeat(4_000), + byteSize: 32_000, + truncated: false, + }); + const prompt = await buildAutoRoutingPrompt({ + text: "Review this migration", + assistantPrompt: + "# Loaded Skill: migrations\nSource: /skills/migrations/SKILL.md\nReview schema compatibility.", + attachments: [ + { + id: "file", + kind: "file", + name: "migration.sql", + path: "/work/migration.sql", + mimeType: "text/plain", + }, + ], + }); + expect(prompt).toContain("Review schema compatibility."); + expect(prompt).toContain("Source: /skills/migrations/SKILL.md"); + expect(prompt).toContain("Size: 32000 bytes"); + expect(prompt).toContain("Contents:\ncontent"); + expect(prompt).toContain("[Truncated for model selection]"); + expect(prompt.length).toBeLessThan(25_000); + }); + + it("does not read remote files or image bytes and tolerates unreadable attachments", async () => { + vi.mocked(readTextFile).mockClear(); + const attachment = { + id: "file", + kind: "file" as const, + name: "file.bin", + path: "/remote/file.bin", + }; + const remote = await buildAutoRoutingPrompt({ + text: "Inspect", + attachments: [attachment], + remote: true, + }); + expect(readTextFile).not.toHaveBeenCalled(); + expect(remote).toContain("Path: /remote/file.bin"); + vi.mocked(readTextFile).mockRejectedValue(new Error("binary file")); + expect( + await buildAutoRoutingPrompt({ + text: "Inspect", + attachments: [attachment], + }), + ).toContain("Text preview unavailable"); + }); + + it("excludes agent-hidden and replay coordination before selecting recent history", async () => { + const message = ( + id: string, + role: Message["role"], + text: string, + metadata?: Message["metadata"], + ): Message => ({ + id, + role, + created: 1, + content: [{ type: "text", text }], + metadata, + }); + const prompt = await buildAutoRoutingPrompt({ + text: "Continue the implementation", + messages: [ + message("user", "user", "VISIBLE_USER"), + message("assistant", "assistant", "VISIBLE_ASSISTANT", { + agentVisible: true, + }), + message("hidden-user", "user", "PRIVATE_USER", { agentVisible: false }), + message("hidden-assistant", "assistant", "PRIVATE_EXPERT", { + agentVisible: false, + origin: "voice_conversation", + }), + message( + "replay", + "user", + "[Voice transcript; cursor 1] Spokesperson said: PRIVATE_REPLAY", + { origin: "voice_conversation" }, + ), + message("compact", "user", "/compact"), + message("system", "system", "PRIVATE_SYSTEM"), + ], + }); + expect(prompt).toContain("user: VISIBLE_USER"); + expect(prompt).toContain("assistant: VISIBLE_ASSISTANT"); + expect(prompt).not.toContain("PRIVATE_"); + expect(prompt).not.toContain("/compact"); + }); + + it("caps the whole request, keeping task and selected skill before history", async () => { + const prompt = await buildAutoRoutingPrompt({ + text: "t".repeat(40_000), + assistantPrompt: "s".repeat(80_000), + }); + expect(prompt.length).toBeLessThanOrEqual(176_000); + expect(prompt).toContain("Selected skill and request instructions:"); + }); + + it("retains 24k from every source without starving files or recent history", async () => { + vi.mocked(readTextFile).mockResolvedValue({ + contents: "f".repeat(24_000) + "FILE_OVERFLOW", + byteSize: 25_000, + truncated: false, + }); + const prompt = await buildAutoRoutingPrompt({ + text: "t".repeat(24_000) + "TASK_OVERFLOW", + assistantPrompt: "s".repeat(24_000) + "SKILL_OVERFLOW", + attachments: Array.from({ length: 4 }, (_, index) => ({ + id: String(index), + kind: "file" as const, + name: `file-${index}.ts`, + path: `/work/file-${index}.ts`, + })), + messages: [ + { + id: "history", + role: "user", + created: Date.now(), + content: [ + { type: "text", text: "HISTORY_OVERFLOW" + "h".repeat(24_000) }, + ], + }, + ], + }); + expect(prompt).toContain("t".repeat(24_000)); + expect(prompt).toContain("s".repeat(24_000)); + expect(prompt.split("f".repeat(24_000))).toHaveLength(5); + expect(prompt).toContain("h".repeat(24_000)); + expect(prompt).not.toContain("OVERFLOW"); + expect(prompt.length).toBeLessThanOrEqual(176_000); + }); +}); diff --git a/src/features/chat/lib/autoRoutingPrompt.ts b/src/features/chat/lib/autoRoutingPrompt.ts new file mode 100644 index 000000000..3ee2dc06f --- /dev/null +++ b/src/features/chat/lib/autoRoutingPrompt.ts @@ -0,0 +1,86 @@ +import { readTextFile } from "@/shared/api/system"; +import type { ChatAttachmentDraft, Message } from "@/shared/types/messages"; +import { sanitizeReplayMessages } from "./replaySanitizer"; + +// Keep classification bounded independently of the inference prompt. Give each +// context source enough room for complex tasks, skills, and attached files. +const MAX_PROMPT_CHARS = 24_000; +const MAX_SKILL_CHARS = 24_000; +const MAX_FILE_CHARS = 24_000; +const MAX_HISTORY_CHARS = 24_000; +const MAX_FILES = 4; +// Seven 24k sections (task, skills, four files, history), plus metadata. +const MAX_CONTEXT_CHARS = 176_000; + +function bounded(text: string, limit: number): string { + return text.length <= limit + ? text + : `${text.slice(0, limit)}\n[Truncated for model selection]`; +} + +export async function buildAutoRoutingPrompt(input: { + text: string; + assistantPrompt?: string; + attachments?: ChatAttachmentDraft[]; + messages?: Message[]; + remote?: boolean; +}): Promise { + const sections = [`Task:\n${bounded(input.text, MAX_PROMPT_CHARS)}`]; + if (input.assistantPrompt?.trim()) { + sections.push( + `Selected skill and request instructions:\n${bounded(input.assistantPrompt, MAX_SKILL_CHARS)}`, + ); + } + const attachments = input.attachments ?? []; + const previews = await Promise.all( + attachments.slice(0, MAX_FILES).map(async (attachment) => { + const metadata = [ + `Attachment: ${bounded(attachment.name, 256)}`, + `Kind: ${attachment.kind}`, + ...(attachment.path + ? [`Path: ${bounded(attachment.path, 1_024)}`] + : []), + ...("mimeType" in attachment && attachment.mimeType + ? [`Media type: ${attachment.mimeType}`] + : []), + ]; + // Only explicitly attached local files are read. Images retain metadata; + // neither their base64 bytes nor remote filesystem paths are opened. + if (attachment.kind === "file" && attachment.path && !input.remote) { + try { + const file = await readTextFile(attachment.path); + metadata.push( + `Size: ${file.byteSize} bytes`, + `Contents:\n${bounded(file.contents, MAX_FILE_CHARS)}`, + ); + } catch { + metadata.push("Text preview unavailable"); + } + } + return metadata.join("\n"); + }), + ); + if (previews.length) sections.push(previews.join("\n\n")); + if (attachments.length > MAX_FILES) { + sections.push(`${attachments.length - MAX_FILES} additional attachments`); + } + const history = sanitizeReplayMessages(input.messages ?? []) + .filter( + (message) => + message.metadata?.agentVisible !== false && + (message.role === "user" || message.role === "assistant"), + ) + .slice(-4) + .map( + (message) => + `${message.role}: ${message.content + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join("\n")}`, + ) + .join("\n\n"); + if (history) { + // Preserve the most recent context for follow-ups such as "fix that". + sections.push(`Recent conversation:\n${history.slice(-MAX_HISTORY_CHARS)}`); + } + return sections.join("\n\n").slice(0, MAX_CONTEXT_CHARS); +} diff --git a/src/features/chat/lib/backgroundSend.ts b/src/features/chat/lib/backgroundSend.ts index 33d4332a5..fcd8e3077 100644 --- a/src/features/chat/lib/backgroundSend.ts +++ b/src/features/chat/lib/backgroundSend.ts @@ -58,6 +58,7 @@ export function sendPromptInBackground( onUserMessageCommitted, onPromptDispatched, prepare: validateExecutionTarget, + dispatchToken: sendOptions.sessionSelectionToken, background: true, }).catch((error) => { // Readiness changed at the final reversible boundary. The intent owner diff --git a/src/features/chat/lib/newChat.test.ts b/src/features/chat/lib/newChat.test.ts index e5410bb59..f5f004803 100644 --- a/src/features/chat/lib/newChat.test.ts +++ b/src/features/chat/lib/newChat.test.ts @@ -18,6 +18,24 @@ function makeSession( } describe("findExistingDraft", () => { + it("does not reuse a draft with different auto selection intent", () => { + const draft = makeSession("draft", { autoModeId: "balanced" }); + const args = { + sessions: [draft], + activeSessionId: draft.id, + draftsBySession: {}, + messagesBySession: {}, + request: { title: "New chat", autoModeId: "fast" }, + }; + expect(findExistingDraft(args)).toBeUndefined(); + expect( + findExistingDraft({ + ...args, + request: { title: "New chat", autoModeId: "balanced" }, + }), + ).toBe(draft); + }); + it("does not reuse an unavailable remote project chat", () => { const missing = makeSession("remote-missing", { projectId: "alpha", diff --git a/src/features/chat/lib/newChat.ts b/src/features/chat/lib/newChat.ts index 6cf624492..dbd315f98 100644 --- a/src/features/chat/lib/newChat.ts +++ b/src/features/chat/lib/newChat.ts @@ -7,6 +7,7 @@ import { } from "./sessionExecutionTarget"; interface NewChatRequest { + autoModeId?: string | null; title: string; projectId?: string; executionTarget?: SessionExecutionTarget; @@ -37,6 +38,7 @@ function isMatchingContext( request: Omit, ): boolean { return ( + (session.autoModeId ?? null) === (request.autoModeId ?? null) && session.projectId === request.projectId && normalizeRemoteHost(session.remoteHost) === normalizeRemoteHost(request.remoteHost) && diff --git a/src/features/chat/lib/queuedSessionSend.ts b/src/features/chat/lib/queuedSessionSend.ts index fe4c2d3d1..88f4029b5 100644 --- a/src/features/chat/lib/queuedSessionSend.ts +++ b/src/features/chat/lib/queuedSessionSend.ts @@ -441,6 +441,7 @@ export async function sendQueuedPromptToExistingSessionInBackground( { ...sendOptions, executionSystemPrompt, + sessionSelectionToken: targetLease.token, }, payload.attachments, () => { diff --git a/src/features/chat/lib/sendCore.test.ts b/src/features/chat/lib/sendCore.test.ts index 1ba004e63..8e0121a11 100644 --- a/src/features/chat/lib/sendCore.test.ts +++ b/src/features/chat/lib/sendCore.test.ts @@ -13,6 +13,12 @@ const mocks = vi.hoisted(() => ({ acpSendMessage: vi.fn(), archiveSession: vi.fn(), unarchiveSession: vi.fn(), + prepareAutoRoutedPrompt: vi.fn(), +})); + +vi.mock("./autoRoutingDispatch", () => ({ + prepareAutoRoutedPrompt: (...args: unknown[]) => + mocks.prepareAutoRoutedPrompt(...args), })); vi.mock("@/shared/api/acp", () => ({ @@ -741,6 +747,32 @@ describe("dispatchPrompt archived session restore", () => { ); }); + it("finishes auto routing before dispatching or committing the user turn", async () => { + seedSession({ autoModeId: "balanced" }); + const routing = deferred(); + mocks.prepareAutoRoutedPrompt.mockReturnValueOnce(routing.promise); + const dispatchToken = Symbol("dispatch"); + const send = dispatchPrompt("session-1", "Use the loaded skill", { + assistantPrompt: "Full SKILL.md contents", + dispatchToken, + }); + await vi.waitFor(() => + expect(mocks.prepareAutoRoutedPrompt).toHaveBeenCalledTimes(1), + ); + expect(mocks.acpSendMessage).not.toHaveBeenCalled(); + expect(mocks.prepareAutoRoutedPrompt).toHaveBeenCalledWith( + "session-1", + expect.objectContaining({ + text: "Use the loaded skill", + assistantPrompt: "Full SKILL.md contents", + dispatchToken, + }), + ); + routing.resolve(); + await send; + expect(mocks.acpSendMessage).toHaveBeenCalledTimes(1); + }); + it("waits for a shared durable restore before dispatching", async () => { seedSession({ archivedAt: ARCHIVED_AT }); const restore = deferred(); diff --git a/src/features/chat/lib/sendCore.ts b/src/features/chat/lib/sendCore.ts index ea1d7f9f1..ad70d2b4a 100644 --- a/src/features/chat/lib/sendCore.ts +++ b/src/features/chat/lib/sendCore.ts @@ -1,4 +1,5 @@ import { PreCommitSendRejectedError } from "@/features/chat/lib/preCommitSendRejection"; +import { prepareAutoRoutedPrompt } from "./autoRoutingDispatch"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { appendAttachmentPaths, @@ -90,6 +91,8 @@ export interface SendCoreOptions { */ prepare?: () => void | Promise; signal?: AbortSignal; + /** Queue lease retained while auto routing chooses this turn's model. */ + dispatchToken?: symbol; /** Fires immediately before the user message is committed to local stores. */ beforeUserMessageCommitted?: () => void; /** @@ -460,6 +463,17 @@ export async function dispatchPrompt( throwIfAborted(signal); useChatSessionStore.getState().assertSessionActive(sessionId); + if (useChatSessionStore.getState().getSession(sessionId)?.autoModeId) { + await prepareAutoRoutedPrompt(sessionId, { + text, + assistantPrompt, + attachments: dispatchAttachments, + dispatchToken: opts.dispatchToken, + signal, + }); + throwIfAborted(signal); + } + const commitUserMessage = () => { throwIfAborted(signal); beforeUserMessageCommitted?.(); diff --git a/src/features/chat/lib/sessionTargetCoordinator.test.ts b/src/features/chat/lib/sessionTargetCoordinator.test.ts index 93854799b..5da499502 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.test.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.test.ts @@ -13,6 +13,7 @@ import { replaceSessionTargetAfterDispatch, resetSessionTargetCoordinatorsForTests, transitionSessionTarget, + transitionSessionDispatchTarget, } from "./sessionTargetCoordinator"; const mockPrepare = vi.fn(); @@ -63,6 +64,77 @@ describe("session target coordinator", () => { }); }); + it("allows the dispatch owner to route to a new model without releasing its lease", async () => { + mockPrepare.mockImplementation(async () => { + observeSessionTargetModelSnapshot({ + sessionId: "s", + snapshot: { modelId: "a", modelName: "a" }, + context: { origin: "notification", providerId: "openai" }, + }); + return { + model: { modelId: "b", modelName: "b" }, + reasoningEffort, + }; + }); + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + await expect( + transitionSessionDispatchTarget({ + sessionId: "s", + target: target("b"), + workingDir: "/w", + dispatchToken: lease.token, + }), + ).resolves.toMatchObject({ applied: true, target: target("b") }); + expect(acquireSessionDispatchTarget("s").status).toBe("contended"); + expect( + useChatSessionStore.getState().getSession("s")?.executionTarget, + ).toEqual(target("b")); + lease.release(); + expect( + useChatSessionStore.getState().getSession("s")?.executionTarget, + ).toEqual(target("b")); + }); + + it("does not let a router overwrite an explicit selection waiting behind dispatch", async () => { + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + recordSessionTargetSelection({ + sessionId: "s", + operationId: "manual", + target: target("manual"), + }); + await expect( + transitionSessionDispatchTarget({ + sessionId: "s", + target: target("b"), + workingDir: "/w", + dispatchToken: lease.token, + }), + ).resolves.toMatchObject({ status: "superseded" }); + expect(mockPrepare).not.toHaveBeenCalled(); + lease.release(); + expect(getSessionTargetSelection("s")?.target).toEqual(target("manual")); + }); + + it("restores the dispatch target after a routing model preparation failure", async () => { + mockPrepare.mockRejectedValue(new Error("unavailable")); + const lease = acquireSessionDispatchTarget("s"); + if (lease.status !== "acquired") throw new Error("Expected lease"); + await expect( + transitionSessionDispatchTarget({ + sessionId: "s", + target: target("b"), + workingDir: "/w", + dispatchToken: lease.token, + }), + ).rejects.toThrow("unavailable"); + expect( + useChatSessionStore.getState().getSession("s")?.executionTarget, + ).toEqual(target("a")); + lease.release(); + }); + it("commits the concrete model acknowledged by a provider-only transition", async () => { mockPrepare.mockResolvedValue({ model: { modelId: "claude-default", modelName: "Claude Default" }, diff --git a/src/features/chat/lib/sessionTargetCoordinator.ts b/src/features/chat/lib/sessionTargetCoordinator.ts index 4720276b2..0f07ca10b 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.ts @@ -35,6 +35,8 @@ export interface SessionTargetTransition { dispatchToken?: symbol; /** Ensure target-qualified reasoning metadata as part of this operation. */ requireReasoningEffort?: boolean; + /** Reject inventory substitutions before changing the live backend target. */ + requireExactTarget?: boolean; } export type SessionTargetOutcome = @@ -285,6 +287,14 @@ async function execute( resolveSuperseded(actor, operation); return; } + if ( + request.requireExactTarget && + !sameSessionExecutionTarget(effective, request.target) + ) { + throw new Error( + "The requested model is unavailable from its configured provider.", + ); + } transition(actor, { type: "RESOLVED", operationId, effective }); const sessionBeforePrepare = useChatSessionStore .getState() @@ -729,6 +739,68 @@ export function getSessionDispatchTarget( return dispatch.target; } +/** Change a not-yet-dispatched turn's target while retaining its queue lease. */ +export async function transitionSessionDispatchTarget( + request: SessionTargetTransition & { dispatchToken: symbol }, +): Promise { + const actor = actors.get(request.sessionId); + const dispatch = actor?.dispatch; + if (!actor || !dispatch || dispatch.token !== request.dispatchToken) { + return { status: "superseded", applied: false }; + } + // An explicit picker choice waiting behind this turn must win. Let the + // caller release its lease and retry against the new selection. + if ( + actor.deferredSelection || + actor.deferredTargetMutation?.source === "ui" + ) { + return { status: "superseded", applied: false }; + } + const previousTarget = dispatch.target; + const previousReasoning = dispatch.reasoningEffort; + const discardDeferredObservation = () => { + if (actor.deferredTargetMutation?.source === "acp") { + actor.deferredTargetMutation = undefined; + } + }; + const publish = (target: SessionExecutionTarget) => { + dispatch.target = target; + restoringLeasedTarget = true; + try { + useChatSessionStore + .getState() + .replaceSessionExecutionTarget(request.sessionId, target); + } finally { + restoringLeasedTarget = false; + } + }; + dispatch.reasoningEffort = undefined; + // ACP observations collected before this owned change describe the previous + // model. They must not restore it when the successful dispatch releases. + actor.deferredTargetMutation = undefined; + publish(request.target); + try { + const outcome = await transitionSessionTarget(request); + if (outcome.applied) { + // The owned acknowledgement supersedes observations of the previous + // model that arrived while preparation was in flight. + discardDeferredObservation(); + dispatch.target = outcome.target; + dispatch.reasoningEffort = useChatSessionStore + .getState() + .getSession(request.sessionId)?.reasoningEffort; + return outcome; + } + publish(previousTarget); + dispatch.reasoningEffort = previousReasoning; + return outcome; + } catch (error) { + publish(previousTarget); + dispatch.reasoningEffort = previousReasoning; + throw error; + } +} + export async function transitionSessionTarget( request: SessionTargetTransition, ): Promise { diff --git a/src/features/chat/stores/__tests__/chatSessionStore.test.ts b/src/features/chat/stores/__tests__/chatSessionStore.test.ts index 61c3842fb..f54c9a498 100644 --- a/src/features/chat/stores/__tests__/chatSessionStore.test.ts +++ b/src/features/chat/stores/__tests__/chatSessionStore.test.ts @@ -1,3 +1,5 @@ +import { loadAutoMode } from "../autoModePersistence"; +import { acpSessionToChatSession } from "../../lib/acpSessionMapping"; import { getModelSelectionIntent } from "@/features/chat/model-selection/modelSelectionIntent"; import { beginModelSelectionIntent } from "@/features/chat/model-selection/modelSelectionIntent"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -153,6 +155,7 @@ describe("chatSessionStore", () => { window.localStorage.removeItem("goose:right-rail-open"); window.localStorage.removeItem("goose:context-panel-open"); window.localStorage.removeItem(CHAT_WORKSPACE_METADATA_STORAGE_KEY); + localStorage.removeItem("goose:chat-auto-modes"); resetStore(); useSecurityConfirmationStore.setState({ pendingBySessionId: {}, @@ -166,6 +169,49 @@ describe("chatSessionStore", () => { mocks.unarchiveSession.mockResolvedValue(undefined); }); + it("preserves auto intent through draft promotion and reload without persisting router responses", () => { + const store = useChatSessionStore.getState(); + const draft = store.createDraftSession({ + workingDir: "/tmp/auto-mode", + autoModeId: "balanced", + }); + store.patchSession(draft.id, { + autoRoutingDecision: { + modeId: "balanced", + modelId: "concrete", + modelProviderId: "provider", + effort: "high", + source: "router", + response: { privatePrompt: "never persist this" }, + }, + }); + store.promoteDraftSession(draft.id, "backend-auto"); + expect(loadAutoMode(draft.id)).toBeUndefined(); + expect(loadAutoMode("backend-auto")).toBe("balanced"); + expect(store.getSession("backend-auto")?.autoRoutingDecision?.modelId).toBe( + "concrete", + ); + expect(localStorage.getItem("goose:chat-auto-modes")).not.toContain( + "privatePrompt", + ); + expect( + acpSessionToChatSession({ + sessionId: "backend-auto", + messageCount: 1, + } as AcpSessionInfo).autoModeId, + ).toBe("balanced"); + store.patchSession("backend-auto", { + autoModeId: null, + autoRoutingDecision: undefined, + }); + expect( + acpSessionToChatSession({ + sessionId: "backend-auto", + messageCount: 1, + } as AcpSessionInfo).autoModeId, + ).toBeNull(); + }); + it("releases a windowed session when removing it locally", () => { seedSession({ id: "session-1" }); useSessionWindowStore diff --git a/src/features/chat/stores/autoModePersistence.ts b/src/features/chat/stores/autoModePersistence.ts new file mode 100644 index 000000000..fd5df37ee --- /dev/null +++ b/src/features/chat/stores/autoModePersistence.ts @@ -0,0 +1,39 @@ +const STORAGE_KEY = "goose:chat-auto-modes"; + +function readModes(): Record { + try { + const value: unknown = JSON.parse( + localStorage.getItem(STORAGE_KEY) ?? "{}", + ); + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter( + ([, mode]) => + mode === null || (typeof mode === "string" && mode.length > 0), + ), + ); + } catch { + return {}; + } +} + +/** Only selection intent is durable. Router responses remain session-local. */ +export function loadAutoMode(sessionId: string): string | null | undefined { + return readModes()[sessionId]; +} + +export function persistAutoMode( + sessionId: string, + modeId: string | null | undefined, +): void { + try { + const modes = readModes(); + if (modeId === undefined) delete modes[sessionId]; + else modes[sessionId] = modeId; + if (Object.keys(modes).length) + localStorage.setItem(STORAGE_KEY, JSON.stringify(modes)); + else localStorage.removeItem(STORAGE_KEY); + } catch { + // Storage can be unavailable. + } +} diff --git a/src/features/chat/stores/chatSessionStore.ts b/src/features/chat/stores/chatSessionStore.ts index 5027f3763..ac6fb3efb 100644 --- a/src/features/chat/stores/chatSessionStore.ts +++ b/src/features/chat/stores/chatSessionStore.ts @@ -1,3 +1,5 @@ +import type { AutoRoutingDecision } from "@/shared/api/autoRouting"; +import { loadAutoMode, persistAutoMode } from "./autoModePersistence"; import { create } from "zustand"; import { acpCreateSession, acpListSessionsPage } from "@/shared/api/acp"; import type { @@ -78,6 +80,10 @@ export interface ChatSession { projectId?: string | null; executionTarget?: SessionExecutionTarget; executionTargetSource?: "ui" | "acp"; + /** Auto selection intent, separate from the concrete ACP execution target. */ + autoModeId?: string | null; + /** Transient routing explanation; never persisted. */ + autoRoutingDecision?: AutoRoutingDecision; personaId?: string; reasoningEffort?: ChatSessionReasoningEffortConfig; workingDir?: string | null; @@ -194,6 +200,7 @@ interface ChatSessionStoreState { } interface CreateSessionOpts { + autoModeId?: string | null; title?: string; projectId?: string; executionTarget?: SessionExecutionTarget; @@ -591,6 +598,7 @@ export const useChatSessionStore = create((set, get) => ({ projectId: opts.projectId, executionTarget, executionTargetSource: "ui", + autoModeId: opts.autoModeId, personaId: opts.personaId, reasoningEffort: configOptionsSnapshot?.reasoningEffort ?? undefined, workingDir: opts.workingDir, @@ -608,6 +616,7 @@ export const useChatSessionStore = create((set, get) => ({ targetAgentDraftSaved: false, }); set((state) => ({ sessions: [chatSession, ...state.sessions] })); + persistAutoMode(chatSession.id, chatSession.autoModeId); logReasoningEffortInfo("createSession inserted", { sessionId: shortLogId(sessionId), providerId, @@ -634,6 +643,7 @@ export const useChatSessionStore = create((set, get) => ({ projectId: opts.projectId, executionTarget, executionTargetSource: "ui", + autoModeId: opts.autoModeId, personaId: opts.personaId, workingDir: opts.workingDir, ...(opts.remoteHost ? { remoteHost: opts.remoteHost } : {}), @@ -657,6 +667,7 @@ export const useChatSessionStore = create((set, get) => ({ registerSessionBackend(id, backendIdForSession(opts)); } set((state) => ({ sessions: [chatSession, ...state.sessions] })); + persistAutoMode(chatSession.id, chatSession.autoModeId); return chatSession; }, @@ -776,6 +787,8 @@ export const useChatSessionStore = create((set, get) => ({ // No-op for local sessions: the draft id is only registered when the // draft was created with a remote host. transferSessionBackend(draftSessionId, backendSessionId); + persistAutoMode(draftSessionId, undefined); + persistAutoMode(backendSessionId, promoted.autoModeId); migratePersistedChatWorkspaceMetadata(draftSessionId, backendSessionId); persistWorkspaceMetadataForSession(promoted); if (promoted.remoteHost) { @@ -889,6 +902,8 @@ export const useChatSessionStore = create((set, get) => ({ "Use replaceSessionExecutionTarget to change session execution state.", ); } + if (Object.hasOwn(patch, "autoModeId")) + persistAutoMode(id, patch.autoModeId); const includesReasoningEffort = patchIncludesReasoningEffort(patch); let sessionForWorkspacePersistence: ChatSession | null = null; let sessionForRemotePersistence: ChatSession | null = null; @@ -1011,7 +1026,13 @@ export const useChatSessionStore = create((set, get) => ({ const existing = state.sessions.findIndex( (candidate) => candidate.id === session.id, ); - const backfilledSession = withWorkspaceBackfill(session); + const backfilledSession = withWorkspaceBackfill({ + ...session, + autoModeId: + session.autoModeId === undefined + ? loadAutoMode(session.id) + : session.autoModeId, + }); if (existing >= 0) { const updated = [...state.sessions]; const merged = withWorkspaceBackfill({ @@ -1064,6 +1085,7 @@ export const useChatSessionStore = create((set, get) => ({ }; }); removePersistedChatWorkspaceMetadata(id); + persistAutoMode(id, undefined); removeRemoteSessionRecord(id); useSecurityConfirmationStore.getState().cancelAll(id); releaseWindowedSession(id); diff --git a/src/features/chat/types.ts b/src/features/chat/types.ts index 913df2b9a..de5b21cda 100644 --- a/src/features/chat/types.ts +++ b/src/features/chat/types.ts @@ -1,3 +1,4 @@ +import type { AutoRoutingDecision } from "@/shared/api/autoRouting"; import type { ReactNode, RefObject } from "react"; import type { AcpProvider } from "@/shared/api/acp"; import type { AgentProviderReadiness } from "@/features/providers/hooks/useAgentProviderStatus"; @@ -144,7 +145,15 @@ export interface AgentPickerOption extends AcpProvider { setupAction?: AgentPickerSetupAction; } +export interface ChatInputAutoRouting { + modes: Array<{ id: string; displayName: string }>; + selectedModeId?: string | null; + decision?: AutoRoutingDecision; + onModeChange: (modeId: string) => void; +} + export interface ChatInputAgentModelPicker { + autoRouting?: ChatInputAutoRouting; providers?: AgentPickerOption[]; providersLoading?: boolean; selectedProvider?: string; diff --git a/src/features/chat/ui/AgentModelPicker.tsx b/src/features/chat/ui/AgentModelPicker.tsx index cac28a43b..bd11a5a63 100644 --- a/src/features/chat/ui/AgentModelPicker.tsx +++ b/src/features/chat/ui/AgentModelPicker.tsx @@ -1,3 +1,4 @@ +import { modelDisplayNameFromId } from "@/features/providers/lib/modelRecommendations"; import { useCallback, useEffect, @@ -9,6 +10,7 @@ import { import { IconAiAgents, IconArrowsExchange, + IconArrowsSplit2, IconCheck, IconChevronDown, } from "@tabler/icons-react"; @@ -35,6 +37,7 @@ import { import type { AgentPickerOption, ChatInputReasoningEffort, + ChatInputAutoRouting, ModelOption, } from "../types"; import { @@ -44,6 +47,7 @@ import { import { PickerItem } from "./AgentModelPickerItem"; interface AgentModelPickerProps { + autoRouting?: ChatInputAutoRouting; agents: AgentPickerOption[]; selectedAgentId: string; onAgentChange: (agentId: string) => void; @@ -168,6 +172,7 @@ function ReasoningEffortList({ } export function AgentModelPicker({ + autoRouting, agents, selectedAgentId, onAgentChange, @@ -285,22 +290,43 @@ export function AgentModelPicker({ displayModelLabel, selectedAgentId, ]); - const triggerLabel = showSelectedModelInTrigger - ? resolvePickerTriggerLabel({ - currentModelId, - currentModelName, - currentModelProviderId, - availableModels: displayedModels, - selectedAgentLabel, - }) - : selectedAgentLabel; + const autoDecisionModelLabel = autoRouting?.decision + ? (resolveDisplayModelLabel({ + currentModelId: autoRouting.decision.modelId, + currentModelProviderId: autoRouting.decision.modelProviderId, + availableModels, + }) ?? + modelDisplayNameFromId( + autoRouting.decision.modelProviderId, + autoRouting.decision.modelId, + )) + : null; + const selectedAutoMode = autoRouting?.modes.find( + (mode) => mode.id === autoRouting.selectedModeId, + ); + const triggerLabel = autoRouting?.selectedModeId + ? (selectedAutoMode?.displayName ?? autoRouting.selectedModeId) + : showSelectedModelInTrigger + ? resolvePickerTriggerLabel({ + currentModelId, + currentModelName, + currentModelProviderId, + availableModels: displayedModels, + selectedAgentLabel, + }) + : selectedAgentLabel; const triggerTitle = triggerLabel ?? (loading ? t("toolbar.loading") : undefined); const triggerButtonSize = triggerIconOnly ? "icon-pill-sm" : "sm"; - const triggerProviderIcon = - getProviderIcon(selectedAgentId, "size-4") ?? - (triggerIconOnly ? : null); - const reasoningEffortConfig = reasoningEffort?.config; + const triggerProviderIcon = autoRouting?.selectedModeId ? ( +