diff --git a/apps/staged/README.md b/apps/staged/README.md index 326805a7c..87473c221 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -162,7 +162,7 @@ src/ ├── ProjectHome.svelte # Project dashboard ├── BranchTimeline.svelte # Branch history and commit timeline ├── SessionLauncher.svelte # AI session creation and management - ├── AgentSelector.svelte # Agent provider picker + ├── AcpConfigPicker.svelte # Agent provider/model/effort picker ├── TopBar.svelte # Navigation and project controls ├── commands.ts # Tauri command bindings ├── types.ts # Shared TypeScript types diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index c78d6c21c..c1024ff2e 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -7,6 +7,7 @@ name = "Staged" version = "0.1.8" dependencies = [ "acp-client", + "agent-client-protocol", "anyhow", "async-trait", "axum", diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 6c673f1d5..54c1125f1 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -53,6 +53,7 @@ doctor = { path = "../../../crates/doctor" } # Actions framework builderbot-actions = { path = "../../../crates/builderbot-actions" } acp-client = { path = "../../../crates/acp-client" } +agent-client-protocol = { version = "0.15.1", features = ["unstable"] } blox-cli = { path = "../../../crates/blox-cli" } regex = "1" diff --git a/apps/staged/src-tauri/examples/acp_stream_probe.rs b/apps/staged/src-tauri/examples/acp_stream_probe.rs index 7796f6d90..248403c12 100644 --- a/apps/staged/src-tauri/examples/acp_stream_probe.rs +++ b/apps/staged/src-tauri/examples/acp_stream_probe.rs @@ -196,6 +196,7 @@ fn main() -> Result<()> { &writer, &cancel_token, None, + &[], ) .await; diff --git a/apps/staged/src-tauri/src/acp_config.rs b/apps/staged/src-tauri/src/acp_config.rs new file mode 100644 index 000000000..cb3efb8f3 --- /dev/null +++ b/apps/staged/src-tauri/src/acp_config.rs @@ -0,0 +1,348 @@ +//! Normalization helpers for ACP session configuration options. + +use acp_client::AcpSessionConfigOptionSelection; +use agent_client_protocol::schema::v1::{ + SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, + SessionConfigSelectOptions, +}; +use serde::{Deserialize, Serialize}; + +use crate::store::{AcpConfigSelection, AcpConfigValueSelection}; + +/// Product-facing ACP configuration selectors. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NormalizedAcpConfigOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) effort: Option, +} + +/// A normalized select-style ACP configuration option. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NormalizedAcpConfigSelector { + pub(crate) config_id: String, + pub(crate) label: String, + pub(crate) current_value_id: String, + pub(crate) options: Vec, +} + +/// One flattened selectable value for an ACP configuration option. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NormalizedAcpConfigValueOption { + pub(crate) value_id: String, + pub(crate) label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) group_label: Option, +} + +/// Extract the model and reasoning-effort selectors from ACP config options. +pub(crate) fn normalize_acp_config_options( + config_options: &[SessionConfigOption], +) -> NormalizedAcpConfigOptions { + NormalizedAcpConfigOptions { + model: normalize_selector_for_category(config_options, &SessionConfigOptionCategory::Model), + effort: normalize_selector_for_category( + config_options, + &SessionConfigOptionCategory::ThoughtLevel, + ), + } +} + +pub(crate) fn selected_acp_config_options( + selection: Option<&AcpConfigSelection>, +) -> Vec { + let Some(selection) = selection else { + return Vec::new(); + }; + + let mut options = Vec::new(); + if let Some(model) = &selection.model { + options.push(selected_config_option( + SessionConfigOptionCategory::Model, + model, + )); + } + if let Some(effort) = &selection.effort { + options.push(selected_config_option( + SessionConfigOptionCategory::ThoughtLevel, + effort, + )); + } + options +} + +fn selected_config_option( + category: SessionConfigOptionCategory, + selection: &AcpConfigValueSelection, +) -> AcpSessionConfigOptionSelection { + AcpSessionConfigOptionSelection { + category, + config_id: selection.config_id.clone(), + value_id: selection.value_id.clone(), + } +} + +fn normalize_selector_for_category( + config_options: &[SessionConfigOption], + category: &SessionConfigOptionCategory, +) -> Option { + config_options + .iter() + .filter(|option| option.category.as_ref() == Some(category)) + .find_map(normalize_select_option) +} + +fn normalize_select_option( + config_option: &SessionConfigOption, +) -> Option { + let SessionConfigKind::Select(select) = &config_option.kind else { + return None; + }; + + Some(NormalizedAcpConfigSelector { + config_id: config_option.id.to_string(), + label: config_option.name.clone(), + current_value_id: select.current_value.to_string(), + options: flatten_select_options(&select.options), + }) +} + +fn flatten_select_options( + options: &SessionConfigSelectOptions, +) -> Vec { + match options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(|option| normalize_value(option, None)) + .collect(), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group + .options + .iter() + .map(|option| normalize_value(option, Some(&group.name))) + }) + .collect(), + _ => Vec::new(), + } +} + +fn normalize_value( + option: &SessionConfigSelectOption, + group_label: Option<&str>, +) -> NormalizedAcpConfigValueOption { + NormalizedAcpConfigValueOption { + value_id: option.value.to_string(), + label: option.name.clone(), + group_label: group_label.map(str::to_string), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::v1::{ + SessionConfigBoolean, SessionConfigSelectGroup, SessionConfigSelectOption, + }; + + #[test] + fn extracts_model_and_effort_selectors() { + let options = vec![ + SessionConfigOption::select( + "model", + "Model", + "gpt-5", + vec![ + SessionConfigSelectOption::new("gpt-5", "GPT-5"), + SessionConfigSelectOption::new("gpt-5-mini", "GPT-5 mini"), + ], + ) + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "reasoning", + "Reasoning", + "high", + vec![ + SessionConfigSelectOption::new("low", "Low"), + SessionConfigSelectOption::new("high", "High"), + ], + ) + .category(SessionConfigOptionCategory::ThoughtLevel), + ]; + + let normalized = normalize_acp_config_options(&options); + + let model = normalized.model.expect("model selector"); + assert_eq!(model.config_id, "model"); + assert_eq!(model.current_value_id, "gpt-5"); + assert_eq!( + model.options, + vec![ + NormalizedAcpConfigValueOption { + value_id: "gpt-5".to_string(), + label: "GPT-5".to_string(), + group_label: None, + }, + NormalizedAcpConfigValueOption { + value_id: "gpt-5-mini".to_string(), + label: "GPT-5 mini".to_string(), + group_label: None, + }, + ] + ); + + let effort = normalized.effort.expect("effort selector"); + assert_eq!(effort.config_id, "reasoning"); + assert_eq!(effort.current_value_id, "high"); + assert_eq!(effort.options[1].value_id, "high"); + } + + #[test] + fn filters_unsupported_options() { + let options = vec![ + SessionConfigOption::select( + "mode", + "Mode", + "default", + vec![SessionConfigSelectOption::new("default", "Default")], + ) + .category(SessionConfigOptionCategory::Mode), + SessionConfigOption::new( + "model_toggle", + "Model toggle", + SessionConfigKind::Boolean(SessionConfigBoolean::new(false)), + ) + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "model", + "Model", + "opus", + vec![SessionConfigSelectOption::new("opus", "Opus")], + ) + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "effort", + "Effort", + "medium", + vec![SessionConfigSelectOption::new("medium", "Medium")], + ) + .category(SessionConfigOptionCategory::ThoughtLevel), + ]; + + let normalized = normalize_acp_config_options(&options); + + assert_eq!( + normalized.model.expect("model selector").current_value_id, + "opus" + ); + assert!(normalized.effort.is_some()); + } + + #[test] + fn flattens_grouped_options() { + let options = vec![SessionConfigOption::select( + "model", + "Model", + "sonnet", + vec![ + SessionConfigSelectGroup::new( + "fast", + "Fast", + vec![SessionConfigSelectOption::new("haiku", "Haiku")], + ), + SessionConfigSelectGroup::new( + "smart", + "Smart", + vec![ + SessionConfigSelectOption::new("sonnet", "Sonnet"), + SessionConfigSelectOption::new("opus", "Opus"), + ], + ), + ], + ) + .category(SessionConfigOptionCategory::Model)]; + + let normalized = normalize_acp_config_options(&options); + let model = normalized.model.expect("model selector"); + + assert_eq!(model.current_value_id, "sonnet"); + assert_eq!( + model.options, + vec![ + NormalizedAcpConfigValueOption { + value_id: "haiku".to_string(), + label: "Haiku".to_string(), + group_label: Some("Fast".to_string()), + }, + NormalizedAcpConfigValueOption { + value_id: "sonnet".to_string(), + label: "Sonnet".to_string(), + group_label: Some("Smart".to_string()), + }, + NormalizedAcpConfigValueOption { + value_id: "opus".to_string(), + label: "Opus".to_string(), + group_label: Some("Smart".to_string()), + }, + ] + ); + } + + #[test] + fn returns_none_for_missing_categories() { + let options = vec![ + SessionConfigOption::select( + "uncategorized_model", + "Model", + "default", + vec![SessionConfigSelectOption::new("default", "Default")], + ), + SessionConfigOption::select( + "custom", + "Custom", + "custom", + vec![SessionConfigSelectOption::new("custom", "Custom")], + ) + .category(SessionConfigOptionCategory::Other("_custom".to_string())), + ]; + + let normalized = normalize_acp_config_options(&options); + + assert!(normalized.model.is_none()); + assert!(normalized.effort.is_none()); + } + + #[test] + fn selected_config_options_preserve_model_then_effort_order() { + let selection = AcpConfigSelection { + model: Some(AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "sonnet".to_string(), + label: Some("Sonnet".to_string()), + }), + effort: Some(AcpConfigValueSelection { + config_id: "reasoning".to_string(), + value_id: "high".to_string(), + label: Some("High".to_string()), + }), + }; + + let selected = selected_acp_config_options(Some(&selection)); + + assert_eq!(selected.len(), 2); + assert_eq!(selected[0].category, SessionConfigOptionCategory::Model); + assert_eq!(selected[0].config_id, "model"); + assert_eq!(selected[0].value_id, "sonnet"); + assert_eq!( + selected[1].category, + SessionConfigOptionCategory::ThoughtLevel + ); + assert_eq!(selected[1].config_id, "reasoning"); + assert_eq!(selected[1].value_id, "high"); + } +} diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 04720c456..d8b75c9c7 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ //! Tauri commands for the new frontend, built incrementally. //! See `src-archive/lib.rs` for the previous implementation. +pub(crate) mod acp_config; pub mod actions; pub mod agent; pub mod background_sync; @@ -2311,6 +2312,7 @@ pub fn run() { util_commands::open_in_app, // Sessions session_commands::discover_acp_providers, + session_commands::discover_acp_config, session_commands::get_session, session_commands::get_session_messages, session_commands::get_session_messages_since, diff --git a/apps/staged/src-tauri/src/pikchr_subsession.rs b/apps/staged/src-tauri/src/pikchr_subsession.rs index e48dbeca5..bae44e0f4 100644 --- a/apps/staged/src-tauri/src/pikchr_subsession.rs +++ b/apps/staged/src-tauri/src/pikchr_subsession.rs @@ -81,6 +81,7 @@ pub(crate) async fn generate_pikchr_source( &writer_dyn, cancel_token, agent_session_id.as_deref(), + &[], ) .await?; @@ -302,6 +303,7 @@ box "Sink = NO-OP (default / external clone)" "no socket, no Block deps → buil writer: &Arc, _cancel_token: &CancellationToken, agent_session_id: Option<&str>, + _config_options: &[acp_client::AcpSessionConfigOptionSelection], ) -> Result { let idx = { let mut calls = self.calls.lock().unwrap(); diff --git a/apps/staged/src-tauri/src/project_mcp.rs b/apps/staged/src-tauri/src/project_mcp.rs index 1079dae83..a1e785b99 100644 --- a/apps/staged/src-tauri/src/project_mcp.rs +++ b/apps/staged/src-tauri/src/project_mcp.rs @@ -18,8 +18,8 @@ use tauri::AppHandle; use crate::actions::{ActionExecutor, ActionRegistry}; use crate::session_runner::SessionRegistry; use crate::store::{ - Branch, CompletionReason, MessageRole, ProjectRepo, Session, SessionMessage, SessionStatus, - Store, + AcpConfigSelection, Branch, CompletionReason, MessageRole, ProjectRepo, Session, + SessionMessage, SessionStatus, Store, }; use tokio_util::sync::CancellationToken; @@ -430,6 +430,10 @@ struct ProjectToolsHandler { /// ACP provider ID inherited from the parent project session. /// All repo sessions spawned by this handler use this provider. provider: Option, + /// ACP config selection inherited from the parent project session. + /// Repo sessions persist it when queued so queue drain uses the selection + /// active when the parent requested the work. + acp_config_selection: Option, /// Cancellation token for the parent project session. /// Signalled when the user cancels the project session. cancel_token: CancellationToken, @@ -445,6 +449,7 @@ impl ProjectToolsHandler { action_executor: Option>, action_registry: Option>, provider: Option, + acp_config_selection: Option, cancel_token: CancellationToken, ) -> Self { Self { @@ -456,6 +461,7 @@ impl ProjectToolsHandler { action_executor, action_registry, provider, + acp_config_selection, cancel_token, } } @@ -487,6 +493,9 @@ impl ProjectToolsHandler { if let Some(ref provider) = self.provider { session = session.with_provider(provider); } + if let Some(selection) = self.acp_config_selection.clone() { + session = session.with_acp_config_selection(selection); + } if let Err(e) = self.store.create_session(&session) { return format!("Error creating queued session: {e}"); } @@ -954,6 +963,7 @@ pub async fn start_project_mcp_server( action_executor: Option>, action_registry: Option>, provider: Option, + acp_config_selection: Option, cancel_token: CancellationToken, ) -> Result<(u16, JoinHandle<()>), String> { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -972,6 +982,7 @@ pub async fn start_project_mcp_server( action_executor, action_registry, provider, + acp_config_selection, cancel_token, ); log::debug!( diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index e343a3018..c05df8d88 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -17,12 +17,29 @@ //! `Store` directly. use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; +use std::process::Stdio; use std::sync::{Arc, Mutex, OnceLock}; - +use std::time::{Duration, Instant}; + +use agent_client_protocol::{ + schema::{ + v1::{ + Implementation, InitializeRequest, NewSessionRequest, SessionConfigKind, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOptions, + SetSessionConfigOptionRequest, + }, + ProtocolVersion, + }, + ByteStreams, Client, ConnectTo, ErrorCode, +}; use serde::{Deserialize, Serialize}; use tauri::path::BaseDirectory; use tauri::Manager; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use crate::actions::{ActionExecutor, ActionRegistry}; use crate::agent::{self, AcpProviderInfo}; @@ -319,6 +336,555 @@ pub async fn discover_acp_providers() -> Vec { .unwrap_or_default() } +/// Product-facing ACP config discovery for the provider/model/effort picker. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConfigDiscovery { + provider_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + effort: Option, +} + +const ACP_CONFIG_DISCOVERY_SETUP_TIMEOUT: Duration = Duration::from_secs(90); +const ACP_CONFIG_DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(30 * 60); + +#[derive(Debug, Clone)] +struct AcpConfigDiscoveryCacheEntry { + discovery: AcpConfigDiscovery, + fetched_at: Instant, +} + +static ACP_CONFIG_DISCOVERY_CACHE: OnceLock>> = + OnceLock::new(); +static ACP_CONFIG_DISCOVERY_MODEL_CACHE: OnceLock< + Mutex>, +> = OnceLock::new(); + +fn acp_config_discovery_cache() -> &'static Mutex> { + ACP_CONFIG_DISCOVERY_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn acp_config_discovery_model_cache( +) -> &'static Mutex> { + ACP_CONFIG_DISCOVERY_MODEL_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn normalized_acp_config_provider_id(provider_id: &str) -> Result { + let provider_id = provider_id.trim().to_string(); + if provider_id.is_empty() { + return Err("ACP provider ID is required for config discovery".to_string()); + } + Ok(provider_id) +} + +fn acp_config_discovery_model_cache_key(provider_id: &str, selected_model_value: &str) -> String { + format!("{provider_id}\0{selected_model_value}") +} + +#[derive(Debug)] +struct AcpConfigDiscoverySpawnCommand { + program: PathBuf, + args: Vec, + uses_explicit_interpreter: bool, +} + +fn acp_config_discovery_from_options( + provider_id: String, + config_options: &[acp_client::SessionConfigOption], +) -> AcpConfigDiscovery { + let normalized = crate::acp_config::normalize_acp_config_options(config_options); + AcpConfigDiscovery { + provider_id, + model: normalized.model, + effort: normalized.effort, + } +} + +fn resolve_acp_config_discovery_working_dir(working_dir: Option) -> PathBuf { + working_dir + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(std::env::temp_dir) +} + +fn normalize_selected_model_value(selected_model_value: Option) -> Option { + selected_model_value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn resolve_model_config_id_for_discovery( + config_options: &[SessionConfigOption], + selected_model_value: &str, +) -> Result { + let Some(option) = config_options.iter().find(|option| { + option.category.as_ref() == Some(&SessionConfigOptionCategory::Model) + && matches!(&option.kind, SessionConfigKind::Select(_)) + }) else { + return Err("ACP config discovery did not return a model selector".to_string()); + }; + + if !select_config_option_has_value(option, selected_model_value) { + return Err(format!( + "ACP config discovery model value '{selected_model_value}' is no longer available for config option '{}'", + option.id + )); + } + + Ok(option.id.to_string()) +} + +fn select_config_option_has_value(option: &SessionConfigOption, value_id: &str) -> bool { + let SessionConfigKind::Select(select) = &option.kind else { + return false; + }; + + match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|option| option.value.to_string() == value_id), + SessionConfigSelectOptions::Grouped(groups) => groups.iter().any(|group| { + group + .options + .iter() + .any(|option| option.value.to_string() == value_id) + }), + _ => false, + } +} + +fn acp_config_discovery_spawn_command( + binary_path: &Path, + acp_args: &[String], + interpreter_env_snapshot: &[(String, String)], +) -> AcpConfigDiscoverySpawnCommand { + let interpreter_path = interpreter_env_snapshot + .iter() + .find(|(key, _)| key == "PATH") + .and_then(|(_, value)| { + doctor::resolve::resolve_env_shebang_interpreter_from_path(binary_path, value) + }); + if let Some(interpreter) = interpreter_path { + let mut args = vec![binary_path.as_os_str().to_os_string()]; + args.extend(acp_args.iter().map(OsString::from)); + return AcpConfigDiscoverySpawnCommand { + program: interpreter, + args, + uses_explicit_interpreter: true, + }; + } + + AcpConfigDiscoverySpawnCommand { + program: binary_path.to_path_buf(), + args: acp_args.iter().map(OsString::from).collect(), + uses_explicit_interpreter: false, + } +} + +fn apply_acp_config_discovery_env( + cmd: &mut Command, + env_vars: &[(String, String)], + binary_path: &Path, + uses_explicit_interpreter: bool, +) { + cmd.env_clear(); + let mut path_value: Option<&str> = None; + for (key, value) in env_vars { + if key == "PATH" { + path_value = Some(value.as_str()); + } + cmd.env(key, value); + } + + if uses_explicit_interpreter { + return; + } + + if let Some(path) = doctor::resolve::guarded_path_for_env_shebang_launcher( + binary_path, + path_value.unwrap_or_default(), + ) { + cmd.env("PATH", path); + } +} + +async fn run_acp_config_discovery_protocol( + provider_id: &str, + working_dir: &Path, + stdin: tokio::process::ChildStdin, + stdout: tokio::process::ChildStdout, + selected_model_value: Option<&str>, +) -> Result, String> { + let stdin_compat = stdin.compat_write(); + let stdout_compat = stdout.compat(); + let transport = ByteStreams::new(stdin_compat, stdout_compat); + run_acp_config_discovery_transport(provider_id, working_dir, transport, selected_model_value) + .await +} + +async fn run_acp_config_discovery_transport( + provider_id: &str, + working_dir: &Path, + transport: T, + selected_model_value: Option<&str>, +) -> Result, String> +where + T: ConnectTo + 'static, +{ + let working_dir = working_dir.to_path_buf(); + let provider_id = provider_id.to_string(); + let protocol_provider_id = provider_id.clone(); + let selected_model_value = selected_model_value.map(str::to_string); + + Client + .builder() + .name("staged-acp-config-discovery") + .connect_with(transport, async move |connection| { + tokio::time::timeout(ACP_CONFIG_DISCOVERY_SETUP_TIMEOUT, async { + let client_info = + Implementation::new("staged-acp-config-discovery", env!("CARGO_PKG_VERSION")); + let init_request = + InitializeRequest::new(ProtocolVersion::V1).client_info(client_info); + let init_response = connection + .send_request(init_request) + .block_task() + .await + .map_err(|e| { + format!( + "ACP config discovery init failed for {protocol_provider_id}: {e:?}" + ) + })?; + + if init_response.protocol_version != ProtocolVersion::V1 { + return Err(format!( + "Agent negotiated unsupported ACP protocol version {} (expected {})", + init_response.protocol_version, + ProtocolVersion::V1 + )); + } + + let session_response = match connection + .send_request(NewSessionRequest::new(working_dir)) + .block_task() + .await + { + Ok(response) => response, + Err(e) if e.code == ErrorCode::AuthRequired => { + log::debug!( + "ACP config discovery skipped authentication-required provider {protocol_provider_id}" + ); + return Ok(Vec::new()); + } + Err(e) => { + return Err(format!( + "ACP config discovery failed to create session for {protocol_provider_id}: {e:?}" + )); + } + }; + + let config_options = session_response.config_options.unwrap_or_default(); + let Some(selected_model_value) = selected_model_value.as_deref() else { + return Ok(config_options); + }; + + let model_config_id = + resolve_model_config_id_for_discovery(&config_options, selected_model_value)?; + let response = connection + .send_request(SetSessionConfigOptionRequest::new( + session_response.session_id.to_string(), + model_config_id, + selected_model_value, + )) + .block_task() + .await + .map_err(|e| { + format!( + "ACP config discovery failed to set selected model for {protocol_provider_id}: {e:?}" + ) + })?; + + Ok(response.config_options) + }) + .await + .map_err(|_| { + agent_client_protocol::util::internal_error(format!( + "Timed out waiting for ACP config discovery startup after {}s", + ACP_CONFIG_DISCOVERY_SETUP_TIMEOUT.as_secs() + )) + })? + .map_err(agent_client_protocol::util::internal_error) + }) + .await + .map_err(|e| format!("ACP config discovery protocol failed for {provider_id}: {e:?}")) +} + +async fn discover_acp_config_for_provider_async( + provider_id: String, + working_dir: PathBuf, + selected_model_value: Option, +) -> Result { + let agent = acp_client::find_acp_agent_by_id(&provider_id) + .ok_or_else(|| format!("Unknown or unavailable agent provider: {provider_id}"))?; + + let cache = session_runner::shell_env_cache(); + let home_snapshot = crate::shell_env::home_env_vars_with_extended_path(cache.as_ref()).await; + let spawn_command = + acp_config_discovery_spawn_command(agent.path(), &agent.acp_args, &home_snapshot); + + let mut cmd = Command::new(&spawn_command.program); + cmd.args(&spawn_command.args) + .current_dir(&working_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + match cache.get(&working_dir).await { + Ok(snapshot) => apply_acp_config_discovery_env( + &mut cmd, + snapshot.vars(), + agent.path(), + spawn_command.uses_explicit_interpreter, + ), + Err(e) => { + log::warn!( + "ACP config discovery: failed to capture shell env snapshot for {} \ + (falling back to inherited environment): {e}", + working_dir.display() + ); + } + } + + let mut child = cmd.spawn().map_err(|e| { + format!( + "Failed to spawn {} for ACP config discovery (binary: {}, cwd: {}): {e}", + agent.name(), + agent.path().display(), + working_dir.display() + ) + })?; + + if let Some(stderr) = child.stderr.take() { + let agent_label = agent.name().to_string(); + tokio::task::spawn_local(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + log::warn!("[{agent_label} stderr][config discovery] {line}"); + } + }); + } + + let stdin = child + .stdin + .take() + .ok_or_else(|| "Failed to get ACP config discovery stdin".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Failed to get ACP config discovery stdout".to_string())?; + + let config_options = run_acp_config_discovery_protocol( + &provider_id, + &working_dir, + stdin, + stdout, + selected_model_value.as_deref(), + ) + .await; + + let _ = child.kill().await; + let _ = child.wait().await; + + let config_options = config_options?; + Ok(acp_config_discovery_from_options( + provider_id, + &config_options, + )) +} + +fn discover_acp_config_for_provider( + provider_id: String, + working_dir: PathBuf, + selected_model_value: Option, +) -> Result { + let provider_id = normalized_acp_config_provider_id(&provider_id)?; + let selected_model_value = normalize_selected_model_value(selected_model_value); + + let handle = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("Failed to create runtime for ACP config discovery: {e}"))?; + + let local = tokio::task::LocalSet::new(); + local.block_on( + &rt, + discover_acp_config_for_provider_async(provider_id, working_dir, selected_model_value), + ) + }); + + handle + .join() + .map_err(|_| "ACP config discovery thread panicked".to_string())? +} + +fn discover_acp_config_for_provider_default( + provider_id: String, + working_dir: PathBuf, +) -> Result { + discover_acp_config_for_provider(provider_id, working_dir, None) +} + +fn discover_acp_config_for_provider_with_cache( + provider_id: String, + working_dir: PathBuf, + force: bool, + fetch: F, +) -> Result +where + F: FnOnce(String, PathBuf) -> Result, +{ + let provider_id = normalized_acp_config_provider_id(&provider_id)?; + let cached_entry = { + let cache = acp_config_discovery_cache().lock().unwrap(); + cache.get(&provider_id).cloned() + }; + + if !force { + if let Some(entry) = &cached_entry { + if entry.fetched_at.elapsed() < ACP_CONFIG_DISCOVERY_CACHE_TTL { + return Ok(entry.discovery.clone()); + } + } + } + + match fetch(provider_id.clone(), working_dir) { + Ok(discovery) => { + let mut cache = acp_config_discovery_cache().lock().unwrap(); + cache.insert( + provider_id, + AcpConfigDiscoveryCacheEntry { + discovery: discovery.clone(), + fetched_at: Instant::now(), + }, + ); + Ok(discovery) + } + Err(e) => { + if let Some(entry) = cached_entry { + log::warn!( + "ACP config discovery refresh failed for {provider_id}; using stale cached options: {e}" + ); + Ok(entry.discovery) + } else { + Err(e) + } + } + } +} + +fn discover_acp_config_for_model_with_cache( + provider_id: String, + working_dir: PathBuf, + selected_model_value: String, + force: bool, + fetch: F, +) -> Result +where + F: FnOnce(String, PathBuf, Option) -> Result, +{ + let provider_id = normalized_acp_config_provider_id(&provider_id)?; + let selected_model_value = normalize_selected_model_value(Some(selected_model_value)) + .ok_or_else(|| { + "ACP model value is required for model-specific config discovery".to_string() + })?; + let cache_key = acp_config_discovery_model_cache_key(&provider_id, &selected_model_value); + let cached_entry = { + let cache = acp_config_discovery_model_cache().lock().unwrap(); + cache.get(&cache_key).cloned() + }; + + if !force { + if let Some(entry) = &cached_entry { + if entry.fetched_at.elapsed() < ACP_CONFIG_DISCOVERY_CACHE_TTL { + return Ok(entry.discovery.clone()); + } + } + } + + match fetch( + provider_id.clone(), + working_dir, + Some(selected_model_value.clone()), + ) { + Ok(discovery) => { + let mut cache = acp_config_discovery_model_cache().lock().unwrap(); + cache.insert( + cache_key, + AcpConfigDiscoveryCacheEntry { + discovery: discovery.clone(), + fetched_at: Instant::now(), + }, + ); + Ok(discovery) + } + Err(e) => { + if let Some(entry) = cached_entry { + log::warn!( + "ACP config discovery refresh failed for {provider_id}/{selected_model_value}; using stale cached options: {e}" + ); + Ok(entry.discovery) + } else { + Err(e) + } + } + } +} + +/// Discover model/effort selectors for a provider in the given working +/// directory context. +#[tauri::command(rename_all = "camelCase")] +pub async fn discover_acp_config( + provider_id: String, + working_dir: Option, + force: Option, + selected_model_value: Option, +) -> Result { + let working_dir = resolve_acp_config_discovery_working_dir(working_dir); + let force = force.unwrap_or(false); + let selected_model_value = normalize_selected_model_value(selected_model_value); + tokio::task::spawn_blocking(move || { + if let Some(selected_model_value) = selected_model_value { + discover_acp_config_for_model_with_cache( + provider_id, + working_dir, + selected_model_value, + force, + discover_acp_config_for_provider, + ) + } else { + discover_acp_config_for_provider_with_cache( + provider_id, + working_dir, + force, + discover_acp_config_for_provider_default, + ) + } + }) + .await + .map_err(|e| format!("ACP config discovery task failed: {e}"))? +} + // ============================================================================= // Read-only queries (used by frontend polling) // ============================================================================= @@ -402,10 +968,14 @@ pub async fn start_session( prompt: String, working_dir: String, provider: Option, + acp_config_selection: Option, ) -> Result { let store = get_store(&store)?; let working_dir = PathBuf::from(working_dir); - let mut session = store::Session::new_running(&prompt, &working_dir); + let mut session = with_optional_acp_config_selection( + store::Session::new_running(&prompt, &working_dir), + acp_config_selection.clone(), + ); if let Some(ref p) = provider { session = session.with_provider(p); } @@ -428,6 +998,7 @@ pub async fn start_session( image_ids: vec![], queued_message_id: None, pending_auto_review_branch_id: None, + acp_config_selection, branch_id: None, project_id: None, expose_pikchr_tools: false, @@ -461,6 +1032,7 @@ pub async fn resume_session( prompt: String, image_ids: Option>, branch_id: Option, + acp_config_selection: Option, ) -> Result<(), String> { let store = get_store(&store)?; resume_session_for_store( @@ -473,6 +1045,7 @@ pub async fn resume_session( prompt, image_ids, branch_id, + acp_config_selection, None, None, ) @@ -490,6 +1063,7 @@ pub(crate) async fn resume_session_for_store( prompt: String, image_ids: Option>, branch_id: Option, + acp_config_selection: Option, queued_message_id: Option, pending_auto_review_branch_id: Option, ) -> Result<(), String> { @@ -503,6 +1077,9 @@ pub(crate) async fn resume_session_for_store( let provider = session.provider.clone(); let agent_session_id = session.agent_id.clone(); let working_dir = PathBuf::from(&session.working_dir); + let effective_acp_config_selection = + resolve_resume_acp_config_selection(&store, &session, acp_config_selection); + let acp_config_selection_to_persist = effective_acp_config_selection.clone(); // Check if this session is linked to a project note — if so, we need // to start the MCP server so the agent has access to project tools. @@ -645,9 +1222,18 @@ pub(crate) async fn resume_session_for_store( }, ); + // Persist the effective selection before spawning the run: on an + // unavailable-config failure the run clears the stored selection, so + // persisting afterwards could resurrect the stale value it just cleared. + if acp_config_selection_to_persist != session.acp_config_selection { + store + .set_session_acp_config_selection(&session_id, acp_config_selection_to_persist.as_ref()) + .map_err(|e| e.to_string())?; + } + session_runner::start_session( SessionConfig { - session_id, + session_id: session_id.clone(), prompt, working_dir, agent_session_id, @@ -674,11 +1260,12 @@ pub(crate) async fn resume_session_for_store( image_ids: image_ids.unwrap_or_default(), queued_message_id, pending_auto_review_branch_id, + acp_config_selection: effective_acp_config_selection, branch_id: config_branch_id, project_id: config_project_id, expose_pikchr_tools, }, - store, + Arc::clone(&store), app_handle, Arc::clone(®istry), )?; @@ -770,6 +1357,7 @@ pub(crate) async fn send_queued_session_message_for_store( message.content.clone(), Some(message.image_ids.clone()), message.branch_id.clone(), + None, Some(message.id.clone()), None, ) @@ -809,6 +1397,7 @@ pub(crate) async fn drain_queued_message_for_session( message.content.clone(), Some(message.image_ids.clone()), message.branch_id.clone(), + None, Some(message.id.clone()), pending_auto_review_branch_id, ) @@ -1009,6 +1598,135 @@ fn extra_env_for_branch_session(session_type: &BranchSessionType) -> Vec<(String } } +fn with_optional_acp_config_selection( + session: store::Session, + acp_config_selection: Option, +) -> store::Session { + match acp_config_selection { + Some(selection) => session.with_acp_config_selection(selection), + None => session, + } +} + +pub(crate) fn resolve_resume_acp_config_selection( + store: &Store, + session: &store::Session, + requested_selection: Option, +) -> Option { + if requested_selection.is_some() { + return normalize_empty_acp_config_selection(requested_selection); + } + + let stored_selection = + normalize_empty_acp_config_selection(session.acp_config_selection.clone())?; + let discovery = fresh_cached_acp_config_discovery(session.provider.as_deref()).or_else(|| { + latest_acp_config_discovery_from_session_metadata( + store, + &session.id, + session.provider.as_deref(), + ) + }); + + match discovery.as_ref() { + Some(discovery) => { + sanitize_acp_config_selection_against_discovery(stored_selection, discovery) + } + None => Some(stored_selection), + } +} + +fn normalize_empty_acp_config_selection( + selection: Option, +) -> Option { + let selection = selection?; + if selection.model.is_none() && selection.effort.is_none() { + None + } else { + Some(selection) + } +} + +fn sanitize_acp_config_selection_against_discovery( + selection: store::AcpConfigSelection, + discovery: &AcpConfigDiscovery, +) -> Option { + normalize_empty_acp_config_selection(Some(store::AcpConfigSelection { + model: sanitize_acp_config_value_selection(selection.model, discovery.model.as_ref()), + effort: sanitize_acp_config_value_selection(selection.effort, discovery.effort.as_ref()), + })) +} + +fn sanitize_acp_config_value_selection( + selection: Option, + selector: Option<&crate::acp_config::NormalizedAcpConfigSelector>, +) -> Option { + let selection = selection?; + let selector = selector?; + let option = selector + .options + .iter() + .find(|option| option.value_id == selection.value_id)?; + + Some(store::AcpConfigValueSelection { + config_id: selector.config_id.clone(), + value_id: selection.value_id, + label: Some(option.label.clone()), + }) +} + +fn latest_acp_config_discovery_from_session_metadata( + store: &Store, + session_id: &str, + provider_id: Option<&str>, +) -> Option { + let provider_id = provider_id?; + let messages = match store.get_session_acp_metadata_messages(session_id) { + Ok(messages) => messages, + Err(e) => { + log::warn!( + "Failed to read ACP config metadata for session {session_id}; using stored selection as-is: {e}" + ); + return None; + } + }; + + let options_value = messages.iter().rev().find_map(|message| { + if message.acp.acp_event_kind.as_deref() == Some("config_options_update") { + message + .acp + .acp_config_options + .as_ref() + .or(message.acp.acp_content.as_ref()) + } else { + None + } + })?; + + match serde_json::from_value::>(options_value.clone()) { + Ok(options) => Some(acp_config_discovery_from_options( + provider_id.to_string(), + &options, + )), + Err(e) => { + log::warn!( + "Failed to parse ACP config metadata for session {session_id}; using stored selection as-is: {e}" + ); + None + } + } +} + +fn fresh_cached_acp_config_discovery(provider_id: Option<&str>) -> Option { + let provider_id = provider_id?; + let cache = acp_config_discovery_cache().lock().unwrap(); + let entry = cache.get(provider_id)?; + if entry.fetched_at.elapsed() < ACP_CONFIG_DISCOVERY_CACHE_TTL { + Some(entry.discovery.clone()) + } else { + None + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum BranchSessionScheduleKind { Commit, @@ -1398,6 +2116,7 @@ pub async fn start_project_session( prompt: String, provider: Option, image_ids: Option>, + acp_config_selection: Option, ) -> Result { let store = get_store(&store)?; @@ -1429,7 +2148,10 @@ pub async fn start_project_session( .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")); // Create the session - let mut session = store::Session::new_running(&full_prompt, &working_dir); + let mut session = with_optional_acp_config_selection( + store::Session::new_running(&full_prompt, &working_dir), + acp_config_selection.clone(), + ); if let Some(ref p) = provider { session = session.with_provider(p); } @@ -1460,6 +2182,7 @@ pub async fn start_project_session( image_ids: image_ids.unwrap_or_default(), queued_message_id: None, pending_auto_review_branch_id: None, + acp_config_selection, branch_id: None, project_id: Some(project_id), // Project sessions are always local and write project notes. @@ -1492,6 +2215,12 @@ struct CreatedBranchSession { artifact_id: String, } +fn acp_config_selection_for_session_start( + session: &store::Session, +) -> Option { + session.acp_config_selection.clone() +} + fn resolve_branch_session_provider( store: &Arc, branch_id: &str, @@ -1699,8 +2428,12 @@ fn insert_running_branch_session( store: &Arc, prepared: &PreparedBranchSessionStart, prompt: &str, + acp_config_selection: Option, ) -> Result { - let mut session = store::Session::new_running(&prepared.full_prompt, &prepared.working_dir); + let mut session = with_optional_acp_config_selection( + store::Session::new_running(&prepared.full_prompt, &prepared.working_dir), + acp_config_selection, + ); if let Some(ref p) = prepared.provider { session = session.with_provider(p); } @@ -1735,6 +2468,7 @@ fn insert_running_branch_session( }) } +#[allow(clippy::too_many_arguments)] fn insert_queued_branch_session( store: &Arc, branch_id: &str, @@ -1743,9 +2477,13 @@ fn insert_queued_branch_session( provider: Option, image_ids: &[String], launch_context: Option<&BranchSessionLaunchContext>, + acp_config_selection: Option, ) -> Result { let queued_prompt = embed_launch_context(prompt, launch_context)?; - let mut session = store::Session::new_queued(&queued_prompt); + let mut session = with_optional_acp_config_selection( + store::Session::new_queued(&queued_prompt), + acp_config_selection, + ); if let Some(ref p) = provider { session = session.with_provider(p); } @@ -1828,6 +2566,7 @@ fn launch_running_branch_session( image_ids, queued_message_id: None, pending_auto_review_branch_id: None, + acp_config_selection: acp_config_selection_for_session_start(&created.session), branch_id: Some(branch_id), project_id: Some(project_id), expose_pikchr_tools, @@ -1855,6 +2594,7 @@ pub async fn start_or_queue_branch_session_for_store( provider: Option, image_ids: Option>, launch_context: Option, + acp_config_selection: Option, ) -> Result { let image_ids = image_ids.unwrap_or_default(); @@ -1879,6 +2619,7 @@ pub async fn start_or_queue_branch_session_for_store( provider, &image_ids, launch_context.as_ref(), + acp_config_selection.clone(), ); } } @@ -1905,9 +2646,10 @@ pub async fn start_or_queue_branch_session_for_store( provider, &image_ids, launch_context.as_ref(), + acp_config_selection.clone(), ); } - insert_running_branch_session(&store, &prepared, &prompt)? + insert_running_branch_session(&store, &prepared, &prompt, acp_config_selection)? }; launch_running_branch_session(store, registry, app_handle, prepared, created, image_ids) @@ -1923,6 +2665,7 @@ pub fn queue_branch_session_for_store( provider: Option, image_ids: Option>, launch_context: Option, + acp_config_selection: Option, ) -> Result { let image_ids = image_ids.unwrap_or_default(); @@ -1944,6 +2687,7 @@ pub fn queue_branch_session_for_store( provider, &image_ids, launch_context.as_ref(), + acp_config_selection, ) } @@ -1963,6 +2707,7 @@ pub async fn start_branch_session( provider: Option, image_ids: Option>, launch_context: Option, + acp_config_selection: Option, ) -> Result { let store = get_store(&store)?; start_or_queue_branch_session_for_store( @@ -1975,6 +2720,7 @@ pub async fn start_branch_session( provider, image_ids, launch_context, + acp_config_selection, ) .await } @@ -1991,6 +2737,7 @@ pub async fn start_or_queue_branch_session( provider: Option, image_ids: Option>, launch_context: Option, + acp_config_selection: Option, ) -> Result { let store = get_store(&store)?; start_or_queue_branch_session_for_store( @@ -2003,6 +2750,7 @@ pub async fn start_or_queue_branch_session( provider, image_ids, launch_context, + acp_config_selection, ) .await } @@ -2027,6 +2775,7 @@ pub fn queue_branch_session( provider: Option, image_ids: Option>, launch_context: Option, + acp_config_selection: Option, ) -> Result { let store = get_store(&store)?; queue_branch_session_for_store( @@ -2038,6 +2787,7 @@ pub fn queue_branch_session( provider, image_ids, launch_context, + acp_config_selection, ) } @@ -2376,6 +3126,7 @@ async fn start_queued_session_for_branch( image_ids, queued_message_id: None, pending_auto_review_branch_id: None, + acp_config_selection: acp_config_selection_for_session_start(&session), branch_id: Some(branch_id), project_id: Some(branch.project_id.clone()), expose_pikchr_tools: local_note_pikchr_tools_available( @@ -2729,6 +3480,7 @@ pub async fn trigger_auto_review( image_ids: vec![], queued_message_id: None, pending_auto_review_branch_id: None, + acp_config_selection: None, branch_id: Some(branch_id.clone()), project_id: Some(branch.project_id.clone()), // Auto-review sessions don't write notes. @@ -4198,8 +4950,9 @@ pub(crate) fn extract_launch_context( #[cfg(test)] mod tests { use super::*; - use std::path::Path; - use std::sync::Arc; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; fn setup_branch_store() -> (Arc, store::Branch) { let store = Arc::new(Store::in_memory().unwrap()); @@ -4235,6 +4988,84 @@ mod tests { values.iter().map(|value| value.to_string()).collect() } + fn test_acp_config_discovery(provider_id: &str, current_value_id: &str) -> AcpConfigDiscovery { + AcpConfigDiscovery { + provider_id: provider_id.to_string(), + model: Some(crate::acp_config::NormalizedAcpConfigSelector { + config_id: "model".to_string(), + label: "Model".to_string(), + current_value_id: current_value_id.to_string(), + options: vec![ + crate::acp_config::NormalizedAcpConfigValueOption { + value_id: "sonnet".to_string(), + label: "Sonnet".to_string(), + group_label: None, + }, + crate::acp_config::NormalizedAcpConfigValueOption { + value_id: "opus".to_string(), + label: "Opus".to_string(), + group_label: None, + }, + ], + }), + effort: None, + } + } + + fn test_acp_config_discovery_with_effort( + provider_id: &str, + current_model_value_id: &str, + current_effort_value_id: &str, + effort_values: &[&str], + ) -> AcpConfigDiscovery { + let mut discovery = test_acp_config_discovery(provider_id, current_model_value_id); + discovery.effort = Some(crate::acp_config::NormalizedAcpConfigSelector { + config_id: "reasoning".to_string(), + label: "Reasoning".to_string(), + current_value_id: current_effort_value_id.to_string(), + options: effort_values + .iter() + .map(|value| crate::acp_config::NormalizedAcpConfigValueOption { + value_id: (*value).to_string(), + label: value.to_ascii_uppercase(), + group_label: None, + }) + .collect(), + }); + discovery + } + + fn test_acp_config_value_selection( + config_id: &str, + value_id: &str, + label: &str, + ) -> store::AcpConfigValueSelection { + store::AcpConfigValueSelection { + config_id: config_id.to_string(), + value_id: value_id.to_string(), + label: Some(label.to_string()), + } + } + + fn stale_acp_config_cache_fetch_time() -> Instant { + Instant::now() + .checked_sub(ACP_CONFIG_DISCOVERY_CACHE_TTL + Duration::from_secs(1)) + .unwrap_or_else(Instant::now) + } + + fn remove_acp_config_cache_entry(provider_id: &str) { + acp_config_discovery_cache() + .lock() + .unwrap() + .remove(provider_id); + } + + fn remove_acp_config_model_cache_entry(provider_id: &str, selected_model_value: &str) { + acp_config_discovery_model_cache().lock().unwrap().remove( + &acp_config_discovery_model_cache_key(provider_id, selected_model_value), + ); + } + fn create_auto_review( store: &Arc, branch_id: &str, @@ -4610,9 +5441,21 @@ mod tests { } #[test] - fn explicit_queue_response_reports_queued_status() { + fn explicit_queue_response_reports_queued_status_and_stores_acp_config_selection() { let (store, branch) = setup_branch_store(); let registry = Arc::new(session_runner::SessionRegistry::new()); + let selection = store::AcpConfigSelection { + model: Some(store::AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "gpt-5".to_string(), + label: Some("GPT-5".to_string()), + }), + effort: Some(store::AcpConfigValueSelection { + config_id: "reasoning".to_string(), + value_id: "medium".to_string(), + label: Some("Medium".to_string()), + }), + }; let response = queue_branch_session_for_store( Arc::clone(&store), @@ -4623,12 +5466,125 @@ mod tests { None, None, None, + Some(selection.clone()), ) .unwrap(); assert_eq!(response.session_status, BranchSessionLaunchStatus::Queued); let session = store.get_session(&response.session_id).unwrap().unwrap(); assert_eq!(session.status, store::SessionStatus::Queued); + assert_eq!(session.acp_config_selection, Some(selection)); + } + + #[test] + fn queued_session_start_uses_stored_acp_config_selection() { + let selection = store::AcpConfigSelection { + model: Some(store::AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "gpt-5".to_string(), + label: Some("GPT-5".to_string()), + }), + effort: Some(store::AcpConfigValueSelection { + config_id: "reasoning".to_string(), + value_id: "high".to_string(), + label: Some("High".to_string()), + }), + }; + let session = + store::Session::new_queued("queued").with_acp_config_selection(selection.clone()); + + assert_eq!( + acp_config_selection_for_session_start(&session), + Some(selection) + ); + } + + #[test] + fn resume_selection_drops_stale_stored_values_from_metadata() { + use agent_client_protocol::schema::v1::{ + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, + }; + + let store = Store::in_memory().unwrap(); + let stored = store::AcpConfigSelection { + model: Some(test_acp_config_value_selection("model", "sonnet", "Sonnet")), + effort: Some(test_acp_config_value_selection("reasoning", "high", "High")), + }; + let session = store::Session::new_running("resume", Path::new("/tmp")) + .with_provider("claude") + .with_acp_config_selection(stored); + store.create_session(&session).unwrap(); + + let config_options = vec![ + SessionConfigOption::select( + "model", + "Model", + "opus", + vec![SessionConfigSelectOption::new("opus", "Opus")], + ) + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "reasoning", + "Effort", + "high", + vec![ + SessionConfigSelectOption::new("low", "Low"), + SessionConfigSelectOption::new("high", "High"), + ], + ) + .category(SessionConfigOptionCategory::ThoughtLevel), + ]; + store + .add_acp_metadata_message( + &session.id, + &store::AcpMessageMetadata { + acp_event_kind: Some("config_options_update".to_string()), + acp_config_options: serde_json::to_value(config_options).ok(), + ..Default::default() + }, + ) + .unwrap(); + + let resolved = resolve_resume_acp_config_selection(&store, &session, None); + + assert_eq!( + resolved, + Some(store::AcpConfigSelection { + model: None, + effort: Some(test_acp_config_value_selection("reasoning", "high", "High")), + }) + ); + } + + #[test] + fn resume_selection_clears_when_fresh_discovery_has_no_selectors() { + let provider_id = "cache-provider-no-selectors"; + remove_acp_config_cache_entry(provider_id); + acp_config_discovery_cache().lock().unwrap().insert( + provider_id.to_string(), + AcpConfigDiscoveryCacheEntry { + discovery: AcpConfigDiscovery { + provider_id: provider_id.to_string(), + model: None, + effort: None, + }, + fetched_at: Instant::now(), + }, + ); + + let store = Store::in_memory().unwrap(); + let stored = store::AcpConfigSelection { + model: Some(test_acp_config_value_selection("model", "sonnet", "Sonnet")), + effort: Some(test_acp_config_value_selection("reasoning", "high", "High")), + }; + let session = store::Session::new_running("resume", Path::new("/tmp")) + .with_provider(provider_id) + .with_acp_config_selection(stored); + + let resolved = resolve_resume_acp_config_selection(&store, &session, None); + + assert_eq!(resolved, None); + remove_acp_config_cache_entry(provider_id); } #[test] @@ -4789,6 +5745,513 @@ mod tests { assert_eq!(resolve_preferred_provider_id(None, &[], &[]), None); } + #[test] + fn acp_config_discovery_returns_provider_when_selectors_are_absent() { + let discovery = acp_config_discovery_from_options("goose".to_string(), &[]); + + assert_eq!(discovery.provider_id, "goose"); + assert!(discovery.model.is_none()); + assert!(discovery.effort.is_none()); + } + + #[test] + fn acp_config_discovery_ignores_non_picker_config_options() { + use agent_client_protocol::schema::v1::{ + SessionConfigBoolean, SessionConfigKind, SessionConfigOption, + SessionConfigOptionCategory, SessionConfigSelectOption, + }; + + let options = vec![ + SessionConfigOption::select( + "mode", + "Mode", + "default", + vec![SessionConfigSelectOption::new("default", "Default")], + ) + .category(SessionConfigOptionCategory::Mode), + SessionConfigOption::new( + "model-toggle", + "Model toggle", + SessionConfigKind::Boolean(SessionConfigBoolean::new(false)), + ) + .category(SessionConfigOptionCategory::Model), + ]; + + let discovery = acp_config_discovery_from_options("claude".to_string(), &options); + + assert_eq!(discovery.provider_id, "claude"); + assert!(discovery.model.is_none()); + assert!(discovery.effort.is_none()); + } + + #[tokio::test(flavor = "current_thread")] + async fn acp_config_discovery_does_not_authenticate_advertised_methods() { + use agent_client_protocol::schema::v1::{ + AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, + InitializeRequest, InitializeResponse, NewSessionRequest, NewSessionResponse, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, + }; + + let auth_called = Arc::new(AtomicBool::new(false)); + let auth_called_for_handler = Arc::clone(&auth_called); + let agent = agent_client_protocol::Agent + .builder() + .on_receive_request( + async |initialize: InitializeRequest, responder, _cx| { + responder.respond( + InitializeResponse::new(initialize.protocol_version).auth_methods(vec![ + AuthMethod::Agent(AuthMethodAgent::new("browser", "Browser Login")), + ]), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: AuthenticateRequest, responder, _cx| { + auth_called_for_handler.store(true, Ordering::SeqCst); + responder.respond(AuthenticateResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async |_request: NewSessionRequest, responder, _cx| { + responder.respond(NewSessionResponse::new("discovery-session").config_options( + vec![ + SessionConfigOption::select( + "model", + "Model", + "sonnet", + vec![SessionConfigSelectOption::new("sonnet", "Sonnet")], + ) + .category(SessionConfigOptionCategory::Model), + ], + )) + }, + agent_client_protocol::on_receive_request!(), + ); + + let options = + run_acp_config_discovery_transport("test-provider", Path::new("/tmp"), agent, None) + .await + .unwrap(); + + assert_eq!(options.len(), 1); + assert!(!auth_called.load(Ordering::SeqCst)); + } + + #[tokio::test(flavor = "current_thread")] + async fn acp_config_discovery_auth_required_returns_no_options_without_authenticating() { + use agent_client_protocol::schema::v1::{ + AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, + InitializeRequest, InitializeResponse, NewSessionRequest, + }; + + let auth_called = Arc::new(AtomicBool::new(false)); + let auth_called_for_handler = Arc::clone(&auth_called); + let agent = agent_client_protocol::Agent + .builder() + .on_receive_request( + async |initialize: InitializeRequest, responder, _cx| { + responder.respond( + InitializeResponse::new(initialize.protocol_version).auth_methods(vec![ + AuthMethod::Agent(AuthMethodAgent::new("browser", "Browser Login")), + ]), + ) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_request: AuthenticateRequest, responder, _cx| { + auth_called_for_handler.store(true, Ordering::SeqCst); + responder.respond(AuthenticateResponse::new()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async |_request: NewSessionRequest, responder, _cx| { + responder.respond_with_error(agent_client_protocol::Error::auth_required()) + }, + agent_client_protocol::on_receive_request!(), + ); + + let options = + run_acp_config_discovery_transport("test-provider", Path::new("/tmp"), agent, None) + .await + .unwrap(); + + assert!(options.is_empty()); + assert!(!auth_called.load(Ordering::SeqCst)); + } + + #[tokio::test(flavor = "current_thread")] + async fn acp_config_discovery_with_selected_model_returns_post_set_config_options() { + use agent_client_protocol::schema::v1::{ + InitializeRequest, InitializeResponse, NewSessionRequest, NewSessionResponse, + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, + }; + + let calls = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + let calls_for_handler = Arc::clone(&calls); + let agent = agent_client_protocol::Agent + .builder() + .on_receive_request( + async |initialize: InitializeRequest, responder, _cx| { + responder.respond(InitializeResponse::new(initialize.protocol_version)) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async |_request: NewSessionRequest, responder, _cx| { + responder.respond(NewSessionResponse::new("discovery-session").config_options( + vec![ + SessionConfigOption::select( + "model", + "Model", + "sonnet", + vec![ + SessionConfigSelectOption::new("sonnet", "Sonnet"), + SessionConfigSelectOption::new("opus", "Opus"), + ], + ) + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "reasoning", + "Reasoning", + "high", + vec![ + SessionConfigSelectOption::new("low", "Low"), + SessionConfigSelectOption::new("high", "High"), + ], + ) + .category(SessionConfigOptionCategory::ThoughtLevel), + ], + )) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |request: SetSessionConfigOptionRequest, responder, _cx| { + calls_for_handler.lock().unwrap().push(( + request.config_id.to_string(), + request + .value + .as_value_id() + .expect("selected config value should be a value ID") + .to_string(), + )); + responder.respond(SetSessionConfigOptionResponse::new(vec![ + SessionConfigOption::select( + "model", + "Model", + "opus", + vec![ + SessionConfigSelectOption::new("sonnet", "Sonnet"), + SessionConfigSelectOption::new("opus", "Opus"), + ], + ) + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select( + "reasoning", + "Reasoning", + "low", + vec![SessionConfigSelectOption::new("low", "Low")], + ) + .category(SessionConfigOptionCategory::ThoughtLevel), + ])) + }, + agent_client_protocol::on_receive_request!(), + ); + + let options = run_acp_config_discovery_transport( + "test-provider", + Path::new("/tmp"), + agent, + Some("opus"), + ) + .await + .unwrap(); + let discovery = acp_config_discovery_from_options("test-provider".to_string(), &options); + + assert_eq!( + calls.lock().unwrap().as_slice(), + &[(String::from("model"), String::from("opus"))] + ); + assert_eq!(discovery.model.as_ref().unwrap().current_value_id, "opus"); + let effort = discovery.effort.as_ref().unwrap(); + assert_eq!(effort.current_value_id, "low"); + assert_eq!(effort.options.len(), 1); + assert_eq!(effort.options[0].value_id, "low"); + } + + #[test] + fn acp_config_discovery_cache_is_provider_scoped_across_working_dirs() { + let provider_id = "cache-provider-scoped-dirs"; + remove_acp_config_cache_entry(provider_id); + let calls = Arc::new(Mutex::new(Vec::::new())); + let calls_for_fetch = Arc::clone(&calls); + + let first = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo-one"), + false, + move |provider_id, working_dir| { + calls_for_fetch.lock().unwrap().push(working_dir); + Ok(test_acp_config_discovery(&provider_id, "sonnet")) + }, + ) + .unwrap(); + let second = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo-two"), + false, + |_provider_id, _working_dir| panic!("fresh cache hit should not refetch"), + ) + .unwrap(); + + assert_eq!(first, second); + assert_eq!( + calls.lock().unwrap().as_slice(), + &[PathBuf::from("/repo-one")] + ); + } + + #[test] + fn acp_config_discovery_force_bypasses_and_replaces_fresh_cache() { + let provider_id = "cache-provider-force"; + remove_acp_config_cache_entry(provider_id); + + let first = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + false, + |provider_id, _working_dir| Ok(test_acp_config_discovery(&provider_id, "sonnet")), + ) + .unwrap(); + let second = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + true, + |provider_id, _working_dir| Ok(test_acp_config_discovery(&provider_id, "opus")), + ) + .unwrap(); + let third = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + false, + |_provider_id, _working_dir| panic!("replaced cache hit should not refetch"), + ) + .unwrap(); + + assert_eq!( + first.model.as_ref().unwrap().current_value_id, + "sonnet".to_string() + ); + assert_eq!( + second.model.as_ref().unwrap().current_value_id, + "opus".to_string() + ); + assert_eq!(third, second); + } + + #[test] + fn acp_config_discovery_model_cache_is_provider_and_model_scoped() { + let provider_id = "cache-provider-model-scoped"; + remove_acp_config_model_cache_entry(provider_id, "sonnet"); + remove_acp_config_model_cache_entry(provider_id, "opus"); + let calls = Arc::new(Mutex::new(Vec::<(PathBuf, Option)>::new())); + + let calls_for_sonnet = Arc::clone(&calls); + let sonnet = discover_acp_config_for_model_with_cache( + provider_id.to_string(), + PathBuf::from("/repo-one"), + "sonnet".to_string(), + false, + move |provider_id, working_dir, selected_model_value| { + calls_for_sonnet + .lock() + .unwrap() + .push((working_dir, selected_model_value)); + Ok(test_acp_config_discovery_with_effort( + &provider_id, + "sonnet", + "medium", + &["low", "medium"], + )) + }, + ) + .unwrap(); + let sonnet_cached = discover_acp_config_for_model_with_cache( + provider_id.to_string(), + PathBuf::from("/repo-two"), + "sonnet".to_string(), + false, + |_provider_id, _working_dir, _selected_model_value| { + panic!("fresh model cache hit should not refetch") + }, + ) + .unwrap(); + let calls_for_opus = Arc::clone(&calls); + let opus = discover_acp_config_for_model_with_cache( + provider_id.to_string(), + PathBuf::from("/repo-three"), + "opus".to_string(), + false, + move |provider_id, working_dir, selected_model_value| { + calls_for_opus + .lock() + .unwrap() + .push((working_dir, selected_model_value)); + Ok(test_acp_config_discovery_with_effort( + &provider_id, + "opus", + "low", + &["low"], + )) + }, + ) + .unwrap(); + + assert_eq!(sonnet_cached, sonnet); + assert_eq!( + sonnet + .effort + .as_ref() + .unwrap() + .options + .iter() + .map(|option| option.value_id.as_str()) + .collect::>(), + vec!["low", "medium"] + ); + assert_eq!( + opus.effort + .as_ref() + .unwrap() + .options + .iter() + .map(|option| option.value_id.as_str()) + .collect::>(), + vec!["low"] + ); + assert_eq!( + calls.lock().unwrap().as_slice(), + &[ + (PathBuf::from("/repo-one"), Some("sonnet".to_string())), + (PathBuf::from("/repo-three"), Some("opus".to_string())), + ] + ); + + remove_acp_config_model_cache_entry(provider_id, "sonnet"); + remove_acp_config_model_cache_entry(provider_id, "opus"); + } + + #[test] + fn acp_config_discovery_model_cache_does_not_replace_provider_cache() { + let provider_id = "cache-provider-model-does-not-poison-default"; + remove_acp_config_cache_entry(provider_id); + remove_acp_config_model_cache_entry(provider_id, "opus"); + + let default = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + false, + |provider_id, _working_dir| Ok(test_acp_config_discovery(&provider_id, "sonnet")), + ) + .unwrap(); + let model_specific = discover_acp_config_for_model_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + "opus".to_string(), + false, + |provider_id, _working_dir, _selected_model_value| { + Ok(test_acp_config_discovery_with_effort( + &provider_id, + "opus", + "low", + &["low"], + )) + }, + ) + .unwrap(); + let default_again = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + false, + |_provider_id, _working_dir| panic!("provider cache should remain fresh"), + ) + .unwrap(); + + assert_eq!(default_again, default); + assert!(default_again.effort.is_none()); + assert_eq!( + model_specific + .effort + .as_ref() + .unwrap() + .options + .iter() + .map(|option| option.value_id.as_str()) + .collect::>(), + vec!["low"] + ); + + remove_acp_config_cache_entry(provider_id); + remove_acp_config_model_cache_entry(provider_id, "opus"); + } + + #[test] + fn acp_config_discovery_refresh_failure_returns_stale_cache() { + let provider_id = "cache-provider-stale"; + let stale = test_acp_config_discovery(provider_id, "sonnet"); + acp_config_discovery_cache().lock().unwrap().insert( + provider_id.to_string(), + AcpConfigDiscoveryCacheEntry { + discovery: stale.clone(), + fetched_at: stale_acp_config_cache_fetch_time(), + }, + ); + + let result = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + false, + |_provider_id, _working_dir| Err("provider unavailable".to_string()), + ) + .unwrap(); + + assert_eq!(result, stale); + } + + #[test] + fn acp_config_discovery_refresh_failure_without_stale_cache_returns_error() { + let provider_id = "cache-provider-miss-failure"; + remove_acp_config_cache_entry(provider_id); + + let err = discover_acp_config_for_provider_with_cache( + provider_id.to_string(), + PathBuf::from("/repo"), + false, + |_provider_id, _working_dir| Err("provider unavailable".to_string()), + ) + .unwrap_err(); + + assert_eq!(err, "provider unavailable"); + } + + #[test] + fn acp_config_discovery_cache_rejects_blank_provider_ids() { + let err = discover_acp_config_for_provider_with_cache( + " ".to_string(), + PathBuf::from("/repo"), + false, + |_provider_id, _working_dir| panic!("blank provider should not fetch"), + ) + .unwrap_err(); + + assert!(err.contains("ACP provider ID is required")); + } + #[test] fn resolve_provider_from_ids_rejects_unavailable_provider() { let available = ids(&["goose", "claude"]); diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index ddd60ae78..d511b6129 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -52,9 +52,9 @@ use crate::agent::{AcpDriver, AgentDriver, MessageWriter}; use crate::git::Span; use crate::shell_env::ShellEnvCache; use crate::store::{ - Comment, CommentAuthor, CommentType, CompletionReason, FailureStrategy, MessageRole, - PipelineExecution, PipelineKind, PipelineStep, SessionMessage, SessionStatus, StepStatus, - StepType, Store, + AcpConfigSelection, Comment, CommentAuthor, CommentType, CompletionReason, FailureStrategy, + MessageRole, PipelineExecution, PipelineKind, PipelineStep, SessionMessage, SessionStatus, + StepStatus, StepType, Store, }; const PIPELINE_STEP_PROMPT_OUTPUT_MAX_CHARS: usize = 30_000; @@ -336,6 +336,10 @@ pub struct SessionConfig { pub queued_message_id: Option, /// Branch with a commit waiting for auto-review once queued follow-ups drain. pub pending_auto_review_branch_id: Option, + /// Selected ACP config values to apply after session setup and before the + /// prompt. Command handlers also store successful selections on the session + /// row so queued and resumed sessions use their own selection. + pub acp_config_selection: Option, /// Branch that owns this session (branch-level sessions only). /// Threaded through so terminal events carry the same context as start events. pub branch_id: Option, @@ -403,6 +407,9 @@ pub fn start_session( } }; + let selected_acp_config_options = + crate::acp_config::selected_acp_config_options(config.acp_config_selection.as_ref()); + // Persist the user message right away so it's visible immediately. // Include image IDs so the frontend can display them alongside the text. // We also mark attached images as session-scoped immediately after so they @@ -514,6 +521,7 @@ pub fn start_session( config.action_executor.clone(), config.action_registry.clone(), config.provider.clone(), + config.acp_config_selection.clone(), cancel_token.clone(), ) .await @@ -656,6 +664,7 @@ pub fn start_session( &writer_trait, &cancel_token, agent_session_id.as_deref(), + &selected_acp_config_options, ) .await; @@ -715,6 +724,20 @@ pub fn start_session( .cancellation_completion_reason(&session_id_for_status) .unwrap_or(CompletionReason::Interrupted); + if let Err(ref e) = result { + if config.acp_config_selection.is_some() + && acp_client::is_config_selection_unavailable_error(e) + { + if let Err(clear_err) = + store_for_status.set_session_acp_config_selection(&session_id_for_status, None) + { + log::warn!( + "Failed to clear stale ACP config selection for session {session_id_for_status}: {clear_err}" + ); + } + } + } + // Transition the session to its terminal state, but only if it is // still "running". This prevents a late-arriving "completed" from // overwriting a "cancelled" that was set by a concurrent cancel @@ -1178,6 +1201,7 @@ pub fn start_pipeline_session( image_ids: vec![], queued_message_id: None, pending_auto_review_branch_id: None, + acp_config_selection: None, branch_id: config.branch_id.clone(), project_id: config.project_id.clone(), // Deterministic pipelines hand off to a code-focused AI step, diff --git a/apps/staged/src-tauri/src/store/migration_tests.rs b/apps/staged/src-tauri/src/store/migration_tests.rs index 79c8508e8..0fdffa8f3 100644 --- a/apps/staged/src-tauri/src/store/migration_tests.rs +++ b/apps/staged/src-tauri/src/store/migration_tests.rs @@ -145,7 +145,7 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { ) .unwrap(); - assert_eq!(version, 19); + assert_eq!(version, 20); assert_eq!(app_version, super::APP_VERSION); assert!(table_exists(&conn, "projects")); assert!(table_exists(&conn, "project_notes")); @@ -158,6 +158,7 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() { "session_messages", "acp_agent_capabilities" )); + assert!(column_exists(&conn, "sessions", "acp_config_selection")); let trigger_count: i64 = conn .query_row( @@ -221,8 +222,9 @@ fn test_store_repairs_github_comment_tracking_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 19); + assert_eq!(version, 20); assert!(column_exists(&conn, "sessions", "pipeline")); + assert!(column_exists(&conn, "sessions", "acp_config_selection")); assert!(column_exists( &conn, "session_messages", @@ -278,7 +280,7 @@ fn test_store_repairs_pipeline_user_version() { let version: i64 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(version, 19); + assert_eq!(version, 20); assert!(column_exists(&conn, "comments", "github_comment_id")); assert!(column_exists(&conn, "comments", "github_comment_type")); assert!(column_exists(&conn, "comments", "github_comment_stale")); @@ -288,6 +290,7 @@ fn test_store_repairs_pipeline_user_version() { "acp_agent_capabilities" )); assert!(table_exists(&conn, "queued_session_messages")); + assert!(column_exists(&conn, "sessions", "acp_config_selection")); cleanup_db(&path); } diff --git a/apps/staged/src-tauri/src/store/migrations/0020-add-session-acp-config-selection/up.sql b/apps/staged/src-tauri/src/store/migrations/0020-add-session-acp-config-selection/up.sql new file mode 100644 index 000000000..3409f8d89 --- /dev/null +++ b/apps/staged/src-tauri/src/store/migrations/0020-add-session-acp-config-selection/up.sql @@ -0,0 +1,2 @@ +-- Persist selected ACP config values for a session as category-keyed JSON. +ALTER TABLE sessions ADD COLUMN acp_config_selection TEXT DEFAULT NULL; diff --git a/apps/staged/src-tauri/src/store/models.rs b/apps/staged/src-tauri/src/store/models.rs index b18c8acf9..64cabdd50 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -524,6 +524,9 @@ pub struct Session { /// command pipeline (deterministic steps before/instead of AI). #[serde(skip_serializing_if = "Option::is_none")] pub pipeline: Option, + /// Selected ACP config values to apply before prompting the agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_config_selection: Option, } /// Persistent follow-up message waiting to be sent to an existing session. @@ -612,6 +615,7 @@ impl Session { updated_at: now, owner_pid: Some(std::process::id()), pipeline: None, + acp_config_selection: None, } } @@ -633,6 +637,7 @@ impl Session { updated_at: now, owner_pid: None, pipeline: None, + acp_config_selection: None, } } @@ -645,6 +650,31 @@ impl Session { self.agent_id = Some(agent_id.to_string()); self } + + pub fn with_acp_config_selection(mut self, selection: AcpConfigSelection) -> Self { + self.acp_config_selection = Some(selection); + self + } +} + +/// Session-level ACP config selections keyed by product-facing category. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConfigSelection { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effort: Option, +} + +/// Selected value for one ACP `session/set_config_option` config ID. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpConfigValueSelection { + pub config_id: String, + pub value_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, } // ============================================================================= diff --git a/apps/staged/src-tauri/src/store/sessions.rs b/apps/staged/src-tauri/src/store/sessions.rs index e8924039c..835402f7c 100644 --- a/apps/staged/src-tauri/src/store/sessions.rs +++ b/apps/staged/src-tauri/src/store/sessions.rs @@ -2,7 +2,9 @@ use rusqlite::{params, OptionalExtension}; -use super::models::{CompletionReason, PipelineExecution, Session, SessionStatus}; +use super::models::{ + AcpConfigSelection, CompletionReason, PipelineExecution, Session, SessionStatus, +}; use super::{now_timestamp, Store, StoreError}; impl Store { @@ -14,9 +16,11 @@ impl Store { .map(serde_json::to_string) .transpose() .map_err(|e| StoreError(format!("Failed to serialize pipeline: {e}")))?; + let acp_config_selection_json = + serialize_acp_config_selection(session.acp_config_selection.as_ref())?; conn.execute( - "INSERT INTO sessions (id, prompt, status, working_dir, provider, agent_id, error_message, completion_reason, created_at, updated_at, owner_pid, pipeline) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "INSERT INTO sessions (id, prompt, status, working_dir, provider, agent_id, error_message, completion_reason, created_at, updated_at, owner_pid, pipeline, acp_config_selection) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ session.id, session.prompt, @@ -30,6 +34,7 @@ impl Store { session.updated_at, session.owner_pid, pipeline_json, + acp_config_selection_json, ], )?; Ok(()) @@ -38,7 +43,7 @@ impl Store { pub fn get_session(&self, id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, prompt, status, working_dir, provider, agent_id, error_message, completion_reason, created_at, updated_at, owner_pid, pipeline + "SELECT id, prompt, status, working_dir, provider, agent_id, error_message, completion_reason, created_at, updated_at, owner_pid, pipeline, acp_config_selection FROM sessions WHERE id = ?1", params![id], Self::row_to_session, @@ -212,7 +217,7 @@ impl Store { pub fn get_running_sessions(&self) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, prompt, status, working_dir, provider, agent_id, error_message, completion_reason, created_at, updated_at, owner_pid, pipeline + "SELECT id, prompt, status, working_dir, provider, agent_id, error_message, completion_reason, created_at, updated_at, owner_pid, pipeline, acp_config_selection FROM sessions WHERE status = 'running'", )?; let sessions = stmt @@ -285,7 +290,7 @@ impl Store { ) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT s.id, s.prompt, s.status, s.working_dir, s.provider, s.agent_id, s.error_message, s.completion_reason, s.created_at, s.updated_at, s.owner_pid, s.pipeline + "SELECT s.id, s.prompt, s.status, s.working_dir, s.provider, s.agent_id, s.error_message, s.completion_reason, s.created_at, s.updated_at, s.owner_pid, s.pipeline, s.acp_config_selection FROM sessions s WHERE s.status = 'queued' AND ( @@ -391,6 +396,21 @@ impl Store { Ok(()) } + /// Update the selected ACP config values for a session. + pub fn set_session_acp_config_selection( + &self, + id: &str, + selection: Option<&AcpConfigSelection>, + ) -> Result<(), StoreError> { + let conn = self.conn.lock().unwrap(); + let json = serialize_acp_config_selection(selection)?; + conn.execute( + "UPDATE sessions SET acp_config_selection = ?1, updated_at = ?2 WHERE id = ?3", + params![json, now_timestamp(), id], + )?; + Ok(()) + } + /// Update the pipeline execution state for a session. pub fn update_session_pipeline( &self, @@ -411,11 +431,17 @@ impl Store { let status_str: String = row.get(2)?; let reason_str: Option = row.get(7)?; let pipeline_json: Option = row.get(11)?; + let acp_config_selection_json: Option = row.get(12)?; let pipeline = pipeline_json.as_deref().and_then(|s| { serde_json::from_str(s) .map_err(|e| log::warn!("Failed to deserialize pipeline JSON: {e}")) .ok() }); + let acp_config_selection = acp_config_selection_json.as_deref().and_then(|s| { + serde_json::from_str(s) + .map_err(|e| log::warn!("Failed to deserialize ACP config selection JSON: {e}")) + .ok() + }); Ok(Session { id: row.get(0)?, prompt: row.get(1)?, @@ -429,6 +455,16 @@ impl Store { updated_at: row.get(9)?, owner_pid: row.get(10)?, pipeline, + acp_config_selection, }) } } + +fn serialize_acp_config_selection( + selection: Option<&AcpConfigSelection>, +) -> Result, StoreError> { + selection + .map(serde_json::to_string) + .transpose() + .map_err(|e| StoreError(format!("Failed to serialize ACP config selection: {e}"))) +} diff --git a/apps/staged/src-tauri/src/store/tests.rs b/apps/staged/src-tauri/src/store/tests.rs index c57da689c..bbeef4194 100644 --- a/apps/staged/src-tauri/src/store/tests.rs +++ b/apps/staged/src-tauri/src/store/tests.rs @@ -555,6 +555,80 @@ fn test_queued_session_message_claims_pending_images() { assert_eq!(claimed.session_id.as_deref(), Some(session.id.as_str())); } +#[test] +fn test_session_acp_config_selection_round_trips() { + let store = Store::in_memory().unwrap(); + let selection = AcpConfigSelection { + model: Some(AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "gpt-5".to_string(), + label: Some("GPT-5".to_string()), + }), + effort: Some(AcpConfigValueSelection { + config_id: "reasoning_effort".to_string(), + value_id: "high".to_string(), + label: None, + }), + }; + + let session = Session::new_running("configured", Path::new("/tmp")) + .with_acp_config_selection(selection.clone()); + store.create_session(&session).unwrap(); + + let fetched = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(fetched.acp_config_selection, Some(selection.clone())); + + let replacement = AcpConfigSelection { + model: Some(AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "gpt-5-mini".to_string(), + label: Some("GPT-5 mini".to_string()), + }), + effort: None, + }; + store + .set_session_acp_config_selection(&session.id, Some(&replacement)) + .unwrap(); + let updated = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(updated.acp_config_selection, Some(replacement)); + + store + .set_session_acp_config_selection(&session.id, None) + .unwrap(); + let cleared = store.get_session(&session.id).unwrap().unwrap(); + assert_eq!(cleared.acp_config_selection, None); +} + +#[test] +fn test_queued_session_acp_config_selection_round_trips() { + let store = Store::in_memory().unwrap(); + let project = Project::new("test-owner/test-repo"); + store.create_project(&project).unwrap(); + let branch = Branch::new(&project.id, "feature", "main"); + store.create_branch(&branch).unwrap(); + + let selection = AcpConfigSelection { + model: Some(AcpConfigValueSelection { + config_id: "model".to_string(), + value_id: "gpt-5".to_string(), + label: Some("GPT-5".to_string()), + }), + effort: Some(AcpConfigValueSelection { + config_id: "thought_level".to_string(), + value_id: "medium".to_string(), + label: Some("Medium".to_string()), + }), + }; + let session = Session::new_queued("queued").with_acp_config_selection(selection.clone()); + store.create_session(&session).unwrap(); + let note = Note::new(&branch.id, "queued", "").with_session(&session.id); + store.create_note(¬e).unwrap(); + + let queued = store.get_queued_sessions_for_branch(&branch.id).unwrap(); + assert_eq!(queued.len(), 1); + assert_eq!(queued[0].acp_config_selection, Some(selection)); +} + #[test] fn test_delete_queued_session_message_releases_claimed_images() { let store = Store::in_memory().unwrap(); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 2d92c9f2a..915d596e7 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2758,6 +2758,20 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let provider_id: String = arg(&args, "providerId")?; + let working_dir: Option = opt_arg(&args, "workingDir")?; + let force: Option = opt_arg(&args, "force")?; + let selected_model_value: Option = opt_arg(&args, "selectedModelValue")?; + let config = crate::session_commands::discover_acp_config( + provider_id, + working_dir, + force, + selected_model_value, + ) + .await?; + Ok(serde_json::to_value(config).unwrap()) + } "get_session" => { let store = get_store(store_mutex)?; let session_id: String = arg(&args, "sessionId")?; @@ -2811,9 +2825,14 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result = opt_arg(&args, "provider")?; + let acp_config_selection: Option = + opt_arg(&args, "acpConfigSelection")?; let working_dir = std::path::PathBuf::from(working_dir); let mut session = store::Session::new_running(&prompt, &working_dir); + if let Some(selection) = acp_config_selection.clone() { + session = session.with_acp_config_selection(selection); + } if let Some(ref p) = provider { session = session.with_provider(p); } @@ -2836,6 +2855,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result> = opt_arg(&args, "imageIds")?; let branch_id: Option = opt_arg(&args, "branchId")?; + let acp_config_selection: Option = + opt_arg(&args, "acpConfigSelection")?; let session = store .get_session(&session_id) @@ -2862,6 +2884,13 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result Result Result> = opt_arg(&args, "imageIds")?; let launch_context: Option = opt_arg(&args, "launchContext")?; + let acp_config_selection: Option = + opt_arg(&args, "acpConfigSelection")?; let result = session_commands::start_or_queue_branch_session_for_store( store, @@ -3113,6 +3158,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result = opt_arg(&args, "provider")?; let image_ids: Option> = opt_arg(&args, "imageIds")?; + let acp_config_selection: Option = + opt_arg(&args, "acpConfigSelection")?; let project = store .get_project(&project_id) @@ -3150,6 +3198,9 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result Result> = opt_arg(&args, "imageIds")?; let launch_context: Option = opt_arg(&args, "launchContext")?; + let acp_config_selection: Option = + opt_arg(&args, "acpConfigSelection")?; let result = session_commands::queue_branch_session_for_store( store, @@ -3215,6 +3269,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + function selectedAcpConfig() { + return { + model: { configId: 'model', valueId: 'opus', label: 'Opus' }, + effort: { configId: 'reasoning_effort', valueId: 'high', label: 'High' }, + }; + } + beforeEach(() => { vi.resetModules(); }); @@ -120,6 +127,175 @@ describe('browser-native command wrappers', () => { ['send_queued_session_message', { id: 'queue-1' }], ]); }); + + it('forwards provider and ACP config selection when starting standalone sessions', async () => { + const invokeCommand = vi.fn().mockResolvedValue({ id: 'session-1' }); + vi.doMock('./transport', () => ({ + invokeCommand, + isTauri: true, + })); + + const { startSession } = await import('./commands'); + const acpConfigSelection = selectedAcpConfig(); + + await startSession('Investigate', '/repo', 'codex', acpConfigSelection); + + expect(invokeCommand).toHaveBeenCalledWith('start_session', { + prompt: 'Investigate', + workingDir: '/repo', + provider: 'codex', + acpConfigSelection, + }); + }); + + it('forwards provider and ACP config selection when starting project sessions', async () => { + const invokeCommand = vi.fn().mockResolvedValue({ + sessionId: 'session-1', + noteId: 'note-1', + }); + vi.doMock('./transport', () => ({ + invokeCommand, + isTauri: true, + })); + + const { startProjectSession } = await import('./commands'); + const acpConfigSelection = selectedAcpConfig(); + + await startProjectSession( + 'project-1', + 'Plan the work', + 'codex', + ['image-1'], + acpConfigSelection + ); + + expect(invokeCommand).toHaveBeenCalledWith('start_project_session', { + projectId: 'project-1', + prompt: 'Plan the work', + provider: 'codex', + imageIds: ['image-1'], + acpConfigSelection, + }); + }); + + it('forwards provider and ACP config selection when starting or queueing branch sessions', async () => { + const invokeCommand = vi.fn().mockResolvedValue({ + sessionId: 'session-1', + artifactId: 'commit-1', + sessionStatus: 'running', + }); + vi.doMock('./transport', () => ({ + invokeCommand, + isTauri: true, + })); + + const { startOrQueueBranchSession } = await import('./commands'); + const launchContext = { + source: 'diff_viewer' as const, + scope: 'commit' as const, + commitSha: 'abc123', + reviewId: 'review-1', + }; + const acpConfigSelection = selectedAcpConfig(); + + await startOrQueueBranchSession( + 'branch-1', + 'Fix the bug', + 'commit', + 'codex', + ['image-1'], + launchContext, + acpConfigSelection + ); + + expect(invokeCommand).toHaveBeenCalledWith('start_or_queue_branch_session', { + branchId: 'branch-1', + prompt: 'Fix the bug', + sessionType: 'commit', + provider: 'codex', + imageIds: ['image-1'], + launchContext, + acpConfigSelection, + }); + }); + + it('forwards provider and ACP config selection when explicitly queueing branch sessions', async () => { + const invokeCommand = vi.fn().mockResolvedValue({ + sessionId: 'session-1', + artifactId: 'note-1', + sessionStatus: 'queued', + }); + vi.doMock('./transport', () => ({ + invokeCommand, + isTauri: true, + })); + + const { queueBranchSession } = await import('./commands'); + const launchContext = { + source: 'diff_viewer' as const, + scope: 'branch' as const, + commitSha: 'abc123', + }; + const acpConfigSelection = selectedAcpConfig(); + + await queueBranchSession( + 'branch-1', + 'Write a note', + 'note', + 'codex', + ['image-1'], + launchContext, + acpConfigSelection + ); + + expect(invokeCommand).toHaveBeenCalledWith('queue_branch_session', { + branchId: 'branch-1', + prompt: 'Write a note', + sessionType: 'note', + provider: 'codex', + imageIds: ['image-1'], + launchContext, + acpConfigSelection, + }); + }); + + it('forwards ACP config selection when resuming a session', async () => { + const invokeCommand = vi.fn().mockResolvedValue(undefined); + vi.doMock('./transport', () => ({ + invokeCommand, + isTauri: true, + })); + + const { resumeSession } = await import('./commands'); + const acpConfigSelection = selectedAcpConfig(); + + await resumeSession('session-1', 'Continue', ['image-1'], 'branch-1', acpConfigSelection); + + expect(invokeCommand).toHaveBeenCalledWith('resume_session', { + sessionId: 'session-1', + prompt: 'Continue', + imageIds: ['image-1'], + branchId: 'branch-1', + acpConfigSelection, + }); + }); + + it('drains queued sessions without overriding the queued session provider or config', async () => { + const invokeCommand = vi.fn().mockResolvedValue(true); + vi.doMock('./transport', () => ({ + invokeCommand, + isTauri: true, + })); + + const { drainQueuedSessions } = await import('./commands'); + + await drainQueuedSessions('branch-1'); + + expect(invokeCommand).toHaveBeenCalledWith('drain_queued_sessions', { + branchId: 'branch-1', + provider: null, + }); + }); }); describe('cached mutation command wrappers', () => { @@ -263,4 +439,85 @@ describe('cached mutation command wrappers', () => { }); expect(cachedCommand).toHaveBeenCalledWith('discover_acp_providers', undefined, { ttl: 0 }); }); + + it('discovers ACP config through the backend without frontend caching', async () => { + const config = { + providerId: 'goose', + model: null, + effort: null, + }; + invokeCommand.mockResolvedValue(config); + + const { discoverAcpConfig } = await import('./commands'); + + await expect(discoverAcpConfig('goose', '/repo')).resolves.toEqual({ + data: config, + revalidating: null, + }); + await discoverAcpConfig('goose', '/other-repo'); + + expect(invokeCommand).toHaveBeenNthCalledWith(1, 'discover_acp_config', { + providerId: 'goose', + workingDir: '/repo', + force: false, + selectedModelValue: null, + }); + expect(invokeCommand).toHaveBeenNthCalledWith(2, 'discover_acp_config', { + providerId: 'goose', + workingDir: '/other-repo', + force: false, + selectedModelValue: null, + }); + expect(cachedCommand).not.toHaveBeenCalledWith( + 'discover_acp_config', + expect.anything(), + expect.anything() + ); + }); + + it('passes ACP config discovery force to the backend command', async () => { + const config = { + providerId: 'goose', + model: null, + effort: null, + }; + invokeCommand.mockResolvedValue(config); + + const { discoverAcpConfig } = await import('./commands'); + + await expect(discoverAcpConfig('goose', null, { force: true })).resolves.toEqual({ + data: config, + revalidating: null, + }); + expect(invokeCommand).toHaveBeenCalledWith('discover_acp_config', { + providerId: 'goose', + workingDir: null, + force: true, + selectedModelValue: null, + }); + }); + + it('passes selected ACP model discovery through to the backend command', async () => { + const config = { + providerId: 'goose', + model: null, + effort: null, + }; + invokeCommand.mockResolvedValue(config); + + const { discoverAcpConfig } = await import('./commands'); + + await expect( + discoverAcpConfig('goose', '/repo', { selectedModelValue: 'opus' }) + ).resolves.toEqual({ + data: config, + revalidating: null, + }); + expect(invokeCommand).toHaveBeenCalledWith('discover_acp_config', { + providerId: 'goose', + workingDir: '/repo', + force: false, + selectedModelValue: 'opus', + }); + }); }); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 1bd46c80e..27b35a8a4 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -41,6 +41,7 @@ import type { PollWorkspaceResult, Image, SuggestedRepo, + AcpConfigSelection, } from './types'; export type DiffScope = 'branch' | 'commit' | 'worktree'; @@ -236,13 +237,15 @@ export function startProjectSession( projectId: string, prompt: string, provider?: string, - imageIds?: string[] + imageIds?: string[], + acpConfigSelection?: AcpConfigSelection ): Promise { return invokeCommand('start_project_session', { projectId, prompt, provider: provider ?? null, imageIds: imageIds?.length ? imageIds : null, + acpConfigSelection: acpConfigSelection ?? null, }); } @@ -691,10 +694,34 @@ export interface AcpProviderInfo { label: string; } +export interface AcpConfigValueOption { + valueId: string; + label: string; + groupLabel?: string | null; +} + +export interface AcpConfigSelector { + configId: string; + label: string; + currentValueId: string; + options: AcpConfigValueOption[]; +} + +export interface AcpConfigDiscovery { + providerId: string; + model?: AcpConfigSelector | null; + effort?: AcpConfigSelector | null; +} + export interface DiscoverAcpProvidersOptions { force?: boolean; } +export interface DiscoverAcpConfigOptions { + force?: boolean; + selectedModelValue?: string | null; +} + const ACP_PROVIDER_CACHE_TTL = 30 * 60_000; /** Scan the system for installed ACP-compatible agents. */ @@ -706,6 +733,21 @@ export function discoverAcpProviders( }); } +/** Discover model/effort selectors exposed by a provider for a working directory. */ +export async function discoverAcpConfig( + providerId: string, + workingDir?: string | null, + options: DiscoverAcpConfigOptions = {} +): Promise> { + const data = await invokeCommand('discover_acp_config', { + providerId, + workingDir: workingDir ?? null, + force: options.force ?? false, + selectedModelValue: options.selectedModelValue ?? null, + }); + return { data, revalidating: null }; +} + // ============================================================================= // Sessions // ============================================================================= @@ -745,9 +787,15 @@ export function countAssistantMessagesAfter( export function startSession( prompt: string, workingDir: string, - provider?: string + provider?: string, + acpConfigSelection?: AcpConfigSelection ): Promise { - return invokeCommand('start_session', { prompt, workingDir, provider: provider ?? null }); + return invokeCommand('start_session', { + prompt, + workingDir, + provider: provider ?? null, + acpConfigSelection: acpConfigSelection ?? null, + }); } /** Send a follow-up message to an existing session. @@ -756,13 +804,15 @@ export function resumeSession( sessionId: string, prompt: string, imageIds?: string[], - branchId?: string | null + branchId?: string | null, + acpConfigSelection?: AcpConfigSelection ): Promise { return invokeCommand('resume_session', { sessionId, prompt, imageIds: imageIds ?? null, branchId: branchId ?? null, + acpConfigSelection: acpConfigSelection ?? null, }); } @@ -819,7 +869,8 @@ export function startBranchSession( sessionType: BranchSessionType, provider?: string, imageIds?: string[], - launchContext?: BranchSessionLaunchContext + launchContext?: BranchSessionLaunchContext, + acpConfigSelection?: AcpConfigSelection ): Promise { return invokeCommand('start_branch_session', { branchId, @@ -828,6 +879,7 @@ export function startBranchSession( provider: provider ?? null, imageIds: imageIds ?? null, launchContext: launchContext ?? null, + acpConfigSelection: acpConfigSelection ?? null, }); } @@ -838,7 +890,8 @@ export function startOrQueueBranchSession( sessionType: BranchSessionType, provider?: string, imageIds?: string[], - launchContext?: BranchSessionLaunchContext + launchContext?: BranchSessionLaunchContext, + acpConfigSelection?: AcpConfigSelection ): Promise { return invokeCommand('start_or_queue_branch_session', { branchId, @@ -847,6 +900,7 @@ export function startOrQueueBranchSession( provider: provider ?? null, imageIds: imageIds ?? null, launchContext: launchContext ?? null, + acpConfigSelection: acpConfigSelection ?? null, }); } @@ -857,7 +911,8 @@ export function queueBranchSession( sessionType: BranchSessionType, provider?: string, imageIds?: string[], - launchContext?: BranchSessionLaunchContext + launchContext?: BranchSessionLaunchContext, + acpConfigSelection?: AcpConfigSelection ): Promise { return invokeCommand('queue_branch_session', { branchId, @@ -866,6 +921,7 @@ export function queueBranchSession( provider: provider ?? null, imageIds: imageIds ?? null, launchContext: launchContext ?? null, + acpConfigSelection: acpConfigSelection ?? null, }); } diff --git a/apps/staged/src/lib/features/agents/AcpConfigPicker.svelte b/apps/staged/src/lib/features/agents/AcpConfigPicker.svelte new file mode 100644 index 000000000..73e4539f9 --- /dev/null +++ b/apps/staged/src/lib/features/agents/AcpConfigPicker.svelte @@ -0,0 +1,390 @@ + + + +{#if shouldRender} + +
+ + {#if agents.length > 1} + + {#each agents as provider (provider.id)} + + + + {provider.label} + + + {/each} + + {:else} + + + + {selectedProvider?.label ?? 'Agent'} + + + {/if} +
+ + {#if modelSelector} +
+ +
+ {/if} + + {#if effortSelector} +
+ +
+ {:else if loadingEffortOptions} +
+ {#if retainedEffortSelector} + {}} + /> + {:else} + + + + + Loading options… + + + {/if} +
+ {/if} + + {#if configLoading && !loadingEffortOptions && (!modelSelector || !effortSelector)} +
+ + + + Loading options… + + +
+ {/if} + + {#snippet footer()} + {#if configError && !modelSelector && !effortSelector && agents.length <= 1} + + Using provider defaults + + {/if} + {/snippet} +
+{/if} diff --git a/apps/staged/src/lib/features/agents/AcpConfigPickerSection.svelte b/apps/staged/src/lib/features/agents/AcpConfigPickerSection.svelte new file mode 100644 index 000000000..20c484644 --- /dev/null +++ b/apps/staged/src/lib/features/agents/AcpConfigPickerSection.svelte @@ -0,0 +1,77 @@ + + + +{#if selector.options.length > 0} + { + if (!disabled) onValueChange(next); + }} + > + {#each groups as group, groupIndex (`${group.label ?? 'ungrouped'}-${groupIndex}`)} + {#if group.label} +
{group.label}
+ {/if} + {#each group.options as option (option.valueId)} + + {option.label} + + {/each} + {/each} +
+{:else} + + Default + +{/if} + + diff --git a/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte b/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte new file mode 100644 index 000000000..c94f8cf72 --- /dev/null +++ b/apps/staged/src/lib/features/agents/AcpConfigPickerShell.svelte @@ -0,0 +1,274 @@ + + + +{#snippet labelContent()} + + {#each renderedTriggerParts as part, index (part.id)} + {#if index > 0} + + {/if} + + {#key part.label} + + {part.label} + + {/key} + + {/each} + + +{/snippet} + +{#if canOpen} + + + + {@render labelContent()} + {#if loading} + + {:else} + + {/if} + + handleAcpPickerOpenAutoFocus(event, contentEl)} + onkeydowncapture={handlePickerKeydown} + > + {#if hasColumns} +
+ {@render children?.()} +
+ {/if} + + {@render footer?.()} +
+
+{:else} + +{/if} + + diff --git a/apps/staged/src/lib/features/agents/AcpFixedConfigPicker.svelte b/apps/staged/src/lib/features/agents/AcpFixedConfigPicker.svelte new file mode 100644 index 000000000..85e7c6362 --- /dev/null +++ b/apps/staged/src/lib/features/agents/AcpFixedConfigPicker.svelte @@ -0,0 +1,193 @@ + + + +{#if shouldRender} + + {#if modelSelector} +
+ onModelChange?.(value)} + /> +
+ {/if} + + {#if effortSelector} +
+ onEffortChange?.(value)} + /> +
+ {:else if loadingEffortOptions} +
+ {#if retainedEffortSelector} + {}} + /> + {:else} + + + + + Loading options… + + + {/if} +
+ {/if} + + {#if loading && !loadingEffortOptions && (!modelSelector || !effortSelector)} +
+ + + + Loading options… + + +
+ {/if} + + {#snippet footer()} + {#if error && !modelSelector && !effortSelector} + {#if hasPickerColumns} + + {/if} + + Using provider defaults + + {/if} + {/snippet} +
+{/if} diff --git a/apps/staged/src/lib/features/agents/AgentSelector.svelte b/apps/staged/src/lib/features/agents/AgentSelector.svelte deleted file mode 100644 index bd87c0e0f..000000000 --- a/apps/staged/src/lib/features/agents/AgentSelector.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - - -{#if (remote || agentState.loaded) && agents.length > 0} - {#if agents.length > 1} - - - - {currentLabel} - - - - setAiAgent(id)} - > - {#each agents as provider (provider.id)} - - - - {provider.label} - - - {/each} - - - - {:else} - - {/if} -{/if} diff --git a/apps/staged/src/lib/features/agents/acpConfigSelection.test.ts b/apps/staged/src/lib/features/agents/acpConfigSelection.test.ts new file mode 100644 index 000000000..c8dacb0fc --- /dev/null +++ b/apps/staged/src/lib/features/agents/acpConfigSelection.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import type { AcpConfigSelector } from '../../api/commands'; +import { buildAcpConfigSelection } from './acpConfigSelection'; + +function selector(overrides: Partial = {}): AcpConfigSelector { + return { + configId: 'model', + label: 'Model', + currentValueId: 'sonnet', + options: [ + { valueId: 'sonnet', label: 'Sonnet' }, + { valueId: 'opus', label: 'Opus' }, + ], + ...overrides, + }; +} + +describe('buildAcpConfigSelection', () => { + it('builds model and effort payloads from selected selector values', () => { + const model = selector(); + const effort = selector({ + configId: 'reasoning_effort', + label: 'Effort', + currentValueId: 'medium', + options: [ + { valueId: 'medium', label: 'Medium' }, + { valueId: 'high', label: 'High' }, + ], + }); + + expect( + buildAcpConfigSelection({ + model: { selector: model, valueId: 'opus', explicit: true }, + effort: { selector: effort, valueId: 'high', explicit: true }, + }) + ).toEqual({ + model: { configId: 'model', valueId: 'opus', label: 'Opus' }, + effort: { configId: 'reasoning_effort', valueId: 'high', label: 'High' }, + }); + }); + + it('omits untouched default selector values', () => { + expect( + buildAcpConfigSelection({ + model: { selector: selector(), valueId: 'sonnet' }, + }) + ).toBeNull(); + }); + + it('can explicitly send the current selector value', () => { + expect( + buildAcpConfigSelection({ + model: { selector: selector(), valueId: 'sonnet', explicit: true }, + }) + ).toEqual({ + model: { configId: 'model', valueId: 'sonnet', label: 'Sonnet' }, + effort: null, + }); + }); + + it('omits effort after a model change until effort is explicitly reselected', () => { + const effort = selector({ + configId: 'reasoning_effort', + label: 'Effort', + currentValueId: 'medium', + options: [ + { valueId: 'medium', label: 'Medium' }, + { valueId: 'high', label: 'High' }, + ], + }); + + expect( + buildAcpConfigSelection({ + model: { selector: selector(), valueId: 'opus', explicit: true }, + effort: { selector: effort, valueId: 'medium', explicit: false }, + }) + ).toEqual({ + model: { configId: 'model', valueId: 'opus', label: 'Opus' }, + effort: null, + }); + }); + + it('does not remap stale selected values to the current selector value', () => { + expect( + buildAcpConfigSelection({ + model: { selector: selector(), valueId: 'removed-model', explicit: true }, + }) + ).toBeNull(); + }); + + it('omits unavailable selectors and returns null when there is no selectable value', () => { + expect( + buildAcpConfigSelection({ + model: { selector: selector({ options: [] }), valueId: null, explicit: true }, + effort: { selector: null, valueId: null, explicit: true }, + }) + ).toBeNull(); + }); +}); diff --git a/apps/staged/src/lib/features/agents/acpConfigSelection.ts b/apps/staged/src/lib/features/agents/acpConfigSelection.ts new file mode 100644 index 000000000..cafbc155b --- /dev/null +++ b/apps/staged/src/lib/features/agents/acpConfigSelection.ts @@ -0,0 +1,50 @@ +import type { AcpConfigSelector } from '../../api/commands'; +import type { AcpConfigSelection, AcpConfigValueSelection } from '../../types'; + +export interface AcpConfigPickerSelection { + providerId: string | null; + acpConfigSelection: AcpConfigSelection | null; +} + +interface SelectorSelection { + selector: AcpConfigSelector | null; + valueId: string | null; + explicit?: boolean; +} + +interface AcpSelectorSelections { + model?: SelectorSelection; + effort?: SelectorSelection; +} + +function selectedValueId(selector: AcpConfigSelector, valueId: string | null): string | null { + if (valueId && selector.options.some((option) => option.valueId === valueId)) { + return valueId; + } + return null; +} + +function valueSelection(selection: SelectorSelection | undefined): AcpConfigValueSelection | null { + const selector = selection?.selector ?? null; + if (!selector || !selection?.explicit) return null; + + const valueId = selectedValueId(selector, selection?.valueId ?? null); + if (!valueId) return null; + + const option = selector.options.find((candidate) => candidate.valueId === valueId); + return { + configId: selector.configId, + valueId, + label: option?.label ?? null, + }; +} + +export function buildAcpConfigSelection( + selections: AcpSelectorSelections +): AcpConfigSelection | null { + const model = valueSelection(selections.model); + const effort = valueSelection(selections.effort); + + if (!model && !effort) return null; + return { model, effort }; +} diff --git a/apps/staged/src/lib/features/agents/acpPickerKeyboard.test.ts b/apps/staged/src/lib/features/agents/acpPickerKeyboard.test.ts new file mode 100644 index 000000000..113a80b9a --- /dev/null +++ b/apps/staged/src/lib/features/agents/acpPickerKeyboard.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import { describe, expect, it, beforeEach } from 'vitest'; +import { focusInitialAcpPickerColumn, handleAcpPickerGridKeydown } from './acpPickerKeyboard'; + +describe('acp picker keyboard navigation', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('starts focus in the model column', () => { + const root = buildPicker(); + + expect(focusInitialAcpPickerColumn(root)).toBe(true); + expect(document.activeElement?.textContent).toBe('Model B'); + }); + + it('keeps up and down navigation inside the active column', () => { + const root = buildPicker(); + listenForPickerKeys(root); + getItem(root, 'Model A').focus(); + + expect(pressKey('ArrowUp')).toBe(true); + expect(document.activeElement?.textContent).toBe('Model A'); + + getItem(root, 'Model C').focus(); + + expect(pressKey('ArrowDown')).toBe(true); + expect(document.activeElement?.textContent).toBe('Model C'); + }); + + it('moves vertically within a column when there is another row', () => { + const root = buildPicker(); + listenForPickerKeys(root); + getItem(root, 'Model B').focus(); + + expect(pressKey('ArrowUp')).toBe(true); + expect(document.activeElement?.textContent).toBe('Model A'); + + expect(pressKey('ArrowDown')).toBe(true); + expect(document.activeElement?.textContent).toBe('Model B'); + }); + + it('activates values as up and down move through a column', () => { + const root = buildPicker(); + const clicked: string[] = []; + trackClicks(root, clicked); + listenForPickerKeys(root); + getItem(root, 'Model B').focus(); + + expect(pressKey('ArrowUp')).toBe(true); + expect(document.activeElement?.textContent).toBe('Model A'); + expect(clicked).toEqual(['Model A']); + + expect(pressKey('ArrowDown')).toBe(true); + expect(document.activeElement?.textContent).toBe('Model B'); + expect(clicked).toEqual(['Model A', 'Model B']); + }); + + it('moves left and right between columns', () => { + const root = buildPicker(); + listenForPickerKeys(root); + getItem(root, 'Model A').focus(); + + expect(pressKey('ArrowRight')).toBe(true); + expect(document.activeElement?.textContent).toBe('Effort High'); + + expect(pressKey('ArrowLeft')).toBe(true); + expect(document.activeElement?.textContent).toBe('Model B'); + }); + + it('dismisses on return without activating the focused item', () => { + const root = buildPicker(); + const clicked: string[] = []; + let dismissed = false; + trackClicks(root, clicked); + listenForPickerKeys(root, () => { + dismissed = true; + }); + getItem(root, 'Model B').focus(); + + expect(pressKey('Enter')).toBe(true); + expect(dismissed).toBe(true); + expect(clicked).toEqual([]); + }); +}); + +function buildPicker(): HTMLElement { + const root = document.createElement('div'); + root.append( + column('provider', [ + item('Codex'), + item('Claude', { + checked: true, + }), + ]), + column('model', [item('Model A'), item('Model B', { checked: true }), item('Model C')]), + column('effort', [item('Effort Low'), item('Effort High', { checked: true })]) + ); + document.body.append(root); + return root; +} + +function column(kind: string, items: HTMLElement[]): HTMLElement { + const element = document.createElement('div'); + element.className = 'picker-column'; + element.dataset.pickerColumn = kind; + element.append(...items); + return element; +} + +function item(label: string, options: { checked?: boolean } = {}): HTMLElement { + const element = document.createElement('div'); + element.dataset.slot = 'dropdown-menu-radio-item'; + element.tabIndex = -1; + element.textContent = label; + if (options.checked) { + element.setAttribute('aria-checked', 'true'); + } + return element; +} + +function listenForPickerKeys(root: HTMLElement, onDismiss?: () => void): void { + root.addEventListener( + 'keydown', + (event) => { + handleAcpPickerGridKeydown(event, root, { onDismiss }); + }, + { capture: true } + ); +} + +function pressKey(key: string): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) { + throw new Error('Expected an active element'); + } + + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + activeElement.dispatchEvent(event); + return event.defaultPrevented; +} + +function getItem(root: HTMLElement, label: string): HTMLElement { + const item = Array.from( + root.querySelectorAll("[data-slot='dropdown-menu-radio-item']") + ).find((candidate) => candidate.textContent === label); + + if (!item) { + throw new Error(`Missing item: ${label}`); + } + + return item; +} + +function trackClicks(root: HTMLElement, clicked: string[]): void { + root.querySelectorAll("[data-slot='dropdown-menu-radio-item']").forEach((item) => { + item.addEventListener('click', () => { + clicked.push(item.textContent ?? ''); + }); + }); +} diff --git a/apps/staged/src/lib/features/agents/acpPickerKeyboard.ts b/apps/staged/src/lib/features/agents/acpPickerKeyboard.ts new file mode 100644 index 000000000..45ea44c99 --- /dev/null +++ b/apps/staged/src/lib/features/agents/acpPickerKeyboard.ts @@ -0,0 +1,171 @@ +const PICKER_COLUMN_SELECTOR = '.picker-column'; +const PICKER_ITEM_SELECTOR = + "[data-slot='dropdown-menu-radio-item']:not([data-disabled]), [data-slot='dropdown-menu-item']:not([data-disabled])"; +const CHECKED_ITEM_SELECTOR = "[aria-checked='true']"; + +type PickerDirection = 'previous' | 'next'; + +interface PickerKeydownOptions { + onDismiss?: () => void; +} + +export function handleAcpPickerOpenAutoFocus(event: Event, root: HTMLElement | null): void { + event.preventDefault(); + window.setTimeout(() => focusInitialAcpPickerColumn(root), 0); +} + +export function handleAcpPickerGridKeydown( + event: KeyboardEvent, + root: HTMLElement | null, + options: PickerKeydownOptions = {} +): boolean { + if (event.defaultPrevented || !root) return false; + + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + options.onDismiss?.(); + return true; + } + + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + return focusVertical(event, root, event.key === 'ArrowUp' ? 'previous' : 'next'); + } + + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + return focusHorizontal(event, root, event.key === 'ArrowLeft' ? 'previous' : 'next'); + } + + return false; +} + +export function focusInitialAcpPickerColumn(root: HTMLElement | null): boolean { + if (!root) return false; + + const columns = getFocusableColumns(root); + const column = + columns.find((candidate) => candidate.dataset.pickerColumn === 'model') ?? + columns.find((candidate) => candidate.dataset.pickerColumn !== 'provider') ?? + columns[0]; + + return focusPreferredItem(column); +} + +function focusVertical( + event: KeyboardEvent, + root: HTMLElement, + direction: PickerDirection +): boolean { + const current = getCurrentColumnState(root, event.target); + if (!current) return false; + + const nextIndex = + direction === 'previous' + ? Math.max(0, current.itemIndex - 1) + : Math.min(current.items.length - 1, current.itemIndex + 1); + + event.preventDefault(); + event.stopPropagation(); + focusAndActivateItem(current.items[nextIndex]); + return true; +} + +function focusHorizontal( + event: KeyboardEvent, + root: HTMLElement, + direction: PickerDirection +): boolean { + const current = getCurrentColumnState(root, event.target); + const columns = getFocusableColumns(root); + if (!current || columns.length === 0) return false; + + const columnIndex = columns.indexOf(current.column); + const nextColumnIndex = + direction === 'previous' + ? Math.max(0, columnIndex - 1) + : Math.min(columns.length - 1, columnIndex + 1); + const nextColumn = columns[nextColumnIndex]; + + event.preventDefault(); + event.stopPropagation(); + focusPreferredItem(nextColumn, current.itemIndex); + return true; +} + +function getCurrentColumnState(root: HTMLElement, target: EventTarget | null) { + const targetElement = target instanceof Element ? target : null; + const activeElement = root.ownerDocument.activeElement; + const focusedElement = + targetElement && root.contains(targetElement) + ? targetElement + : activeElement instanceof Element && root.contains(activeElement) + ? activeElement + : null; + const column = focusedElement?.closest(PICKER_COLUMN_SELECTOR); + const fallbackColumn = focusInitialColumn(root); + const currentColumn = column && root.contains(column) ? column : fallbackColumn; + if (!currentColumn) return null; + + const items = getColumnItems(currentColumn); + if (items.length === 0) return null; + + const focusedItem = focusedElement?.closest(PICKER_ITEM_SELECTOR); + const itemIndex = focusedItem ? items.indexOf(focusedItem) : -1; + + return { + column: currentColumn, + items, + itemIndex: itemIndex >= 0 ? itemIndex : preferredItemIndex(items), + }; +} + +function focusInitialColumn(root: HTMLElement): HTMLElement | null { + const columns = getFocusableColumns(root); + return ( + columns.find((candidate) => candidate.dataset.pickerColumn === 'model') ?? + columns.find((candidate) => candidate.dataset.pickerColumn !== 'provider') ?? + columns[0] ?? + null + ); +} + +function getFocusableColumns(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(PICKER_COLUMN_SELECTOR)).filter( + (column) => getColumnItems(column).length > 0 + ); +} + +function getColumnItems(column: HTMLElement): HTMLElement[] { + return Array.from(column.querySelectorAll(PICKER_ITEM_SELECTOR)); +} + +function focusPreferredItem(column: HTMLElement | undefined, fallbackIndex = 0): boolean { + if (!column) return false; + + const items = getColumnItems(column); + if (items.length === 0) return false; + + const checkedItem = column.querySelector(CHECKED_ITEM_SELECTOR); + const item = + checkedItem && items.includes(checkedItem) + ? checkedItem + : items[Math.min(Math.max(fallbackIndex, 0), items.length - 1)]; + + focusItem(item); + return true; +} + +function preferredItemIndex(items: HTMLElement[]): number { + const checkedIndex = items.findIndex((item) => item.matches(CHECKED_ITEM_SELECTOR)); + return checkedIndex >= 0 ? checkedIndex : 0; +} + +function focusItem(item: HTMLElement | undefined): void { + item?.focus({ preventScroll: true }); +} + +function focusAndActivateItem(item: HTMLElement | undefined): void { + if (!item) return; + focusItem(item); + item.click(); +} diff --git a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts index 92b2bb990..bf84dff3c 100644 --- a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts +++ b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts @@ -10,7 +10,12 @@ * launches render through the same timeline rows. */ -import type { Branch, BranchTimeline as BranchTimelineData, BranchSessionType } from '../../types'; +import type { + AcpConfigSelection, + Branch, + BranchTimeline as BranchTimelineData, + BranchSessionType, +} from '../../types'; import * as commands from '../../api/commands'; import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; @@ -25,6 +30,11 @@ import { startOrQueueBranchSessionWithPending, } from './branchSessionLaunch.svelte'; +interface BranchSessionLaunchOptions { + provider?: string | null; + acpConfigSelection?: AcpConfigSelection | null; +} + export default class BranchCardSessionManager { // Private callback refs — declared first so $derived fields can reference them private getBranch: () => Branch = undefined!; @@ -203,7 +213,12 @@ export default class BranchCardSessionManager { } } - async startOrQueueSession(mode: BranchSessionType, prompt: string, imageIds: string[] = []) { + async startOrQueueSession( + mode: BranchSessionType, + prompt: string, + imageIds: string[] = [], + launchOptions: BranchSessionLaunchOptions = {} + ) { const branch = this.getBranch(); const isRemote = this.getIsRemote(); @@ -217,6 +232,8 @@ export default class BranchCardSessionManager { mode, prompt, imageIds, + provider: launchOptions.provider, + acpConfigSelection: launchOptions.acpConfigSelection, getTimeline: () => this.getTimeline(), onTimelineRefresh: () => this.loadTimeline(), }); @@ -264,6 +281,8 @@ export default class BranchCardSessionManager { prompt: string; mode: BranchSessionType; imageIds: string[]; + provider?: string; + acpConfigSelection?: AcpConfigSelection | null; }) { this.newSessionMode = data.mode; this.showNewSession = false; @@ -276,14 +295,21 @@ export default class BranchCardSessionManager { void this.startOrQueueSession( data.mode, 'Review the code changes on this branch.', - data.imageIds + data.imageIds, + { + provider: data.provider, + acpConfigSelection: data.acpConfigSelection, + } ); return; } const prompt = data.prompt || (data.mode === 'review' ? 'Review the code changes on this branch.' : ''); - void this.startOrQueueSession(data.mode, prompt, data.imageIds); + void this.startOrQueueSession(data.mode, prompt, data.imageIds, { + provider: data.provider, + acpConfigSelection: data.acpConfigSelection, + }); } handleTimelineSessionClick(sessionId: string) { diff --git a/apps/staged/src/lib/features/branches/branchSessionLaunch.svelte.ts b/apps/staged/src/lib/features/branches/branchSessionLaunch.svelte.ts index fdf8f1966..0133e56a3 100644 --- a/apps/staged/src/lib/features/branches/branchSessionLaunch.svelte.ts +++ b/apps/staged/src/lib/features/branches/branchSessionLaunch.svelte.ts @@ -1,5 +1,6 @@ import { toast } from 'svelte-sonner'; import type { + AcpConfigSelection, BranchSessionLaunchContext, BranchSessionLaunchStatus, BranchSessionResponse, @@ -188,8 +189,10 @@ interface StartOrQueueBranchSessionOptions { isRemote: boolean; mode: BranchSessionType; prompt: string; + provider?: string | null; imageIds?: string[]; launchContext?: BranchSessionLaunchContext; + acpConfigSelection?: AcpConfigSelection | null; getTimeline?: () => BranchTimeline | null; onTimelineRefresh?: () => void | Promise; willQueueHint?: boolean; @@ -202,8 +205,10 @@ export async function startOrQueueBranchSessionWithPending({ isRemote, mode, prompt, + provider, imageIds = [], launchContext, + acpConfigSelection = null, getTimeline, onTimelineRefresh, willQueueHint, @@ -235,9 +240,10 @@ export async function startOrQueueBranchSessionWithPending({ branchId, prompt, mode, - getPreferredAgent(agents) ?? undefined, + provider ?? getPreferredAgent(agents) ?? undefined, imageIds.length > 0 ? imageIds : undefined, - launchContext + launchContext, + acpConfigSelection ?? undefined ); if (!result || !result.sessionId) { diff --git a/apps/staged/src/lib/features/diff/DiffModal.svelte b/apps/staged/src/lib/features/diff/DiffModal.svelte index 1f24e4beb..c205915e2 100644 --- a/apps/staged/src/lib/features/diff/DiffModal.svelte +++ b/apps/staged/src/lib/features/diff/DiffModal.svelte @@ -44,6 +44,7 @@ createFileSelectionWithSearch, } from '@builderbot/diff-viewer/utils'; import type { + AcpConfigSelection, Branch, BranchSessionType, BranchSessionLaunchStatus, @@ -616,6 +617,8 @@ prompt: string; mode: BranchSessionType; imageIds: string[]; + provider?: string; + acpConfigSelection?: AcpConfigSelection | null; }) { showNewSessionModal = false; // Capture (and clear) the origin comment before awaiting so a follow-up @@ -635,7 +638,9 @@ mode: data.mode, prompt: data.prompt, imageIds: data.imageIds, + provider: data.provider, launchContext, + acpConfigSelection: data.acpConfigSelection, onTimelineRefresh: () => commands.invalidateBranchTimeline(branchId), errorTitle: `Unable to start ${data.mode} session`, }); diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 712ee5523..8ae2f295f 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -8,7 +8,14 @@ import { onMount, onDestroy } from 'svelte'; import { listenToEvent } from '../../transport'; import FileText from '@lucide/svelte/icons/file-text'; - import type { Project, ProjectRepo, Branch, ProjectNote, HashtagItem } from '../../types'; + import type { + AcpConfigSelection, + Project, + ProjectRepo, + Branch, + ProjectNote, + HashtagItem, + } from '../../types'; import * as commands from '../../api/commands'; import { buildProjectHashtagItems } from '../sessions/hashtagItems'; import { branchTimelineReadyKey } from '../branches/branchTimelineReady'; @@ -202,17 +209,24 @@ void ensureHashtagItems(); } - async function handleSubmitProjectSession(data: { prompt: string; imageIds: string[] }) { + async function handleSubmitProjectSession(data: { + prompt: string; + imageIds: string[]; + provider?: string; + acpConfigSelection?: AcpConfigSelection | null; + }) { const text = data.prompt.trim(); - if (!text || !preferredProvider) return; + const provider = data.provider ?? preferredProvider; + if (!text || !provider) return; const imageIdsToSend = data.imageIds.length > 0 ? [...data.imageIds] : undefined; try { const response = await commands.startProjectSession( project.id, text, - preferredProvider, - imageIdsToSend + provider, + imageIdsToSend, + data.acpConfigSelection ?? undefined ); activeSessionIds = new Set([...activeSessionIds, response.sessionId]); sessionRegistry.register(response.sessionId, project.id, 'note'); @@ -522,7 +536,12 @@ onSubmit={(data) => { draftProjectPrompt = ''; draftProjectImageIds = []; - void handleSubmitProjectSession({ prompt: data.prompt, imageIds: data.imageIds }); + void handleSubmitProjectSession({ + prompt: data.prompt, + imageIds: data.imageIds, + provider: data.provider, + acpConfigSelection: data.acpConfigSelection, + }); }} /> {/if} diff --git a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte index 606e9f1b8..09c363e9f 100644 --- a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte @@ -14,7 +14,7 @@ repoLabel — optional repo label for display (githubRepo + subpath) initialPrompt — pre-fill the textarea (e.g. from a previous close) onClose — called with { prompt, mode, imageIds } when dismissed - onSubmit — called with { prompt, mode, imageIds } when submit is pressed + onSubmit — called with { prompt, mode, imageIds, provider, acpConfigSelection } when submit is pressed --> @@ -188,6 +198,12 @@ disabled={creating} class="flex-1" /> +