Agent chat: queue messages while the agent works, Send now, Stop, New chat, and a model picker (#1566, #1571) (#1566)

Thanks @bherbruck.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-15 23:58:28 -04:00
1 parent fdb059cfed
commit baf44657c1
16 files changed
+1293 -33

No files matched your search

+59
View File
@@ -115,6 +115,40 @@ pub fn modes(session: &Value) -> Option<(String, Vec<(String, String)>)> {
(!available.is_empty()).then_some((current, available))
}
/// One model an agent offers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub description: String,
}
/// The agent's models in a `session/new` or `session/load` reply (`models`: `currentModelId` and
/// `availableModels: [{modelId, name, description?}]`): the current one and each model. `None`
/// when the agent reports none.
pub fn models(session: &Value) -> Option<(String, Vec<ModelInfo>)> {
let m = session.get("models")?;
let current = m.get("currentModelId").and_then(Value::as_str).unwrap_or("").to_string();
let available: Vec<ModelInfo> = m
.get("availableModels")?
.as_array()?
.iter()
.take(200)
.filter_map(|x| {
let id = x.get("modelId").or_else(|| x.get("id"))?.as_str()?.to_string();
let name = x.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or(&id).to_string();
let description = x.get("description").and_then(Value::as_str).unwrap_or("").to_string();
Some(ModelInfo { id, name, description })
})
.collect();
(!available.is_empty()).then_some((current, available))
}
/// `session/set_model` parameters.
pub fn set_model_params(session: &str, model: &str) -> Value {
json!({"sessionId": session, "modelId": model})
}
/// Whether the agent takes pictures in a message (`agentCapabilities.promptCapabilities.image`).
pub fn takes_images(init: &Value) -> bool {
init.pointer("/agentCapabilities/promptCapabilities/image").and_then(Value::as_bool).unwrap_or(false)
@@ -245,6 +279,8 @@ pub enum Incoming {
AuthStatus(String),
/// The agent switched to another of its modes.
ModeChanged(String),
/// The agent switched to another model.
ModelChanged(String),
/// A request we don't serve (file system, terminal, …): answer "method not found".
Unsupported { rpc: Value, method: String },
/// Anything else (mode and command lists, usage): nothing to show.
@@ -351,6 +387,10 @@ fn update(u: &Value) -> Incoming {
Some(m) => Incoming::ModeChanged(m),
None => Incoming::Ignored,
},
Some("current_model_update") => match s("currentModelId").or_else(|| s("modelId")) {
Some(m) => Incoming::ModelChanged(m),
None => Incoming::Ignored,
},
Some("plan") => Incoming::Plan(
u.get("entries")
.and_then(Value::as_array)
@@ -496,4 +536,23 @@ mod tests {
let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() };
assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…'));
}
#[test]
fn the_models_of_a_session_reply_are_read_like_its_modes() {
let v = json!({"sessionId": "s", "models": {"currentModelId": "sonnet", "availableModels": [
{"modelId": "opus", "name": "Opus", "description": "Most capable"},
{"modelId": "sonnet", "name": "Sonnet"},
{"modelId": "haiku"},
{"name": "no id"}]}});
let (current, list) = models(&v).unwrap();
assert_eq!(current, "sonnet");
let names: Vec<_> = list.iter().map(|m| (m.id.as_str(), m.name.as_str(), m.description.as_str())).collect();
assert_eq!(names, [("opus", "Opus", "Most capable"), ("sonnet", "Sonnet", ""), ("haiku", "haiku", "")]);
// An agent without models, or with an empty list, reports none.
assert!(models(&json!({"sessionId": "s"})).is_none());
assert!(models(&json!({"models": {"currentModelId": "x", "availableModels": []}})).is_none());
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"}));
}
}
+20 -1
View File
@@ -17,6 +17,7 @@ enum Pending {
Prompt,
Authenticate,
SetMode,
SetModel,
}
/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port
@@ -90,6 +91,9 @@ impl AcpBackend {
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::Ready { agent: self.agent.clone(), images: self.images });
}
None => out.push(Event::Error("session/new: no sessionId".into())),
@@ -100,7 +104,7 @@ impl AcpBackend {
}
// Signed in: try the session again.
(Pending::Authenticate, Ok(_)) => self.new_session(),
(Pending::SetMode, Ok(_)) => {}
(Pending::SetMode | Pending::SetModel, Ok(_)) => {}
(what, Err((code, message))) => {
let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth");
if auth {
@@ -128,6 +132,7 @@ impl AcpBackend {
}
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::Unsupported { rpc, method } => {
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`")));
}
@@ -178,6 +183,14 @@ impl ChatBackend for AcpBackend {
}
}
fn new_session(&mut self) {
if self.pending.values().any(|p| *p == Pending::Prompt) {
self.cancel();
}
self.session = None;
self.new_session();
}
fn answer_permission(&mut self, key: u64, option: Option<&str>) {
if let Some(rpc) = self.permissions.remove(&key) {
self.proc.send(&acp::permission_reply(rpc, option));
@@ -194,6 +207,12 @@ impl ChatBackend for AcpBackend {
}
}
fn set_model(&mut self, model: &str) {
if let Some(s) = self.session.clone() {
self.request(Pending::SetModel, "session/set_model", acp::set_model_params(&s, model));
}
}
fn pid(&self) -> Option<u32> {
self.proc.pid()
}
+236 -10
View File
@@ -14,6 +14,7 @@ use std::time::Duration;
use serde_json::{Value, json};
use super::acp::ModelInfo;
use super::backend::{ChatBackend, Event, HttpFn, HttpRequest, PromptPart, StartContext, ToolStatus};
use super::process::{AgentProcess, FromAgent, ProcessHandle};
@@ -27,6 +28,8 @@ const MAX_HISTORY: usize = 240;
const REQUEST_TIMEOUT_S: u64 = 300;
/// How long one tool call may take (it may wait for the user's Allow).
const TOOL_TIMEOUT: Duration = Duration::from_secs(330);
/// How often a waiting request or tool call looks at the cancel flag.
const CANCEL_POLL: Duration = Duration::from_millis(40);
/// Tools the model is not offered.
const HIDDEN_TOOLS: &[&str] = &["crash_reports"];
@@ -41,6 +44,8 @@ pub struct ApiConfig {
enum Cmd {
Prompt(Vec<PromptPart>),
NewChat,
SetModel(String),
}
pub struct ApiBackend {
@@ -90,6 +95,18 @@ impl ChatBackend for ApiBackend {
fn cancel(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
fn set_model(&mut self, model: &str) {
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::SetModel(model.to_string()));
}
}
fn new_session(&mut self) {
// A running turn is cancelled; the worker resets the history once it has ended.
self.cancel.store(true, Ordering::Relaxed);
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::NewChat);
}
}
fn answer_permission(&mut self, _key: u64, _option: Option<&str>) {}
fn authenticate(&mut self, _method: &str) {}
fn pid(&self) -> Option<u32> {
@@ -134,9 +151,25 @@ impl Worker {
}
self.history.push(json!({"role": "system", "content": super::acp::SYSTEM_NOTE}));
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
// The endpoint's models for the picker; an endpoint that does not list them shows none.
if let Ok(list) = model_list(&self.cfg.http, &self.cfg.base_url, self.cfg.key.as_deref()) {
let mut available = list;
if !self.cfg.model.is_empty() && !available.iter().any(|m| m.id == self.cfg.model) {
available.insert(0, ModelInfo { id: self.cfg.model.clone(), name: self.cfg.model.clone(), description: String::new() });
}
if !available.is_empty() {
self.emit(Event::Models { current: self.cfg.model.clone(), available });
}
}
for cmd in cmds {
match cmd {
Cmd::Prompt(parts) => self.turn(parts),
Cmd::SetModel(m) => self.cfg.model = m,
Cmd::NewChat => {
self.history.truncate(1);
self.cancel.store(false, Ordering::Relaxed);
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
}
}
}
if let Some(mut m) = self.mcp.take() {
@@ -152,7 +185,12 @@ impl Worker {
m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let deadline = std::time::Instant::now() + TOOL_TIMEOUT;
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
// Wait in short slices so Stop and Send now do not wait for a slow tool: its late
// answer is skipped later (its id no longer matches).
if self.cancel.load(Ordering::Relaxed) {
return Err("cancelled by the user".into());
}
let left = deadline.saturating_duration_since(std::time::Instant::now()).min(CANCEL_POLL);
match m.from_agent.recv_timeout(left) {
Ok(FromAgent::Line(l)) => {
let Ok(v) = serde_json::from_str::<Value>(l.trim()) else { continue };
@@ -165,6 +203,7 @@ impl Worker {
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
}
Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => {}
Err(_) => return Err("the SolidCraft tools did not answer".into()),
}
}
@@ -196,12 +235,25 @@ impl Worker {
self.history.remove(1);
}
for _ in 0..MAX_REQUESTS_PER_TURN {
// The history as of here is complete (every tool call answered): a cancelled request
// goes back to it, so no half-finished round is left to confuse the next message.
let sound = self.history.len();
if self.cancel.load(Ordering::Relaxed) {
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
let reply = match self.request() {
Ok(_) if self.cancel.load(Ordering::Relaxed) => {
self.history.truncate(sound);
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
Ok(r) => r,
Err(_) if self.cancel.load(Ordering::Relaxed) => {
self.history.truncate(sound);
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
Err(e) => {
self.emit(Event::Error(e));
self.emit(Event::TurnEnded { reason: "error".into() });
@@ -282,7 +334,23 @@ impl Worker {
body: Some(body.to_string().into_bytes()),
timeout_s: REQUEST_TIMEOUT_S,
};
let (status, bytes) = (self.cfg.http)(&req)?;
// The request runs on a thread of its own so that Stop does not wait for the model: a
// cancelled one is abandoned (the host's own timeout ends it) and its answer dropped.
let (tx, rx) = channel();
let http = self.cfg.http.clone();
std::thread::spawn(move || {
let _ = tx.send(http(&req));
});
let (status, bytes) = loop {
if self.cancel.load(Ordering::Relaxed) {
return Err("cancelled by the user".into());
}
match rx.recv_timeout(CANCEL_POLL) {
Ok(r) => break r?,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => return Err("the request ended without an answer".into()),
}
};
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
@@ -413,6 +481,32 @@ fn tool_title(name: &str, args: &Value) -> String {
}
}
/// `GET {base_url}/models`: the model ids, in the order the endpoint lists them.
fn model_ids(http: &HttpFn, base_url: &str, headers: Vec<(String, String)>) -> Result<Vec<String>, String> {
let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 };
let (status, bytes) = http(&req)?;
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
}
Ok(v.get("data")
.and_then(Value::as_array)
.map(|a| a.iter().take(500).filter_map(|m| m.get("id").and_then(Value::as_str).map(String::from)).collect())
.unwrap_or_default())
}
/// The endpoint's models for the picker.
pub fn model_list(http: &HttpFn, base_url: &str, key: Option<&str>) -> Result<Vec<ModelInfo>, String> {
let mut headers = Vec::new();
if let Some(k) = key {
if !super::agents::key_safe_for(base_url) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
}
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
}
Ok(model_ids(http, base_url, headers)?.into_iter().map(|id| ModelInfo { name: id.clone(), id, description: String::new() }).collect())
}
/// Ask an endpoint for its models (Test connection): how many, and whether `model` is one.
pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result<String, String> {
let mut headers = Vec::new();
@@ -422,14 +516,8 @@ pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &s
}
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
}
let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 };
let (status, bytes) = http(&req)?;
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
}
let ids: Vec<&str> =
v.get("data").and_then(Value::as_array).map(|a| a.iter().filter_map(|m| m.get("id").and_then(Value::as_str)).collect()).unwrap_or_default();
let ids = model_ids(http, base_url, headers)?;
let ids: Vec<&str> = ids.iter().map(String::as_str).collect();
let found = model.is_empty() || ids.contains(&model);
Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &ids.len().to_string()), ("model", model)]))
}
@@ -469,6 +557,9 @@ mod tests {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
let a = answers.lock().unwrap().pop().unwrap_or(json!({}));
Ok((200, a.to_string().into_bytes()))
@@ -494,6 +585,138 @@ mod tests {
b.shutdown(true);
}
/// Cancel while a model request is in flight ends the turn at once (the request is
/// abandoned, its late answer dropped), leaves no half-finished round in the history and
/// the next message goes through.
#[test]
fn cancelling_a_request_ends_the_turn_and_the_next_message_still_works() {
let (release_tx, release_rx) = channel::<()>();
let release_rx = std::sync::Mutex::new(release_rx);
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
let n = {
let mut l = log.lock().unwrap();
l.push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
l.len()
};
if n == 1 {
// The first request hangs until the test lets it go, then answers late.
let _ = release_rx.lock().unwrap().recv_timeout(Duration::from_secs(20));
return Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Too late."}}]}).to_string().into_bytes()));
}
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Steered."}}]}).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
let wait = |b: &mut ApiBackend, events: &mut Vec<Event>| {
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
};
b.prompt(&[PromptPart::Text("long job".into())]);
let start = std::time::Instant::now();
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(10));
}
b.cancel();
let mut events = Vec::new();
wait(&mut b, &mut events);
assert!(start.elapsed() < Duration::from_secs(8), "the turn ended while the request still hung");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason == "cancelled"), "{events:?}");
assert!(!events.iter().any(|e| matches!(e, Event::Text { .. } | Event::Error(_))), "{events:?}");
// The next message is answered, and the late answer of the first never shows up.
b.prompt(&[PromptPart::Text("[interrupted] do this instead".into())]);
let mut events = Vec::new();
wait(&mut b, &mut events);
let _ = release_tx.send(());
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Steered.")), "{events:?}");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
let sent = seen.lock().unwrap();
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
assert_eq!(roles, ["system", "user", "user"], "no reply or tool call of the cancelled round is left");
b.shutdown(true);
}
/// New chat resets the model's history to the system note: the next request carries nothing
/// of the conversation before it.
#[test]
fn a_new_chat_resets_the_history_to_the_system_note() {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
let run = |b: &mut ApiBackend, f: &dyn Fn(&mut ApiBackend), want: usize| {
f(b);
let mut events = Vec::new();
let start = std::time::Instant::now();
while events.iter().filter(|e| matches!(e, Event::TurnEnded { .. } | Event::Ready { .. })).count() < want
&& start.elapsed() < Duration::from_secs(10)
{
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
};
run(&mut b, &|b| b.prompt(&[PromptPart::Text("one".into())]), 2);
run(&mut b, &|b| b.new_session(), 1);
run(&mut b, &|b| b.prompt(&[PromptPart::Text("two".into())]), 1);
let sent = seen.lock().unwrap();
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
assert_eq!(roles, ["system", "user"]);
assert_eq!(sent[1]["messages"][1]["content"], "two");
b.shutdown(true);
}
/// The endpoint's `/models` fills the picker (with the current model first if it is not
/// listed), and choosing one changes the model of the next request.
#[test]
fn the_endpoint_lists_models_and_a_choice_changes_the_next_request() {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
assert!(r.url.ends_with("/models"));
return Ok((200, json!({"data": [{"id": "big"}, {"id": "small"}]}).to_string().into_bytes()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "local".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
let mut events = Vec::new();
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::Models { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
let Some(Event::Models { current, available }) = events.iter().find(|e| matches!(e, Event::Models { .. })) else { panic!("{events:?}") };
assert_eq!(current, "local");
assert_eq!(available.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["local", "big", "small"]);
b.set_model("big");
b.prompt(&[PromptPart::Text("hi".into())]);
let start = std::time::Instant::now();
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(seen.lock().unwrap()[0]["model"], "big");
b.shutdown(true);
}
#[test]
fn tool_arguments_are_an_object_or_an_error() {
assert_eq!(tool_arguments(None), Ok(json!({})));
@@ -515,6 +738,9 @@ mod tests {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, answers.lock().unwrap().pop().unwrap_or(json!({})).to_string().into_bytes()))
});
+7
View File
@@ -40,6 +40,8 @@ pub enum Event {
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)> },
/// Its models (empty `available`: only the current one changed).
Models { current: String, available: Vec<super::acp::ModelInfo> },
/// The message is answered (`reason`: why it stopped, empty for a normal end).
TurnEnded { reason: String },
/// Something failed; the backend is still running unless [`Event::Exited`] follows.
@@ -69,10 +71,15 @@ pub trait ChatBackend {
fn prompt(&mut self, parts: &[PromptPart]);
/// Stop the message being answered; open permission questions count as cancelled.
fn cancel(&mut self);
/// Start a fresh conversation on the running backend (no restart); [`Event::Ready`] follows.
/// A message being answered is cancelled first.
fn new_session(&mut self);
/// Answer an [`Event::Permission`] with one of its options (`None`: cancelled).
fn answer_permission(&mut self, key: u64, option: Option<&str>);
/// Ask the backend to sign in by itself with one of its methods (it opens the browser).
fn authenticate(&mut self, method: &str);
/// Use one of the models it reported in [`Event::Models`].
fn set_model(&mut self, _model: &str) {}
/// Switch to one of the modes it reported in [`Event::Modes`].
fn set_mode(&mut self, _mode: &str) {}
/// Its process, for status and tests.
+259 -7
View File
@@ -145,6 +145,13 @@ struct Turn {
label: String,
}
/// A message typed while the agent works: shown in the transcript as "Queued" until it is sent.
#[derive(Clone, Debug, PartialEq)]
pub struct Pending {
pub text: String,
pub image: bool,
}
/// A request held for Allow or Deny.
struct Held {
id: u64,
@@ -171,6 +178,25 @@ pub struct AgentChat {
auth_status: Option<String>,
/// A message typed before the agent was ready.
queued: Option<(String, bool)>,
/// Messages typed while the agent works, in order. They go as one prompt when the turn ends
/// (or at once with Send now); after Stop they wait for Send.
pub pending: Vec<Pending>,
/// The queued message being edited, and its text so far.
pub pending_edit: Option<(usize, String)>,
/// Send now was pressed: the queue goes as soon as the cancelled turn has ended.
steering: bool,
/// The turn was stopped: its queue is not sent by itself.
stopped: bool,
/// Why the last turn ended.
ended: Option<String>,
/// New chat while a message ran: its late end is not reported.
discard_end: bool,
/// The mode to put back when the fresh session is ready.
restore_mode: Option<String>,
/// New chat is waiting for the user's yes (a message is running or queued).
pub confirm_new: bool,
/// Where the panel was drawn last (for its shortcuts).
pub panel_rect: Option<egui::Rect>,
/// The last message sent, kept to send again after a sign-in.
last: Option<(String, bool)>,
/// The command the running agent was started with.
@@ -182,6 +208,13 @@ pub struct AgentChat {
/// The agent's own modes, and the current one.
modes: Vec<(String, String)>,
mode: String,
/// The models the agent offers (none: no picker), and the current one.
pub models: Vec<acp::ModelInfo>,
pub model: String,
/// 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).
running_endpoint: String,
backend: Option<Box<dyn ChatBackend>>,
turn: Option<Turn>,
held: Vec<Held>,
@@ -227,6 +260,7 @@ impl AgentChat {
}
fn stop_backend(&mut self, wait: bool) {
self.steering = false;
if let Some(mut b) = self.backend.take() {
b.cancel();
b.shutdown(wait);
@@ -275,6 +309,14 @@ impl AgentChat {
self.images = images;
self.state = Some(State::Ready);
self.picking = Some(false);
// Keep the mode across a new chat.
if let Some(m) = self.restore_mode.take()
&& self.modes.iter().any(|(id, _)| *id == m)
&& let Some(b) = self.backend.as_mut()
{
b.set_mode(&m);
self.mode = m;
}
}
Event::Text { thought, text } => {
if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut()
@@ -320,6 +362,15 @@ impl AgentChat {
}
self.mode = current;
}
Event::Models { current, available } => {
if !available.is_empty() {
if !self.running_endpoint.is_empty() {
self.models_cache.insert(self.running_endpoint.clone(), available.clone());
}
self.models = available;
}
self.model = current;
}
Event::AuthRequired { methods } => {
self.auth = methods;
self.state = Some(State::NeedsAuth);
@@ -334,6 +385,10 @@ impl AgentChat {
if self.state() == State::Busy {
self.state = Some(State::Ready);
}
self.ended = Some(reason.clone());
if std::mem::take(&mut self.discard_end) {
return Some(Outcome::TurnEnd);
}
if !matches!(reason.as_str(), "" | "auth" | "error") {
self.notice(format!("({reason})"), false);
}
@@ -347,6 +402,7 @@ impl AgentChat {
}
Event::Exited { tail } => {
self.state = Some(State::Exited);
self.steering = false;
let last: Vec<String> = tail.into_iter().rev().take(6).collect::<Vec<_>>().into_iter().rev().collect();
let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) };
self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true);
@@ -468,6 +524,14 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) {
chat.running_id = entry.id.clone();
chat.modes.clear();
chat.mode.clear();
chat.models.clear();
chat.model.clear();
chat.running_endpoint = if entry.kind == agents::Kind::Api { entry.base_url.clone() } else { String::new() };
// An endpoint's models from the last time, until the new list arrives.
if let Some(list) = chat.models_cache.get(&chat.running_endpoint) {
chat.models = list.clone();
chat.model = entry.model.clone();
}
chat.state = Some(State::Starting);
chat.auth.clear();
chat.auth_status = None;
@@ -496,17 +560,44 @@ pub fn shutdown(app: &mut SolveApp) {
app.agent_chat.stop_backend(true);
}
/// Send what the user typed: now if the agent is ready, else once it is (starting it if needed).
/// Most messages waiting in the queue.
const MAX_PENDING: usize = 20;
/// Said to the agent before a message that interrupted it, so it knows its last step may be
/// incomplete.
const INTERRUPT_NOTE: &str = "[The user interrupted your previous step.]";
/// Send what the user typed: now if the agent is ready, queued behind the running message if it
/// is working, else once it is ready (starting it if needed).
pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
let text: String = text.chars().take(MAX_PROMPT).collect();
if text.trim().is_empty() {
return;
}
let image = app.agent_chat.attach_view && app.agent_chat.images;
if app.agent_chat.state() == State::Busy {
let chat = &mut app.agent_chat;
if chat.pending.len() >= MAX_PENDING {
chat.notice(tr("ui.agent_chat_queue_full"), false);
} else {
chat.pending.push(Pending { text, image });
}
return;
}
// Messages left waiting by Stop go first, in one prompt with this one.
if !app.agent_chat.pending.is_empty() {
app.agent_chat.pending.push(Pending { text, image });
send_queue(app, ctx, false);
return;
}
submit(app, ctx, text, image, false);
}
/// Put `text` in the conversation and send it (`interrupted`: the agent is told it cut in on its
/// previous step).
fn submit(app: &mut SolveApp, ctx: &egui::Context, text: String, image: bool, interrupted: bool) {
app.agent_chat.push(Item::User { text: text.clone(), image });
match app.agent_chat.state() {
State::Ready => start_turn(app, text, image),
State::Busy => app.agent_chat.notice(tr("ui.agent_chat_wait_turn"), false),
State::Ready => start_turn_with(app, text, image, interrupted.then_some(INTERRUPT_NOTE)),
s => {
app.agent_chat.queued = Some((text, image));
#[cfg(not(target_arch = "wasm32"))]
@@ -519,8 +610,105 @@ pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
}
}
/// Send the queued messages as one prompt, in order.
fn send_queue(app: &mut SolveApp, ctx: &egui::Context, interrupted: bool) {
let chat = &mut app.agent_chat;
chat.pending_edit = None;
if chat.pending.is_empty() {
return;
}
let all = std::mem::take(&mut chat.pending);
let image = all.iter().any(|p| p.image);
let text = all.into_iter().map(|p| p.text).collect::<Vec<_>>().join("\n\n");
let text: String = text.chars().take(MAX_PROMPT).collect();
submit(app, ctx, text, image, interrupted);
}
/// Send now: stop the running step and send the queue as the next prompt once the agent has said
/// it stopped (its note tells the agent it was interrupted). With nothing running, sends the queue.
pub fn send_now(app: &mut SolveApp, ctx: &egui::Context) {
if app.agent_chat.pending.is_empty() {
return;
}
if app.agent_chat.state() == State::Busy {
app.agent_chat.steering = true;
app.agent_chat.stopped = false;
interrupt(app);
} else {
send_queue(app, ctx, false);
}
}
/// New chat: stop what runs (as Stop does), clear the conversation and the queue, and start a
/// fresh session on the same agent (no restart); the agent and its mode stay, the design's undo
/// history is untouched.
pub fn new_chat(app: &mut SolveApp) {
let running = app.agent_chat.state() == State::Busy;
if running {
cancel(app);
}
decline_all(app, "the chat was cleared");
end_turn(app);
let chat = &mut app.agent_chat;
chat.confirm_new = false;
chat.items.clear();
chat.pending.clear();
chat.pending_edit = None;
chat.queued = None;
chat.last = None;
chat.steering = false;
chat.stopped = false;
chat.ended = None;
chat.discard_end = running;
if matches!(chat.state(), State::Ready | State::Busy) {
chat.restore_mode = Some(chat.mode.clone()).filter(|m| !m.is_empty());
chat.state = Some(State::Starting);
if let Some(b) = chat.backend.as_mut() {
b.new_session();
}
}
}
/// Whether New chat would throw something away that the user may want (so it asks first).
pub fn new_chat_needs_confirm(app: &SolveApp) -> bool {
app.agent_chat.state() == State::Busy || !app.agent_chat.pending.is_empty()
}
/// Take a queued message out of the queue.
pub fn remove_pending(app: &mut SolveApp, index: usize) {
let chat = &mut app.agent_chat;
if index < chat.pending.len() {
chat.pending.remove(index);
}
chat.pending_edit = None;
}
/// The turn ended: the queue goes on its own after a normal end, and after a Send now; not after
/// Stop, a sign-in request or an error.
fn after_turn(app: &mut SolveApp, ctx: &egui::Context) {
let reason = app.agent_chat.ended.take().unwrap_or_default();
let steering = std::mem::take(&mut app.agent_chat.steering);
if app.agent_chat.state() != State::Ready || app.agent_chat.pending.is_empty() {
return;
}
if steering && !matches!(reason.as_str(), "auth" | "error") {
send_queue(app, ctx, reason == "cancelled");
} else if !app.agent_chat.stopped && reason.is_empty() {
send_queue(app, ctx, false);
}
}
fn start_turn(app: &mut SolveApp, text: String, image: bool) {
let mut parts = vec![PromptPart::Text(text.clone())];
start_turn_with(app, text, image, None);
}
/// Start a message; `note` goes before the text for the agent, not into the conversation.
fn start_turn_with(app: &mut SolveApp, text: String, image: bool, note: Option<&str>) {
let sent = match note {
Some(n) => format!("{n}\n\n{text}"),
None => text.clone(),
};
let mut parts = vec![PromptPart::Text(sent)];
if image {
match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }),
@@ -532,13 +720,24 @@ fn start_turn(app: &mut SolveApp, text: String, image: bool) {
Some(Turn { start_doc: app.session.doc.clone(), start_undo: app.session.undo.len(), changes: 0, requests: 0, limit_told: false, label });
app.agent_chat.last = Some((text, image));
app.agent_chat.state = Some(State::Busy);
app.agent_chat.stopped = false;
app.agent_chat.steering = false;
app.agent_chat.ended = None;
if let Some(b) = app.agent_chat.backend.as_mut() {
b.prompt(&parts);
}
}
/// Ask the agent to stop the message it is on; requests waiting for approval are declined.
/// Stop: ask the agent to stop the message it is on; requests waiting for approval are declined.
/// Messages queued meanwhile stay queued (they wait for Send).
pub fn cancel(app: &mut SolveApp) {
app.agent_chat.steering = false;
app.agent_chat.stopped = true;
interrupt(app);
}
/// Ask the agent to stop the message it is on and decline what waits for approval.
fn interrupt(app: &mut SolveApp) {
decline_all(app, "the user stopped the message");
if let Some(b) = app.agent_chat.backend.as_mut() {
b.cancel();
@@ -775,10 +974,16 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) {
for e in events {
let ready = matches!(e, Event::Ready { .. });
match app.agent_chat.on_event(e) {
Some(Outcome::TurnEnd) => end_turn(app),
Some(Outcome::TurnEnd) => {
end_turn(app);
after_turn(app, ctx);
}
Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options),
None => {}
}
if ready {
apply_saved_model(app);
}
if ready && let Some((text, image)) = app.agent_chat.queued.take() {
start_turn(app, text, image);
}
@@ -804,6 +1009,37 @@ pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) {
app.agent_chat.detecting = Some(rx);
}
/// 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 !app.agent_chat.models.iter().any(|m| m.id == model) {
return;
}
if let Some(b) = app.agent_chat.backend.as_mut() {
b.set_model(model);
}
app.agent_chat.model = model.to_string();
let agent = app.agent_chat.running_id.clone();
if let Some(a) = app.preferences.agents.iter_mut().find(|a| a.id == agent && a.kind == agents::Kind::Api) {
a.model = model.to_string();
}
app.preferences.agent_models.insert(agent, model.to_string());
}
/// A new session is ready: put back the model last chosen for this agent, when it offers it.
fn apply_saved_model(app: &mut SolveApp) {
let chat = &app.agent_chat;
let Some(saved) = app.preferences.agent_models.get(&chat.running_id).cloned() else { return };
if saved != chat.model && chat.models.iter().any(|m| m.id == saved) {
set_model(app, &saved);
}
}
/// Whether the model picker shows: only for an agent that reports models.
pub fn models_shown(app: &SolveApp) -> bool {
!app.agent_chat.models.is_empty() && app.agent_chat.state().running()
}
/// Switch the agent to one of its own modes (the header's mode picker).
pub fn set_mode(app: &mut SolveApp, mode: &str) {
if app.agent_chat.modes.iter().any(|(id, _)| id == mode)
@@ -817,7 +1053,7 @@ pub fn set_mode(app: &mut SolveApp, mode: &str) {
/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, `open`,
/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `send`, `attach_view`,
/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held
/// request), `cancel`, `undo` (the newest message's changes); returns the panel's state.
/// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state.
pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Value, String> {
if let Some(e) = p.get("features").and_then(Value::as_bool) {
app.preferences.ai_features = e;
@@ -875,6 +1111,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if let Some(m) = p.get("mode").and_then(Value::as_str) {
set_mode(app, m);
}
if let Some(m) = p.get("model").and_then(Value::as_str) {
set_model(app, m);
}
if !shown(app) {
return Ok(status(app));
}
@@ -932,6 +1171,15 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if p.get("cancel").and_then(Value::as_bool) == Some(true) {
cancel(app);
}
if p.get("new_chat").and_then(Value::as_bool) == Some(true) {
new_chat(app);
}
if p.get("send_now").and_then(Value::as_bool) == Some(true) {
send_now(app, ctx);
}
if let Some(n) = p.get("remove_pending").and_then(Value::as_u64) {
remove_pending(app, n as usize);
}
if p.get("undo").and_then(Value::as_bool) == Some(true) {
let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { .. })) else { return Err("no changes to undo".into()) };
undo_changes(app, n);
@@ -976,6 +1224,8 @@ pub fn status(app: &SolveApp) -> Value {
"permissions": app.preferences.ai_permissions.id(),
"agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": if e.kind == agents::Kind::Api { "api" } else { "acp" }, "found": e.found, "version": e.version, "login": e.login})).collect::<Vec<_>>(),
"currentAgent": agents::current(app).map(|e| e.id),
"models": c.models.iter().map(|m| json!({"id": m.id, "name": m.name, "description": m.description})).collect::<Vec<_>>(),
"model": c.model,
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
"mode": c.mode,
"open": c.open,
@@ -990,6 +1240,8 @@ pub fn status(app: &SolveApp) -> Value {
"toolsPort": c.tools_port,
"pid": pid,
"waiting": c.waiting(),
"pending": c.pending.iter().map(|p| json!({"text": p.text, "image": p.image})).collect::<Vec<_>>(),
"stopped": c.stopped,
"turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})),
"detection": detection,
"items": items,
+188 -7
View File
@@ -58,6 +58,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
toggle(app);
}
// New chat, when the chat has focus (its input, or the pointer over the panel).
let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")))
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
request_new_chat(app);
}
if !app.agent_chat.open {
return;
}
@@ -81,13 +87,27 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6)))
.show(ui, |ui| body(app, ui, left));
crate::scenario::publish_rect("agent_chat:panel", r.response.rect);
app.agent_chat.panel_rect = Some(r.response.rect);
let w = r.response.rect.width();
if w.is_finite() && (MIN_WIDTH..=MAX_WIDTH).contains(&w) {
app.preferences.agent_chat_width = w;
}
}
/// A small drawn button: `kind` is `minimize`, `close` or `expand`.
/// New chat (when the chat has focus).
pub const NEW_CHAT_SHORTCUT: egui::KeyboardShortcut =
egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::L);
/// Clear the chat, asking first when something would be lost.
fn request_new_chat(app: &mut SolveApp) {
if new_chat_needs_confirm(app) {
app.agent_chat.confirm_new = true;
} else {
new_chat(app);
}
}
/// A small drawn button: `kind` is `minimize`, `close`, `stopturn` or `expand`.
fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
@@ -104,6 +124,15 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
}
"newchat" => {
// A speech bubble with a plus.
ui.painter().rect_stroke(Rect::from_center_size(c, vec2(14.0, 11.0)), 2.5, s, egui::StrokeKind::Inside);
ui.painter().line_segment([c + vec2(-2.5, 0.0), c + vec2(2.5, 0.0)], Stroke::new(1.2, t.text));
ui.painter().line_segment([c + vec2(0.0, -2.5), c + vec2(0.0, 2.5)], Stroke::new(1.2, t.text));
}
"stopturn" => {
ui.painter().rect_filled(Rect::from_center_size(c, vec2(9.0, 9.0)), 1.5, t.error);
}
_ => {}
}
crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center());
@@ -185,6 +214,16 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
app.preferences.agent_chat_minimized = true;
}
#[cfg(not(target_arch = "wasm32"))]
if glyph_button(
ui,
"newchat",
&format!("{} ({})", tr("ui.agent_chat_new"), if cfg!(target_os = "macos") { "Cmd+Shift+L" } else { "Ctrl+Shift+L" }),
)
.clicked()
{
request_new_chat(app);
}
#[cfg(not(target_arch = "wasm32"))]
{
let b = if state.running() {
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip"))
@@ -261,6 +300,23 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
refresh(app, ui.ctx());
}
let state = app.agent_chat.state();
if app.agent_chat.confirm_new {
let t = Tokens::get();
egui::Frame::NONE.fill(t.accent_soft).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(tr("ui.agent_chat_new_confirm"));
ui.horizontal(|ui| {
let yes = ui.button(tr("ui.agent_chat_new_yes"));
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
if yes.clicked() {
new_chat(app);
}
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
app.agent_chat.confirm_new = false;
}
});
});
}
picker(app, ui);
if state == State::NeedsAuth {
auth_box(app, ui);
@@ -275,21 +331,38 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.separator();
let busy = state == State::Busy;
let edit = egui::TextEdit::multiline(&mut app.agent_chat.input)
.hint_text(tr("ui.agent_chat_placeholder"))
.hint_text(tr(if busy { "ui.agent_chat_placeholder_busy" } else { "ui.agent_chat_placeholder" }))
.desired_rows(2)
.desired_width(f32::INFINITY)
.id_salt("sc_agent_chat_input");
// Enter sends (queues while the agent works); Ctrl/Cmd+Enter sends now; Esc stops. They are
// read before the field sees them so no newline is typed.
let focused = ui.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")));
let (steer, esc) = if focused {
ui.input_mut(|i| (i.consume_key(egui::Modifiers::COMMAND, egui::Key::Enter), busy && i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))
} else {
(false, false)
};
let r = ui.add(edit);
crate::scenario::publish_rect("agent_chat:input", r.rect);
let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
if esc {
cancel(app);
}
ui.horizontal(|ui| {
let send_b = ui.add_enabled(!busy, egui::Button::new(tr("ui.agent_chat_send")));
let send_b = ui.button(tr(if busy { "ui.agent_chat_queue" } else { "ui.agent_chat_send" }));
crate::scenario::publish_handle("agent_chat:send", send_b.rect.center());
if (send_b.clicked() || (enter && !busy)) && !app.agent_chat.input.trim().is_empty() {
if (send_b.clicked() || enter || steer) && !app.agent_chat.input.trim().is_empty() {
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
send(app, ui.ctx(), text);
if steer {
send_now(app, ui.ctx());
}
} else if steer {
// Nothing typed: Ctrl+Enter sends what is already queued.
send_now(app, ui.ctx());
}
if busy && ui.button(tr("ui.agent_chat_cancel")).clicked() {
if busy && glyph_button(ui, "stopturn", tr("ui.agent_chat_stop_turn_tip")).clicked() {
cancel(app);
}
// A picture of the view goes with the message to agents that take pictures.
@@ -350,7 +423,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|| 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 w = (ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 120.0 }).clamp(120.0, 260.0);
let models = models_shown(app);
let w =
(ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } - if models { 146.0 } else { 0.0 }).clamp(90.0, 260.0);
let mut picked: Option<String> = None;
let mut add = false;
let combo = egui::ComboBox::from_id_salt("sc_agent_switch").width(w).selected_text(RichText::new(label).strong()).show_ui(ui, |ui| {
@@ -405,6 +480,27 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
set_mode(app, &m);
}
}
// The agent's models (ACP `models`, or an endpoint's list): shown only when it reports some.
if models {
let list = app.agent_chat.models.clone();
let cur = list.iter().find(|m| m.id == app.agent_chat.model).map_or_else(|| app.agent_chat.model.clone(), |m| m.name.clone());
let mut pick: Option<String> = None;
let r = egui::ComboBox::from_id_salt("sc_agent_model").width(140.0).truncate().selected_text(cur).show_ui(ui, |ui| {
for m in &list {
let row = ui.selectable_label(m.id == app.agent_chat.model, &m.name);
let row = if m.description.is_empty() { row } else { row.on_hover_text(&m.description) };
crate::scenario::publish_handle(&format!("agent_chat:model:{}", m.id), row.rect.center());
if row.clicked() {
pick = Some(m.id.clone());
}
}
});
crate::scenario::publish_handle("agent_chat:model", r.response.rect.center());
r.response.on_hover_text(tr("ui.agent_chat_model_tip"));
if let Some(m) = pick {
set_model(app, &m);
}
}
});
if let Some(a) = &app.agent_chat.auth_status {
ui.label(RichText::new(a).color(t.text_dim).size(11.0));
@@ -641,12 +737,97 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
}
ui.add_space(2.0);
}
if app.agent_chat.items.is_empty() {
let (mut send_now_clicked, mut send_queue_clicked) = (false, false);
let mut remove: Option<usize> = None;
let mut edit_done: Option<(usize, Option<String>)> = None;
let busy = app.agent_chat.state() == State::Busy;
let stopped = app.agent_chat.stopped;
let editing = app.agent_chat.pending_edit.clone();
let mut start_edit: Option<(usize, String)> = None;
let mut edit_buf = editing.as_ref().map(|(_, b)| b.clone());
for (n, p) in app.agent_chat.pending.iter().enumerate() {
ui.add_space(6.0);
egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" };
ui.label(RichText::new(tr(tag)).color(t.accent).strong().size(11.0));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let x = ui.add(egui::Button::new(RichText::new("×").size(14.0)).frame(false)).on_hover_text(tr("ui.agent_chat_remove"));
crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center());
if x.clicked() {
remove = Some(n);
}
if editing.as_ref().is_none_or(|(i, _)| *i != n) {
let e = ui.add(egui::Button::new(RichText::new(tr("ui.agent_chat_edit")).size(11.5)).frame(false));
crate::scenario::publish_handle(&format!("agent_chat:pending_edit:{n}"), e.rect.center());
if e.clicked() {
start_edit = Some((n, p.text.clone()));
}
}
});
});
match (&editing, edit_buf.as_mut()) {
(Some((i, _)), Some(buf)) if *i == n => {
let te = ui.add(egui::TextEdit::multiline(buf).desired_rows(2).desired_width(f32::INFINITY));
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
ui.horizontal(|ui| {
if ui.button(tr("ui.agent_chat_save")).clicked() {
edit_done = Some((n, Some(buf.clone())));
}
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
edit_done = Some((n, None));
}
});
}
_ => {
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
if p.image {
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0));
}
}
}
});
}
if !app.agent_chat.pending.is_empty() && editing.is_none() {
ui.horizontal(|ui| {
if busy {
let b = ui.button(tr("ui.agent_chat_send_now")).on_hover_text(tr("ui.agent_chat_send_now_tip"));
crate::scenario::publish_handle("agent_chat:send_now", b.rect.center());
send_now_clicked = b.clicked();
} else {
let b = ui.add(egui::Button::new(tr("ui.agent_chat_send")).fill(t.accent_soft));
crate::scenario::publish_handle("agent_chat:send_queue", b.rect.center());
send_queue_clicked = b.clicked();
}
});
}
if let Some(start) = start_edit {
app.agent_chat.pending_edit = Some(start);
} else if let (Some(buf), Some(slot)) = (edit_buf, app.agent_chat.pending_edit.as_mut()) {
slot.1 = buf;
}
if app.agent_chat.items.is_empty() && app.agent_chat.pending.is_empty() {
ui.add_space(12.0);
ui.label(RichText::new(tr("ui.agent_chat_intro")).color(t.text_dim));
ui.add_space(6.0);
ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(11.0));
}
if let Some((n, text)) = edit_done {
if let (Some(text), Some(p)) = (text.filter(|t| !t.trim().is_empty()), app.agent_chat.pending.get_mut(n)) {
p.text = text;
}
app.agent_chat.pending_edit = None;
}
if let Some(n) = remove {
remove_pending(app, n);
}
if send_now_clicked {
send_now(app, ui.ctx());
}
if send_queue_clicked {
send_now(app, ui.ctx());
}
if let Some((n, o)) = answer {
answer_permission(app, n, o.as_deref());
}
+453
View File
@@ -303,3 +303,456 @@ fn the_agents_own_questions_are_answered_by_the_mode_or_asked() {
on_permission(&mut app, 3, "Run ls".into(), opts());
assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "remembered");
}
// ---- Messages typed while the agent works ----
use std::sync::Mutex;
/// A backend that records what the panel asks of it, and reports what the test pushes.
#[derive(Clone, Default)]
struct Script {
calls: Arc<Mutex<Vec<String>>>,
events: Arc<Mutex<Vec<Event>>>,
}
impl Script {
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn report(&self, e: Event) {
self.events.lock().unwrap().push(e);
}
}
impl ChatBackend for Script {
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut *self.events.lock().unwrap())
}
fn prompt(&mut self, parts: &[PromptPart]) {
let text = parts.iter().find_map(|p| if let PromptPart::Text(t) = p { Some(t.clone()) } else { None }).unwrap_or_default();
self.calls.lock().unwrap().push(format!("prompt: {text}"));
}
fn cancel(&mut self) {
self.calls.lock().unwrap().push("cancel".into());
}
fn new_session(&mut self) {
self.calls.lock().unwrap().push("new_session".into());
}
fn set_model(&mut self, model: &str) {
self.calls.lock().unwrap().push(format!("set_model: {model}"));
}
fn answer_permission(&mut self, _: u64, _: Option<&str>) {}
fn authenticate(&mut self, _: &str) {}
fn shutdown(&mut self, _: bool) {}
}
/// An app whose agent is scripted and ready.
fn app_with_script() -> (SolveApp, Script, egui::Context) {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let script = Script::default();
app.agent_chat.backend = Some(Box::new(script.clone()));
app.agent_chat.state = Some(State::Ready);
(app, script, egui::Context::default())
}
fn ended(script: &Script, reason: &str) {
script.report(Event::TurnEnded { reason: reason.into() });
}
fn texts(app: &SolveApp) -> Vec<String> {
app.agent_chat.pending.iter().map(|p| p.text.clone()).collect()
}
/// While the agent works a message is queued, not dropped and not sent; it is not in the
/// transcript as a sent message, but waits as a pending one that can be edited and removed.
#[test]
fn a_message_typed_while_busy_is_queued() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "make a box".into());
assert_eq!(app.agent_chat.state(), State::Busy);
send(&mut app, &ctx, "then a cylinder".into());
send(&mut app, &ctx, " ".into());
send(&mut app, &ctx, "and a sphere".into());
assert_eq!(texts(&app), ["then a cylinder", "and a sphere"]);
assert_eq!(script.calls(), ["prompt: make a box"], "nothing else was sent");
let users = app.agent_chat.items.iter().filter(|i| matches!(i, Item::User { .. })).count();
assert_eq!(users, 1, "queued messages are not sent messages yet");
// Remove one; edit the other.
remove_pending(&mut app, 0);
assert_eq!(texts(&app), ["and a sphere"]);
app.agent_chat.pending[0].text = "and a cone".into();
assert_eq!(app.agent_chat.waiting(), 0);
assert!(status(&app)["pending"][0]["text"] == "and a cone");
}
/// When the turn ends normally the queue goes as the next prompt, all messages in order in one.
#[test]
fn the_queue_is_sent_when_the_turn_ends() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "first".into());
send(&mut app, &ctx, "second".into());
send(&mut app, &ctx, "third".into());
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: first", "prompt: second\n\nthird"]);
assert!(app.agent_chat.pending.is_empty());
assert_eq!(app.agent_chat.state(), State::Busy, "the next turn is under way");
assert!(matches!(app.agent_chat.items.last(), Some(Item::User { text, .. }) if text == "second\n\nthird"));
// Nothing queued: the end of the turn just ends it.
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.state(), State::Ready);
assert_eq!(script.calls().len(), 2);
}
/// Stop does not send the queue by itself; it stays, with a Send that sends it later.
#[test]
fn stop_keeps_the_queue() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "first".into());
send(&mut app, &ctx, "later".into());
cancel(&mut app);
assert_eq!(script.calls(), ["prompt: first", "cancel"]);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.state(), State::Ready);
assert_eq!(texts(&app), ["later"], "still queued");
assert_eq!(script.calls().len(), 2, "not sent");
// Send (the bubble's button) sends it, without the interruption note.
send_now(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: first", "cancel", "prompt: later"]);
assert!(app.agent_chat.pending.is_empty());
}
/// Send now: cancel first, the prompt only after the agent has ended the cancelled turn, with a
/// note that the previous step was interrupted (shown without it in the conversation).
#[test]
fn send_now_cancels_then_sends_with_a_note() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "build a gearbox".into());
send(&mut app, &ctx, "use 12 teeth instead".into());
send_now(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: build a gearbox", "cancel"], "cancel alone, the prompt waits for the end");
poll(&mut app, &ctx);
assert_eq!(script.calls().len(), 2);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: build a gearbox".to_string(), "cancel".into(), format!("prompt: {INTERRUPT_NOTE}\n\nuse 12 teeth instead")]);
assert!(matches!(app.agent_chat.items.last(), Some(Item::User { text, .. }) if text == "use 12 teeth instead"));
// Stop after Send now wins: nothing more is sent by itself.
send(&mut app, &ctx, "one more".into());
send_now(&mut app, &ctx);
cancel(&mut app);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(texts(&app), ["one more"]);
}
/// The steered message also goes after a turn that ended by itself just before the cancel arrived.
#[test]
fn send_now_after_the_turn_already_ended_still_sends() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "a".into());
send(&mut app, &ctx, "b".into());
send_now(&mut app, &ctx);
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(script.calls().last().map(String::as_str), Some("prompt: b"), "no note: nothing was interrupted");
}
/// What an interrupted turn changed undoes as one step, and the steered message starts its own.
#[test]
fn an_interrupted_turns_changes_undo_as_one_step() {
let (mut app, script, ctx) = app_with_script();
let (tx, rx) = channel();
app.agent_chat.tools_rx = Some(rx);
let before = app.session.doc.clone();
let depth = app.session.undo.len();
send(&mut app, &ctx, "two boxes".into());
let replies: Vec<_> = [0.0, 20.0].into_iter().map(|x| ask(&tx, "engine.execute", boxed(x))).collect();
frame(&mut app);
for r in replies {
assert_eq!(r.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true);
}
send(&mut app, &ctx, "stop, make it one".into());
send_now(&mut app, &ctx);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(app.session.undo.len(), depth + 1, "the interrupted message is one step");
let n = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { steps: 2, .. })).expect("its changes are listed");
// The steered message is on its own turn.
assert!(app.agent_chat.turn.as_ref().is_some_and(|t| t.label.contains("stop, make it one") && t.changes == 0));
undo_changes(&mut app, n);
assert!(Arc::ptr_eq(&app.session.doc, &before));
}
/// The same with a real agent process speaking ACP: `session/cancel` goes out, the agent answers
/// the held prompt with the cancelled stop reason, and only then does the queued text go as a new
/// `session/prompt`.
#[cfg(unix)]
#[test]
fn send_now_with_an_acp_agent_sends_cancel_then_the_prompt() {
let dir = std::env::temp_dir().join(format!("sc-agent-chat-steer-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("received.log");
// A fake agent: answers initialize and session/new; holds a prompt whose text says "slow"
// until it gets session/cancel (then answers it "cancelled"); answers other prompts at once.
let script = r#"
log="$1"; held=""
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":{"protocolVersion":1,"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;;
*'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1"}}\n' "$id" ;;
*'"method":"session/prompt"'*)
case "$line" in
*slow*) held="$id" ;;
*) printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" ;;
esac ;;
*'"method":"session/cancel"'*)
if [ -n "$held" ]; then printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"cancelled"}}\n' "$held"; held=""; fi ;;
esac
done
"#;
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
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.state = Some(State::Starting);
let ctx = egui::Context::default();
let until = |app: &mut SolveApp, what: &str, done: &dyn Fn(&SolveApp) -> bool| {
let t = std::time::Instant::now();
while !done(app) {
assert!(t.elapsed() < std::time::Duration::from_secs(20), "timed out waiting for {what}");
poll(app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(10));
}
};
until(&mut app, "the agent to be ready", &|a| a.agent_chat.state() == State::Ready);
send(&mut app, &ctx, "slow job".into());
send(&mut app, &ctx, "do this instead".into());
assert_eq!(texts(&app), ["do this instead"]);
send_now(&mut app, &ctx);
until(&mut app, "the steered turn to finish", &|a| a.agent_chat.state() == State::Ready && a.agent_chat.pending.is_empty());
let methods: Vec<String> = std::fs::read_to_string(&log)
.unwrap()
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter_map(|v| {
let m = v["method"].as_str()?.to_string();
let text = v.pointer("/params/prompt/0/text").and_then(Value::as_str).unwrap_or("");
Some(if text.is_empty() { m } else { format!("{m}: {text}") })
})
.collect();
assert_eq!(
methods,
[
"initialize".to_string(),
"session/new".into(),
"session/prompt: slow job".into(),
"session/cancel".into(),
format!("session/prompt: {INTERRUPT_NOTE}\n\ndo this instead"),
]
);
disconnect(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
/// New chat: the running message is stopped, the transcript and the queue are cleared, a fresh
/// session starts on the same agent, the mode stays, and the design's undo history is untouched.
#[test]
fn new_chat_clears_everything_and_keeps_the_agent_and_mode() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.modes = vec![("plan".into(), "Plan".into()), ("code".into(), "Code".into())];
app.agent_chat.mode = "plan".into();
app.agent_chat.agent_id = Some("claude".into());
let depth = app.session.undo.len();
assert!(!new_chat_needs_confirm(&app));
send(&mut app, &ctx, "first".into());
send(&mut app, &ctx, "queued".into());
assert!(new_chat_needs_confirm(&app), "a running message and a queue ask first");
new_chat(&mut app);
assert_eq!(script.calls(), ["prompt: first", "cancel", "new_session"]);
assert!(app.agent_chat.items.is_empty() && app.agent_chat.pending.is_empty());
assert_eq!(app.agent_chat.state(), State::Starting);
assert_eq!(app.agent_chat.agent_id.as_deref(), Some("claude"));
// The cancelled message's late end is not reported; the fresh session is ready again.
ended(&script, "cancelled");
script.report(Event::Modes { current: "code".into(), available: vec![] });
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.state(), State::Ready);
assert!(app.agent_chat.items.is_empty(), "no notice about the old message");
assert_eq!(app.agent_chat.mode, "plan", "the mode is kept");
assert_eq!(app.session.undo.len(), depth);
assert!(!new_chat_needs_confirm(&app));
}
/// With a real ACP agent, New chat sends `session/new` on the same process (no restart).
#[cfg(unix)]
#[test]
fn new_chat_with_an_acp_agent_opens_a_new_session_on_the_same_process() {
let dir = std::env::temp_dir().join(format!("sc-agent-chat-new-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("received.log");
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","version":"1"}}}\n' "$id" ;;
*'"method":"session/new"'*) n=$((n+1)); printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s%s"}}\n' "$id" "$n" ;;
*'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" ;;
esac
done
"#;
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
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.state = Some(State::Starting);
let ctx = egui::Context::default();
let until = |app: &mut SolveApp, done: &dyn Fn(&SolveApp) -> bool| {
let t = std::time::Instant::now();
while !done(app) {
assert!(t.elapsed() < std::time::Duration::from_secs(20), "timed out");
poll(app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(10));
}
};
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
send(&mut app, &ctx, "hello".into());
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
assert!(!app.agent_chat.items.is_empty());
let pid = app.agent_chat.backend.as_ref().and_then(|b| b.pid());
new_chat(&mut app);
assert!(app.agent_chat.items.is_empty());
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
assert_eq!(app.agent_chat.backend.as_ref().and_then(|b| b.pid()), pid, "same process");
send(&mut app, &ctx, "again".into());
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
let log_text = std::fs::read_to_string(&log).unwrap();
let methods: Vec<String> =
log_text.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).filter_map(|v| v["method"].as_str().map(String::from)).collect();
assert_eq!(methods, ["initialize", "session/new", "session/prompt", "session/new", "session/prompt"]);
assert!(log_text.lines().last().is_some_and(|l| l.contains("\"s2\"")), "the prompt goes to the new session: {log_text}");
disconnect(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
fn models_reply(current: &str) -> Event {
let m = |id: &str| acp::ModelInfo { id: id.into(), name: id.to_uppercase(), description: String::new() };
Event::Models { current: current.into(), available: vec![m("fast"), m("smart")] }
}
/// Choosing a model tells the agent, shows it as current and remembers it for this agent; a model
/// the agent did not offer is ignored.
#[test]
fn choosing_a_model_sets_it_and_remembers_it() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.running_id = "claude".into();
script.report(models_reply("fast"));
poll(&mut app, &ctx);
assert!(models_shown(&app));
assert_eq!(app.agent_chat.model, "fast");
set_model(&mut app, "smart");
set_model(&mut app, "nonsense");
assert_eq!(script.calls(), ["set_model: smart"]);
assert_eq!(app.agent_chat.model, "smart");
assert_eq!(app.preferences.agent_models.get("claude").map(String::as_str), Some("smart"));
assert_eq!(status(&app)["model"], "smart");
// The agent switching its own model is shown too.
script.report(Event::Models { current: "fast".into(), available: vec![] });
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.model, "fast");
assert_eq!(app.agent_chat.models.len(), 2, "the list stays");
}
/// The next session puts back the model chosen last time, when the agent offers it.
#[test]
fn the_remembered_model_is_set_on_the_next_session() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.running_id = "claude".into();
app.preferences.agent_models.insert("claude".into(), "smart".into());
script.report(models_reply("fast"));
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert_eq!(script.calls(), ["set_model: smart"]);
assert_eq!(app.agent_chat.model, "smart");
// Already on it, or no longer offered: nothing is sent.
script.report(models_reply("smart"));
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
app.preferences.agent_models.insert("claude".into(), "gone".into());
script.report(models_reply("fast"));
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert_eq!(script.calls().len(), 1);
}
/// 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() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.running_id = "other".into();
app.preferences.agent_models.insert("claude".into(), "smart".into());
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert!(!models_shown(&app));
assert!(script.calls().is_empty());
assert_eq!(status(&app)["models"], json!([]));
}
/// With a real ACP agent the choice goes out as `session/set_model` with the session and model.
#[cfg(unix)]
#[test]
fn set_model_goes_to_an_acp_agent() {
let dir = std::env::temp_dir().join(format!("sc-agent-chat-model-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("received.log");
let script = r#"
log="$1"
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","version":"1"}}}\n' "$id" ;;
*'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1","models":{"currentModelId":"fast","availableModels":[{"modelId":"fast","name":"Fast"},{"modelId":"smart","name":"Smart"}]}}}\n' "$id" ;;
*'"method":"session/set_model"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
esac
done
"#;
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
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.state = Some(State::Starting);
app.agent_chat.running_id = "fake".into();
app.preferences.agent_models.insert("fake".into(), "smart".into());
let ctx = egui::Context::default();
let t = std::time::Instant::now();
loop {
poll(&mut app, &ctx);
let logged = std::fs::read_to_string(&log).unwrap_or_default();
if logged.contains("session/set_model") {
break;
}
assert!(t.elapsed() < std::time::Duration::from_secs(20), "no set_model: {logged}");
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(models_shown(&app) && app.agent_chat.model == "smart", "the remembered model was put back");
let logged = std::fs::read_to_string(&log).unwrap();
let set: Value = logged.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).find(|v| v["method"] == "session/set_model").unwrap();
assert_eq!(set["params"], json!({"sessionId": "s1", "modelId": "smart"}));
// A choice made in the picker goes out too.
set_model(&mut app, "fast");
let t = std::time::Instant::now();
while std::fs::read_to_string(&log).unwrap_or_default().matches("session/set_model").count() < 2 {
assert!(t.elapsed() < std::time::Duration::from_secs(20));
std::thread::sleep(std::time::Duration::from_millis(10));
}
disconnect(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
+3
View File
@@ -348,6 +348,8 @@ pub struct Prefs {
/// How much the agent may do without asking (`ask`, `edits`, `all`), and per agent.
pub ai_permissions: crate::agent_chat::agents::Mode,
pub agent_permissions: std::collections::BTreeMap<String, crate::agent_chat::agents::Mode>,
/// The model last chosen in the chat, per agent: put back when the agent offers it again.
pub agent_models: std::collections::BTreeMap<String, String>,
/// "Always allow" answers: per agent, the tools it may use without asking.
pub agent_always_allow: std::collections::BTreeMap<String, Vec<String>>,
/// The side the panel docks on (`left` or `right`), its width, and whether it is folded to a strip.
@@ -435,6 +437,7 @@ impl Default for Prefs {
agent_default: String::new(),
ai_permissions: crate::agent_chat::agents::Mode::Edits,
agent_permissions: Default::default(),
agent_models: Default::default(),
agent_always_allow: Default::default(),
agent_chat_side: "right".into(),
agent_chat_width: 380.0,