Files
OneCAD/crates/ui-egui/src/agent_chat/acp_client.rs
T
bherbruckandClaude Opus 5.5 2d069ca186 In-app agent chat: your own coding agents over ACP, plus OpenAI-compatible endpoints (#1102)
* Agent chat prototype: drive an ACP agent the user already has from a side panel

A desktop-only chat panel (off by default) that starts a coding agent over the
Agent Client Protocol and gives it SolidCraft's MCP server, bridged to the open
window through the token-protected control channel with the default folder
limits. It finds installed agents (Claude Code and Codex adapters, Gemini CLI,
Copilot, Qwen, OpenCode, goose) with version and login state, streams text,
tool calls and plans, shows the agent's permission requests, and when an agent
isn't signed in shows its own login methods without handling any credential.

Design notes and findings: docs/design/in-app-agent-chat.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: say only what the end-to-end run showed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: gate, one undo per message, docking, process groups, discovery polish

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: Settings → AI, several agents with a switcher, OpenAI-compatible endpoints, permission modes, toolbar button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: translations, tool cards, Add agent dialog fixes, test agent saves on its own

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: third-party licences for the keychain crates; clippy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: badge text from the theme tokens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: Auto-allow model edits is the default permission mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:22:38 -04:00

247 lines
9.9 KiB
Rust

//! The ACP backend (desktop only): starts the agent the user picked as a child process and holds
//! the protocol's side of the conversation (initialize, the session, prompts, permission questions,
//! sign-in). The panel sees only [`Event`]s.
use std::collections::HashMap;
use serde_json::{Value, json};
use super::acp::{self, Incoming};
use super::backend::{ChatBackend, Event, PromptPart, StartContext};
use super::process::{AgentProcess, FromAgent};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pending {
Initialize,
SessionNew,
Prompt,
Authenticate,
SetMode,
}
/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port
/// would otherwise reach the agent and outrank the chat endpoint's own token file.
const ENV_REMOVE: &[&str] = &["SOLIDCRAFT_CONTROL_TOKEN"];
pub struct AcpBackend {
proc: AgentProcess,
cwd: std::path::PathBuf,
bridge: Option<acp::McpBridge>,
next_id: u64,
pending: HashMap<u64, Pending>,
session: Option<String>,
agent: String,
images: bool,
auth: Vec<acp::AuthMethod>,
/// Open permission questions: our key → the agent's request id.
permissions: HashMap<u64, Value>,
next_key: u64,
exited: bool,
}
impl AcpBackend {
/// Start `argv` and say hello (`initialize`); the session follows when the agent answers.
pub fn start(argv: &[String], ctx: StartContext) -> Result<AcpBackend, String> {
let wake = ctx.wake;
let proc = AgentProcess::spawn(argv, &ctx.cwd, ENV_REMOVE, wake)?;
let mut b = AcpBackend {
proc,
cwd: ctx.cwd,
bridge: ctx.bridge,
next_id: 0,
pending: HashMap::new(),
session: None,
agent: String::new(),
images: false,
auth: Vec::new(),
permissions: HashMap::new(),
next_key: 0,
exited: false,
};
b.request(Pending::Initialize, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION")));
Ok(b)
}
fn request(&mut self, what: Pending, method: &str, params: Value) {
self.next_id += 1;
self.pending.insert(self.next_id, what);
self.proc.send(&acp::request(self.next_id, method, params));
}
fn new_session(&mut self) {
let params = acp::session_new_params(&self.cwd, self.bridge.as_ref());
self.request(Pending::SessionNew, "session/new", params);
}
fn handle(&mut self, msg: Incoming, out: &mut Vec<Event>) {
match msg {
Incoming::Reply { id, result } => {
let Some(what) = self.pending.remove(&id) else { return };
match (what, result) {
(Pending::Initialize, Ok(v)) => {
self.agent = acp::agent_info(&v);
self.images = acp::takes_images(&v);
self.auth = acp::auth_methods(&v);
self.new_session();
}
(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 });
}
out.push(Event::Ready { agent: self.agent.clone(), images: self.images });
}
None => out.push(Event::Error("session/new: no sessionId".into())),
},
(Pending::Prompt, Ok(v)) => {
let reason = v.get("stopReason").and_then(Value::as_str).unwrap_or("");
out.push(Event::TurnEnded { reason: if reason == "end_turn" { String::new() } else { reason.to_string() } });
}
// Signed in: try the session again.
(Pending::Authenticate, Ok(_)) => self.new_session(),
(Pending::SetMode, Ok(_)) => {}
(what, Err((code, message))) => {
let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth");
if auth {
self.session = None;
if what == Pending::Prompt {
out.push(Event::TurnEnded { reason: "auth".into() });
}
out.push(Event::AuthRequired { methods: self.auth.clone() });
} else {
if what == Pending::Prompt {
out.push(Event::TurnEnded { reason: "error".into() });
}
out.push(Event::Error(message));
}
}
}
}
Incoming::Text { thought, text } => out.push(Event::Text { thought, text }),
Incoming::Tool { id, title, kind, status, output } => out.push(Event::Tool { id, title, kind, status, output }),
Incoming::Plan(p) => out.push(Event::Plan(p)),
Incoming::Permission { rpc, title, options } => {
self.next_key += 1;
self.permissions.insert(self.next_key, rpc);
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::Unsupported { rpc, method } => {
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`")));
}
Incoming::Ignored => {}
}
}
}
impl ChatBackend for AcpBackend {
fn poll(&mut self) -> Vec<Event> {
let mut out = Vec::new();
if self.exited {
return out;
}
for _ in 0..512 {
match self.proc.from_agent.try_recv() {
Ok(FromAgent::Line(l)) => {
if let Some(m) = acp::parse(&l) {
self.handle(m, &mut out);
}
}
Ok(FromAgent::Exited(tail)) => {
self.exited = true;
self.session = None;
self.pending.clear();
out.push(Event::Exited { tail });
break;
}
Err(_) => break,
}
}
out
}
fn prompt(&mut self, parts: &[PromptPart]) {
if let Some(s) = self.session.clone() {
self.request(Pending::Prompt, "session/prompt", acp::prompt_params(&s, parts));
}
}
fn cancel(&mut self) {
// Open permission questions are answered "cancelled" first, as the protocol asks.
for (_, rpc) in self.permissions.drain() {
self.proc.send(&acp::permission_reply(rpc, None));
}
if let Some(s) = &self.session {
self.proc.send(&acp::notification("session/cancel", json!({"sessionId": s})));
}
}
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));
}
}
fn authenticate(&mut self, method: &str) {
self.request(Pending::Authenticate, "authenticate", json!({"methodId": method}));
}
fn set_mode(&mut self, mode: &str) {
if let Some(s) = self.session.clone() {
self.request(Pending::SetMode, "session/set_mode", json!({"sessionId": s, "modeId": mode}));
}
}
fn pid(&self) -> Option<u32> {
self.proc.pid()
}
fn shutdown(&mut self, wait: bool) {
self.proc.shutdown(wait);
}
}
/// Test connection: start the agent, say hello, open a session without tools, end it. What it
/// answered (its name and version), or what went wrong (it isn't signed in, it didn't answer).
pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, String> {
let mut p = AgentProcess::spawn(argv, cwd, ENV_REMOVE, || {})?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let wait_for = |p: &AgentProcess, id: u64| -> Result<Value, String> {
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match p.from_agent.recv_timeout(left) {
Ok(FromAgent::Line(l)) => {
if let Some(Incoming::Reply { id: got, result }) = acp::parse(&l)
&& got == id
{
return result.map_err(|(code, m)| {
if code == acp::AUTH_REQUIRED || m.to_ascii_lowercase().contains("auth") {
crate::i18n::tr("ui.agent_chat_state_auth").to_string()
} else {
m
}
});
}
}
Ok(FromAgent::Exited(tail)) => {
return Err(tail.last().cloned().unwrap_or_else(|| crate::i18n::tr("ui.agent_chat_exited").to_string()));
}
Err(_) => return Err(crate::i18n::tr("ui.ai_no_answer").to_string()),
}
}
};
p.send(&acp::request(1, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION"))));
let init = wait_for(&p, 1);
let result = init.and_then(|v| {
p.send(&acp::request(2, "session/new", acp::session_new_params(cwd, None)));
wait_for(&p, 2).map(|_| {
let who = acp::agent_info(&v);
crate::i18n::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })])
})
});
p.shutdown(true);
result
}