mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* 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:
@@ -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(¬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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user