diff --git a/crates/ui-egui/src/agent_chat/acp.rs b/crates/ui-egui/src/agent_chat/acp.rs index 092c366162..897f91d40a 100644 --- a/crates/ui-egui/src/agent_chat/acp.rs +++ b/crates/ui-egui/src/agent_chat/acp.rs @@ -7,8 +7,119 @@ //! reaches the design through SolidCraft's MCP server instead), and terminal login, so an agent //! that needs one describes a command for the user to run rather than asking for a key. +use serde::Serialize; use serde_json::{Value, json}; +/// One advertised value, with an optional human-readable group label. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigChoice { + pub value: String, + pub name: String, + pub description: String, + pub group: Option, +} +/// A supported ACP select option; identifiers and categories belong to the agent. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigOption { + pub id: String, + pub name: String, + pub description: String, + pub category: String, + pub current_value: String, + pub options: Vec, +} + +/// Parse ACP select options as a bounded authoritative snapshot. +#[derive(Debug, PartialEq, Eq)] +pub enum ConfigParse { + Absent, + Invalid, + Snapshot(Vec), +} +pub fn config_options(v: &Value) -> ConfigParse { + let Some(raw_list) = v.get("configOptions") else { + return ConfigParse::Absent; + }; + let Some(list) = raw_list.as_array() else { + return ConfigParse::Invalid; + }; + let mut out = Vec::new(); + let mut ids = std::collections::HashSet::new(); + for x in list.iter().take(64) { + match x.get("type").and_then(Value::as_str) { + Some("select") => {} + Some(_) => continue, + None => return ConfigParse::Invalid, + } + let Some(id) = x.get("id").and_then(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 256) else { + return ConfigParse::Invalid; + }; + let Some(current_value) = x.get("currentValue").and_then(Value::as_str).filter(|s| s.len() <= 256) else { + return ConfigParse::Invalid; + }; + let Some(raw) = x.get("options").and_then(Value::as_array) else { + return ConfigParse::Invalid; + }; + if !ids.insert(id.to_string()) { + continue; + } + let mut choices = Vec::new(); + let mut values = std::collections::HashSet::new(); + 'items: for item in raw.iter().take(128) { + if choices.len() >= 128 { + break; + } + let group = + item.get("name").and_then(Value::as_str).or_else(|| item.get("group").and_then(Value::as_str)).map(|s| s.chars().take(256).collect()); + if let Some(cs) = item.get("options").and_then(Value::as_array) { + for c in cs.iter().take(128) { + if choices.len() >= 128 { + break 'items; + } + let Some(value) = c.get("value").and_then(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 256) else { + continue; + }; + if !values.insert(value.to_string()) { + continue; + } + choices.push(ConfigChoice { + value: value.into(), + name: c.get("name").and_then(Value::as_str).unwrap_or(value).chars().take(256).collect(), + description: c.get("description").and_then(Value::as_str).unwrap_or("").chars().take(1024).collect(), + group: group.clone(), + }); + } + } else { + let Some(value) = item.get("value").and_then(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 256) else { + continue; + }; + if values.insert(value.to_string()) { + choices.push(ConfigChoice { + value: value.into(), + name: item.get("name").and_then(Value::as_str).unwrap_or(value).chars().take(256).collect(), + description: item.get("description").and_then(Value::as_str).unwrap_or("").chars().take(1024).collect(), + group: None, + }); + } + } + } + if choices.is_empty() { + return ConfigParse::Invalid; + } + out.push(ConfigOption { + id: id.into(), + name: x.get("name").and_then(Value::as_str).unwrap_or(id).chars().take(256).collect(), + description: x.get("description").and_then(Value::as_str).unwrap_or("").chars().take(1024).collect(), + category: x.get("category").and_then(Value::as_str).unwrap_or("").chars().take(128).collect(), + current_value: current_value.into(), + options: choices, + }); + } + ConfigParse::Snapshot(out) +} + /// The protocol version we speak. pub const PROTOCOL_VERSION: u64 = 1; @@ -183,6 +294,9 @@ pub fn models(session: &Value) -> Option<(String, Vec)> { pub fn set_model_params(session: &str, model: &str) -> Value { json!({"sessionId": session, "modelId": model}) } +pub fn set_config_option_params(session: &str, id: &str, value: &str) -> Value { + json!({"sessionId": session, "configId": id, "value": value}) +} /// Whether the agent takes embedded documents in a message /// (`agentCapabilities.promptCapabilities.embeddedContext`: it reads `resource` blocks). @@ -307,23 +421,47 @@ pub struct PermissionOption { #[derive(Clone, Debug, PartialEq)] pub enum Incoming { /// The reply to one of our requests. - Reply { id: u64, result: Result }, + Reply { + id: u64, + result: Result, + }, /// Text the agent streams (`thought`: its reasoning, shown dimmed). - Text { thought: bool, text: String }, + Text { + thought: bool, + text: String, + }, /// A new tool call, or news about one (`title`/`status` when they changed). - Tool { id: String, title: Option, kind: Option, status: Option, input: Option, output: Option }, + Tool { + id: String, + title: Option, + kind: Option, + status: Option, + input: Option, + output: Option, + }, /// The agent's plan: each entry with its status. Plan(Vec<(String, String)>), /// The agent asks before running a tool call; answered with [`permission_reply`]. - Permission { rpc: Value, title: String, options: Vec }, + Permission { + rpc: Value, + title: String, + options: Vec, + }, /// The agent says whether it is logged in (an extension both adapters send). AuthStatus(String), /// The agent switched to another of its modes. ModeChanged(String), /// The agent switched to another model. ModelChanged(String), + ConfigOptions { + session: String, + options: Vec, + }, /// A request we don't serve (file system, terminal, …): answer "method not found". - Unsupported { rpc: Value, method: String }, + Unsupported { + rpc: Value, + method: String, + }, /// Anything else (mode and command lists, usage): nothing to show. Ignored, } @@ -399,7 +537,7 @@ pub fn parse(line: &str) -> Option { Some(Incoming::Permission { rpc: id.clone(), title, options }) } (Some(m), Some(id)) => Some(Incoming::Unsupported { rpc: id.clone(), method: m.to_string() }), - (Some("session/update"), None) => Some(update(params.get("update")?)), + (Some("session/update"), None) => Some(update(params.get("update")?, params.get("sessionId").and_then(Value::as_str).unwrap_or(""))), (Some("_auth/status_update"), None) => { params.get("authStatus").and_then(|s| s.get("label")).and_then(Value::as_str).map(|l| Incoming::AuthStatus(l.to_string())) } @@ -407,7 +545,7 @@ pub fn parse(line: &str) -> Option { } } -fn update(u: &Value) -> Incoming { +fn update(u: &Value, session: &str) -> Incoming { let s = |k: &str| u.get(k).and_then(Value::as_str).map(str::to_string); match u.get("sessionUpdate").and_then(Value::as_str) { Some(kind @ ("agent_message_chunk" | "agent_thought_chunk")) => match u.get("content").and_then(content_text) { @@ -434,6 +572,10 @@ fn update(u: &Value) -> Incoming { Some(m) => Incoming::ModelChanged(m), None => Incoming::Ignored, }, + Some("config_option_update") => match config_options(u) { + ConfigParse::Snapshot(options) if !session.is_empty() => Incoming::ConfigOptions { session: session.into(), options }, + _ => Incoming::Ignored, + }, Some("plan") => Incoming::Plan( u.get("entries") .and_then(Value::as_array) @@ -629,7 +771,7 @@ mod tests { fn long_tool_output_is_cut_on_a_character_boundary() { let long = "é".repeat(3000); let u = json!({"sessionUpdate": "tool_call", "toolCallId": "t", "content": [{"type": "content", "content": {"type": "text", "text": long}}]}); - let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() }; + let Incoming::Tool { output: Some(o), .. } = update(&u, "s") else { panic!() }; assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…')); } @@ -650,5 +792,42 @@ mod tests { let changed = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_model_update","currentModelId":"haiku"}}}"#; assert_eq!(parse(changed), Some(Incoming::ModelChanged("haiku".into()))); assert_eq!(set_model_params("s", "opus"), json!({"sessionId": "s", "modelId": "opus"})); + assert_eq!(set_config_option_params("s1", "vendor.model", "smart"), json!({"sessionId":"s1","configId":"vendor.model","value":"smart"})); + } + + #[test] + fn config_options_parse_flat_and_grouped_selects_and_ignore_unsupported() { + let v = json!({"configOptions":[ + {"id":"arbitrary","name":"Model","category":"model","type":"select","currentValue":"a","options":[{"value":"a","name":"A"}]}, + {"id":"effort","category":"thought_level","type":"select","currentValue":"high","options":[{"group":"Reasoning","options":[{"value":"high","name":"High"}]}]}, + {"id":"bad","type":"boolean"}]}); + let ConfigParse::Snapshot(options) = config_options(&v) else { panic!() }; + assert_eq!(options.len(), 2); + assert_eq!(options[0].id, "arbitrary"); + assert_eq!(options[1].options[0].group.as_deref(), Some("Reasoning")); + assert!(matches!( + config_options(&json!({"configOptions":[{"type":"select","id":"broken","currentValue":"x","options":[]}]})), + ConfigParse::Invalid + )); + assert_eq!(config_options(&json!({"configOptions":[]})), ConfigParse::Snapshot(vec![])); + let notification = json!({"sessionUpdate":"config_option_update","configOptions":[]}); + assert_eq!(update(¬ification, "s"), Incoming::ConfigOptions { session: "s".into(), options: vec![] }); + } + + #[test] + fn config_snapshots_bound_choices_and_preserve_unknown_categories() { + let choices: Vec<_> = (0..300).map(|n| json!({"value":n.to_string(),"name":"Choice"})).collect(); + let option = json!({"id":"custom","name":"Custom","type":"select","currentValue":"0","options":[ + {"group":"internal-id","name":"Readable group","options":choices}, + {"value":"extra","name":"Extra"} + ]}); + let ConfigParse::Snapshot(options) = config_options(&json!({"configOptions":[option.clone(),option]})) else { panic!() }; + assert_eq!(options.len(), 1, "duplicate IDs do not produce duplicate controls"); + assert_eq!(options[0].options.len(), 128, "the choice cap spans groups and flat entries"); + assert_eq!(options[0].options[0].group.as_deref(), Some("Readable group")); + assert_eq!(options[0].category, "", "categories are optional metadata"); + assert_eq!(config_options(&json!({})), ConfigParse::Absent); + assert_eq!(config_options(&json!({"configOptions":null})), ConfigParse::Invalid); + assert_eq!(config_options(&json!({"configOptions":[{"type":"select","id":"broken"}]})), ConfigParse::Invalid); } } diff --git a/crates/ui-egui/src/agent_chat/acp_client.rs b/crates/ui-egui/src/agent_chat/acp_client.rs index d1c91ae1aa..3f0e1eb348 100644 --- a/crates/ui-egui/src/agent_chat/acp_client.rs +++ b/crates/ui-egui/src/agent_chat/acp_client.rs @@ -18,6 +18,7 @@ enum Pending { Authenticate, SetMode, SetModel, + SetConfig, } /// Variables the agent doesn't inherit: a control token set for the app's own `--control` port @@ -39,6 +40,8 @@ pub struct AcpBackend { permissions: HashMap, next_key: u64, exited: bool, + config_pending: Option<(u64, String)>, + modern_config: bool, } impl AcpBackend { @@ -63,6 +66,8 @@ impl AcpBackend { permissions: HashMap::new(), next_key: 0, exited: false, + config_pending: None, + modern_config: false, }; b.request(Pending::Initialize, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION"))); Ok(b) @@ -75,6 +80,8 @@ impl AcpBackend { } fn new_session(&mut self) { + self.config_pending = None; + self.pending.retain(|_, request| *request != Pending::SetConfig); let params = acp::session_new_params(&self.cwd, self.bridge.as_ref()); self.request(Pending::SessionNew, "session/new", params); } @@ -94,11 +101,20 @@ impl AcpBackend { (Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) { Some(s) => { self.session = Some(s.to_string()); - if let Some((current, available)) = acp::modes(&v) { - out.push(Event::Modes { current, available }); - } - if let Some((current, available)) = acp::models(&v) { - out.push(Event::Models { current, available }); + match acp::config_options(&v) { + acp::ConfigParse::Snapshot(options) => { + self.modern_config = true; + out.push(Event::ConfigOptions(options)); + } + _ => { + self.modern_config = false; + if let Some((current, available)) = acp::modes(&v) { + out.push(Event::Modes { current, available }); + } + if let Some((current, available)) = acp::models(&v) { + out.push(Event::Models { current, available }); + } + } } out.push(Event::Documents(self.documents)); out.push(Event::Ready { agent: self.agent.clone(), images: self.images }); @@ -112,15 +128,36 @@ impl AcpBackend { // Signed in: try the session again. (Pending::Authenticate, Ok(_)) => self.new_session(), (Pending::SetMode | Pending::SetModel, Ok(_)) => {} + (Pending::SetConfig, Ok(v)) => { + if let Some((request_id, config_id)) = self.config_pending.take() + && request_id == id + { + let result = match acp::config_options(&v) { + acp::ConfigParse::Snapshot(o) => Ok(o), + _ => Err("The agent returned no valid configuration options.".into()), + }; + out.push(Event::ConfigOptionFinished { id: config_id, result }); + } + } (what, Err((code, message))) => { + let config_error = what == Pending::SetConfig; + if what == Pending::SetConfig + && let Some((request_id, config_id)) = self.config_pending.take() + && request_id == id + { + out.push(Event::ConfigOptionFinished { id: config_id, result: Err(message.clone()) }); + } let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth"); if auth { self.session = None; + self.modern_config = false; + self.config_pending = None; + self.pending.retain(|_, request| *request != Pending::SetConfig); if what == Pending::Prompt { out.push(Event::TurnEnded { reason: "auth".into() }); } out.push(Event::AuthRequired { methods: self.auth.clone() }); - } else { + } else if !config_error { if what == Pending::Prompt { out.push(Event::TurnEnded { reason: "error".into() }); } @@ -138,8 +175,12 @@ impl AcpBackend { out.push(Event::Permission { key: self.next_key, title, options }); } Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)), - Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }), - Incoming::ModelChanged(m) => out.push(Event::Models { current: m, available: Vec::new() }), + Incoming::ModeChanged(m) if !self.modern_config => out.push(Event::Modes { current: m, available: Vec::new() }), + Incoming::ModelChanged(m) if !self.modern_config => out.push(Event::Models { current: m, available: Vec::new() }), + Incoming::ConfigOptions { session, options } if self.session.as_deref() == Some(session.as_str()) => { + out.push(Event::ConfigOptions(options)) + } + Incoming::ModeChanged(_) | Incoming::ModelChanged(_) | Incoming::ConfigOptions { .. } => {} Incoming::Unsupported { rpc, method } => { self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`"))); } @@ -165,6 +206,7 @@ impl ChatBackend for AcpBackend { self.exited = true; self.session = None; self.pending.clear(); + self.config_pending = None; out.push(Event::Exited { tail }); break; } @@ -195,6 +237,9 @@ impl ChatBackend for AcpBackend { self.cancel(); } self.session = None; + self.modern_config = false; + self.config_pending = None; + self.pending.retain(|_, request| *request != Pending::SetConfig); self.new_session(); } @@ -220,6 +265,16 @@ impl ChatBackend for AcpBackend { } } + fn set_config_option(&mut self, id: &str, value: &str) { + if self.config_pending.is_some() { + return; + } + if let Some(s) = self.session.clone() { + self.request(Pending::SetConfig, "session/set_config_option", acp::set_config_option_params(&s, id, value)); + self.config_pending = Some((self.next_id, id.to_string())); + } + } + fn pid(&self) -> Option { self.proc.pid() } @@ -271,3 +326,51 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result Value { + json!({"sessionId":session,"configOptions":[{"id":"provider.model","category":"model","name":"Model","type":"select","currentValue":value,"options":[{"value":"fast","name":"Fast"},{"value":"smart","name":"Smart"}]}]}) + } + + #[test] + fn config_updates_and_replies_are_isolated_to_the_active_session() { + let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) }; + let mut backend = AcpBackend::start(&["sh".into(), "-c".into(), "cat >/dev/null".into()], ctx).unwrap(); + let mut events = Vec::new(); + backend.handle(Incoming::Reply { id: 1, result: Ok(json!({"agentInfo":{"name":"fake"}})) }, &mut events); + backend.handle(Incoming::Reply { id: 2, result: Ok(snapshot("s1", "fast")) }, &mut events); + assert_eq!(backend.session.as_deref(), Some("s1")); + events.clear(); + backend.set_config_option("provider.model", "smart"); + let old_request = backend.config_pending.as_ref().map(|p| p.0).unwrap(); + ChatBackend::new_session(&mut backend); + let new_session_request = backend.next_id; + assert!(backend.config_pending.is_none()); + let old_notification = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"config_option_update","configOptions":[]}}}"#; + backend.handle(acp::parse(old_notification).unwrap(), &mut events); + assert!(events.is_empty()); + backend.handle(Incoming::Reply { id: new_session_request, result: Ok(snapshot("s2", "fast")) }, &mut events); + assert_eq!(backend.session.as_deref(), Some("s2")); + events.clear(); + backend.set_config_option("provider.model", "smart"); + let new_request = backend.config_pending.as_ref().map(|p| p.0).unwrap(); + backend.handle(Incoming::Reply { id: old_request, result: Ok(snapshot("s1", "smart")) }, &mut events); + backend.handle(Incoming::Reply { id: old_request, result: Err((-32001, "old rejected request".into())) }, &mut events); + backend.handle(acp::parse(old_notification).unwrap(), &mut events); + assert!(events.is_empty(), "old requests and session notifications are ignored"); + assert_eq!(backend.config_pending.as_ref().map(|p| p.0), Some(new_request), "an old reply cannot clear the new session's pending request"); + let new_notification = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s2","update":{"sessionUpdate":"config_option_update","configOptions":[]}}}"#; + backend.handle(acp::parse(new_notification).unwrap(), &mut events); + assert!(matches!(events.as_slice(), [Event::ConfigOptions(options)] if options.is_empty())); + assert_eq!(backend.config_pending.as_ref().map(|p| p.0), Some(new_request), "a notification is not an acknowledgement"); + events.clear(); + backend.handle(Incoming::Reply { id: new_request, result: Ok(snapshot("s2", "smart")) }, &mut events); + assert!(backend.config_pending.is_none()); + assert!(matches!(events.as_slice(), [Event::ConfigOptionFinished { result: Ok(options), .. }] if options[0].current_value == "smart")); + backend.shutdown(true); + } +} diff --git a/crates/ui-egui/src/agent_chat/backend.rs b/crates/ui-egui/src/agent_chat/backend.rs index 98f5ad2468..afa1c44e9e 100644 --- a/crates/ui-egui/src/agent_chat/backend.rs +++ b/crates/ui-egui/src/agent_chat/backend.rs @@ -9,6 +9,7 @@ use serde_json::Value; pub use super::acp::{AuthMethod, PermissionOption, ToolStatus}; +pub use super::acp::{ConfigChoice, ConfigOption}; /// A connection test keeps authentication failures distinct from transport or protocol errors. #[derive(Clone, Debug, PartialEq, Eq)] @@ -53,29 +54,63 @@ pub enum Event { /// Whether SolidCraft tools were offered by the backend. ToolsAvailable(bool), /// Ready for a message. `agent`: its name and version; `images`: it takes pictures. - Ready { agent: String, images: bool }, + Ready { + agent: String, + images: bool, + }, /// Text it streams (`thought`: its reasoning, shown dimmed). - Text { thought: bool, text: String }, + Text { + thought: bool, + text: String, + }, /// A new tool call, or news about one (`input`: its arguments, when the agent says). - Tool { id: String, title: Option, kind: Option, status: Option, input: Option, output: Option }, + Tool { + id: String, + title: Option, + kind: Option, + status: Option, + input: Option, + output: Option, + }, /// Its plan, replaced whole: each entry with its status. Plan(Vec<(String, String)>), /// It asks before running something of its own; answered with [`ChatBackend::answer_permission`]. - Permission { key: u64, title: String, options: Vec }, + Permission { + key: u64, + title: String, + options: Vec, + }, /// It needs the user to sign in; `methods` are its own ways to do so. - AuthRequired { methods: Vec }, + AuthRequired { + methods: Vec, + }, /// What it says about its login. AuthStatus(String), /// Its own modes (an ACP agent's `modes`: ask, accept edits, plan, …) and the current one. - Modes { current: String, available: Vec<(String, String)> }, + Modes { + current: String, + available: Vec<(String, String)>, + }, /// Its models (empty `available`: only the current one changed). - Models { current: String, available: Vec }, + Models { + current: String, + available: Vec, + }, + ConfigOptions(Vec), + ConfigOptionFinished { + id: String, + result: Result, String>, + }, /// The message is answered (`reason`: why it stopped, empty for a normal end). - TurnEnded { reason: String }, + TurnEnded { + reason: String, + }, /// Something failed; the backend is still running unless [`Event::Exited`] follows. Error(String), /// It ended; its last error output. - Exited { tail: Vec }, + Exited { + tail: Vec, + }, } /// What a backend gets to start. @@ -112,6 +147,7 @@ pub trait ChatBackend { fn set_mode(&mut self, _mode: &str) {} /// An endpoint's new request timeout, in seconds, from the next request on. fn set_request_timeout(&mut self, _secs: u64) {} + fn set_config_option(&mut self, _id: &str, _value: &str) {} /// Its process, for status and tests. fn pid(&self) -> Option { None diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index 4899225da5..30fb5b07f5 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -399,6 +399,8 @@ pub struct AgentChat { /// The provider picked in the header when the models come from several providers (`None`: the /// current model's). It only narrows the model list; the agent keeps its model until one is chosen. pub model_provider: Option, + pub config_options: Option>, + pub config_pending: bool, /// An API endpoint's models, kept per endpoint (its base URL) for the next connection. models_cache: std::collections::HashMap>, /// The running API agent's endpoint (empty for other agents). @@ -459,12 +461,41 @@ impl AgentChat { pub(crate) fn disconnect_document(&mut self, session: &mut solidcraft_engine::Session, wait: bool) { finish_turn(self, session); + self.clear_config(); self.stop_backend(wait); if self.state.is_some() { self.state = Some(State::Off); } } + fn clear_config(&mut self) { + if self.config_options.take().is_some() { + self.models.clear(); + self.model.clear(); + self.modes.clear(); + self.mode.clear(); + self.model_provider = None; + } + self.config_pending = false; + } + + fn apply_config_snapshot(&mut self, options: Vec) { + self.config_options = Some(options.clone()); + let model = options.iter().find(|o| o.category == "model"); + self.models = model + .map(|o| { + o.options.iter().map(|x| acp::ModelInfo { id: x.value.clone(), name: x.name.clone(), description: x.description.clone() }).collect() + }) + .unwrap_or_default(); + let current = model.map_or_else(String::new, |o| o.current_value.clone()); + if current != self.model { + self.model_provider = None; + } + self.model = current; + let mode = options.iter().find(|o| o.category == "mode"); + self.modes = mode.map(|o| o.options.iter().map(|x| (x.value.clone(), x.name.clone())).collect()).unwrap_or_default(); + self.mode = mode.map_or_else(String::new, |o| o.current_value.clone()); + } /// Put a file on the next message with an id of its own; its name back. fn add_file(&mut self, file: attach::Attachment) -> String { let id = self.next_file_id(); @@ -610,7 +641,8 @@ impl AgentChat { self.state = Some(State::Ready); self.picking = Some(false); // Keep the mode across a new chat. - if let Some(m) = self.restore_mode.take() + if self.config_options.is_none() + && let Some(m) = self.restore_mode.take() && self.modes.iter().any(|(id, _)| *id == m) && let Some(b) = self.backend.as_mut() { @@ -671,24 +703,41 @@ impl AgentChat { } Event::Permission { key, title, options } => return Some(Outcome::Permission { key, title, options }), Event::Modes { current, available } => { - if !available.is_empty() { + if self.config_options.is_none() && !available.is_empty() { self.modes = available; } - self.mode = current; + if self.config_options.is_none() { + self.mode = current; + } } Event::Models { current, available } => { - if !available.is_empty() { + if self.config_options.is_none() && !available.is_empty() { if !self.running_endpoint.is_empty() { self.models_cache.insert(self.running_endpoint.clone(), available.clone()); } self.models = available; } - if current != self.model { - self.model_provider = None; + if self.config_options.is_none() { + if current != self.model { + self.model_provider = None; + } + self.model = current; + } + } + Event::ConfigOptions(options) => { + self.apply_config_snapshot(options); + } + Event::ConfigOptionFinished { result, .. } => { + self.config_pending = false; + match result { + Ok(options) => { + self.apply_config_snapshot(options); + } + Err(message) => self.notice(message, true), } - self.model = current; } Event::AuthRequired { methods } => { + self.clear_config(); self.auth = methods; self.state = Some(State::NeedsAuth); self.picking = Some(false); @@ -724,6 +773,7 @@ impl AgentChat { } } Event::Exited { tail } => { + self.clear_config(); self.state = Some(if matches!(self.state(), State::Starting | State::Failed) { State::Failed } else { State::Exited }); self.steering = false; let last: Vec = tail.into_iter().rev().take(6).collect::>().into_iter().rev().collect(); @@ -1236,6 +1286,7 @@ pub fn new_chat(app: &mut SolveApp) { b.new_session(); } } + chat.clear_config(); } /// Whether New chat would throw something away that the user may want (so it asks first). @@ -1749,6 +1800,13 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { let events = app.agent_chat.backend.as_mut().map(|b| b.poll()).unwrap_or_default(); for e in events { let ready = matches!(e, Event::Ready { .. }); + let configured = matches!(e, Event::ConfigOptionFinished { .. }); + let confirmed_model = match &e { + Event::ConfigOptionFinished { id, result: Ok(options) } => { + options.iter().find(|o| o.category == "model").filter(|o| o.id == *id).map(|o| o.current_value.clone()) + } + _ => None, + }; match app.agent_chat.on_event(e) { Some(Outcome::TurnEnd) => { end_turn(app); @@ -1762,11 +1820,27 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options), None => {} } + if let Some(model) = confirmed_model { + app.preferences.agent_models.insert(app.agent_chat.running_id.clone(), model.clone()); + if let Some(a) = app.preferences.agents.iter_mut().find(|a| a.id == app.agent_chat.running_id && a.kind == agents::Kind::Api) { + a.model = model; + } + } if ready { apply_saved_model(app); } - if ready && let Some((text, image, at, files)) = app.agent_chat.queued.take() { - start_turn(app, text, image, at, files); + if (ready || configured) && !app.agent_chat.config_pending { + if app.agent_chat.config_options.is_some() + && let Some(mode) = app.agent_chat.restore_mode.take() + && mode != app.agent_chat.mode + { + set_mode(app, &mode); + } + if !app.agent_chat.config_pending + && let Some((text, image, at, files)) = app.agent_chat.queued.take() + { + start_turn(app, text, image, at, files); + } } } #[cfg(not(target_arch = "wasm32"))] @@ -1798,6 +1872,14 @@ pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) { /// Choose the model for this chat (the header's model picker): the agent switches to it, and it is /// remembered for this agent; an API endpoint's agent also keeps it as its default model. pub fn set_model(app: &mut SolveApp, model: &str) { + if let Some(options) = app.agent_chat.config_options.as_ref() { + let Some(option) = options.iter().find(|x| x.category == "model") else { + return; + }; + let id = option.id.clone(); + set_config_option(app, &id, model); + return; + } if !app.agent_chat.models.iter().any(|m| m.id == model) { return; } @@ -1906,6 +1988,14 @@ pub fn set_model_provider(app: &mut SolveApp, provider: &str) { /// Switch the agent to one of its own modes (the header's mode picker). pub fn set_mode(app: &mut SolveApp, mode: &str) { + if let Some(options) = app.agent_chat.config_options.as_ref() { + let Some(option) = options.iter().find(|x| x.category == "mode") else { + return; + }; + let id = option.id.clone(); + set_config_option(app, &id, mode); + return; + } if app.agent_chat.modes.iter().any(|(id, _)| id == mode) && let Some(b) = app.agent_chat.backend.as_mut() { @@ -1914,6 +2004,23 @@ pub fn set_mode(app: &mut SolveApp, mode: &str) { } } +/// Change a reported modern ACP configuration option; only acknowledged snapshots change state. +pub fn set_config_option(app: &mut SolveApp, id: &str, value: &str) { + let Some(option) = app.agent_chat.config_options.as_ref().and_then(|xs| xs.iter().find(|x| x.id == id)) else { + return; + }; + if app.agent_chat.config_pending + || !matches!(app.agent_chat.state(), State::Ready | State::Busy) + || !option.options.iter().any(|x| x.value == value) + { + return; + } + if let Some(b) = app.agent_chat.backend.as_mut() { + b.set_config_option(id, value); + app.agent_chat.config_pending = true; + } +} + /// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, /// `connect_at_start` and `open_at_start` (the launch preferences), `open`, /// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the @@ -2001,6 +2108,11 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result Value { let pid = c.backend.as_ref().and_then(|b| b.pid()); json!({ "features": app.preferences.ai_features, + "configOptions": c.config_options, + "configPending": c.config_pending, "toolbarButton": app.preferences.ai_toolbar_button, "connectAtStart": app.preferences.agent_connect_at_start, "openAtStart": app.preferences.agent_chat_open_at_start, diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 1016e0e675..9700911306 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -1035,15 +1035,17 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let all = agents::entries(app); let current = agents::current(app); + // An advertised (even empty) config list replaces the older ACP model/mode pickers. + let modern = app.agent_chat.config_options.is_some(); ui.horizontal_wrapped(|ui| { let label = current.as_ref().map_or_else( || tr(if app.agent_chat.detecting.is_some() { "ui.agent_chat_detecting" } else { "ui.agent_chat_none_found_short" }).to_string(), |e| e.name.clone(), ); - let models = models_shown(app); + let models = !modern && models_shown(app); let providers = if models { model_providers(app) } else { Vec::new() }; let w = (ui.available_width() - - if app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } + - if modern || app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } - if models { 146.0 } else { 0.0 } - if providers.is_empty() { 0.0 } else { 146.0 }) .clamp(90.0, 260.0); @@ -1084,7 +1086,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { app.agent_chat.agent_id = Some(id); } // The agent's own modes (ACP `modes`): its own tools follow them. - if !app.agent_chat.modes.is_empty() { + if !modern && !app.agent_chat.modes.is_empty() { let modes = app.agent_chat.modes.clone(); let cur = modes.iter().find(|(id, _)| *id == app.agent_chat.mode).map_or_else(|| app.agent_chat.mode.clone(), |(_, n)| n.clone()); let mut pick: Option = None; @@ -1168,6 +1170,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(warning) = settings::model_warning(app) { ui.add(egui::Label::new(RichText::new(warning).color(t.warning).size(t.font_small)).wrap()); } + if let Some(options) = app.agent_chat.config_options.clone() { + config_selects(app, ui, &options); + } ui.horizontal(|ui| { let b = if app.agent_chat.state().running() { ui_kit::button(ui, tr("ui.agent_chat_stop"), ui_kit::Button::Normal).on_hover_text(tr("ui.agent_chat_stop_tip")) @@ -1224,6 +1229,221 @@ fn picker_width(ui: &egui::Ui, label: &str) -> f32 { text.clamp(t.form_label_width, most) } +/// An agent's select options are authoritative. Compact value pickers share a strip below the +/// agent switcher; only a narrow chat wraps them, rather than spending two full rows per option. +#[cfg(not(target_arch = "wasm32"))] +fn config_selects(app: &mut SolveApp, ui: &mut egui::Ui, options: &[acp::ConfigOption]) { + let t = Tokens::get(); + let pending = app.agent_chat.config_pending; + let strip_width = ui.available_width().min(ui.max_rect().width()).max(0.0); + let mut row = Vec::new(); + let mut used = 0.0; + for option in options { + let current = option.options.iter().find(|choice| choice.value == option.current_value); + let selected = current.map_or(option.current_value.as_str(), |choice| choice.name.as_str()); + let preview = compact_text(selected, 32); + let preferred = ui.painter().layout_no_wrap(preview, egui::FontId::proportional(t.font_label), t.text).size().x + 34.0; + let width = preferred.clamp(76.0, strip_width.clamp(76.0, 200.0)); + let spacing = if row.is_empty() { 0.0 } else { ui.spacing().item_spacing.x }; + if !row.is_empty() && used + spacing + width > strip_width { + ui.horizontal(|ui| { + for (option, width) in row.drain(..) { + config_select(app, ui, option, width, pending); + } + }); + used = 0.0; + } + used += if used == 0.0 { width } else { spacing + width }; + row.push((option, width)); + } + if !row.is_empty() { + ui.horizontal(|ui| { + for (option, width) in row { + config_select(app, ui, option, width, pending); + } + }); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn config_select(app: &mut SolveApp, ui: &mut egui::Ui, option: &acp::ConfigOption, width: f32, pending: bool) { + let t = Tokens::get(); + let current = option.options.iter().find(|choice| choice.value == option.current_value); + let selected = current.map_or(option.current_value.as_str(), |choice| choice.name.as_str()); + // Bound both the measurement and the visible label: agent-provided text may be huge. + // Width follows the current value, leaving short choices room to sit on one line. + let font = egui::FontId::proportional(t.font_label); + let mut chars = 32; + let displayed = loop { + let text = compact_text(selected, chars); + if chars == 1 || ui.painter().layout_no_wrap(text.clone(), font.clone(), t.text).size().x <= width - 36.0 { + break text; + } + chars -= 1; + }; + let name = if option.name.is_empty() { &option.id } else { &option.name }; + let tip = [name.as_str(), option.description.as_str(), current.map_or("", |choice| choice.description.as_str())] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + let mut picked = None; + let response = ui + .add_enabled_ui(!pending && !option.options.is_empty(), |ui| { + ui.allocate_ui(vec2(width, t.control_height), |ui| { + ui_kit::dropdown_menu(ui, ("sc_agent_config", &option.id), &displayed, |ui| { + let mut previous_group: Option<&str> = None; + for choice in &option.options { + let group = choice.group.as_deref().filter(|g| !g.is_empty()); + if let Some(group) = group + && previous_group != Some(group) + { + ui.label(RichText::new(compact_text(group, 32)).color(t.text_dim).size(t.font_small)).on_hover_text(group); + } + previous_group = group; + let row = ui_kit::list_picker(ui, &choice.value, &compact_text(&choice.name, 32), choice.value == option.current_value); + let row = row.on_hover_text( + [choice.name.as_str(), choice.description.as_str()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"), + ); + crate::scenario::publish_handle(&format!("agent_chat:config:{}:{}", option.id, choice.value), row.rect.center()); + if option.category == "model" { + crate::scenario::publish_handle(&format!("agent_chat:model:{}", choice.value), row.rect.center()); + } + if row.clicked() { + picked = Some(choice.value.clone()); + } + } + }) + }) + .inner + }) + .inner; + crate::scenario::publish_handle(&format!("agent_chat:config:{}", option.id), response.rect.center()); + crate::scenario::publish_rect(&format!("agent_chat:config:{}", option.id), response.rect); + if option.category == "model" { + crate::scenario::publish_handle("agent_chat:model", response.rect.center()); + } else if option.category == "mode" { + crate::scenario::publish_handle("agent_chat:mode", response.rect.center()); + } + response + .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, !pending && !option.options.is_empty(), format!("{name}: {selected}"))); + response.on_hover_text(tip); + if let Some(value) = picked { + super::set_config_option(app, &option.id, &value); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn compact_text(text: &str, max_chars: usize) -> String { + let mut chars = text.chars(); + let short: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { format!("{short}…") } else { short } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod config_ui_tests { + use super::*; + + fn option(id: &str, category: &str, choices: &[(&str, &str, Option<&str>)]) -> acp::ConfigOption { + acp::ConfigOption { + id: id.into(), + name: id.into(), + description: format!("Choose {id}"), + category: category.into(), + current_value: choices.first().map_or("", |choice| choice.0).into(), + options: choices + .iter() + .map(|(value, name, group)| acp::ConfigChoice { + value: (*value).into(), + name: (*name).into(), + description: format!("Description for {name}"), + group: group.map(str::to_owned), + }) + .collect(), + } + } + + fn draw_switcher(app: &mut SolveApp, ctx: &egui::Context, width: f32) { + crate::scenario::clear_handles(); + let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 700.0)); + let _ = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + ui.allocate_ui(vec2(width - 16.0, 600.0), |ui| { + ui_kit::scope(ui, |ui| switcher(app, ui)); + }); + }); + } + + #[test] + fn config_options_share_one_row_when_they_fit_and_wrap_without_legacy_duplicates() { + assert_eq!(compact_text("Énergie très élevée", 7), "Énergie…"); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + // Make legacy provider/model pickers eligible so their absence is meaningful. + app.agent_chat.state = Some(State::Ready); + app.agent_chat.modes = vec![("legacy".into(), "Legacy mode".into())]; + app.agent_chat.models = vec![ + acp::ModelInfo { id: "old".into(), name: "North · Old model".into(), description: String::new() }, + acp::ModelInfo { id: "other".into(), name: "South · Other model".into(), description: String::new() }, + ]; + assert!(models_shown(&app)); + assert_eq!(model_providers(&app).len(), 2); + app.agent_chat.config_options = Some(vec![ + option( + "engine", + "model", + &[("fast", "Énergie très élevée pour une conception détaillée", Some("Standard")), ("large", "Large", Some("Advanced"))], + ), + option("thought", "thought_level", &[("low", "Max", None), ("high", "High", None)]), + option("workflow", "mode", &[("plan", "Build", None)]), + ]); + let ctx = crate::fonts::test_context(); + draw_switcher(&mut app, &ctx, 760.0); + let at = |name| crate::scenario::handle_at(name).expect("config picker handle"); + let agent = at("agent_chat:switcher"); + let model = at("agent_chat:config:engine"); + let effort = at("agent_chat:config:thought"); + let mode = at("agent_chat:config:workflow"); + assert!(agent.y < model.y && (model.y - effort.y).abs() < 2.0 && (effort.y - mode.y).abs() < 2.0); + assert!(model.x < effort.x && effort.x < mode.x && mode.x < 760.0); + assert_eq!(at("agent_chat:model"), model); + assert_eq!(at("agent_chat:mode"), mode); + assert_eq!(crate::scenario::handles("agent_chat:mode").iter().filter(|(name, _)| name == "agent_chat:mode").count(), 1); + assert_eq!(crate::scenario::handles("agent_chat:model").iter().filter(|(name, _)| name == "agent_chat:model").count(), 1); + assert!(crate::scenario::handle_at("agent_chat:provider").is_none()); + assert!(crate::scenario::handle_at("agent_chat:model:old").is_none()); + assert!(crate::scenario::handle_at("agent_chat:model:other").is_none()); + + draw_switcher(&mut app, &ctx, 280.0); + let model = at("agent_chat:config:engine"); + let effort = at("agent_chat:config:thought"); + let mode = at("agent_chat:config:workflow"); + assert!(model.y < effort.y && (effort.y - mode.y).abs() < 2.0, "only the long model needs its own line: {model:?}, {effort:?}, {mode:?}"); + for id in ["engine", "thought", "workflow"] { + let rect = crate::scenario::rect_of(&format!("agent_chat:config:{id}")).expect("config control bounds"); + assert!(rect.left() >= 0.0 && rect.right() <= 280.0, "{id} exceeds the narrow panel: {rect:?}"); + } + + app.agent_chat.config_options = Some(vec![option("engine", "model", &[("fast", "Fast", None)])]); + app.agent_chat.config_pending = true; + draw_switcher(&mut app, &ctx, 280.0); + assert!(crate::scenario::handle_at("agent_chat:config:engine").is_some()); + assert!(crate::scenario::handle_at("agent_chat:config:thought").is_none()); + assert!(crate::scenario::handle_at("agent_chat:config:workflow").is_none()); + assert!(crate::scenario::handle_at("agent_chat:switcher").is_some()); + + app.agent_chat.config_options = Some(vec![]); + draw_switcher(&mut app, &ctx, 280.0); + assert!(crate::scenario::handle_at("agent_chat:switcher").is_some()); + assert!(crate::scenario::handle_at("agent_chat:config:engine").is_none()); + assert!(crate::scenario::handle_at("agent_chat:mode").is_none()); + assert!(crate::scenario::handle_at("agent_chat:provider").is_none()); + assert!(crate::scenario::handle_at("agent_chat:model").is_none()); + } +} + /// Before the first connection: what's installed, how to install the rest, and Add agent. #[cfg(not(target_arch = "wasm32"))] fn picker(app: &mut SolveApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index 0426529df1..de6a329915 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -555,6 +555,9 @@ impl ChatBackend for Script { fn answer_permission(&mut self, key: u64, option: Option<&str>) { self.calls.lock().unwrap().push(format!("permission: {key} {option:?}")); } + fn set_config_option(&mut self, id: &str, value: &str) { + self.calls.lock().unwrap().push(format!("set_config_option: {id}={value}")); + } fn authenticate(&mut self, _: &str) {} fn shutdown(&mut self, _: bool) {} } @@ -1233,6 +1236,367 @@ fn hostile_provider_names_are_bounded() { assert_eq!(model_provider(&m("model".into(), "Provider: A\nsecond line • current")).map(|p| p.0), Some("A".into())); } +fn modern_options(model: &str) -> Vec { + vec![ + acp::ConfigOption { + id: "some.provider.model".into(), + name: "Model".into(), + description: String::new(), + category: "model".into(), + current_value: model.into(), + options: ["fast", "smart"] + .into_iter() + .map(|v| acp::ConfigChoice { value: v.into(), name: v.into(), description: String::new(), group: None }) + .collect(), + }, + acp::ConfigOption { + id: "custom-mode-key".into(), + name: "Mode".into(), + description: String::new(), + category: "mode".into(), + current_value: "ask".into(), + options: ["ask", "plan"] + .into_iter() + .map(|v| acp::ConfigChoice { value: v.into(), name: v.into(), description: String::new(), group: None }) + .collect(), + }, + ] +} + +#[test] +fn modern_snapshots_replace_provider_choices_and_ignore_legacy_updates() { + let (mut app, script, ctx) = app_with_script(); + script.report(Event::Models { current: "plain".into(), available: provider_models() }); + poll(&mut app, &ctx); + set_model_provider(&mut app, "Host"); + script.report(Event::ConfigOptions(modern_options("smart"))); + script.report(Event::Models { current: "plain".into(), available: provider_models() }); + script.report(Event::Modes { current: "old-mode".into(), available: vec![("old-mode".into(), "Old mode".into())] }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.agent_chat.mode, "ask"); + assert!(app.agent_chat.model_provider.is_none()); + assert!(model_providers(&app).is_empty()); + assert_eq!(listed_models(&app).into_iter().map(|(m, _)| m.id).collect::>(), ["fast", "smart"]); + script.report(Event::ConfigOptions(vec![])); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.config_options, Some(vec![])); + assert!(app.agent_chat.models.is_empty() && app.agent_chat.modes.is_empty()); + set_model(&mut app, "plain"); + set_mode(&mut app, "old-mode"); + assert!(script.calls().is_empty(), "an empty modern snapshot never falls back to stale legacy choices"); +} + +#[test] +fn modern_acknowledgements_stay_with_their_document_without_resuming_its_queue() { + let (mut app, first, ctx) = app_with_script(); + app.agent_chat.running_id = "first-modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + app.preferences.agent_models.insert("first-modern".into(), "smart".into()); + new_chat(&mut app); + send(&mut app, &ctx, "first request".into()); + first.report(Event::ConfigOptions(modern_options("fast"))); + first.report(Event::Ready { agent: "first-modern".into(), images: false }); + poll(&mut app, &ctx); + assert!(app.agent_chat.config_pending && app.agent_chat.queued.is_some()); + + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + let second = Script::default(); + app.agent_chat.backend = Some(Box::new(second.clone())); + app.agent_chat.state = Some(State::Ready); + app.agent_chat.running_id = "second-modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + first.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert!(app.agent_chat.config_pending, "the hidden tab's acknowledgement cannot unlock this one"); + assert_eq!(app.agent_chat.model, "fast"); + assert!(!app.preferences.agent_models.contains_key("second-modern")); + second.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert_eq!(app.preferences.agent_models.get("second-modern").map(String::as_str), Some("smart")); + + crate::documents::switch(&mut app, 0); + assert!(app.agent_chat.config_pending, "pending configuration belongs to the conversation"); + poll(&mut app, &ctx); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.preferences.agent_models.get("first-modern").map(String::as_str), Some("smart")); + assert_eq!(texts(&app), ["first request"]); + assert!(app.agent_chat.queued.is_none() && app.agent_chat.stopped); + assert!(first.prompts().is_empty() && second.prompts().is_empty(), "a late configuration reply cannot resume a departed tab's prompt"); +} + +#[test] +fn closing_a_pending_modern_chat_preserves_the_surviving_api_endpoint_and_saved_models() { + for close_active in [false, true] { + let (mut app, modern, ctx) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + app.preferences.agent_models.insert("modern".into(), "fast".into()); + set_model(&mut app, "smart"); + assert!(app.agent_chat.config_pending); + + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + let endpoint = "http://127.0.0.1:12345/v1"; + let api = Script::default(); + app.agent_chat.backend = Some(Box::new(api.clone())); + app.agent_chat.state = Some(State::Ready); + app.agent_chat.running_id = "endpoint-agent".into(); + app.agent_chat.running_endpoint = endpoint.into(); + app.preferences.agents.push(agents::AgentConfig { + id: "endpoint-agent".into(), + kind: agents::Kind::Api, + base_url: endpoint.into(), + model: "fast".into(), + ..Default::default() + }); + api.report(models_reply("fast")); + poll(&mut app, &ctx); + set_model(&mut app, "smart"); + let cached = app.agent_chat.models_cache.get(endpoint).cloned().unwrap(); + modern.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + modern.report(Event::ConfigOptions(vec![])); + if close_active { + crate::documents::switch(&mut app, 0); + assert!(app.agent_chat.config_pending); + } + crate::documents::close(&mut app, 0, true); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.running_id, "endpoint-agent"); + assert_eq!(app.agent_chat.running_endpoint, endpoint); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.agent_chat.models_cache.get(endpoint), Some(&cached)); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("fast")); + assert_eq!(app.preferences.agent_models.get("endpoint-agent").map(String::as_str), Some("smart")); + assert_eq!(app.preferences.agents.iter().find(|a| a.id == "endpoint-agent").unwrap().model, "smart"); + assert_eq!(api.calls(), ["set_model: smart"], "the closed chat's events cannot reach the surviving backend"); + + // Reusing the same agent in a fresh design must restore only the last confirmed model. + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + let fresh = Script::default(); + app.agent_chat.backend = Some(Box::new(fresh.clone())); + app.agent_chat.state = Some(State::Starting); + app.agent_chat.running_id = "modern".into(); + fresh.report(Event::ConfigOptions(modern_options("fast"))); + fresh.report(Event::Ready { agent: "modern".into(), images: false }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "fast"); + assert!(!app.agent_chat.config_pending); + assert!(fresh.calls().is_empty(), "an unacknowledged choice from the closed chat is not restored"); + assert_eq!(app.agent_chat.models_cache.get(endpoint), Some(&cached)); + } +} + +#[test] +fn document_disconnect_paths_clear_modern_configuration_and_pending_requests() { + let assert_cleared = |app: &SolveApp| { + assert!(app.agent_chat.backend.is_none()); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + assert!(app.agent_chat.models.is_empty() && app.agent_chat.modes.is_empty()); + assert!(app.agent_chat.model.is_empty() && app.agent_chat.mode.is_empty()); + assert!(app.agent_chat.model_provider.is_none()); + assert_eq!(app.agent_chat.state(), State::Off); + }; + let (mut app, _, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + disconnect(&mut app); + assert_cleared(&app); + + for quitting in [false, true] { + let (mut app, first, ctx) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + crate::documents::open_tab(&mut app, solidcraft_engine::Session::default()).unwrap(); + app.agent_chat.backend = Some(Box::new(Script::default())); + app.agent_chat.state = Some(State::Ready); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + first.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + if quitting { + shutdown(&mut app); + } else { + app.preferences.ai_features = false; + poll(&mut app, &ctx); + } + assert_cleared(&app); + crate::documents::switch(&mut app, 0); + assert_cleared(&app); + assert!(app.preferences.agent_models.is_empty(), "an unpolled acknowledgement is discarded with its backend"); + } +} + +#[cfg(unix)] +#[test] +fn modern_config_requests_roundtrip_success_rejection_malformed_and_retry() { + let dir = std::env::temp_dir().join(format!("sc-chat-config-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("requests.jsonl"); + let script = r#" +log="$1"; n=0 +while IFS= read -r line; do + printf '%s\n' "$line" >> "$log" + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"agentInfo":{"name":"fake"}}}\n' "$id" ;; + *'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1","configOptions":[{"id":"vendor.model-key","name":"Model","category":"model","type":"select","currentValue":"fast","options":[{"value":"fast","name":"Fast"},{"value":"smart","name":"Smart"}]}]}}\n' "$id" ;; + *'"method":"session/set_config_option"'*) + n=$((n+1)) + case "$n" in + 1|4) v=smart; [ "$n" = 4 ] && v=fast; printf '{"jsonrpc":"2.0","id":%s,"result":{"configOptions":[{"id":"vendor.model-key","name":"Model","category":"model","type":"select","currentValue":"%s","options":[{"value":"fast","name":"Fast"},{"value":"smart","name":"Smart"}]}]}}\n' "$id" "$v" ;; + 2) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32001,"message":"rejected"}}\n' "$id" ;; + 3) printf '{"jsonrpc":"2.0","id":%s,"result":{"configOptions":[{"type":"select"}]}}\n' "$id" ;; + esac ;; + esac +done +"#; + let argv = vec!["sh".into(), "-c".into(), script.into(), "fake".into(), log.to_string_lossy().into_owned()]; + let (wake_tx, wake_rx) = channel(); + let start = backend::StartContext { + cwd: dir.clone(), + bridge: None, + tools: None, + wake: Box::new(move || { + let _ = wake_tx.send(()); + }), + }; + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap())); + app.agent_chat.running_id = "modern".into(); + app.agent_chat.state = Some(State::Starting); + let ctx = crate::fonts::test_context(); + let until = |app: &mut SolveApp, done: &dyn Fn(&SolveApp) -> bool| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + loop { + poll(app, &ctx); + if done(app) { + return; + } + let left = deadline.saturating_duration_since(std::time::Instant::now()); + assert!(!left.is_zero(), "timed out; status={}", status(app)); + wake_rx.recv_timeout(left).expect("backend wake"); + } + }; + until(&mut app, &|a| a.agent_chat.state() == State::Ready); + assert_eq!(app.agent_chat.model, "fast"); + for (value, expected, saved) in [("smart", "smart", "smart"), ("fast", "smart", "smart"), ("smart", "smart", "smart"), ("fast", "fast", "fast")] { + set_model(&mut app, value); + assert!(app.agent_chat.config_pending); + until(&mut app, &|a| !a.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, expected); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some(saved)); + } + disconnect(&mut app); + let requests: Vec = std::fs::read_to_string(&log).unwrap().lines().filter_map(|l| serde_json::from_str(l).ok()).collect(); + let configs: Vec<_> = requests.iter().filter(|v| v["method"] == "session/set_config_option").collect(); + assert_eq!(configs.len(), 4); + assert_eq!( + configs.iter().map(|v| v["params"].clone()).collect::>(), + ["smart", "fast", "smart", "fast"].map(|value| json!({"sessionId":"s1","configId":"vendor.model-key","value":value})) + ); + assert!(!requests.iter().any(|v| v["method"] == "session/set_model")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn modern_controls_route_by_category_and_wait_for_acknowledgement() { + let (mut app, script, _) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + assert!(app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "fast"); + assert!(!app.preferences.agent_models.contains_key("modern")); + assert_eq!(script.calls(), ["set_config_option: some.provider.model=smart"]); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + assert!(app.agent_chat.config_pending, "a notification does not acknowledge the request"); + app.agent_chat.on_event(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "smart"); + app.preferences.agent_models.insert("modern".into(), app.agent_chat.model.clone()); + set_mode(&mut app, "ask"); + assert_eq!(script.calls().last().map(String::as_str), Some("set_config_option: custom-mode-key=ask")); +} + +#[test] +fn modern_rejection_and_malformed_success_unlock_without_changing_confirmed_value() { + let (mut app, script, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("fast"))); + set_model(&mut app, "smart"); + app.agent_chat.on_event(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Err("rejected".into()) }); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "fast"); + set_model(&mut app, "smart"); + app.agent_chat.on_event(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Err("malformed snapshot".into()) }); + assert!(!app.agent_chat.config_pending); + assert_eq!(app.agent_chat.model, "fast"); + assert_eq!(script.calls().len(), 2); +} + +#[test] +fn modern_saved_model_restore_waits_for_ack_and_manual_confirmation_persists() { + let (mut app, script, ctx) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.preferences.agent_models.insert("modern".into(), "smart".into()); + script.report(Event::ConfigOptions(modern_options("fast"))); + script.report(Event::Ready { agent: "modern".into(), images: false }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "fast"); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); + assert_eq!(script.calls(), ["set_config_option: some.provider.model=smart"]); + script.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert_eq!(app.agent_chat.model, "smart"); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); +} + +#[test] +fn modern_model_restore_then_mode_restore_are_acknowledged_before_queued_prompt() { + let (mut app, script, ctx) = app_with_script(); + app.agent_chat.running_id = "modern".into(); + app.preferences.agent_models.insert("modern".into(), "smart".into()); + app.agent_chat.restore_mode = Some("plan".into()); + app.agent_chat.queued = Some(("describe the model".into(), false, None, Vec::new())); + script.report(Event::ConfigOptions(modern_options("fast"))); + script.report(Event::Ready { agent: "modern".into(), images: false }); + poll(&mut app, &ctx); + assert_eq!(script.calls(), ["set_config_option: some.provider.model=smart"]); + assert_eq!(app.agent_chat.model, "fast"); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); + script.report(Event::ConfigOptionFinished { id: "some.provider.model".into(), result: Ok(modern_options("smart")) }); + poll(&mut app, &ctx); + assert_eq!(script.calls().get(1).map(String::as_str), Some("set_config_option: custom-mode-key=plan")); + assert!(app.agent_chat.queued.is_some(), "prompt waits for mode confirmation"); + let mut confirmed = modern_options("smart"); + confirmed[1].current_value = "plan".into(); + script.report(Event::ConfigOptionFinished { id: "custom-mode-key".into(), result: Ok(confirmed) }); + poll(&mut app, &ctx); + assert!(script.calls().iter().any(|s| s == "prompt: describe the model")); + assert_eq!(app.preferences.agent_models.get("modern").map(String::as_str), Some("smart")); +} + +#[test] +fn invalidated_modern_sessions_do_not_expose_stale_legacy_choices() { + for event in [Event::AuthRequired { methods: vec![] }, Event::Exited { tail: vec![] }] { + let (mut app, _, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("smart"))); + app.agent_chat.config_pending = true; + app.agent_chat.on_event(event); + assert!(app.agent_chat.config_options.is_none() && !app.agent_chat.config_pending); + assert!(app.agent_chat.models.is_empty() && app.agent_chat.modes.is_empty()); + assert!(app.agent_chat.model.is_empty() && app.agent_chat.mode.is_empty()); + } + let (mut app, _, _) = app_with_script(); + app.agent_chat.on_event(Event::ConfigOptions(modern_options("smart"))); + new_chat(&mut app); + assert_eq!(app.agent_chat.restore_mode.as_deref(), Some("ask")); + assert!(app.agent_chat.config_options.is_none() && app.agent_chat.models.is_empty()); +} + /// An agent that reports no models gets no picker; another agent's choice is not applied to it. #[test] fn an_agent_without_models_shows_no_picker() { diff --git a/docs/agent-chat.md b/docs/agent-chat.md index c4898d0768..7341489c25 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -63,16 +63,25 @@ clicks and scrolling over the panel stay in the chat. The docked layout retains icon, the agent's state, and a count of anything waiting for your answer. Click the icon to unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does. - **The agent switcher** under the title picks the agent for this conversation; switching stops - the one running. Next to it, when the agent has modes of its own (Claude Code has *Default*, - *Accept Edits*, *Plan*…), a second drop-down sets the agent's mode. When the agent reports its models (an ACP agent's - model list, or an endpoint's `/models`), a model drop-down shows the one in use and changes it; - SolidCraft remembers the last choice for each agent and puts it back on the next session. An - endpoint's agent also keeps it as its default model. An agent that reports no models shows no - drop-down. When the models come from more than one provider (Hermes and OpenCode name each - model `Provider · model`, or say `Provider: …` in its description), a provider drop-down comes - first and the model drop-down lists only that provider's models, without the provider in front. - It opens on the provider of the model in use; picking another provider changes nothing until one - of its models is chosen. Models with no provider named are listed under *Other*. + the one running. When an ACP agent reports **configuration options** (including OpenCode V2), + a compact inline strip below it shows the agent's model, reasoning effort, mode, and any other + supported select options, wrapping when space is tight. Hover a selector for its full label and + description. Their names and choices come from the agent: not every model offers adjustable + reasoning effort. Changing the model can change or remove the effort choices. A selection is + shown as applied only after the agent reports it; a rejected change leaves the confirmed + selection in place and shows an error. +- **Older agents and API endpoints** keep their model and mode drop-downs when they report those + choices (an ACP agent's legacy model/mode lists, or an endpoint's `/models`). SolidCraft remembers + the last model choice for each agent and puts it back on the next session when still available. + An endpoint's agent also keeps it as its default model. An agent that reports no choices shows + no drop-down. When these model lists include more than one provider (Hermes and OpenCode name + each model `Provider · model`, or say `Provider: …` in its description), a provider drop-down + comes first and the model drop-down lists only that provider's models, without the provider in + front. It opens on the provider of the model in use; picking another provider changes nothing + until one of its models is chosen. Models with no provider named are listed under *Other*. +- **Reasoning effort is not permissions.** The agent's thinking setting does not change + SolidCraft's Ask / Edits / All approval policy. Reasoning options belong to the current session; + changing them here does not rewrite OpenCode's configuration or another OpenCode session. Messages use right-aligned user bubbles and plain assistant text. Consecutive tool calls share an activity group: click a row to inspect its output; failed calls have an error tint. Approval diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 719ffa47af..b1b6062c4f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -399,6 +399,15 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) | +For an ACP agent advertising configuration options, `ui.agent_chat` also accepts +`{"config_option": {"id": "effort", "value": "high"}}`. Use an option ID and choice value +from its reported `configOptions`; IDs and available values belong to the agent. The reply's +`configOptions` is `null` for a legacy agent, or the current list of supported select options +(including an empty list when none remain). `configPending` is true while a selection waits for +the agent's reply. Read the confirmed state after that reply; submitting a change is not its +confirmation. The existing `model` and `mode` inputs use the corresponding configuration option +when advertised, and retain their existing behavior for agents using legacy lists or API endpoints. + ### `ui.prefs` settings `render_previous_geometry_budget` (Graphics, default false) restores the previous fixed Render preview geometry budget: 512 MiB on 64-bit desktop, 128 MiB elsewhere. With it off, desktop uses the device-based budget. diff --git a/docs/design/in-app-agent-chat.md b/docs/design/in-app-agent-chat.md index ab4ac616ef..203c07e11b 100644 --- a/docs/design/in-app-agent-chat.md +++ b/docs/design/in-app-agent-chat.md @@ -27,10 +27,11 @@ What the prototype uses: |---|---|---| | → agent | `initialize {protocolVersion: 1, clientCapabilities, clientInfo}` | We declare no file system and no terminal (`fs.readTextFile/writeTextFile: false`, `terminal: false`), and terminal login (`auth.terminal: true`, plus the older `_meta["terminal-auth"]`). The reply carries `agentInfo` and `authMethods`. | | → agent | `session/new {cwd, mcpServers}` | `mcpServers` holds one stdio server: `solidcraft-cli mcp --connect 127.0.0.1:PORT`. `cwd` is the open design's folder (else SolidCraft's own `agent` folder). | +| → agent | `session/set_config_option {sessionId, configId, value}` | Set an advertised select option, including a model, reasoning effort or mode. The complete returned `configOptions` snapshot confirms the state; rejection leaves the previous confirmed snapshot intact. | | → agent | `session/prompt {sessionId, prompt: [content]}` | One text block. The reply comes at the end of the turn with `stopReason` (`end_turn`, `cancelled`, `refusal`, …). | | → agent | `session/cancel {sessionId}` (notification) | Cancel button. Open permission prompts are answered `cancelled` first, as the spec asks. | | → agent | `authenticate {methodId}` | Only for an "agent" login method the user clicks (the agent then opens the browser itself). | -| ← agent | `session/update` | `agent_message_chunk`, `agent_thought_chunk` (streamed text), `tool_call` / `tool_call_update` (`toolCallId`, `title`, `kind`, `status`: pending, in_progress, completed, failed, `content`), `plan` (entries with status). Mode, command and usage updates are ignored for now. | +| ← agent | `session/update` | `agent_message_chunk`, `agent_thought_chunk` (streamed text), `tool_call` / `tool_call_update` (`toolCallId`, `title`, `kind`, `status`: pending, in_progress, completed, failed, `content`), `plan` (entries with status), and session-scoped `config_option_update` (a complete replacement snapshot). Legacy current-model/mode updates apply only when modern options are absent. Command and usage updates are ignored. | | ← agent | `session/request_permission {toolCall, options: [{optionId, name, kind}]}` | Shown as a prompt in the conversation; the answer is `{outcome: {outcome: "selected", optionId}}` or `{outcome: "cancelled"}`. `kind` is `allow_once`, `allow_always`, `reject_once` or `reject_always`. | | ← agent | `fs/*`, `terminal/*` | Not offered, answered "method not found". | | ← agent | `_auth/status_update` | An extension both current adapters send ("Not logged in"); shown next to the agent's name. | @@ -38,6 +39,15 @@ What the prototype uses: Errors: an agent that is not signed in answers `session/new` (Codex) or the first `session/prompt` (Claude) with JSON-RPC error `-32000 "Authentication required"`. +Current ACP agents, including OpenCode V2, can return ordered `configOptions` in the session +response instead of legacy model/mode lists. SolidCraft prefers these options, preserves arbitrary +IDs and optional categories, and supports flat or grouped select choices. Only one configuration +change waits for acknowledgement at a time; an unsolicited snapshot does not acknowledge it. +New chats invalidate outstanding configuration requests, and updates from older sessions are +ignored. No boolean capability is advertised. See the +[ACP configuration contract](https://agentclientprotocol.com/protocol/session-config-options) +and [OpenCode V2 ACP reference](https://opencode.ai/v2/docs/cli/acp). + ## The agents All checked on 2026-10-14 with the packages installed into a user prefix