From 985f50f9d855ad5c6cc303489e40e3d3c40be732 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 11:53:10 -0500 Subject: [PATCH 01/22] feat: add configurable auto model modes --- docs/auto-model-routing.md | 145 +++++++++ src-tauri/src/commands/auto_routing.rs | 198 ++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/runtime_config.rs | 298 ++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/app/AppShell.tsx | 56 +++- .../ConversationComposerCapability.tsx | 1 + .../useResolvedAgentModelPicker.test.ts | 136 ++++++++ .../chat/hooks/useAutoRoutingPicker.ts | 18 ++ src/features/chat/hooks/useChat.ts | 1 + .../chat/hooks/useChatSessionController.ts | 5 + .../chat/hooks/useResolvedAgentModelPicker.ts | 41 +++ src/features/chat/lib/acpSessionMapping.ts | 6 + src/features/chat/lib/autoModelSelection.ts | 47 +++ .../chat/lib/autoRoutingDispatch.test.ts | 163 ++++++++++ src/features/chat/lib/autoRoutingDispatch.ts | 139 ++++++++ .../chat/lib/autoRoutingPrompt.test.ts | 68 ++++ src/features/chat/lib/autoRoutingPrompt.ts | 81 +++++ src/features/chat/lib/backgroundSend.ts | 1 + src/features/chat/lib/newChat.test.ts | 18 ++ src/features/chat/lib/newChat.ts | 2 + src/features/chat/lib/queuedSessionSend.ts | 1 + src/features/chat/lib/sendCore.test.ts | 32 ++ src/features/chat/lib/sendCore.ts | 14 + .../chat/lib/sessionTargetCoordinator.test.ts | 65 ++++ .../chat/lib/sessionTargetCoordinator.ts | 54 ++++ .../stores/__tests__/chatSessionStore.test.ts | 46 +++ .../chat/stores/autoModePersistence.ts | 39 +++ src/features/chat/stores/chatSessionStore.ts | 24 +- src/features/chat/types.ts | 9 + src/features/chat/ui/AgentModelPicker.tsx | 121 ++++++- src/features/chat/ui/ChatInput.tsx | 2 + src/features/chat/ui/ChatInputToolbar.tsx | 2 + .../ui/__tests__/AgentModelPicker.test.tsx | 49 +++ src/shared/api/autoRouting.test.ts | 194 ++++++++++++ src/shared/api/autoRouting.ts | 136 ++++++++ src/shared/i18n/locales/en/chat.json | 14 +- src/shared/i18n/locales/es/chat.json | 14 +- src/shared/runtime-config/schema.ts | 159 ++++++++++ src/shared/ui/GlobalComposerPill.test.tsx | 49 +++ src/shared/ui/GlobalComposerPill.tsx | 37 ++- 41 files changed, 2462 insertions(+), 26 deletions(-) create mode 100644 docs/auto-model-routing.md create mode 100644 src-tauri/src/commands/auto_routing.rs create mode 100644 src/features/chat/hooks/useAutoRoutingPicker.ts create mode 100644 src/features/chat/lib/autoModelSelection.ts create mode 100644 src/features/chat/lib/autoRoutingDispatch.test.ts create mode 100644 src/features/chat/lib/autoRoutingDispatch.ts create mode 100644 src/features/chat/lib/autoRoutingPrompt.test.ts create mode 100644 src/features/chat/lib/autoRoutingPrompt.ts create mode 100644 src/features/chat/stores/autoModePersistence.ts create mode 100644 src/shared/api/autoRouting.test.ts create mode 100644 src/shared/api/autoRouting.ts diff --git a/docs/auto-model-routing.md b/docs/auto-model-routing.md new file mode 100644 index 000000000..1c1926416 --- /dev/null +++ b/docs/auto-model-routing.md @@ -0,0 +1,145 @@ +# 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. + +## 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 its text, loaded skill instructions (up to 24,000 +characters), and metadata plus text previews for up to four explicitly attached +local files (6,000 characters each). Recent conversation context helps classify +follow-ups. The complete routing context is capped at 64,000 characters. 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 and is written to the local `berd.log` under the `auto_routing` debug +target, including responses whose selection fails validation. Router evidence +and rationale can quote task context, so these local logs may contain that +context. Request prompts and authentication headers are never logged by the +routing transport. Routing responses are not sent as telemetry and are not +persisted into session history. Invalid JSON responses are available in the +bounded local log, while transport failures have stable fallback reason codes. diff --git a/src-tauri/src/commands/auto_routing.rs b/src-tauri/src/commands/auto_routing.rs new file mode 100644 index 000000000..12c76726d --- /dev/null +++ b/src-tauri/src/commands/auto_routing.rs @@ -0,0 +1,198 @@ +//! Runtime-configured routing transport. Requests and credentials are never logged. +//! Bounded responses are retained in the local debug log for inspection. +use crate::commands::runtime_config::{ + RuntimeAutoRoutingConfig, RuntimeAutoRoutingMode, RuntimeConfigState, +}; +use crate::services::distro_bundle::DistroBundleState; +use serde_json::{json, Value}; +use std::time::Duration; +use tauri::State; + +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, + runtime_config: State<'_, RuntimeConfigState>, + distro_bundle: State<'_, DistroBundleState>, +) -> Result { + if prompt.len() > 256_000 { + return Err("Routing context exceeds limit".to_string()); + } + let runtime = runtime_config.ready_config(&distro_bundle).await?; + let config = runtime + .goose + .auto_routing + .as_ref() + .ok_or("Auto routing is not configured")?; + let mode = config + .modes + .iter() + .find(|mode| mode.id == mode_id) + .ok_or("Auto routing mode is not configured")?; + request_route(config, mode, &prompt).await +} + +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::debug!(target: "auto_routing", "Router response for mode {}: {}", mode.id, String::from_utf8_lossy(&bytes)); + 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() + } + 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 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..527c0946f 100644 --- a/src-tauri/src/commands/runtime_config.rs +++ b/src-tauri/src/commands/runtime_config.rs @@ -76,6 +76,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)] @@ -717,6 +768,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 +842,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 +963,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, @@ -1157,6 +1389,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 { @@ -1673,6 +1906,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..b2092379b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -129,6 +129,7 @@ pub fn run() { tauri_plugin_log::Builder::new() .level(log::LevelFilter::Info) .level_for("perf", log::LevelFilter::Debug) + .level_for("auto_routing", log::LevelFilter::Debug) .max_file_size(APP_LOG_MAX_FILE_SIZE_BYTES) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome( APP_LOG_ARCHIVES_KEPT, @@ -637,6 +638,7 @@ 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, diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index ad0afaf8c..308a4eb83 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -1,3 +1,7 @@ +import { + getSelectedAutoModeId, + getAutoModeExecutionTarget, +} from "@/features/chat/lib/autoModelSelection"; import { projectEnvironment } from "@/features/projects/lib/projectEnvironment"; import { useCallback, @@ -319,6 +323,7 @@ type DraftSessionCreationReady = { >["configOptionsSnapshot"]; }; type ProjectChatDraftOptions = { + autoModeId?: string | null; remoteHost?: string; remoteWorkingDir?: string; executionTarget?: SessionExecutionTarget; @@ -2272,6 +2277,7 @@ export function AppShell({ options: { activate?: boolean; reuseExistingDraft?: boolean; + autoModeId?: string | null; executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; /** SSH host to run the session's backend on. */ @@ -2300,8 +2306,17 @@ export function AppShell({ perfLog( `[perf:newtab] createNewTab start (project=${sessionProject?.id ?? "none"})`, ); - const sessionExecutionTarget = - await resolveSessionCreationTarget(options); + const autoModeId = + 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 +2332,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, reasoningEffortValue: options.reasoningEffort?.value, remoteHost, }, @@ -2358,6 +2374,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir, remoteHost, }); @@ -2377,6 +2394,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir: optimisticWorkingDir, remoteHost, }); @@ -2519,8 +2537,17 @@ export function AppShell({ perfLog( `[perf:newtab] createNewProjectDraft start (project=${project.id})`, ); - const sessionExecutionTarget = - await resolveSessionCreationTarget(options); + const autoModeId = + 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 +2566,7 @@ export function AppShell({ title, projectId: project.id, executionTarget: sessionExecutionTarget, + autoModeId, reasoningEffortValue: options.reasoningEffort?.value, }, allowDraftReuse: options.reuseExistingDraft !== false && !needsStartup, @@ -2561,6 +2589,7 @@ export function AppShell({ title, projectId: project.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir: getOptimisticSessionCwd(project), workspaceAttachments: needsStartup ? asIs?.workspaceAttachments.filter( @@ -2603,6 +2632,7 @@ export function AppShell({ title = DEFAULT_CHAT_TITLE, project?: ProjectInfo, options: { + autoModeId?: string | null; executionTarget?: SessionExecutionTarget; reasoningEffort?: GlobalComposeOptions["reasoningEffort"]; remoteHost?: string; @@ -2626,8 +2656,17 @@ export function AppShell({ perfLog( `[perf:newtab] createBackgroundDraftChat start (project=${sessionProject?.id ?? "none"})`, ); - const sessionExecutionTarget = - await resolveSessionCreationTarget(options); + const autoModeId = + 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 +2680,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, reasoningEffortValue: options.reasoningEffort?.value, remoteHost, }, @@ -2669,6 +2709,7 @@ export function AppShell({ title, projectId: sessionProject?.id, executionTarget: sessionExecutionTarget, + autoModeId, workingDir: optimisticWorkingDir, remoteHost, }); @@ -3078,6 +3119,7 @@ export function AppShell({ } const chatOptions = { + autoModeId: options?.autoModeId, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, remoteHost: options?.remoteHost, @@ -3319,6 +3361,7 @@ export function AppShell({ ? projects.find((candidate) => candidate.id === options.projectId) : undefined; const chatOptions = { + autoModeId: options?.autoModeId, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, remoteHost: options?.remoteHost, @@ -3432,6 +3475,7 @@ export function AppShell({ // The realtime runtime follows the draft id through promotion. activate: realtimeMode, reuseExistingDraft: false, + autoModeId: options?.autoModeId, executionTarget: options?.executionTarget, reasoningEffort: options?.reasoningEffort, remoteHost: options?.remoteHost, 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__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index e462d1aa2..ef247e302 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -1,3 +1,10 @@ +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 +100,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 +172,133 @@ 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("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..34c0fe041 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -1,3 +1,4 @@ +import { getSelectedAutoModeId } from "../lib/autoModelSelection"; import { projectEnvironment } from "@/features/projects/lib/projectEnvironment"; import { useCallback, @@ -1064,6 +1065,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 +1173,7 @@ export function useChatSessionController({ handleModelChange, handlePickerOpen, effectiveModelSelection, + autoRouting, } = useResolvedAgentModelPicker({ providers, selectedProvider, @@ -2656,6 +2659,7 @@ export function useChatSessionController({ await ensureRemoteHostConnected(remoteHost); const created = await useChatSessionStore.getState().createSession({ executionTarget: remoteExecutionTarget, + autoModeId: getSelectedAutoModeId(sessionId), personaId: payload.persona.kind === "persona" ? payload.persona.id @@ -3558,6 +3562,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..d21664cda 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 { @@ -210,6 +216,15 @@ 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. @@ -348,6 +363,7 @@ export function useResolvedAgentModelPicker({ providers, selectedProvider, onProviderSelected: (providerId) => { + selectAutoMode(sessionId, null); selectionVersionRef.current += 1; const versionAtSelection = selectionVersionRef.current; const requestedAgentId = resolveAgentProviderCatalogIdStrictFromEntries( @@ -507,6 +523,7 @@ export function useResolvedAgentModelPicker({ }); }, onModelSelected: (model, requestedAgentId) => { + selectAutoMode(sessionId, null); const targetAgentId = requestedAgentId ?? selectedAgentId; const modelId = model.id; const modelName = model.displayName ?? model.name ?? model.id; @@ -825,6 +842,30 @@ 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 accepted = handleModelChange( + target.modelId, + { + id: target.modelId, + name: target.modelName, + providerId: target.modelProviderId, + }, + "goose", + ); + if ( + accepted === false && + !sameSessionExecutionTarget(session?.executionTarget, target) + ) + return; + 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..d0829dc0a --- /dev/null +++ b/src/features/chat/lib/autoRoutingDispatch.test.ts @@ -0,0 +1,163 @@ +import { PreCommitSendRejectedError } from "./preCommitSendRejection"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { acpPrepareSession, acpSetSessionConfigOption } from "@/shared/api/acp"; +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 { + acquireSessionDispatchTarget, + resetSessionTargetCoordinatorsForTests, +} from "./sessionTargetCoordinator"; + +vi.mock("@/shared/api/acp", () => ({ + acpPrepareSession: vi.fn(), + acpSetSessionConfigOption: vi.fn(), +})); +vi.mock("@/shared/api/autoRouting", () => ({ routeAutoModel: 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(); + resetSessionTargetCoordinatorsForTests(); + useRuntimeConfigStore.setState({ + 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("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"), + }), + ); + 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("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("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..e5a9685a8 --- /dev/null +++ b/src/features/chat/lib/autoRoutingDispatch.ts @@ -0,0 +1,139 @@ +import { i18n } from "@/shared/i18n"; +import { acpSetSessionConfigOption } from "@/shared/api/acp"; +import { + 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 { PreCommitSendRejectedError } from "./preCommitSendRejection"; +import { + transitionSessionDispatchTarget, + transitionSessionTarget, +} 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 || session.executionTarget?.harnessId !== "goose") return; + const config = useRuntimeConfigStore.getState().config?.goose.autoRouting; + const mode = config?.modes.find((candidate) => candidate.id === modeId); + if (!mode) throw new Error(i18n.t("chat:errors.autoModeMissing")); + const assertCurrent = () => { + if (input.signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if ( + 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(); + const decision = await routeAutoModel({ modeId, prompt }); + assertCurrent(); + const request = { + sessionId, + target: { + harnessId: "goose" as const, + modelProviderId: decision.modelProviderId, + modelId: decision.modelId, + modelName: decision.modelId, + }, + workingDir: + useChatSessionStore.getState().activeWorkspaceBySession[sessionId] + ?.path ?? + session.workingDir ?? + "", + }; + const outcome = input.dispatchToken + ? await transitionSessionDispatchTarget({ + ...request, + dispatchToken: input.dispatchToken, + }) + : await transitionSessionTarget(request); + if (!outcome.applied) { + throw new PreCommitSendRejectedError( + i18n.t("chat:errors.sessionPreparationSuperseded"), + ); + } + // Provider inventory repair may substitute a different model. Never send + // the task (or stamp a misleading routing decision) on that substitute. + if ( + outcome.target.modelId !== decision.modelId || + outcome.target.modelProviderId !== decision.modelProviderId || + outcome.target.harnessId !== "goose" + ) { + throw new Error(i18n.t("chat:errors.autoModelUnavailable")); + } + assertCurrent(); + 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..4505d07b7 --- /dev/null +++ b/src/features/chat/lib/autoRoutingPrompt.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { readTextFile } from "@/shared/api/system"; +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(2_000), + byteSize: 16_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: 16000 bytes"); + expect(prompt).toContain("Contents:\ncontent"); + expect(prompt).toContain("[Truncated for model selection]"); + expect(prompt.length).toBeLessThan(7_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("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(64_000); + expect(prompt).toContain("Selected skill and request instructions:"); + }); +}); diff --git a/src/features/chat/lib/autoRoutingPrompt.ts b/src/features/chat/lib/autoRoutingPrompt.ts new file mode 100644 index 000000000..3a4069e30 --- /dev/null +++ b/src/features/chat/lib/autoRoutingPrompt.ts @@ -0,0 +1,81 @@ +import { readTextFile } from "@/shared/api/system"; +import type { ChatAttachmentDraft, Message } from "@/shared/types/messages"; + +// Keep classification bounded independently of the inference prompt. Selected +// skills get more room than file previews because they define the actual task. +const MAX_PROMPT_CHARS = 16_000; +const MAX_SKILL_CHARS = 24_000; +const MAX_FILE_CHARS = 6_000; +const MAX_HISTORY_CHARS = 8_000; +const MAX_FILES = 4; + +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 = (input.messages ?? []) + .filter( + (message) => 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, 64_000); +} 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..c6c3413b5 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,70 @@ describe("session target coordinator", () => { }); }); + it("allows the dispatch owner to route to a new model without releasing its lease", async () => { + mockPrepare.mockResolvedValue({ + 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..d49ba9a32 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.ts @@ -729,6 +729,60 @@ 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 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) { + 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..4212fa64d 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, @@ -35,6 +36,7 @@ import { import type { AgentPickerOption, ChatInputReasoningEffort, + ChatInputAutoRouting, ModelOption, } from "../types"; import { @@ -44,6 +46,7 @@ import { import { PickerItem } from "./AgentModelPickerItem"; interface AgentModelPickerProps { + autoRouting?: ChatInputAutoRouting; agents: AgentPickerOption[]; selectedAgentId: string; onAgentChange: (agentId: string) => void; @@ -168,6 +171,7 @@ function ReasoningEffortList({ } export function AgentModelPicker({ + autoRouting, agents, selectedAgentId, onAgentChange, @@ -285,22 +289,40 @@ 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 reasoningEffortConfig = autoRouting?.selectedModeId + ? undefined + : reasoningEffort?.config; const hasLiveReasoningEffort = Boolean(reasoningEffortConfig?.configId) && (reasoningEffortConfig?.options.length ?? 0) > 1; @@ -478,6 +500,16 @@ export function AgentModelPicker({ {triggerLabel ?? (loading ? t("toolbar.loading") : null)} + {autoRouting?.selectedModeId && autoRouting.decision ? ( + + {[ + autoDecisionModelLabel, + autoRouting.decision.effortApplied, + ] + .filter(Boolean) + .join(" · ")} + + ) : null} {showReasoningEffort && selectedReasoningEffortLabel ? ( {selectedReasoningEffortLabel} @@ -676,7 +708,7 @@ export function AgentModelPicker({
+ {autoRouting && autoRouting.modes.length > 0 ? ( +
+ {autoRouting.modes.map((mode) => ( + { + autoRouting.onModeChange(mode.id); + setOpen(false); + }} + className="justify-between" + > + + {mode.displayName} + + {autoRouting.selectedModeId === mode.id ? ( + + ) : null} + + ))} + {autoRouting.selectedModeId ? ( +
+ {autoRouting.decision ? ( + <> +
+ {t("toolbar.autoRouting.selected", { + model: [ + autoDecisionModelLabel, + autoRouting.decision.effortApplied, + ] + .filter(Boolean) + .join(" · "), + })} +
+
+ + {t("toolbar.autoRouting.details")} + +

+ {autoRouting.decision.rationale} +

+
+ {autoRouting.decision.source === "fallback" + ? t("toolbar.autoRouting.fallback") + : t("toolbar.autoRouting.routed")} +
+ {autoRouting.decision.fallbackReason ? ( +

+ {autoRouting.decision.fallbackReason} +

+ ) : null} +
+                              {JSON.stringify(autoRouting.decision, null, 2)}
+                            
+
+ + ) : ( + t("toolbar.autoRouting.pending") + )} +
+ ) : null} +
+ ) : null} {modelsLoading ? (
@@ -712,7 +807,9 @@ export function AgentModelPicker({ models={displayedModels} favoriteModels={favoriteModels} catalogModels={availableModels} - currentModelId={currentModelId} + currentModelId={ + autoRouting?.selectedModeId ? null : currentModelId + } currentModelProviderId={currentModelProviderId} selectedAgentId={selectedAgentId} agentLabels={ diff --git a/src/features/chat/ui/ChatInput.tsx b/src/features/chat/ui/ChatInput.tsx index 4a5040832..a83e16789 100644 --- a/src/features/chat/ui/ChatInput.tsx +++ b/src/features/chat/ui/ChatInput.tsx @@ -348,6 +348,7 @@ export function ChatInput({ onModelChange, onPickerOpen, providerColumnMode, + autoRouting, } = agentModelPicker ?? {}; const { selectedProjectId = null, @@ -1959,6 +1960,7 @@ export function ChatInput({ 0 || providersLoading) && ( onProviderChange?.(providerId)} diff --git a/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx b/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx index d965d7fe0..fdf398338 100644 --- a/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx +++ b/src/features/chat/ui/__tests__/AgentModelPicker.test.tsx @@ -94,6 +94,55 @@ describe("AgentModelPicker", () => { ).toHaveTextContent("GPT-4o"); }); + it("places configured auto modes first and shows the concrete model and routing details", async () => { + const user = userEvent.setup(); + const onModeChange = vi.fn(); + render( + , + ); + const trigger = screen.getByRole("button", { + name: /choose agent and model/i, + }); + expect(trigger).toHaveTextContent("Auto Balanced"); + expect(trigger).toHaveTextContent("GPT-5"); + await user.click(trigger); + const autoMode = screen.getByRole("button", { name: "Auto Fast" }); + expect( + autoMode.compareDocumentPosition( + screen.getByRole("button", { name: "GPT-5" }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + await user.click(screen.getByText("Why this model?")); + expect(screen.getByText("This task needs deeper reasoning.")).toBeVisible(); + expect(screen.getByText(/full routing response/)).toBeVisible(); + await user.click(autoMode); + expect(onModeChange).toHaveBeenCalledWith("fast"); + }); + // Harness agents (pi-acp, codex-acp, etc.) get a wider picker in the // new-chat composer because their ACP model names can be long; Goose keeps // the compact layout. diff --git a/src/shared/api/autoRouting.test.ts b/src/shared/api/autoRouting.test.ts new file mode 100644 index 000000000..d9b1871ca --- /dev/null +++ b/src/shared/api/autoRouting.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + runtimeAutoRoutingConfigSchema, + runtimeConfigSchema, + type RuntimeAutoRoutingMode, +} from "@/shared/runtime-config/schema"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { + autoRoutingFallback, + buildAutoRoutingRequest, + routeAutoModel, + selectAutoRoutingResponse, +} from "./autoRouting"; +vi.mock("./invokeWithStartupRetry", () => ({ + invokeWithStartupRetry: vi.fn(), +})); +import { invokeWithStartupRetry } from "./invokeWithStartupRetry"; + +const mode: RuntimeAutoRoutingMode = { + id: "auto", + displayName: "Auto", + modelProviderId: "databricks_v2", + fallback: { modelId: "served-gpt", effort: "high" }, + models: [ + { + modelId: "served-gpt", + routerModel: "system.ai.gpt-6.1-sol", + minEffort: "high", + maxEffort: "max", + }, + { + modelId: "served-claude", + routerModel: "system.ai.claude-opus", + maxEffort: "medium", + }, + ], +}; +const config = { url: "https://gateway.example.com/route", modes: [mode] }; +const response = (model: string, extra = {}) => ({ + route_selection: [{ route_option: { model, harness: "codex" }, ...extra }], + rationale: "Selected", +}); +describe("auto routing selection", () => { + it("maps normalized router IDs to concrete model IDs and supplies bounded default effort", () => { + const raw = response("gpt-6-1-sol"); + expect(selectAutoRoutingResponse(mode, raw)).toEqual({ + modeId: "auto", + modelProviderId: "databricks_v2", + modelId: "served-gpt", + effort: "high", + source: "router", + rationale: "Selected", + response: raw, + }); + }); + it("clamps returned effort and preserves the complete response", () => { + const raw = response("claude-opus", { + params: { reasoning_effort: "max", task_tier: "High" }, + }); + const selected = selectAutoRoutingResponse(mode, raw); + expect(selected.effort).toBe("medium"); + expect(selected.response).toBe(raw); + }); + it.each([ + null, + {}, + { route_selection: [] }, + { route_selection: [{}, {}] }, + response("unknown"), + response("gpt-6-1-sol", { params: { effort: "invented" } }), + { + route_selection: [ + { route_option: { model: "gpt-6-1-sol", harness: "other" } }, + ], + }, + ])("falls back for invalid or unconfigured selections %j", (raw) => { + expect(selectAutoRoutingResponse(mode, raw)).toMatchObject({ + modelId: "served-gpt", + effort: "high", + source: "fallback", + response: raw, + }); + }); + it("builds the documented router wire format without leaking concrete IDs", () => { + expect(buildAutoRoutingRequest(mode, "context", config)).toEqual({ + task: { prompt: "context" }, + route_options: [ + { + model: "system.ai.gpt-6.1-sol", + harness: "codex", + min_effort: "high", + max_effort: "max", + }, + { + model: "system.ai.claude-opus", + harness: "codex", + max_effort: "medium", + }, + ], + }); + }); + it("fallback records stable failure reasons", () => + expect(autoRoutingFallback(mode, "request_failed").fallbackReason).toBe( + "request_failed", + )); +}); +describe("auto routing runtime configuration", () => { + it("accepts optional selectors and rejects secrets and invalid references", () => { + expect(runtimeAutoRoutingConfigSchema.safeParse(config).success).toBe(true); + expect( + runtimeAutoRoutingConfigSchema.safeParse({ + ...config, + bearerTokenEnv: "DATABRICKS_TOKEN", + }).success, + ).toBe(true); + for (const invalid of [ + { ...config, url: "https://user:secret@gateway.example.com/route" }, + { ...config, url: "https://gateway.example.com/route?token=secret" }, + { ...config, timeoutMs: 0 }, + { ...config, bearerTokenEnv: "Bearer actual-value" }, + { ...config, bearerTokenEnv: "lowercase" }, + { ...config, defaultModeId: "missing" }, + { ...config, modes: [mode, mode] }, + { + ...config, + routeSelector: { + router_name: "task_v3", + config: { nested: { api_key: "secret" } }, + }, + }, + { + ...config, + modes: [{ ...mode, fallback: { modelId: "unknown", effort: "high" } }], + }, + { + ...config, + modes: [ + { + ...mode, + models: [ + { ...mode.models[0], minEffort: "max", maxEffort: "high" }, + ], + }, + ], + }, + { + ...config, + modes: [ + { ...mode, fallback: { modelId: "served-gpt", effort: "low" } }, + ], + }, + ]) + expect(runtimeAutoRoutingConfigSchema.safeParse(invalid).success).toBe( + false, + ); + expect( + runtimeConfigSchema.safeParse({ + schemaVersion: 1, + goose: { modelProviders: [], autoRouting: config }, + }).success, + ).toBe(false); + }); +}); +describe("auto routing invocation", () => { + beforeEach(() => { + vi.clearAllMocks(); + useRuntimeConfigStore.setState({ + config: { + schemaVersion: 1, + goose: { modelProviders: [], autoRouting: config }, + }, + }); + }); + it("sends only mode ID and context to the backend", async () => { + vi.mocked(invokeWithStartupRetry).mockResolvedValue( + response("gpt-6-1-sol"), + ); + expect( + (await routeAutoModel({ modeId: "auto", prompt: "context" })).source, + ).toBe("router"); + expect(invokeWithStartupRetry).toHaveBeenCalledWith("route_auto_model", { + modeId: "auto", + prompt: "context", + }); + }); + it("falls back on rejected network and timeout requests without retaining sensitive errors", async () => { + vi.mocked(invokeWithStartupRetry).mockRejectedValue( + new Error("secret endpoint error"), + ); + expect(await routeAutoModel({ modeId: "auto", prompt: "context" })).toEqual( + autoRoutingFallback(mode, "request_failed"), + ); + }); +}); diff --git a/src/shared/api/autoRouting.ts b/src/shared/api/autoRouting.ts new file mode 100644 index 000000000..933559ef0 --- /dev/null +++ b/src/shared/api/autoRouting.ts @@ -0,0 +1,136 @@ +import { + autoRoutingEfforts, + type AutoRoutingEffort, + type RuntimeAutoRoutingConfig, + type RuntimeAutoRoutingMode, + type RuntimeAutoRoutingModel, +} from "@/shared/runtime-config/schema"; +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { invokeWithStartupRetry } from "./invokeWithStartupRetry"; + +export interface AutoRoutingResult { + modeId: string; + source: "router" | "fallback"; + rationale?: string; + fallbackReason?: string; + response?: unknown; + effortApplied?: string; + modelProviderId: string; + modelId: string; + effort: AutoRoutingEffort; +} +export type AutoRoutingDecision = AutoRoutingResult; + +export function buildAutoRoutingRequest( + mode: RuntimeAutoRoutingMode, + prompt: string, + config: RuntimeAutoRoutingConfig, +) { + return { + route_options: mode.models.map((model) => ({ + model: model.routerModel, + harness: model.harness ?? "codex", + ...(model.minEffort ? { min_effort: model.minEffort } : {}), + ...(model.maxEffort ? { max_effort: model.maxEffort } : {}), + })), + task: { prompt }, + ...(config.routeSelector ? { route_selector: config.routeSelector } : {}), + }; +} +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} +function normalizedModel(value: string) { + return value.replace(/^system\.ai\./, "").replace(/(\d)\.(?=\d)/g, "$1-"); +} +function boundedEffort( + model: RuntimeAutoRoutingModel, + effort: AutoRoutingEffort, +): AutoRoutingEffort { + const min = autoRoutingEfforts.indexOf(model.minEffort ?? "none"); + const max = autoRoutingEfforts.indexOf(model.maxEffort ?? "max"); + return autoRoutingEfforts[ + Math.max(min, Math.min(max, autoRoutingEfforts.indexOf(effort))) + ]; +} +export function autoRoutingFallback( + mode: RuntimeAutoRoutingMode, + reason: string, + response?: unknown, +): AutoRoutingResult { + return { + modeId: mode.id, + modelProviderId: mode.modelProviderId, + ...mode.fallback, + source: "fallback", + fallbackReason: reason, + ...(response === undefined ? {} : { response }), + }; +} +/** Only configured candidates may be selected. Preserve opaque server diagnostics in memory. */ +export function selectAutoRoutingResponse( + mode: RuntimeAutoRoutingMode, + response: unknown, +): AutoRoutingResult { + const body = record(response); + const selections = body?.route_selection; + if (!Array.isArray(selections) || selections.length !== 1) + return autoRoutingFallback(mode, "invalid_response", response); + const selection = record(selections[0]); + const option = record(selection?.route_option); + if (typeof option?.model !== "string" || typeof option.harness !== "string") + return autoRoutingFallback(mode, "invalid_response", response); + const candidate = mode.models.find( + (model) => + normalizedModel(model.routerModel) === + normalizedModel(option.model as string) && + (model.harness ?? "codex") === option.harness, + ); + if (!candidate) + return autoRoutingFallback(mode, "unconfigured_model", response); + const params = record(selection?.params); + const effort = + params?.reasoning_effort ?? params?.effort ?? selection?.effort; + if ( + effort !== undefined && + (typeof effort !== "string" || + !autoRoutingEfforts.includes(effort as AutoRoutingEffort)) + ) + return autoRoutingFallback(mode, "invalid_effort", response); + return { + modeId: mode.id, + modelProviderId: mode.modelProviderId, + modelId: candidate.modelId, + effort: boundedEffort(candidate, (effort ?? "medium") as AutoRoutingEffort), + source: "router", + response, + ...(typeof body?.rationale === "string" + ? { rationale: body.rationale } + : {}), + }; +} + +export async function routeAutoModel({ + modeId, + prompt, +}: { + modeId: string; + prompt: string; +}): Promise { + const config = useRuntimeConfigStore.getState().config.goose.autoRouting; + const mode = config?.modes.find((entry) => entry.id === modeId); + if (!mode) throw new Error("Auto routing mode is not configured"); + try { + // The backend resolves URL, selector and candidate list from trusted runtime config. + const response = await invokeWithStartupRetry("route_auto_model", { + modeId, + prompt, + }); + return selectAutoRoutingResponse(mode, response); + } catch { + // Error messages may contain endpoint details; diagnostics use stable local codes. + return autoRoutingFallback(mode, "request_failed"); + } +} diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index f89a00550..424088c7b 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -84,7 +84,10 @@ "voiceInputPermissionDescription": "Allow microphone access for Berd in System Settings, then try voice dictation again.", "voiceInputPermissionTitle": "Microphone access is blocked", "voiceInputUnavailableDescription": "Another app may be using your microphone, or macOS could not start recording. Close other recording apps and try again.", - "voiceInputUnavailableTitle": "Microphone is unavailable" + "voiceInputUnavailableTitle": "Microphone is unavailable", + "autoModeMissing": "The selected auto mode is no longer configured.", + "autoModelUnavailable": "The selected model is unavailable from its configured provider.", + "autoEffortUnavailable": "This model has no supported reasoning effort within the auto mode's limits." }, "contextPanel": { "actions": { @@ -662,7 +665,14 @@ "starAgentModel": "Star {{model}}, {{agent}}", "unstarAgentModel": "Unstar {{model}}, {{agent}}", "starModel": "Star {{model}}", - "unstarModel": "Unstar {{model}}" + "unstarModel": "Unstar {{model}}", + "autoRouting": { + "selected": "Using {{model}}", + "details": "Why this model?", + "fallback": "The router was unavailable. Using the configured fallback.", + "routed": "Selected for this prompt. A new prompt chooses again.", + "pending": "Chooses a model and effort for each new prompt." + } }, "tools": { "content": "Content", diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index b79b34ec0..474253c4d 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -83,7 +83,10 @@ "voiceInputPermissionDescription": "Permite el acceso al micrófono para Berd en Configuración del Sistema y vuelve a intentar el dictado por voz.", "voiceInputPermissionTitle": "El acceso al micrófono está bloqueado", "voiceInputUnavailableDescription": "Otra app puede estar usando tu micrófono, o macOS no pudo iniciar la grabación. Cierra otras apps de grabación y vuelve a intentarlo.", - "voiceInputUnavailableTitle": "El micrófono no está disponible" + "voiceInputUnavailableTitle": "El micrófono no está disponible", + "autoModeMissing": "El modo automático seleccionado ya no está configurado.", + "autoModelUnavailable": "El modelo seleccionado no está disponible en el proveedor configurado.", + "autoEffortUnavailable": "Este modelo no admite ningún nivel de esfuerzo dentro de los límites del modo automático." }, "contextPanel": { "actions": { @@ -647,7 +650,14 @@ "starAgentModel": "Destacar {{model}}, {{agent}}", "unstarAgentModel": "Quitar {{model}}, {{agent}} de destacados", "starModel": "Destacar {{model}}", - "unstarModel": "Quitar {{model}} de destacados" + "unstarModel": "Quitar {{model}} de destacados", + "autoRouting": { + "selected": "Usando {{model}}", + "details": "¿Por qué este modelo?", + "fallback": "El enrutador no está disponible. Se usa la alternativa configurada.", + "routed": "Seleccionado para este mensaje. Cada nuevo mensaje vuelve a elegir.", + "pending": "Elige un modelo y esfuerzo para cada mensaje nuevo." + } }, "tools": { "content": "Contenido", diff --git a/src/shared/runtime-config/schema.ts b/src/shared/runtime-config/schema.ts index 4b8792026..f3822ee3f 100644 --- a/src/shared/runtime-config/schema.ts +++ b/src/shared/runtime-config/schema.ts @@ -171,6 +171,155 @@ export const runtimeGooseModelProviderSchema = z }) .strict(); +export const autoRoutingEfforts = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; +export const autoRoutingEffortSchema = z.enum(autoRoutingEfforts); +const routerJsonSchema = z.json(); +function hasNoRouterSecrets(value: unknown): boolean { + if (Array.isArray(value)) return value.every(hasNoRouterSecrets); + if (value && typeof value === "object") { + return Object.entries(value).every( + ([key, item]) => + !/secret|token|apikey|api-key|api_key|password|authorization|bearer/i.test( + key, + ) && hasNoRouterSecrets(item), + ); + } + return ( + typeof value !== "string" || + !/bearer\s|-----BEGIN.*PRIVATE KEY/i.test(value) + ); +} +export const runtimeAutoRoutingModelSchema = z + .object({ + modelId: runtimeIdString("autoRouting modelId"), + routerModel: runtimeIdString("autoRouting routerModel"), + harness: runtimeIdString("autoRouting harness").optional(), + minEffort: autoRoutingEffortSchema.optional(), + maxEffort: autoRoutingEffortSchema.optional(), + }) + .strict() + .refine( + (model) => + autoRoutingEfforts.indexOf(model.minEffort ?? "none") <= + autoRoutingEfforts.indexOf(model.maxEffort ?? "max"), + "autoRouting minEffort must not exceed maxEffort", + ); +export const runtimeAutoRoutingModeSchema = z + .object({ + id: runtimeIdString("autoRouting mode id"), + displayName: nonEmptyString("autoRouting mode displayName"), + modelProviderId: runtimeIdString("autoRouting modelProviderId"), + fallback: z + .object({ + modelId: runtimeIdString("autoRouting fallback modelId"), + effort: autoRoutingEffortSchema, + }) + .strict(), + models: z.array(runtimeAutoRoutingModelSchema).min(1), + }) + .strict() + .superRefine((mode, ctx) => { + const identities = mode.models.map( + (model) => + `${model.routerModel.replace(/^system\.ai\./, "").replace(/(\d)\.(?=\d)/g, "$1-")}\0${model.harness ?? "codex"}`, + ); + if ( + new Set(identities).size !== identities.length || + new Set(mode.models.map((model) => model.modelId)).size !== + mode.models.length + ) { + ctx.addIssue({ + code: "custom", + message: + "autoRouting models must have unique modelIds and router model/harness pairs", + }); + } + const fallback = mode.models.find( + (model) => model.modelId === mode.fallback.modelId, + ); + if (!fallback) + ctx.addIssue({ + code: "custom", + message: "autoRouting fallback must reference a candidate model", + }); + else if ( + autoRoutingEfforts.indexOf(mode.fallback.effort) < + autoRoutingEfforts.indexOf(fallback.minEffort ?? "none") || + autoRoutingEfforts.indexOf(mode.fallback.effort) > + autoRoutingEfforts.indexOf(fallback.maxEffort ?? "max") + ) { + ctx.addIssue({ + code: "custom", + message: "autoRouting fallback effort must be within candidate bounds", + }); + } + }); +export const runtimeAutoRoutingConfigSchema = z + .object({ + url: runtimeIdString("autoRouting url").refine((value) => { + if (!isHttpUrl(value)) return false; + const url = new URL(value); + return !url.username && !url.password && !url.search && !url.hash; + }, "autoRouting url must be an http or https URL without credentials, query or fragment"), + timeoutMs: z.number().int().min(1).max(120_000).optional(), + bearerTokenEnv: z + .string() + .regex( + /^[A-Z_][A-Z0-9_]*$/, + "autoRouting bearerTokenEnv must be an environment variable name", + ) + .optional(), + routeSelector: z + .object({ + router_name: runtimeIdString("autoRouting routeSelector router_name"), + config: z.record(z.string(), routerJsonSchema).optional(), + }) + .strict() + .refine( + hasNoRouterSecrets, + "autoRouting routeSelector must not contain secrets", + ) + .optional(), + defaultModeId: runtimeIdString("autoRouting defaultModeId").optional(), + modes: z.array(runtimeAutoRoutingModeSchema).min(1), + }) + .strict() + .superRefine((config, ctx) => { + if ( + new Set(config.modes.map((mode) => mode.id)).size !== config.modes.length + ) + ctx.addIssue({ + code: "custom", + message: "autoRouting modes must have unique ids", + }); + if ( + config.defaultModeId && + !config.modes.some((mode) => mode.id === config.defaultModeId) + ) + ctx.addIssue({ + code: "custom", + message: "autoRouting defaultModeId must reference a mode", + }); + }); +export type AutoRoutingEffort = z.infer; +export type RuntimeAutoRoutingModel = z.infer< + typeof runtimeAutoRoutingModelSchema +>; +export type RuntimeAutoRoutingMode = z.infer< + typeof runtimeAutoRoutingModeSchema +>; +export type RuntimeAutoRoutingConfig = z.infer< + typeof runtimeAutoRoutingConfigSchema +>; + export const runtimeGooseConfigSchema = z .object({ defaultModelProviderId: runtimeIdString( @@ -178,6 +327,7 @@ export const runtimeGooseConfigSchema = z ).optional(), defaultModelId: runtimeIdString("goose defaultModelId").optional(), modelProviders: z.array(runtimeGooseModelProviderSchema), + autoRouting: runtimeAutoRoutingConfigSchema.optional(), }) .strict() .superRefine((goose, ctx) => { @@ -234,6 +384,15 @@ export const runtimeGooseConfigSchema = z } } + for (const [index, mode] of (goose.autoRouting?.modes ?? []).entries()) { + if (!providerIds.has(mode.modelProviderId)) + ctx.addIssue({ + code: "custom", + path: ["autoRouting", "modes", index, "modelProviderId"], + message: "autoRouting modelProviderId must reference modelProviders", + }); + } + if (goose.modelProviders.length === 0) { if (goose.defaultModelProviderId) { ctx.addIssue({ diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index 0db86f855..17ab7bf0b 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -1,3 +1,6 @@ +import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; +import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; +import { usePendingAutoModeStore } from "@/features/chat/lib/autoModelSelection"; import { act, createEvent, @@ -241,6 +244,8 @@ describe("GlobalComposerPill", () => { }); beforeEach(() => { + useRuntimeConfigStore.setState({ config: DEFAULT_RUNTIME_CONFIG }); + usePendingAutoModeStore.setState({ modeId: undefined }); resetVoiceDictationShortcutControllerForTests(); mockOpenDialog.mockReset(); mockInspectAttachmentPaths.mockReset(); @@ -1349,6 +1354,50 @@ describe("GlobalComposerPill", () => { expect(screen.queryByText("Project One")).not.toBeInTheDocument(); }); + it("sends the selected auto mode with its concrete Goose fallback", async () => { + const user = userEvent.setup(); + 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 onSend = renderGlobalComposer(); + await user.click(screen.getByRole("textbox")); + await user.click( + screen.getByRole("button", { name: /choose agent and model/i }), + ); + await user.click(screen.getByRole("button", { name: "Auto Balanced" })); + await user.type(screen.getByRole("textbox"), "Route this task"); + await user.click(screen.getByRole("button", { name: /send message/i })); + expect(onSend).toHaveBeenCalledWith( + "Route this task", + expect.objectContaining({ + autoModeId: "balanced", + executionTarget: { + harnessId: "goose", + modelProviderId: "openai", + modelId: "gpt-5", + modelName: "gpt-5", + }, + }), + ); + }); + it("disables send until there is sendable content", async () => { const user = userEvent.setup(); const onSend = renderGlobalComposer(); diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index 4ef4ea7ab..df48ed7a4 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -1,3 +1,9 @@ +import { useAutoRoutingPicker } from "@/features/chat/hooks/useAutoRoutingPicker"; +import { + getSelectedAutoModeId, + getAutoModeExecutionTarget, + selectAutoMode, +} from "@/features/chat/lib/autoModelSelection"; import { useCallback, useEffect, @@ -62,6 +68,7 @@ import { useTextareaAutosize } from "@/shared/hooks/useTextareaAutosize"; import { useVoiceDictationShortcutTarget } from "@/features/chat/lib/voiceDictationShortcutController"; export interface GlobalComposeOptions { + autoModeId?: string | null; executionTarget?: SessionExecutionTarget; projectId?: string | null; attachments?: ChatAttachmentDraft[]; @@ -453,6 +460,7 @@ export function GlobalComposerPill({ }, []); const placeholder = t("globalPill.placeholder"); + const autoRouting = useAutoRoutingPicker(); const selectedProviderForPicker = providerOverride ?? currentExecutionTarget?.harnessId ?? selectedProvider; const { @@ -470,6 +478,7 @@ export function GlobalComposerPill({ providers, selectedProvider: selectedProviderForPicker, onProviderSelected: (providerId, providerModels) => { + selectAutoMode(null, null); personaOverrideUserOverrideForRef.current = selectedPersonaId; personaOverrideActiveRef.current = false; const nextModel = getPreferredModel(providerModels, providerId); @@ -484,6 +493,7 @@ export function GlobalComposerPill({ setSelectedProvider(providerId); }, onModelSelected: (model, owningAgentId) => { + selectAutoMode(null, null); const agentId = owningAgentId ?? selectedAgentId; const providerId = owningAgentId ?? selectedProviderForPicker; const selection = modelOptionToSelection(model, providerId); @@ -802,7 +812,8 @@ export function GlobalComposerPill({ return false; } - const options: GlobalComposeOptions = {}; + const autoModeId = getSelectedAutoModeId(); + const options: GlobalComposeOptions = autoModeId ? { autoModeId } : {}; if (attachments.length > 0) { options.attachments = attachments; } @@ -887,7 +898,8 @@ export function GlobalComposerPill({ }); } - const options: GlobalComposeOptions = {}; + const autoModeId = getSelectedAutoModeId(); + const options: GlobalComposeOptions = autoModeId ? { autoModeId } : {}; const remountSafeAttachments = makeRemountSafeDraftAttachments(attachments); if (remountSafeAttachments.length > 0) { options.attachments = remountSafeAttachments; @@ -1082,7 +1094,8 @@ export function GlobalComposerPill({ return; } - const options: GlobalComposeOptions = {}; + const autoModeId = getSelectedAutoModeId(); + const options: GlobalComposeOptions = autoModeId ? { autoModeId } : {}; const remountSafeAttachments = makeRemountSafeDraftAttachments(attachments); if (remountSafeAttachments.length > 0) { options.attachments = remountSafeAttachments; @@ -1500,6 +1513,24 @@ export function GlobalComposerPill({ { + const target = getAutoModeExecutionTarget(modeId); + if (!target) return; + const accepted = handleModelChange( + target.modelId, + { + id: target.modelId, + name: target.modelName, + providerId: target.modelProviderId, + }, + "goose", + ); + if (accepted === false && selectedAgentId !== "goose") return; + selectAutoMode(null, modeId); + }, + }} agents={pickerAgents} selectedAgentId={selectedAgentId} onAgentChange={handleProviderChange} From 785510f8752e2b95ae78abfd71c6ff1167b2bdd9 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 11:54:47 -0500 Subject: [PATCH 02/22] fix: preserve auto intent across queued selections --- .../useResolvedAgentModelPicker.test.ts | 63 +++++++++++++++++++ .../chat/hooks/useResolvedAgentModelPicker.ts | 8 ++- .../chat/lib/sessionTargetCoordinator.test.ts | 13 +++- .../chat/lib/sessionTargetCoordinator.ts | 8 +++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index ef247e302..e08aff1b5 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -1,3 +1,4 @@ +import { beginModelSelectionIntent } from "../../model-selection/modelSelectionIntent"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; import { @@ -262,6 +263,68 @@ describe("useResolvedAgentModelPicker", () => { 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("uses configured auto defaults only for new chats and preserves explicit opt-out", () => { expect(getSelectedAutoModeId()).toBeUndefined(); useRuntimeConfigStore.setState({ diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index d21664cda..c1ab89dae 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -24,6 +24,7 @@ import { } from "../lib/modelPreferences"; import { beginModelSelectionIntent, + getModelSelectionIntent, clearCurrentModelSelectionIntent, createModelSelectionRequestId, rollbackToPreviousModel, @@ -582,7 +583,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; } diff --git a/src/features/chat/lib/sessionTargetCoordinator.test.ts b/src/features/chat/lib/sessionTargetCoordinator.test.ts index c6c3413b5..5da499502 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.test.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.test.ts @@ -65,9 +65,16 @@ describe("session target coordinator", () => { }); it("allows the dispatch owner to route to a new model without releasing its lease", async () => { - mockPrepare.mockResolvedValue({ - model: { modelId: "b", modelName: "b" }, - reasoningEffort, + 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"); diff --git a/src/features/chat/lib/sessionTargetCoordinator.ts b/src/features/chat/lib/sessionTargetCoordinator.ts index d49ba9a32..9238d8cd6 100644 --- a/src/features/chat/lib/sessionTargetCoordinator.ts +++ b/src/features/chat/lib/sessionTargetCoordinator.ts @@ -748,6 +748,11 @@ export async function transitionSessionDispatchTarget( } 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; @@ -767,6 +772,9 @@ export async function transitionSessionDispatchTarget( 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() From 1040959c00597fbdca889465bc42ef6e5a19b7c7 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 12:01:34 -0500 Subject: [PATCH 03/22] fix: use key sort for pending handoffs --- src-tauri/crates/berd-call/src/openai_realtime_protocol.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From f98c9aeaaaa753ac7b23e16cfb89a7ed4c865548 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 13:10:04 -0500 Subject: [PATCH 04/22] fix: preserve prepared cwd during auto routing --- .../chat/lib/autoRoutingDispatch.test.ts | 48 ++++++++++++++++++- src/features/chat/lib/autoRoutingDispatch.ts | 13 ++--- src/shared/api/acp.ts | 9 ++++ src/shared/api/acpSessionRegistry.ts | 5 ++ 4 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/features/chat/lib/autoRoutingDispatch.test.ts b/src/features/chat/lib/autoRoutingDispatch.test.ts index d0829dc0a..d397038a3 100644 --- a/src/features/chat/lib/autoRoutingDispatch.test.ts +++ b/src/features/chat/lib/autoRoutingDispatch.test.ts @@ -1,6 +1,10 @@ import { PreCommitSendRejectedError } from "./preCommitSendRejection"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { acpPrepareSession, acpSetSessionConfigOption } from "@/shared/api/acp"; +import { + acpGetPreparedWorkingDir, + acpPrepareSession, + acpSetSessionConfigOption, +} from "@/shared/api/acp"; import { routeAutoModel } from "@/shared/api/autoRouting"; import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; @@ -13,6 +17,7 @@ import { } from "./sessionTargetCoordinator"; vi.mock("@/shared/api/acp", () => ({ + acpGetPreparedWorkingDir: vi.fn(), acpPrepareSession: vi.fn(), acpSetSessionConfigOption: vi.fn(), })); @@ -30,6 +35,7 @@ const decision = { describe("auto routing dispatch", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(acpGetPreparedWorkingDir).mockReturnValue("/work"); resetSessionTargetCoordinatorsForTests(); useRuntimeConfigStore.setState({ config: { @@ -131,6 +137,46 @@ describe("auto routing dispatch", () => { }); }); + 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(); + } + }); + it("skips the router after an explicit model choice", async () => { useChatSessionStore.getState().patchSession("s", { autoModeId: null }); await prepareAutoRoutedPrompt("s", { text: "Task" }); diff --git a/src/features/chat/lib/autoRoutingDispatch.ts b/src/features/chat/lib/autoRoutingDispatch.ts index e5a9685a8..6fe601ef5 100644 --- a/src/features/chat/lib/autoRoutingDispatch.ts +++ b/src/features/chat/lib/autoRoutingDispatch.ts @@ -1,5 +1,8 @@ import { i18n } from "@/shared/i18n"; -import { acpSetSessionConfigOption } from "@/shared/api/acp"; +import { + acpGetPreparedWorkingDir, + acpSetSessionConfigOption, +} from "@/shared/api/acp"; import { routeAutoModel, type AutoRoutingDecision, @@ -63,11 +66,9 @@ export async function prepareAutoRoutedPrompt( modelId: decision.modelId, modelName: decision.modelId, }, - workingDir: - useChatSessionStore.getState().activeWorkspaceBySession[sessionId] - ?.path ?? - session.workingDir ?? - "", + // Normal send preparation already resolved workspace/project/default CWD. + // Routing changes only the model; raw session metadata can omit that CWD. + workingDir: acpGetPreparedWorkingDir(sessionId), }; const outcome = input.dispatchToken ? await transitionSessionDispatchTarget({ diff --git a/src/shared/api/acp.ts b/src/shared/api/acp.ts index cba912b92..c7c8c156f 100644 --- a/src/shared/api/acp.ts +++ b/src/shared/api/acp.ts @@ -380,6 +380,15 @@ function resolveGooseSessionSelection( return requestedSelection; } +/** Preserve the already-prepared CWD when changing only a turn's model. */ +export function acpGetPreparedWorkingDir(sessionId: string): string { + const workingDir = sessionRegistry.getPreparedWorkingDir(sessionId); + if (!workingDir?.trim()) { + throw new Error("Session working directory has not been prepared."); + } + return workingDir; +} + /** Prepare or warm an ACP session ahead of the first prompt. */ export async function acpPrepareSession( sessionId: string, diff --git a/src/shared/api/acpSessionRegistry.ts b/src/shared/api/acpSessionRegistry.ts index 10983e916..07fe12109 100644 --- a/src/shared/api/acpSessionRegistry.ts +++ b/src/shared/api/acpSessionRegistry.ts @@ -398,6 +398,11 @@ export function getPreparedProviderId(sessionId: string): string | undefined { return prepared.get(sessionId)?.executionSelection?.providerId; } +/** CWD actually prepared for the live backend session, including resolved defaults. */ +export function getPreparedWorkingDir(sessionId: string): string | undefined { + return prepared.get(sessionId)?.workingDir; +} + /** Return the complete backend execution selection observed by this window. */ export function requireSessionInvocationSelection( sessionId: string, From 95285d16dbe080d10abc890761c2c73e38b48c4d Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 14:10:32 -0500 Subject: [PATCH 05/22] fix: forward berdctl auto dispatch lease --- .../commands/runtime/sessionSend.test.ts | 62 +++++++++++++++++++ .../berdctl/commands/runtime/sessionSend.ts | 1 + 2 files changed, 63 insertions(+) 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, From c4475271c7b49ea629502adc388ec677b3cf81e7 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 14:14:24 -0500 Subject: [PATCH 06/22] fix: retry unavailable auto model with fallback --- .../chat/lib/autoRoutingDispatch.test.ts | 113 +++++++++++++++++- src/features/chat/lib/autoRoutingDispatch.ts | 97 ++++++++++----- .../chat/lib/sessionTargetCoordinator.ts | 10 ++ 3 files changed, 187 insertions(+), 33 deletions(-) diff --git a/src/features/chat/lib/autoRoutingDispatch.test.ts b/src/features/chat/lib/autoRoutingDispatch.test.ts index d397038a3..fa3740995 100644 --- a/src/features/chat/lib/autoRoutingDispatch.test.ts +++ b/src/features/chat/lib/autoRoutingDispatch.test.ts @@ -5,6 +5,7 @@ import { acpPrepareSession, acpSetSessionConfigOption, } from "@/shared/api/acp"; +import { repairManagedGooseModelSelection } from "@/features/providers/lib/managedModelSelectionRepair"; import { routeAutoModel } from "@/shared/api/autoRouting"; import { DEFAULT_RUNTIME_CONFIG } from "@/shared/runtime-config/schema"; import { useRuntimeConfigStore } from "@/shared/runtime-config/runtimeConfigStore"; @@ -21,7 +22,13 @@ vi.mock("@/shared/api/acp", () => ({ acpPrepareSession: vi.fn(), acpSetSessionConfigOption: vi.fn(), })); -vi.mock("@/shared/api/autoRouting", () => ({ routeAutoModel: 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", @@ -35,6 +42,7 @@ const decision = { describe("auto routing dispatch", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(repairManagedGooseModelSelection).mockResolvedValue(null); vi.mocked(acpGetPreparedWorkingDir).mockReturnValue("/work"); resetSessionTargetCoordinatorsForTests(); useRuntimeConfigStore.setState({ @@ -177,6 +185,109 @@ describe("auto routing dispatch", () => { } }); + 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("skips the router after an explicit model choice", async () => { useChatSessionStore.getState().patchSession("s", { autoModeId: null }); await prepareAutoRoutedPrompt("s", { text: "Task" }); diff --git a/src/features/chat/lib/autoRoutingDispatch.ts b/src/features/chat/lib/autoRoutingDispatch.ts index 6fe601ef5..2bc6ce373 100644 --- a/src/features/chat/lib/autoRoutingDispatch.ts +++ b/src/features/chat/lib/autoRoutingDispatch.ts @@ -4,6 +4,7 @@ import { acpSetSessionConfigOption, } from "@/shared/api/acp"; import { + autoRoutingFallback, routeAutoModel, type AutoRoutingDecision, } from "@/shared/api/autoRouting"; @@ -17,6 +18,7 @@ import { PreCommitSendRejectedError } from "./preCommitSendRejection"; import { transitionSessionDispatchTarget, transitionSessionTarget, + type SessionTargetOutcome, } from "./sessionTargetCoordinator"; const EFFORTS: readonly string[] = autoRoutingEfforts; @@ -56,41 +58,72 @@ export async function prepareAutoRoutedPrompt( remote: Boolean(session.remoteHost), }); assertCurrent(); - const decision = await routeAutoModel({ modeId, prompt }); + let decision = await routeAutoModel({ modeId, prompt }); assertCurrent(); - const request = { - sessionId, - target: { - harnessId: "goose" as const, - modelProviderId: decision.modelProviderId, - modelId: decision.modelId, - modelName: decision.modelId, - }, - // Normal send preparation already resolved workspace/project/default CWD. - // Routing changes only the model; raw session metadata can omit that CWD. - workingDir: acpGetPreparedWorkingDir(sessionId), - }; - const outcome = input.dispatchToken - ? await transitionSessionDispatchTarget({ - ...request, - dispatchToken: input.dispatchToken, - }) - : await transitionSessionTarget(request); - if (!outcome.applied) { - throw new PreCommitSendRejectedError( - i18n.t("chat:errors.sessionPreparationSuperseded"), + 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; } - // Provider inventory repair may substitute a different model. Never send - // the task (or stamp a misleading routing decision) on that substitute. - if ( - outcome.target.modelId !== decision.modelId || - outcome.target.modelProviderId !== decision.modelProviderId || - outcome.target.harnessId !== "goose" - ) { - throw new Error(i18n.t("chat:errors.autoModelUnavailable")); - } - assertCurrent(); const reasoning = outcome.configOptionsSnapshot?.reasoningEffort ?? useChatSessionStore.getState().getSession(sessionId)?.reasoningEffort; diff --git a/src/features/chat/lib/sessionTargetCoordinator.ts b/src/features/chat/lib/sessionTargetCoordinator.ts index 9238d8cd6..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() From 073ad839a85a4f749ea6db7436845701ceb4d9bf Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 14:15:24 -0500 Subject: [PATCH 07/22] fix: expand auto routing context budgets --- docs/auto-model-routing.md | 9 ++-- src-tauri/src/commands/auto_routing.rs | 3 +- .../chat/lib/autoRoutingPrompt.test.ts | 44 ++++++++++++++++--- src/features/chat/lib/autoRoutingPrompt.ts | 14 +++--- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/docs/auto-model-routing.md b/docs/auto-model-routing.md index 1c1926416..1f3d8aa44 100644 --- a/docs/auto-model-routing.md +++ b/docs/auto-model-routing.md @@ -101,10 +101,11 @@ arbitrary authentication headers are not supported. ## Router protocol and diagnostics -Each fresh prompt includes its text, loaded skill instructions (up to 24,000 -characters), and metadata plus text previews for up to four explicitly attached -local files (6,000 characters each). Recent conversation context helps classify -follow-ups. The complete routing context is capped at 64,000 characters. Images +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. diff --git a/src-tauri/src/commands/auto_routing.rs b/src-tauri/src/commands/auto_routing.rs index 12c76726d..ca7af0543 100644 --- a/src-tauri/src/commands/auto_routing.rs +++ b/src-tauri/src/commands/auto_routing.rs @@ -33,7 +33,8 @@ pub async fn route_auto_model( runtime_config: State<'_, RuntimeConfigState>, distro_bundle: State<'_, DistroBundleState>, ) -> Result { - if prompt.len() > 256_000 { + // 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()); } let runtime = runtime_config.ready_config(&distro_bundle).await?; diff --git a/src/features/chat/lib/autoRoutingPrompt.test.ts b/src/features/chat/lib/autoRoutingPrompt.test.ts index 4505d07b7..53ae86a9e 100644 --- a/src/features/chat/lib/autoRoutingPrompt.test.ts +++ b/src/features/chat/lib/autoRoutingPrompt.test.ts @@ -7,8 +7,8 @@ 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(2_000), - byteSize: 16_000, + contents: "content ".repeat(4_000), + byteSize: 32_000, truncated: false, }); const prompt = await buildAutoRoutingPrompt({ @@ -27,10 +27,10 @@ describe("auto routing task context", () => { }); expect(prompt).toContain("Review schema compatibility."); expect(prompt).toContain("Source: /skills/migrations/SKILL.md"); - expect(prompt).toContain("Size: 16000 bytes"); + expect(prompt).toContain("Size: 32000 bytes"); expect(prompt).toContain("Contents:\ncontent"); expect(prompt).toContain("[Truncated for model selection]"); - expect(prompt.length).toBeLessThan(7_000); + expect(prompt.length).toBeLessThan(25_000); }); it("does not read remote files or image bytes and tolerates unreadable attachments", async () => { @@ -62,7 +62,41 @@ describe("auto routing task context", () => { text: "t".repeat(40_000), assistantPrompt: "s".repeat(80_000), }); - expect(prompt.length).toBeLessThanOrEqual(64_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 index 3a4069e30..ab805579c 100644 --- a/src/features/chat/lib/autoRoutingPrompt.ts +++ b/src/features/chat/lib/autoRoutingPrompt.ts @@ -1,13 +1,15 @@ import { readTextFile } from "@/shared/api/system"; import type { ChatAttachmentDraft, Message } from "@/shared/types/messages"; -// Keep classification bounded independently of the inference prompt. Selected -// skills get more room than file previews because they define the actual task. -const MAX_PROMPT_CHARS = 16_000; +// 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 = 6_000; -const MAX_HISTORY_CHARS = 8_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 @@ -77,5 +79,5 @@ export async function buildAutoRoutingPrompt(input: { // 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, 64_000); + return sections.join("\n\n").slice(0, MAX_CONTEXT_CHARS); } From 173933875676b7fe9d2e3155739588333c63f316 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 14:16:37 -0500 Subject: [PATCH 08/22] fix: release auto routing wait on cancellation --- .../chat/lib/autoRoutingDispatch.test.ts | 47 +++++++++++++++++ src/features/chat/lib/autoRoutingDispatch.ts | 2 +- src/shared/api/autoRouting.test.ts | 51 +++++++++++++++++++ src/shared/api/autoRouting.ts | 42 +++++++++++++-- 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/features/chat/lib/autoRoutingDispatch.test.ts b/src/features/chat/lib/autoRoutingDispatch.test.ts index fa3740995..fe9a71472 100644 --- a/src/features/chat/lib/autoRoutingDispatch.test.ts +++ b/src/features/chat/lib/autoRoutingDispatch.test.ts @@ -6,6 +6,7 @@ import { 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"; @@ -17,6 +18,10 @@ import { resetSessionTargetCoordinatorsForTests, } from "./sessionTargetCoordinator"; +vi.mock("@/shared/api/invokeWithStartupRetry", () => ({ + invokeWithStartupRetry: vi.fn(), +})); + vi.mock("@/shared/api/acp", () => ({ acpGetPreparedWorkingDir: vi.fn(), acpPrepareSession: vi.fn(), @@ -306,6 +311,48 @@ describe("auto routing dispatch", () => { 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 () => { diff --git a/src/features/chat/lib/autoRoutingDispatch.ts b/src/features/chat/lib/autoRoutingDispatch.ts index 2bc6ce373..2db72ad17 100644 --- a/src/features/chat/lib/autoRoutingDispatch.ts +++ b/src/features/chat/lib/autoRoutingDispatch.ts @@ -58,7 +58,7 @@ export async function prepareAutoRoutedPrompt( remote: Boolean(session.remoteHost), }); assertCurrent(); - let decision = await routeAutoModel({ modeId, prompt }); + let decision = await routeAutoModel({ modeId, prompt, signal: input.signal }); assertCurrent(); let fallbackAttempted = decision.modelId === mode.fallback.modelId; let outcome: Extract; diff --git a/src/shared/api/autoRouting.test.ts b/src/shared/api/autoRouting.test.ts index d9b1871ca..ae69f3d2b 100644 --- a/src/shared/api/autoRouting.test.ts +++ b/src/shared/api/autoRouting.test.ts @@ -183,6 +183,57 @@ describe("auto routing invocation", () => { prompt: "context", }); }); + it("rejects immediately on cancellation while native routing is pending", async () => { + let finish!: (response: unknown) => void; + vi.mocked(invokeWithStartupRetry).mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const controller = new AbortController(); + const pending = routeAutoModel({ + modeId: "auto", + prompt: "context", + signal: controller.signal, + }); + const rejection = expect(pending).rejects.toMatchObject({ + name: "AbortError", + }); + controller.abort(); + await rejection; + // Settling the native request afterwards must remain harmless. + finish(response("gpt-6-1-sol")); + await Promise.resolve(); + }); + it("does not invoke native routing for an already cancelled turn", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + routeAutoModel({ + modeId: "auto", + prompt: "context", + signal: controller.signal, + }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(invokeWithStartupRetry).not.toHaveBeenCalled(); + }); + it("removes the cancellation listener after a normal response", async () => { + vi.mocked(invokeWithStartupRetry).mockResolvedValue( + response("gpt-6-1-sol"), + ); + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + expect( + ( + await routeAutoModel({ + modeId: "auto", + prompt: "context", + signal: controller.signal, + }) + ).source, + ).toBe("router"); + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + }); it("falls back on rejected network and timeout requests without retaining sensitive errors", async () => { vi.mocked(invokeWithStartupRetry).mockRejectedValue( new Error("secret endpoint error"), diff --git a/src/shared/api/autoRouting.ts b/src/shared/api/autoRouting.ts index 933559ef0..1aea3be9c 100644 --- a/src/shared/api/autoRouting.ts +++ b/src/shared/api/autoRouting.ts @@ -112,24 +112,58 @@ export function selectAutoRoutingResponse( }; } +function abortableResponse( + request: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) return request; + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + reject(new DOMException("Aborted", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + // The native request may finish later. Observe both outcomes without + // allowing a cancelled turn to select or apply a model from its result. + request.then( + (response) => { + signal.removeEventListener("abort", onAbort); + resolve(response); + }, + (error) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + export async function routeAutoModel({ modeId, prompt, + signal, }: { modeId: string; prompt: string; + signal?: AbortSignal; }): Promise { const config = useRuntimeConfigStore.getState().config.goose.autoRouting; const mode = config?.modes.find((entry) => entry.id === modeId); if (!mode) throw new Error("Auto routing mode is not configured"); + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); try { // The backend resolves URL, selector and candidate list from trusted runtime config. - const response = await invokeWithStartupRetry("route_auto_model", { - modeId, - prompt, - }); + const response = await abortableResponse( + invokeWithStartupRetry("route_auto_model", { + modeId, + prompt, + }), + signal, + ); return selectAutoRoutingResponse(mode, response); } catch { + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); // Error messages may contain endpoint details; diagnostics use stable local codes. return autoRoutingFallback(mode, "request_failed"); } From 1e9b831da5b9ec7c08bd68fb3822cbc838dd4b9b Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 14:18:11 -0500 Subject: [PATCH 09/22] Fix Auto activation intent and rollback Treat the home composer's same-fallback selection as accepted Auto intent.\nRestore the previous Auto mode and diagnostic if fallback activation fails,\nwhile preserving newer selections. These fixes share the activation path. --- .../useResolvedAgentModelPicker.test.ts | 178 ++++++++++++++++++ .../chat/hooks/useResolvedAgentModelPicker.ts | 96 ++++++++-- 2 files changed, 260 insertions(+), 14 deletions(-) diff --git a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index e08aff1b5..3bc22e672 100644 --- a/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -325,6 +325,184 @@ describe("useResolvedAgentModelPicker", () => { 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({ diff --git a/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/src/features/chat/hooks/useResolvedAgentModelPicker.ts index c1ab89dae..32156cfab 100644 --- a/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -14,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 { @@ -230,6 +233,17 @@ export function useResolvedAgentModelPicker({ 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); @@ -524,6 +538,7 @@ export function useResolvedAgentModelPicker({ }); }, onModelSelected: (model, requestedAgentId) => { + const autoActivation = autoActivationRef.current; selectAutoMode(sessionId, null); const targetAgentId = requestedAgentId ?? selectedAgentId; const modelId = model.id; @@ -596,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); @@ -639,8 +668,10 @@ export function useResolvedAgentModelPicker({ requestId, ); if (!applied || !intentStillMatches) { + restoreAutoActivation(); return; } + autoActivationByRequestRef.current.delete(requestId); if (selectionVersionRef.current !== versionAtSelection) { return; } @@ -656,6 +687,7 @@ export function useResolvedAgentModelPicker({ !intentStillMatches || selectionVersionRef.current !== versionAtSelection ) { + autoActivationByRequestRef.current.delete(requestId); return; } if ( @@ -673,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) { @@ -855,20 +890,53 @@ export function useResolvedAgentModelPicker({ onModeChange: (modeId: string) => { const target = getAutoModeExecutionTarget(modeId); if (!target) return; - const accepted = handleModelChange( - target.modelId, - { - id: target.modelId, - name: target.modelName, - providerId: target.modelProviderId, - }, - "goose", - ); + 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 ( - accepted === false && - !sameSessionExecutionTarget(session?.executionTarget, target) - ) - return; + 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); }, }, From 349e301bdd3ecdb5e571768b82492155316145e1 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 14:19:14 -0500 Subject: [PATCH 10/22] Exit Auto when a persona pins its target --- .../useChatSessionController.test.ts | 21 +++++++++++++++++++ .../chat/hooks/useChatSessionController.ts | 6 +++++- src/shared/ui/GlobalComposerPill.test.tsx | 2 ++ src/shared/ui/GlobalComposerPill.tsx | 1 + 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/features/chat/hooks/__tests__/useChatSessionController.test.ts b/src/features/chat/hooks/__tests__/useChatSessionController.test.ts index b3811d6a1..3ce6083a4 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", diff --git a/src/features/chat/hooks/useChatSessionController.ts b/src/features/chat/hooks/useChatSessionController.ts index 34c0fe041..fc2c63556 100644 --- a/src/features/chat/hooks/useChatSessionController.ts +++ b/src/features/chat/hooks/useChatSessionController.ts @@ -1,4 +1,7 @@ -import { getSelectedAutoModeId } from "../lib/autoModelSelection"; +import { + getSelectedAutoModeId, + selectAutoMode, +} from "../lib/autoModelSelection"; import { projectEnvironment } from "@/features/projects/lib/projectEnvironment"; import { useCallback, @@ -1553,6 +1556,7 @@ export function useChatSessionController({ const personaTarget = persona ? resolvePersonaTarget(persona) : undefined; if (personaTarget) { + selectAutoMode(sessionId, null); const harnessId = personaTarget.harnessId; if (!personaTarget.modelId) { if (!sessionId) { diff --git a/src/shared/ui/GlobalComposerPill.test.tsx b/src/shared/ui/GlobalComposerPill.test.tsx index 17ab7bf0b..ce71a0b99 100644 --- a/src/shared/ui/GlobalComposerPill.test.tsx +++ b/src/shared/ui/GlobalComposerPill.test.tsx @@ -770,6 +770,7 @@ describe("GlobalComposerPill", () => { }); it("applies the suggested persona's provider and model to the send payload", async () => { + usePendingAutoModeStore.getState().setModeId("balanced"); const user = userEvent.setup(); useAgentStore.setState({ personas: [ @@ -801,6 +802,7 @@ describe("GlobalComposerPill", () => { }); expect(screen.getByText("Research Scout")).toBeInTheDocument(); + expect(usePendingAutoModeStore.getState().modeId).toBeNull(); await user.type(screen.getByRole("textbox"), "Hello"); await user.click(screen.getByRole("button", { name: /send message/i })); diff --git a/src/shared/ui/GlobalComposerPill.tsx b/src/shared/ui/GlobalComposerPill.tsx index df48ed7a4..a9ce4cb9f 100644 --- a/src/shared/ui/GlobalComposerPill.tsx +++ b/src/shared/ui/GlobalComposerPill.tsx @@ -560,6 +560,7 @@ export function GlobalComposerPill({ return; } + selectAutoMode(null, null); setProviderOverride(personaTarget.harnessId); setModelOverride( personaTarget.modelId From 5d6d1cf966e34d96304cea42a0349e57cb367e32 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Sun, 18 Oct 2026 14:20:10 -0500 Subject: [PATCH 11/22] Add routing icons to Auto model modes --- src/features/chat/ui/AgentModelPicker.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/features/chat/ui/AgentModelPicker.tsx b/src/features/chat/ui/AgentModelPicker.tsx index 4212fa64d..bd11a5a63 100644 --- a/src/features/chat/ui/AgentModelPicker.tsx +++ b/src/features/chat/ui/AgentModelPicker.tsx @@ -10,6 +10,7 @@ import { import { IconAiAgents, IconArrowsExchange, + IconArrowsSplit2, IconCheck, IconChevronDown, } from "@tabler/icons-react"; @@ -317,9 +318,12 @@ export function AgentModelPicker({ const triggerTitle = triggerLabel ?? (loading ? t("toolbar.loading") : undefined); const triggerButtonSize = triggerIconOnly ? "icon-pill-sm" : "sm"; - const triggerProviderIcon = - getProviderIcon(selectedAgentId, "size-4") ?? - (triggerIconOnly ? : null); + const triggerProviderIcon = autoRouting?.selectedModeId ? ( +