Files
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 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Agent chat: badge text from the theme tokens

Co-Authored-By: Claude Opus 5.5 <[email protected]>

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-15 14:22:38 -04:00

227 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A scripted ACP agent for trying the agent chat without an account (docs/agent-chat.md):
//! it speaks the Agent Client Protocol on stdin and stdout like the real adapters, starts the MCP
//! server the chat gives it (`solidcraft-cli mcp --connect …`) and models through it. No model
//! runs: what it does is fixed.
//!
//! ```text
//! cargo build --release -p solidcraft-ui-egui --example acp_test_agent
//! # Preferences → General → Agent chat → Add custom command: target/release/examples/acp_test_agent
//! ```
//!
//! A message: a plan, a box made with one `batch`, a fillet, a measurement. A message with "save"
//! in it only saves to the user's Documents folder (which the chat holds for Allow or Deny);
//! with "ask" in it the agent first asks its own permission for the batch.
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use serde_json::{Value, json};
fn send(v: &Value) {
let mut out = std::io::stdout().lock();
let _ = writeln!(out, "{v}");
let _ = out.flush();
}
fn update(session: &str, u: Value) {
send(&json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": session, "update": u}}));
}
fn say(session: &str, text: &str) {
update(session, json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}));
}
fn tool(session: &str, id: &str, title: &str, kind: &str, status: &str, output: Option<&str>) {
let mut u = json!({"sessionUpdate": "tool_call_update", "toolCallId": id, "title": title, "kind": kind, "status": status});
if status == "pending" {
u["sessionUpdate"] = json!("tool_call");
}
if let Some(o) = output {
u["content"] = json!([{"type": "content", "content": {"type": "text", "text": o}}]);
}
update(session, u);
}
fn plan(session: &str, done: usize) {
let steps = ["Make a 40 × 30 × 20 mm block", "Round its vertical edges", "Measure it"];
let entries: Vec<Value> = steps
.iter()
.enumerate()
.map(|(i, s)| {
let status = if i < done {
"completed"
} else if i == done {
"in_progress"
} else {
"pending"
};
json!({"content": s, "priority": "medium", "status": status})
})
.collect();
update(session, json!({"sessionUpdate": "plan", "entries": entries}));
}
/// The MCP server the chat handed over, as a child speaking JSON-RPC lines.
struct Mcp {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next: u64,
}
impl Mcp {
fn start(server: &Value) -> Option<Mcp> {
let program = server.get("command")?.as_str()?;
let args: Vec<String> = server.get("args")?.as_array()?.iter().filter_map(|a| a.as_str().map(str::to_string)).collect();
let mut child = Command::new(program).args(&args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let stdin = child.stdin.take()?;
let stdout = BufReader::new(child.stdout.take()?);
let mut m = Mcp { child, stdin, stdout, next: 0 };
m.call("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "acp-test-agent", "version": "1"}}))?;
let _ = writeln!(m.stdin, "{}", json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
Some(m)
}
fn call(&mut self, method: &str, params: Value) -> Option<Value> {
self.next += 1;
let id = self.next;
writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).ok()?;
self.stdin.flush().ok()?;
loop {
let mut line = String::new();
if self.stdout.read_line(&mut line).ok()? == 0 {
return None;
}
let v: Value = serde_json::from_str(line.trim()).ok()?;
if v.get("id").and_then(Value::as_u64) == Some(id) {
return Some(v);
}
}
}
/// A tool's text and whether it failed.
fn tool(&mut self, name: &str, args: Value) -> (String, bool) {
match self.call("tools/call", json!({"name": name, "arguments": args})) {
Some(r) => {
let text = r.pointer("/result/content/0/text").and_then(Value::as_str).unwrap_or("").to_string();
let failed = r.pointer("/result/isError").and_then(Value::as_bool).unwrap_or(false) || r.get("error").is_some();
let text = if text.is_empty() { r.get("error").map(Value::to_string).unwrap_or_default() } else { text };
(text.chars().take(600).collect(), failed)
}
None => ("the MCP server did not answer".into(), true),
}
}
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn main() {
let session = "test-session-1";
let mut mcp: Option<Mcp> = None;
let stdin = std::io::stdin();
let mut lines = stdin.lock().lines();
while let Some(Ok(line)) = lines.next() {
let Ok(msg) = serde_json::from_str::<Value>(line.trim()) else { continue };
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let reply = |result: Value| send(&json!({"jsonrpc": "2.0", "id": id, "result": result}));
match msg.get("method").and_then(Value::as_str) {
Some("initialize") => reply(json!({
"protocolVersion": 1,
"agentInfo": {"name": "acp-test-agent", "title": "SolidCraft test agent", "version": env!("CARGO_PKG_VERSION")},
"agentCapabilities": {"promptCapabilities": {"image": true}},
"authMethods": [],
})),
Some("session/new") => {
mcp = msg.pointer("/params/mcpServers/0").and_then(Mcp::start);
reply(json!({"sessionId": session, "modes": {"currentModeId": "default", "availableModes": [
{"id": "default", "name": "Default"}, {"id": "acceptEdits", "name": "Accept edits"}, {"id": "plan", "name": "Plan"},
]}}));
}
Some("session/prompt") => {
let text: String = msg
.pointer("/params/prompt")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|b| b.get("text").and_then(Value::as_str)).collect::<Vec<_>>().join(" "))
.unwrap_or_default()
.to_lowercase();
let images = msg.pointer("/params/prompt").and_then(Value::as_array).map_or(0, |a| a.iter().filter(|b| b["type"] == "image").count());
let Some(m) = mcp.as_mut() else {
say(session, "I have no SolidCraft tools in this session.");
reply(json!({"stopReason": "end_turn"}));
continue;
};
// "save": only the save (the design is there already).
if text.contains("save") {
let path = std::env::var_os("HOME").map(std::path::PathBuf::from).unwrap_or_default().join("Documents/block.solidcraft");
let title = format!("save {}", path.display());
say(session, "I'll save the design to your Documents folder.");
tool(session, "t4", &title, "edit", "in_progress", None);
let (out, failed) = m.tool("save", json!({"path": path.to_string_lossy()}));
tool(session, "t4", &title, "edit", if failed { "failed" } else { "completed" }, Some(&out));
say(session, if failed { "\nThe save didn't go through." } else { "\nSaved." });
reply(json!({"stopReason": "end_turn"}));
continue;
}
plan(session, 0);
if images > 0 {
say(session, "Thanks for the picture of the view. ");
}
say(session, "I'll make a 40 × 30 × 20 mm block with one batch, round its vertical edges, then measure it.");
if text.contains("ask") {
tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "pending", None);
send(&json!({"jsonrpc": "2.0", "id": 900, "method": "session/request_permission", "params": {
"sessionId": session,
// Named as the Claude Code adapter names an MCP tool.
"toolCall": {"toolCallId": "t1", "title": "mcp__solidcraft__batch"},
"options": [{"optionId": "allow", "name": "Allow", "kind": "allow_once"}, {"optionId": "reject", "name": "Reject", "kind": "reject_once"}],
}}));
// Wait for the answer (other lines are not expected meanwhile).
let mut allowed = false;
for l in lines.by_ref().map_while(Result::ok) {
let Ok(a) = serde_json::from_str::<Value>(l.trim()) else { continue };
if a.get("id").and_then(Value::as_u64) == Some(900) {
allowed = a.pointer("/result/outcome/optionId").and_then(Value::as_str) == Some("allow");
break;
}
}
if !allowed {
tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "failed", Some("rejected"));
say(session, "\nOK, I left the design as it was.");
reply(json!({"stopReason": "end_turn"}));
continue;
}
}
tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", "in_progress", None);
let (out, failed) = m.tool(
"batch",
json!({"commands": [{"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [0, 0, 0], "body_name": "Block"}}], "verbose": false}),
);
tool(session, "t1", "batch: solid.box 40 × 30 × 20", "edit", if failed { "failed" } else { "completed" }, Some(&out));
plan(session, 1);
// The block's vertical edges, by a point on each (its corner is at the origin).
let vertical = json!([[0, 0, 10], [40, 0, 10], [40, 30, 10], [0, 30, 10]]);
tool(session, "t2", "execute: solid.fillet r = 4 mm", "edit", "in_progress", None);
let (out, failed) =
m.tool("execute", json!({"command": "solid.fillet", "params": {"edges": vertical, "radius": 4}, "verbose": false}));
tool(session, "t2", "execute: solid.fillet r = 4 mm", "edit", if failed { "failed" } else { "completed" }, Some(&out));
plan(session, 2);
tool(session, "t3", "measure", "read", "in_progress", None);
let (out, failed) = m.tool("measure", json!({}));
tool(session, "t3", "measure", "read", if failed { "failed" } else { "completed" }, Some(&out));
plan(session, 3);
say(session, "\nDone: the block is 40 × 30 × 20 mm with 4 mm rounds on its vertical edges.");
reply(json!({"stopReason": "end_turn"}));
}
Some("session/set_mode") => reply(json!({})),
Some("session/cancel") => {}
Some(_) if !id.is_null() => send(&json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": "method not found"}})),
_ => {}
}
}
}