Agent chat: support ACP configuration options and OpenCode V2 (#3305) (#3867)

* claim: #3305

* fix(agent-chat): support ACP configuration options (#3305)

Handle current select options for model, thinking effort and mode, with confirmed snapshots and legacy compatibility. Full quality gate attempted; draft delivery authorized with unrelated geometry/property/timing failures documented in PR #3867.

* fix(agent-chat): keep configuration selectors inline (#3305)

Use compact value pickers and bounded wrapping to preserve conversation space; verified at normal and narrow widths with the installed OpenCode V2 server.

* style(agent-chat): format tool events
This commit is contained in:
Alex Lytvynenko
2026-10-30 20:00:26 +00:00
committed by GitHub
parent 806adb6c46
commit ffbf9836cc
9 changed files with 1092 additions and 48 deletions
+187 -8
View File
@@ -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<String>,
}
/// 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<ConfigChoice>,
}
/// Parse ACP select options as a bounded authoritative snapshot.
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigParse {
Absent,
Invalid,
Snapshot(Vec<ConfigOption>),
}
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<ModelInfo>)> {
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<Value, (i64, String)> },
Reply {
id: u64,
result: Result<Value, (i64, String)>,
},
/// 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<String>, kind: Option<String>, status: Option<ToolStatus>, input: Option<Value>, output: Option<String> },
Tool {
id: String,
title: Option<String>,
kind: Option<String>,
status: Option<ToolStatus>,
input: Option<Value>,
output: Option<String>,
},
/// 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<PermissionOption> },
Permission {
rpc: Value,
title: String,
options: Vec<PermissionOption>,
},
/// 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<ConfigOption>,
},
/// 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<Incoming> {
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<Incoming> {
}
}
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(&notification, "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);
}
}
+111 -8
View File
@@ -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<u64, Value>,
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<u32> {
self.proc.pid()
}
@@ -271,3 +326,51 @@ pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, Test
p.shutdown(true);
result
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::agent_chat::backend::StartContext;
fn snapshot(session: &str, value: &str) -> 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);
}
}
+45 -9
View File
@@ -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<String>, kind: Option<String>, status: Option<ToolStatus>, input: Option<Value>, output: Option<String> },
Tool {
id: String,
title: Option<String>,
kind: Option<String>,
status: Option<ToolStatus>,
input: Option<Value>,
output: Option<String>,
},
/// 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<PermissionOption> },
Permission {
key: u64,
title: String,
options: Vec<PermissionOption>,
},
/// It needs the user to sign in; `methods` are its own ways to do so.
AuthRequired { methods: Vec<AuthMethod> },
AuthRequired {
methods: Vec<AuthMethod>,
},
/// 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<super::acp::ModelInfo> },
Models {
current: String,
available: Vec<super::acp::ModelInfo>,
},
ConfigOptions(Vec<ConfigOption>),
ConfigOptionFinished {
id: String,
result: Result<Vec<ConfigOption>, 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<String> },
Exited {
tail: Vec<String>,
},
}
/// 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<u32> {
None
+123 -9
View File
@@ -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<String>,
pub config_options: Option<Vec<acp::ConfigOption>>,
pub config_pending: bool,
/// An API endpoint's models, kept per endpoint (its base URL) for the next connection.
models_cache: std::collections::HashMap<String, Vec<acp::ModelInfo>>,
/// 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<acp::ConfigOption>) {
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<String> = tail.into_iter().rev().take(6).collect::<Vec<_>>().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<Val
if let Some(m) = p.get("model").and_then(Value::as_str) {
set_model(app, m);
}
if let Some(option) = p.get("config_option") {
let id = option.get("id").and_then(Value::as_str).ok_or("config_option.id must be a string")?;
let value = option.get("value").and_then(Value::as_str).ok_or("config_option.value must be a string")?;
set_config_option(app, id, value);
}
if !shown(app) {
return Ok(status(app));
}
@@ -2123,6 +2235,8 @@ pub fn status(app: &SolveApp) -> 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,
+223 -3
View File
@@ -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<String> = 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::<Vec<_>>()
.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::<Vec<_>>()
.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) {
+364
View File
@@ -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<acp::ConfigOption> {
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::<Vec<_>>(), ["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<Value> = 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::<Vec<_>>(),
["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() {
+19 -10
View File
@@ -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
+9
View File
@@ -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.
+11 -1
View File
@@ -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