diff --git a/.changeset/local-ai-agents.md b/.changeset/local-ai-agents.md new file mode 100644 index 00000000..78a01fd1 --- /dev/null +++ b/.changeset/local-ai-agents.md @@ -0,0 +1,23 @@ +### New Features +- Claude Code, Codex and OpenCode run as AI models when their CLI is installed, using Stroke's database tools only +- Four access levels for agents: read only, read & write, modify schema, dangerous +- Effort and access chips in the AI composer +- Messages sent while the AI is answering are queued +- Ctrl+B toggles the AI chat list, and the list is resizable + +### Bug Fixes +- The same AI model no longer shows up twice in the picker +- The sidebar assistant works with local agent models +- get_database_stats works on Postgres +- Rows no longer show through the Related panel's header +- The JSON copy button shows only on the hovered line +- No gap after the row range in the table toolbar + +### Changes +- New model picker with provider tabs, search and favourites +- Rebuilt AI chat: tool steps fold into one "Worked for" line, each answer anchors under its message, header buttons float +- Chat titles skip greetings like "hi" +- Slash commands fill the sidebar box instead of sending +- Settings fades in, and dialogs no longer lock page scroll +- Clearer status bar +- "About Stroke" is now "About and license" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a8821b5b..2640eb35 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,53 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "agent-claude" +version = "0.1.0" +dependencies = [ + "agent-core", + "anyhow", + "async-trait", + "base64 0.22.1", + "futures", + "parking_lot", + "serde", + "serde_json", + "smol_str", + "tokio", + "tracing", +] + +[[package]] +name = "agent-cli" +version = "0.1.0" +dependencies = [ + "agent-claude", + "agent-core", + "async-trait", + "futures", + "parking_lot", + "serde", + "serde_json", + "smol_str", + "tokio", + "tracing", +] + +[[package]] +name = "agent-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "serde", + "serde_json", + "smol_str", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "ahash" version = "0.7.8" @@ -110,6 +157,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" name = "app" version = "0.1.4" dependencies = [ + "agent-claude", + "agent-cli", + "agent-core", "axum", "base64 0.22.1", "chrono", @@ -5414,6 +5464,16 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb7109d23f319619d4cc0de5464613c8b7b1a09db2774a58101459cf6be2f10" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.6.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 630067ed..d4e16253 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,10 @@ tower-http = { version = "0.5", features = ["cors"] } sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "mysql", "json", "chrono", "uuid", "rust_decimal"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-webpki-roots", "stream"] } futures = "0.3" +# Local agent CLIs (Claude Code, Codex, OpenCode), vendored from deck. +agent-core = { path = "crates/agent-core" } +agent-claude = { path = "crates/agent-claude" } +agent-cli = { path = "crates/agent-cli" } sysinfo = { version = "0.33", default-features = false, features = ["system"] } libc = "0.2" # Global allocator. glibc malloc keeps one ~64MB arena per worker thread and diff --git a/src-tauri/crates/agent-claude/Cargo.toml b/src-tauri/crates/agent-claude/Cargo.toml new file mode 100644 index 00000000..8af2c1bb --- /dev/null +++ b/src-tauri/crates/agent-claude/Cargo.toml @@ -0,0 +1,19 @@ +# Vendored from deck (github.com/broisnees/deck, crates/agent-claude). +[package] +name = "agent-claude" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +agent-core = { path = "../agent-core" } +anyhow = "1" +async-trait = "0.1" +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +smol_str = { version = "0.3", features = ["serde"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync", "time", "fs"] } +tracing = "0.1" +parking_lot = "0.12" +base64 = "0.22" diff --git a/src-tauri/crates/agent-claude/src/decode.rs b/src-tauri/crates/agent-claude/src/decode.rs new file mode 100644 index 00000000..6764193a --- /dev/null +++ b/src-tauri/crates/agent-claude/src/decode.rs @@ -0,0 +1,1125 @@ +//! Line-oriented decoder: Claude Code stream-json -> [`AgentEvent`]. +//! +//! Kept pure and stateful-but-synchronous so it is exhaustively testable +//! without spawning a process. All the protocol risk lives here. + +use crate::wire::{self, BlockDelta, ContentBlock, ContentField, Line, RawEvent, WireUsage}; +use agent_core::{Action, AgentEvent, Decision, RequestId, StopReason, ToolId, ToolOutput}; +use serde_json::Value; +use std::collections::HashMap; + +/// Hard cap on a single tool result we keep in memory. Beyond this the output is +/// reported as `Truncated`; unbounded tool output is the classic way this class +/// of app dies. +const TOOL_OUTPUT_CAP: usize = 256 * 1024; + +pub struct Decoder { + session_id: Option, + /// Tool name by tool_use id, so a `tool_result` can be rendered with the + /// right renderer (the result line carries no tool name). + tool_names: HashMap, + /// Partial `input_json_delta` accumulation, keyed by content-block index. + partial_tools: HashMap, + /// True once we have streamed actual text/thinking for the message currently + /// in flight. The CLI sends *both* partial deltas and a final complete + /// `assistant` message, so without this the text would emit twice. + /// + /// Scoped per-message and set only by content-bearing events: a contentless + /// `stream_event` (`ping`, `message_start`) must not suppress a whole-message + /// line, and a turn that streams one message then sends another unstreamed + /// must still emit the second. + streamed_content: bool, + /// Our ids for the CLI's open permission requests, by its request id. + asks: HashMap, + /// Requests decoded since the last `take_asks`, for the session to answer. + new_asks: Vec<(RequestId, PendingAsk)>, + /// `total_cost_usd` as of the last result: the CLI keeps a running total + /// for the process, and a turn's cost is what it grew by. + cost_seen: f64, +} + +/// What answering a permission request needs: the CLI's request id, the +/// tool input to echo back (possibly with answers added), and the "always +/// allow" rules it suggested. +#[derive(Debug, Clone)] +pub struct PendingAsk { + pub request_id: String, + pub input: Value, + pub suggestions: Vec, +} + +struct PartialTool { + id: String, + name: String, + json: String, + /// Already sent as a `ToolCall` before its input finished arriving. + shown: bool, +} + +/// How far into a streaming tool input to look for its title field. The +/// field comes first in practice; past this the card waits for the end. +const TITLE_SCAN_LIMIT: usize = 4096; + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + +impl Decoder { + pub fn new() -> Self { + Self { + session_id: None, + tool_names: HashMap::new(), + partial_tools: HashMap::new(), + streamed_content: false, + asks: HashMap::new(), + new_asks: Vec::new(), + cost_seen: 0., + } + } + + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + /// Decode one NDJSON line. Unparseable or unknown lines yield no events + /// rather than failing the session. + pub fn decode_line(&mut self, line: &str) -> Vec { + let line = line.trim(); + if line.is_empty() { + return Vec::new(); + } + let parsed: Line = match serde_json::from_str(line) { + Ok(l) => l, + Err(e) => { + tracing::debug!(error = %e, line = %truncate(line, 200), "undecodable stream-json line"); + return Vec::new(); + } + }; + self.decode(parsed) + } + + /// Permission requests decoded since the last call. + pub fn take_asks(&mut self) -> Vec<(RequestId, PendingAsk)> { + std::mem::take(&mut self.new_asks) + } + + /// `can_use_tool`: a question for the user (AskUserQuestion) or an + /// approval for a tool call. + fn decode_control(&mut self, c: wire::ControlRequestLine, out: &mut Vec) { + let r = &c.request; + if r.get("subtype").and_then(Value::as_str) != Some("can_use_tool") { + tracing::debug!(request = %r, "unhandled control request"); + return; + } + let tool = r + .get("tool_name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input = r.get("input").cloned().unwrap_or(Value::Null); + let tool_use = r + .get("tool_use_id") + .and_then(Value::as_str) + .map(ToolId::new); + let id = RequestId::new(); + self.asks.insert(c.request_id.clone(), id); + self.new_asks.push(( + id, + PendingAsk { + request_id: c.request_id, + input: input.clone(), + suggestions: r + .get("permission_suggestions") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(), + }, + )); + if tool == "AskUserQuestion" { + out.push(AgentEvent::Question { + id, + tool_use, + questions: questions(&input), + }); + return; + } + let s = |k: &str| { + input + .get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }; + let action = match tool.as_str() { + "Bash" => Action::Bash { + command: s("command"), + cwd: None, + }, + "Write" => Action::WriteFile { + path: s("file_path"), + }, + "Edit" | "MultiEdit" | "NotebookEdit" => Action::EditFile { + path: if s("file_path").is_empty() { + s("notebook_path") + } else { + s("file_path") + }, + }, + "Read" => Action::ReadFile { + path: s("file_path"), + }, + "WebFetch" => Action::Fetch { url: s("url") }, + name if name.starts_with("mcp__") => { + let mut parts = name.trim_start_matches("mcp__").splitn(2, "__"); + Action::McpCall { + server: parts.next().unwrap_or("").into(), + tool: parts.next().unwrap_or("").into(), + input: input.clone(), + } + } + name => Action::Other { + name: name.into(), + input: input.clone(), + }, + }; + let reason = r + .get("description") + .or_else(|| r.get("decision_reason")) + .and_then(Value::as_str) + .map(str::to_string); + out.push(AgentEvent::Permission { + id, + action, + suggestions: vec![Decision::Allow, Decision::AllowAlways, Decision::Deny], + tool_use, + input, + reason, + }); + } + + fn decode(&mut self, line: Line) -> Vec { + let mut out = Vec::new(); + match line { + Line::System(s) => { + if let Some(id) = s.session_id { + let first = self.session_id.is_none(); + self.session_id = Some(id.clone()); + if first { + out.push(AgentEvent::Started { + native_session_id: Some(id), + model: s.model.map(Into::into), + }); + } + } + } + + Line::StreamEvent(se) => { + // A message's last word on its tokens. Only a reading of the + // context: the turn's total comes with its result. + if let RawEvent::MessageDelta { usage: Some(u) } = &se.event { + if se.parent_tool_use_id.is_none() { + out.extend(context_reading(u)); + } + } + self.decode_raw(se.event, &mut out); + } + + Line::Assistant(m) => { + if m.parent_tool_use_id.is_none() { + out.extend(m.message.usage.as_ref().and_then(context_reading)); + } + if let ContentField::Blocks(blocks) = m.message.content { + for b in blocks { + match b { + // Already streamed via partial deltas. + ContentBlock::Text { text } if !self.streamed_content => { + out.push(AgentEvent::Text { delta: text }); + } + ContentBlock::Thinking { thinking } if !self.streamed_content => { + out.push(AgentEvent::Reasoning { delta: thinking }); + } + ContentBlock::ToolUse { id, name, input } => { + // Record the name even when partials already + // emitted the call, so tool_result can find it. + let known = self.tool_names.insert(id.clone(), name.clone()); + if known.is_none() { + out.push(tool_call_event(&id, &name, input)); + } + } + _ => {} + } + } + } + // This message is complete; a following one streams afresh. + self.streamed_content = false; + } + + // Our own message read back: the model has it now. + Line::User(m) if m.is_replay => { + if let Some(id) = m.uuid { + out.push(AgentEvent::UserDelivered { id }); + } + } + + Line::ControlRequest(c) => self.decode_control(c, &mut out), + + Line::ControlCancelRequest(c) => { + if let Some(id) = self.asks.remove(&c.request_id) { + out.push(AgentEvent::AskCancelled { id }); + } + } + + Line::CommandLifecycle(c) => { + if let (Some(id), Some("cancelled")) = (c.command_uuid, c.state.as_deref()) { + out.push(AgentEvent::UserDropped { id }); + } + } + + // Tool results come back as a synthetic `user` message. + Line::User(m) => { + if let ContentField::Blocks(blocks) = m.message.content { + for b in blocks { + if let ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } = b + { + let name = self + .tool_names + .get(&tool_use_id) + .map(String::as_str) + .unwrap_or(""); + out.push(AgentEvent::ToolResult { + id: ToolId::new(tool_use_id.as_str()), + output: classify_output(name, &content), + is_error, + }); + } + } + } + } + + Line::Result(r) => { + let cost = r.total_cost_usd.map(|total| { + // Lower than before: a new running total began. + let turn = if total >= self.cost_seen { + total - self.cost_seen + } else { + total + }; + self.cost_seen = total; + turn + }); + let window = r + .model_usage + .values() + .filter_map(|m| m.context_window) + .max(); + if r.usage.is_some() || cost.is_some() || window.is_some() { + let mut u = r.usage.as_ref().map_or_else( + || agent_core::Usage { + cost_usd: cost, + ..Default::default() + }, + |u| u.to_core(cost), + ); + u.context_window = window; + out.push(AgentEvent::Usage(u)); + } + if r.is_error { + out.push(AgentEvent::TurnFailed { + status: r.api_error_status, + message: r.result.clone().unwrap_or_default(), + }); + } + let stop = match r.subtype.as_deref() { + Some("error_max_turns") => StopReason::MaxTurns, + Some("error_during_execution") => StopReason::Error, + _ if r.is_error => StopReason::Error, + _ => StopReason::EndTurn, + }; + out.push(AgentEvent::TurnEnd { stop }); + // A new turn may reuse block indices and will stream fresh + // partials; reset per-turn state but keep tool names, since + // results can straddle the boundary. + self.partial_tools.clear(); + self.streamed_content = false; + } + + Line::Unknown => {} + } + out + } + + fn decode_raw(&mut self, event: RawEvent, out: &mut Vec) { + match event { + RawEvent::ContentBlockStart { + index, + content_block, + } => match content_block { + ContentBlock::ToolUse { id, name, .. } => { + self.tool_names.insert(id.clone(), name.clone()); + // A title that comes from the name alone is real already. + let shown = title_key(&name).is_none(); + if shown { + out.push(tool_call_event(&id, &name, Value::Null)); + } + self.partial_tools.insert( + index, + PartialTool { + id, + name, + json: String::new(), + shown, + }, + ); + } + ContentBlock::Text { text } if !text.is_empty() => { + self.streamed_content = true; + out.push(AgentEvent::Text { delta: text }); + } + ContentBlock::Thinking { thinking } if !thinking.is_empty() => { + self.streamed_content = true; + out.push(AgentEvent::Reasoning { delta: thinking }); + } + _ => {} + }, + + RawEvent::ContentBlockDelta { index, delta } => match delta { + BlockDelta::TextDelta { text } => { + self.streamed_content = true; + out.push(AgentEvent::Text { delta: text }); + } + BlockDelta::ThinkingDelta { thinking } => { + self.streamed_content = true; + out.push(AgentEvent::Reasoning { delta: thinking }); + } + BlockDelta::InputJsonDelta { partial_json } => { + if let Some(p) = self.partial_tools.get_mut(&index) { + p.json.push_str(&partial_json); + // Show the card as soon as its title field has closed, + // so a long input (a whole file for Write) streams + // under a real title instead of nothing at all. + if !p.shown && p.json.len() <= TITLE_SCAN_LIMIT { + if let Some(k) = title_key(&p.name) { + if let Some(v) = closed_string_field(&p.json, k) { + p.shown = true; + let input = serde_json::json!({ k: v }); + out.push(tool_call_event(&p.id, &p.name, input)); + } + } + } + } + } + BlockDelta::Unknown => {} + }, + + // The complete input. A call already shown gets it as a repeat + // `ToolCall`, which the transcript applies to the same card. + RawEvent::ContentBlockStop { index } => { + if let Some(p) = self.partial_tools.remove(&index) { + let input = if p.json.trim().is_empty() { + Value::Null + } else { + serde_json::from_str(&p.json).unwrap_or(Value::Null) + }; + out.push(tool_call_event(&p.id, &p.name, input)); + } + } + + // Read for the context above; counting it here as well would + // count every turn twice. + RawEvent::MessageDelta { .. } => {} + + RawEvent::Unknown => {} + } + } +} + +/// The context's size at one call, and nothing else. +fn context_reading(u: &WireUsage) -> Option { + u.context().map(|context| { + AgentEvent::Usage(agent_core::Usage { + context, + ..Default::default() + }) + }) +} + +fn tool_call_event(id: &str, name: &str, input: Value) -> AgentEvent { + AgentEvent::ToolCall { + id: ToolId::new(id), + name: name.into(), + title: tool_title(name, &input), + input, + } +} + +/// The input field a tool's card title is read from; `None` when the title +/// comes from the tool's name. +fn title_key(name: &str) -> Option<&'static str> { + match name { + "Bash" | "bash" => Some("command"), + "Read" | "read" | "Write" | "write" | "Edit" | "edit" => Some("file_path"), + "Glob" | "glob" | "Grep" | "grep" => Some("pattern"), + "WebFetch" => Some("url"), + "Task" => Some("description"), + _ => None, + } +} + +/// Short label for the collapsed tool card, mirroring what the CLI shows. +fn tool_title(name: &str, input: &Value) -> String { + match title_key(name) { + Some(k) => { + let s = input.get(k).and_then(Value::as_str).unwrap_or(""); + match k { + "command" | "file_path" => truncate(s, 160).to_string(), + _ => s.to_string(), + } + } + // MCP tools are named `mcp__server__tool`; show `server/tool`. + None => match name.strip_prefix("mcp__") { + Some(rest) => rest.replacen("__", "/", 1), + None => name.to_string(), + }, + } +} + +/// The string at top-level `key` of a JSON object still being streamed, once +/// its closing quote has arrived. +fn closed_string_field(json: &str, key: &str) -> Option { + let b = json.as_bytes(); + // Index of the quote closing the string opened at `open`, if it has come. + let close = |open: usize| { + let mut i = open + 1; + while i < b.len() { + match b[i] { + b'\\' => i += 2, + b'"' => return Some(i), + _ => i += 1, + } + } + None + }; + let after_ws = |mut i: usize| { + while b.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + i + }; + let mut depth = 0usize; + let mut i = 0; + while i < b.len() { + match b[i] { + b'{' | b'[' => depth += 1, + b'}' | b']' => depth = depth.saturating_sub(1), + b'"' => { + let end = close(i)?; + let colon = after_ws(end + 1); + if depth == 1 && b.get(colon) == Some(&b':') && &json[i + 1..end] == key { + let open = after_ws(colon + 1); + if b.get(open) != Some(&b'"') { + return None; + } + let end = close(open)?; + return serde_json::from_str(&json[open..=end]).ok(); + } + i = end; + } + _ => {} + } + i += 1; + } + None +} + +/// Pick a renderer for a tool result based on which tool produced it. +fn classify_output(tool_name: &str, content: &Value) -> ToolOutput { + let text = flatten_content(content); + if text.len() > TOOL_OUTPUT_CAP { + return ToolOutput::Truncated { + preview: truncate(&text, 4096).to_string(), + total_bytes: text.len() as u64, + spill_path: None, + }; + } + match tool_name { + "Bash" | "bash" => ToolOutput::Terminal { + text, + exit_code: None, + }, + "Read" | "read" => ToolOutput::FileExcerpt { + path: String::new(), + text, + start_line: 1, + }, + "Edit" | "edit" | "Write" | "write" => ToolOutput::Text(text), + _ if content.is_object() || content.is_array() && text.is_empty() => { + ToolOutput::Structured(content.clone()) + } + _ => ToolOutput::Text(text), + } +} + +/// `tool_result.content` is a string, or an array of `{type:"text",text}` +/// blocks, or an arbitrary object. +fn flatten_content(content: &Value) -> String { + match content { + Value::String(s) => s.clone(), + Value::Array(items) => { + let mut out = String::new(); + for item in items { + if let Some(t) = item.get("text").and_then(Value::as_str) { + out.push_str(t); + } + } + out + } + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Truncate on a char boundary. +fn truncate(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let end = s + .char_indices() + .take_while(|(i, _)| *i <= max) + .last() + .map(|(i, _)| i) + .unwrap_or(0); + &s[..end] +} + +/// AskUserQuestion's input: `{questions: [{question, header, options: +/// [{label, description}], multiSelect}]}`. +fn questions(input: &Value) -> Vec { + let str_of = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + input + .get("questions") + .and_then(Value::as_array) + .map(|qs| { + qs.iter() + .map(|q| agent_core::Question { + header: str_of(q, "header"), + question: str_of(q, "question"), + options: q + .get("options") + .and_then(Value::as_array) + .map(|os| { + os.iter() + .map(|o| agent_core::QuestionOption { + label: str_of(o, "label"), + description: str_of(o, "description"), + }) + .collect() + }) + .unwrap_or_default(), + multi: q + .get("multiSelect") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev_text(events: &[AgentEvent]) -> String { + events + .iter() + .filter_map(|e| match e { + AgentEvent::Text { delta } => Some(delta.as_str()), + _ => None, + }) + .collect() + } + + #[test] + fn init_line_yields_started_once() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"system","subtype":"init","session_id":"abc","model":"claude-opus-5"}"#, + ); + assert!(matches!( + ev.as_slice(), + [AgentEvent::Started { + native_session_id: Some(s), + .. + }] if s == "abc" + )); + assert_eq!(d.session_id(), Some("abc")); + // A second system line must not re-announce the session. + let ev = d.decode_line(r#"{"type":"system","subtype":"other","session_id":"abc"}"#); + assert!(ev.is_empty()); + } + + #[test] + fn whole_message_mode_emits_text() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Yes. It uses gpui 0.2.2"}]}}"#, + ); + assert_eq!(ev_text(&ev), "Yes. It uses gpui 0.2.2"); + } + + #[test] + fn partial_mode_does_not_double_emit_final_message() { + let mut d = Decoder::new(); + let mut all = Vec::new(); + all.extend(d.decode_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hel"}}}"#, + )); + all.extend(d.decode_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"lo"}}}"#, + )); + // The CLI now repeats the complete message. + all.extend(d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Hello"}]}}"#, + )); + assert_eq!(ev_text(&all), "Hello", "text emitted exactly once"); + } + + fn input_delta(d: &mut Decoder, frag: &str) -> Vec { + let line = serde_json::json!({ + "type": "stream_event", + "event": { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "input_json_delta", "partial_json": frag} + } + }); + d.decode_line(&line.to_string()) + } + + #[test] + fn streamed_tool_input_shows_the_card_once_its_title_has_arrived() { + let mut d = Decoder::new(); + let start = d.decode_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tu_1","name":"Write","input":{}}}}"#, + ); + assert!(start.is_empty(), "no card before its title is known"); + assert!(input_delta(&mut d, r#"{"file_path": "/tmp/a"#).is_empty()); + + // The path has closed; the file's content is still on its way. + let early = input_delta(&mut d, r#".py", "content": "one\n"#); + match early.as_slice() { + [AgentEvent::ToolCall { + id, title, input, .. + }] => { + assert_eq!(id.as_str(), "tu_1"); + assert_eq!(title, "/tmp/a.py"); + assert_eq!(input, &serde_json::json!({"file_path": "/tmp/a.py"})); + } + other => panic!("expected the card early, got {other:?}"), + } + assert!(input_delta(&mut d, r#"two"}"#).is_empty(), "shown once"); + + // The end carries the whole input, for the same card. + let end = d.decode_line( + r#"{"type":"stream_event","event":{"type":"content_block_stop","index":1}}"#, + ); + match end.as_slice() { + [AgentEvent::ToolCall { + id, title, input, .. + }] => { + assert_eq!(id.as_str(), "tu_1"); + assert_eq!(title, "/tmp/a.py"); + assert_eq!(input["content"], "one\ntwo"); + } + other => panic!("expected the full input, got {other:?}"), + } + + // The trailing complete assistant message must not add another call. + let dup = d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tu_1","name":"Write","input":{"file_path":"/tmp/a.py","content":"one\ntwo"}}]}}"#, + ); + assert!( + !dup.iter().any(|e| matches!(e, AgentEvent::ToolCall { .. })), + "tool call must not be emitted again" + ); + } + + #[test] + fn a_tool_titled_by_its_name_shows_at_once() { + let mut d = Decoder::new(); + let start = d.decode_line( + r#"{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tu_2","name":"mcp__deck__deck_panes","input":{}}}}"#, + ); + assert!(matches!( + start.as_slice(), + [AgentEvent::ToolCall { title, .. }] if title == "deck/deck_panes" + )); + } + + #[test] + fn closed_string_field_reads_only_a_finished_top_level_key() { + let f = closed_string_field; + assert_eq!(f(r#"{"command": "ls"#, "command"), None, "still open"); + assert_eq!(f(r#"{"command""#, "command"), None, "no value yet"); + assert_eq!( + f(r#"{"command": "echo \"hi\" \\", "#, "command").as_deref(), + Some(r#"echo "hi" \"#), + "escapes" + ); + assert_eq!( + f(r#"{"description": "command", "command": "ls"}"#, "command").as_deref(), + Some("ls"), + "a value equal to the key is not the key" + ); + assert_eq!( + f(r#"{"opts": {"pattern": "x"}, "pattern": "y"}"#, "pattern").as_deref(), + Some("y"), + "nested keys are skipped" + ); + assert_eq!(f(r#"{"timeout": 5, "command": 3}"#, "command"), None); + } + + #[test] + fn tool_result_uses_renderer_of_originating_tool() { + let mut d = Decoder::new(); + d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tu_9","name":"Bash","input":{"command":"ls"}}]}}"#, + ); + let ev = d.decode_line( + r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu_9","content":[{"type":"text","text":"a\nb\n"}]}]}}"#, + ); + match &ev[0] { + AgentEvent::ToolResult { + id, + output, + is_error, + } => { + assert_eq!(id.as_str(), "tu_9"); + assert!(!is_error); + assert!( + matches!(output, ToolOutput::Terminal { text, .. } if text == "a\nb\n"), + "bash output should render as a terminal, got {output:?}" + ); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn string_content_tool_result_flattens() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"x","content":"plain","is_error":true}]}}"#, + ); + match &ev[0] { + AgentEvent::ToolResult { + output, is_error, .. + } => { + assert!(is_error); + assert!(matches!(output, ToolOutput::Text(t) if t == "plain")); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn result_line_reports_usage_cost_and_turn_end() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"result","subtype":"success","total_cost_usd":0.0123,"usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":5}}"#, + ); + match &ev[0] { + AgentEvent::Usage(u) => { + assert_eq!((u.input, u.output, u.cache_read), (10, 20, 5)); + assert_eq!(u.cost_usd, Some(0.0123)); + } + other => panic!("unexpected {other:?}"), + } + assert!(matches!( + ev[1], + AgentEvent::TurnEnd { + stop: StopReason::EndTurn + } + )); + } + + /// Every usage event in `lines`, folded as a transcript would. + fn folded(d: &mut Decoder, lines: &[&str]) -> agent_core::Usage { + let mut total = agent_core::Usage::default(); + for l in lines { + for ev in d.decode_line(l) { + if let AgentEvent::Usage(u) = ev { + total.accumulate(&u); + } + } + } + total + } + + // Shapes from Claude Code 2.1.301, trimmed. + const ASSISTANT: &str = r#"{"type":"assistant","message":{"content":[],"usage":{"input_tokens":2,"cache_creation_input_tokens":17068,"cache_read_input_tokens":11494,"output_tokens":4}},"parent_tool_use_id":null}"#; + const DELTA: &str = r#"{"type":"stream_event","event":{"type":"message_delta","usage":{"input_tokens":2,"cache_creation_input_tokens":17068,"cache_read_input_tokens":11494,"output_tokens":9}},"parent_tool_use_id":null}"#; + const RESULT_1: &str = r#"{"type":"result","subtype":"success","total_cost_usd":0.0052,"usage":{"input_tokens":2,"output_tokens":9,"cache_read_input_tokens":11494,"cache_creation_input_tokens":17068},"modelUsage":{"claude-haiku-5-5":{"inputTokens":2,"costUSD":0.0052,"contextWindow":1000000,"maxOutputTokens":128000}}}"#; + const RESULT_2: &str = r#"{"type":"result","subtype":"success","total_cost_usd":0.0081,"usage":{"input_tokens":2,"output_tokens":3,"cache_read_input_tokens":14865,"cache_creation_input_tokens":13997},"modelUsage":{"claude-haiku-5-5":{"contextWindow":1000000}}}"#; + + #[test] + fn a_turn_counts_once_and_reads_the_context() { + let mut d = Decoder::new(); + let u = folded(&mut d, &[ASSISTANT, DELTA, RESULT_1]); + // The message's own usage lines are readings; the result is the count. + assert_eq!( + (u.input, u.output, u.cache_read, u.cache_write), + (2, 9, 11494, 17068) + ); + assert_eq!(u.context, 2 + 17068 + 11494 + 9); + assert_eq!(u.context_window, Some(1_000_000)); + assert_eq!(u.cost_usd, Some(0.0052)); + } + + #[test] + fn cost_is_what_the_running_total_grew_by() { + let mut d = Decoder::new(); + let u = folded(&mut d, &[RESULT_1, RESULT_2]); + assert!( + (u.cost_usd.unwrap() - 0.0081).abs() < 1e-9, + "{:?}", + u.cost_usd + ); + assert_eq!(u.output, 12); + } + + #[test] + fn a_sub_agents_context_is_not_the_threads() { + let mut d = Decoder::new(); + let sub = ASSISTANT.replace( + r#""parent_tool_use_id":null"#, + r#""parent_tool_use_id":"toolu_1""#, + ); + let u = folded(&mut d, &[&sub]); + assert_eq!(u.context, 0); + } + + #[test] + fn a_failed_result_says_its_status() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"result","subtype":"success","is_error":true,"api_error_status":529,"result":"API Error: overloaded"}"#, + ); + assert!(matches!( + &ev[0], + AgentEvent::TurnFailed { status: Some(529), message } if message == "API Error: overloaded" + )); + assert!(matches!( + ev.last(), + Some(AgentEvent::TurnEnd { + stop: StopReason::Error + }) + )); + let ok = d.decode_line(r#"{"type":"result","subtype":"success","api_error_status":null}"#); + assert!(!ok + .iter() + .any(|e| matches!(e, AgentEvent::TurnFailed { .. }))); + } + + #[test] + fn max_turns_result_maps_to_max_turns_stop() { + let mut d = Decoder::new(); + let ev = d.decode_line(r#"{"type":"result","subtype":"error_max_turns","is_error":true}"#); + assert!(matches!( + ev.last(), + Some(AgentEvent::TurnEnd { + stop: StopReason::MaxTurns + }) + )); + } + + #[test] + fn thinking_deltas_become_reasoning() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}}}"#, + ); + assert!(matches!(&ev[0], AgentEvent::Reasoning { delta } if delta == "hmm")); + } + + #[test] + fn contentless_stream_events_do_not_suppress_a_whole_message() { + let mut d = Decoder::new(); + // A `ping` is a stream_event carrying no content; it must not make the + // decoder believe the upcoming message was already streamed. + assert!(d + .decode_line(r#"{"type":"stream_event","event":{"type":"ping"}}"#) + .is_empty()); + let ev = d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}"#, + ); + assert_eq!(ev_text(&ev), "ok"); + } + + #[test] + fn second_unstreamed_message_in_one_turn_still_emits() { + let mut d = Decoder::new(); + let mut all = Vec::new(); + // First message streams. + all.extend(d.decode_line( + r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"first"}}}"#, + )); + all.extend(d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"first"}]}}"#, + )); + // Second arrives whole, with no partials. + all.extend(d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":" second"}]}}"#, + )); + assert_eq!(ev_text(&all), "first second"); + } + + #[test] + fn garbage_and_unknown_lines_are_ignored_not_fatal() { + let mut d = Decoder::new(); + assert!(d.decode_line("not json at all").is_empty()); + assert!(d + .decode_line(r#"{"type":"brand_new_event","x":1}"#) + .is_empty()); + assert!(d + .decode_line(r#"{"type":"stream_event","event":{"type":"ping"}}"#) + .is_empty()); + assert!(d.decode_line("").is_empty()); + // Decoder still works afterwards. + let ev = d.decode_line( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}"#, + ); + assert_eq!(ev_text(&ev), "ok"); + } + + #[test] + fn oversized_tool_output_is_reported_as_truncated() { + let mut d = Decoder::new(); + let big = "x".repeat(TOOL_OUTPUT_CAP + 10); + let line = serde_json::json!({ + "type": "user", + "message": {"content": [{"type":"tool_result","tool_use_id":"t","content": big}]} + }); + let ev = d.decode_line(&line.to_string()); + match &ev[0] { + AgentEvent::ToolResult { output, .. } => match output { + ToolOutput::Truncated { + total_bytes, + preview, + .. + } => { + assert_eq!(*total_bytes as usize, TOOL_OUTPUT_CAP + 10); + assert!(preview.len() <= 4100); + } + other => panic!("expected truncation, got {other:?}"), + }, + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn mcp_tool_titles_render_as_server_slash_tool() { + assert_eq!( + tool_title("mcp__azure__get_work_item", &Value::Null), + "azure/get_work_item" + ); + } + + #[test] + fn truncate_respects_utf8_boundaries() { + let s = "héllo wörld"; + for n in 0..s.len() + 2 { + let _ = truncate(s, n); // must not panic + } + } + + #[test] + fn our_message_read_back_mid_turn_is_reported_as_delivered() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"user","uuid":"22222222-2222-4222-8222-222222222222","isReplay":true,"parent_tool_use_id":null,"message":{"role":"user","content":[{"type":"text","text":"also 42"}]}}"#, + ); + assert!( + matches!(&ev[..], [AgentEvent::UserDelivered { id }] if id == "22222222-2222-4222-8222-222222222222") + ); + // A tool result is still a tool result. + let ev = d.decode_line( + r#"{"type":"user","uuid":"x","message":{"content":[{"type":"tool_result","tool_use_id":"t","content":"ok"}]}}"#, + ); + assert!(matches!(&ev[..], [AgentEvent::ToolResult { .. }])); + } + + #[test] + fn a_queued_message_the_cli_cancels_is_reported_as_dropped() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"command_lifecycle","command_uuid":"abc","state":"cancelled","uuid":"z"}"#, + ); + assert!(matches!(&ev[..], [AgentEvent::UserDropped { id }] if id == "abc")); + assert!(d + .decode_line(r#"{"type":"command_lifecycle","command_uuid":"abc","state":"queued"}"#) + .is_empty()); + } + + #[test] + fn a_tool_waiting_for_approval_becomes_a_permission_request() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"control_request","request_id":"r-1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"touch a.txt"},"description":"Create a.txt","permission_suggestions":[{"type":"addRules","rules":[{"toolName":"Bash","ruleContent":"touch a.txt"}],"behavior":"allow","destination":"localSettings"}],"tool_use_id":"tu_1"}}"#, + ); + let [AgentEvent::Permission { + id, + action, + tool_use, + reason, + .. + }] = &ev[..] + else { + panic!("{ev:?}") + }; + assert!(matches!(action, Action::Bash { command, .. } if command == "touch a.txt")); + assert_eq!(tool_use.as_ref().map(|t| t.as_str()), Some("tu_1")); + assert_eq!(reason.as_deref(), Some("Create a.txt")); + let asks = d.take_asks(); + assert_eq!(asks.len(), 1); + assert_eq!( + ( + asks[0].0, + asks[0].1.request_id.as_str(), + asks[0].1.suggestions.len() + ), + (*id, "r-1", 1) + ); + // Cancelled before it was answered. + let ev = d.decode_line(r#"{"type":"control_cancel_request","request_id":"r-1"}"#); + assert!(matches!(&ev[..], [AgentEvent::AskCancelled { id: c }] if c == id)); + } + + #[test] + fn ask_user_question_becomes_a_question() { + let mut d = Decoder::new(); + let ev = d.decode_line( + r#"{"type":"control_request","request_id":"q-1","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which color?","header":"Color","options":[{"label":"Red","description":"warm"},{"label":"Blue","description":"cool"}],"multiSelect":false}]},"tool_use_id":"tu_2","requires_user_interaction":true}}"#, + ); + let [AgentEvent::Question { questions, .. }] = &ev[..] else { + panic!("{ev:?}") + }; + assert_eq!(questions[0].question, "Which color?"); + assert_eq!( + questions[0] + .options + .iter() + .map(|o| o.label.as_str()) + .collect::>(), + ["Red", "Blue"] + ); + assert!(!questions[0].multi); + } +} diff --git a/src-tauri/crates/agent-claude/src/lib.rs b/src-tauri/crates/agent-claude/src/lib.rs new file mode 100644 index 00000000..d14fdfde --- /dev/null +++ b/src-tauri/crates/agent-claude/src/lib.rs @@ -0,0 +1,718 @@ +//! Claude Code adapter. +//! +//! Talks to the `claude` CLI over `--input-format stream-json` / +//! `--output-format stream-json`, which is the richest protocol of the backends +//! we support — native session ids, resume, MCP config, usage and cost. The +//! other adapters degrade from the shape established here. + +pub mod decode; +pub mod replay; +pub mod wire; + +use agent_core::{ + AgentAdapter, AgentError, AgentEvent, AgentId, AgentSession, Availability, Caps, Decision, + HydrateMode, McpTransport, PermissionMode, RequestId, SessionCtx, Transcript, UserTurn, +}; +use async_trait::async_trait; +use futures::stream::BoxStream; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, Command}; +use tokio::sync::mpsc; + +/// Tokens of transcript we are willing to replay on a handoff. +const REPLAY_BUDGET: usize = 60_000; + +/// Bound on buffered events. Deep enough to absorb a fast burst, shallow enough +/// that a stalled UI applies backpressure instead of growing without limit. +const EVENT_BUFFER: usize = 1024; + +pub fn agent_id() -> AgentId { + AgentId::new("claude") +} + +pub struct ClaudeAdapter { + /// Path to the `claude` binary; overridable for tests and odd installs. + bin: PathBuf, +} + +impl Default for ClaudeAdapter { + fn default() -> Self { + Self::new() + } +} + +impl ClaudeAdapter { + pub fn new() -> Self { + Self { + bin: PathBuf::from("claude"), + } + } + + pub fn with_bin(bin: impl Into) -> Self { + Self { bin: bin.into() } + } + + /// Build the argv for a session. Split out so it is testable without + /// spawning anything — argv bugs are otherwise only visible at runtime. + fn args(&self, ctx: &SessionCtx, mcp_config: Option<&PathBuf>) -> Vec { + let mut a: Vec = vec![ + "-p".into(), + "--output-format".into(), + "stream-json".into(), + "--input-format".into(), + "stream-json".into(), + // Required by the CLI for stream-json output. + "--verbose".into(), + // Token-level deltas rather than whole messages. + "--include-partial-messages".into(), + // Echo each message we send as the model reads it, so one sent + // mid-turn can be shown where it landed. + "--replay-user-messages".into(), + // Ask us, not nobody: a tool call that needs approval (and + // AskUserQuestion) comes over stdout as a `can_use_tool` request, + // and the user answers it in deck. + "--permission-prompt-tool".into(), + "stdio".into(), + ]; + + // Append rather than replace: the vendor's own harness stays intact and + // ours layers on top. + if !ctx.system_prompt.is_empty() { + a.push("--append-system-prompt".into()); + a.push(ctx.system_prompt.to_string()); + } + if let Some(model) = &ctx.model { + a.push("--model".into()); + a.push(model.to_string()); + } + if let Some(resume) = &ctx.resume { + a.push("--resume".into()); + a.push(resume.clone()); + } + if let Some(cfg) = mcp_config { + a.push("--mcp-config".into()); + a.push(cfg.display().to_string()); + } + if !ctx.allowed_tools.is_empty() { + a.push("--allowedTools".into()); + a.push(ctx.allowed_tools.join(",")); + } + a.push("--permission-mode".into()); + a.push( + match ctx.permission_mode { + PermissionMode::Ask => "default", + PermissionMode::Edit => "acceptEdits", + PermissionMode::Auto => "default", + PermissionMode::Yolo => "bypassPermissions", + } + .into(), + ); + a.extend(ctx.extra_args.iter().cloned()); + a + } + + /// Project the shared MCP registry into the CLI's config format. + async fn write_mcp_config(&self, ctx: &SessionCtx) -> Result, AgentError> { + let enabled: Vec<_> = ctx.mcp.iter().filter(|s| s.enabled).collect(); + if enabled.is_empty() { + return Ok(None); + } + let mut servers = serde_json::Map::new(); + for spec in enabled { + let entry = match &spec.transport { + McpTransport::Stdio { command, args, env } => serde_json::json!({ + "type": "stdio", + "command": command, + "args": args, + "env": to_json_map(env), + }), + McpTransport::Http { url, headers } => serde_json::json!({ + "type": "http", + "url": url, + "headers": to_json_map(headers), + }), + }; + servers.insert(spec.name.to_string(), entry); + } + let doc = serde_json::json!({ "mcpServers": servers }); + + tokio::fs::create_dir_all(&ctx.runtime_dir).await?; + let path = ctx.runtime_dir.join("mcp.json"); + tokio::fs::write(&path, serde_json::to_vec_pretty(&doc).expect("json")).await?; + Ok(Some(path)) + } +} + +#[async_trait] +impl AgentAdapter for ClaudeAdapter { + fn id(&self) -> AgentId { + agent_id() + } + + fn display_name(&self) -> &str { + "Claude" + } + + fn models(&self) -> &'static [agent_core::ModelSpec] { + use agent_core::ModelSpec; + // Aliases rather than pinned ids: the CLI resolves them to the current + // snapshot, so this list does not rot every release. + &[ + ModelSpec { + id: "opus", + label: "Opus", + note: "most capable", + }, + ModelSpec { + id: "sonnet", + label: "Sonnet", + note: "balanced", + }, + ModelSpec { + id: "haiku", + label: "Haiku", + note: "fastest", + }, + ] + } + + fn setup(&self) -> Option { + Some(agent_core::Setup { + verb: "Install", + command: "npm install -g @anthropic-ai/claude-code", + source: "the Claude Code docs", + }) + } + + fn caps(&self) -> Caps { + Caps { ..Caps::FULL } + } + + async fn probe(&self) -> Result { + let out = Command::new(&self.bin).arg("--version").output().await; + match out { + Ok(o) if o.status.success() => Ok(Availability::Ready { + version: Some(String::from_utf8_lossy(&o.stdout).trim().to_string()), + }), + Ok(o) => Ok(Availability::NeedsAuth { + hint: String::from_utf8_lossy(&o.stderr).trim().to_string(), + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Availability::Missing { + hint: "install the Claude Code CLI and ensure `claude` is on PATH".into(), + }), + Err(e) => Err(AgentError::Spawn(e)), + } + } + + async fn spawn(&self, ctx: SessionCtx) -> Result, AgentError> { + let mcp_config = self.write_mcp_config(&ctx).await?; + let args = self.args(&ctx, mcp_config.as_ref()); + + let mut child = Command::new(&self.bin) + .args(&args) + .envs(ctx.env.iter().map(|(k, v)| (k, v))) + .current_dir(&ctx.cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(AgentError::Spawn)?; + + let stdin = child.stdin.take().expect("stdin piped"); + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + let (tx, rx) = mpsc::channel(EVENT_BUFFER); + + // Decode stdout on its own task. The decoder lives here, so `Decoder`'s + // state never needs a lock. + let session_tx = tx.clone(); + let native_id = std::sync::Arc::new(Slot::default()); + let native_id_writer = native_id.clone(); + let asks: Asks = Default::default(); + let asks_writer = asks.clone(); + tokio::spawn(async move { + let mut decoder = decode::Decoder::new(); + let mut lines = BufReader::new(stdout).lines(); + loop { + match lines.next_line().await { + Ok(Some(line)) => { + let events = decoder.decode_line(&line); + // Filed before the event goes out, so an answer can + // never arrive for a request we have not recorded. + asks_writer.lock().extend(decoder.take_asks()); + for ev in events { + if let AgentEvent::Started { + native_session_id: Some(id), + .. + } = &ev + { + native_id_writer.set(id.clone()); + } + if session_tx.send(ev).await.is_err() { + return; // consumer dropped + } + } + } + Ok(None) => break, + Err(e) => { + let _ = session_tx + .send(AgentEvent::Fatal { + message: format!("stdout read failed: {e}"), + retryable: true, + }) + .await; + return; + } + } + } + // Stdout closed: the CLI exited. Report it so the supervisor can + // decide whether to restart. + let _ = session_tx + .send(AgentEvent::Fatal { + message: "claude exited".into(), + retryable: true, + }) + .await; + }); + + // Keep the last few stderr lines; they are the only diagnostic when the + // CLI refuses to start. + let stderr_tail = std::sync::Arc::new(Slot::default()); + let stderr_writer = stderr_tail.clone(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(target: "agent.claude.stderr", "{line}"); + stderr_writer.set(line); + } + }); + + Ok(Box::new(ClaudeSession { + child, + stdin: Some(stdin), + rx, + native_id, + stderr_tail, + resumed_from: ctx.resume.clone(), + control_seq: 0, + asks, + })) + } +} + +pub struct ClaudeSession { + child: Child, + stdin: Option, + rx: mpsc::Receiver, + native_id: std::sync::Arc, + stderr_tail: std::sync::Arc, + resumed_from: Option, + control_seq: u64, + /// Permission requests waiting for the user, by our id. + asks: Asks, +} + +type Asks = std::sync::Arc< + parking_lot::Mutex>, +>; + +impl ClaudeSession { + async fn write_line(&mut self, value: &serde_json::Value) -> Result<(), AgentError> { + let stdin = self + .stdin + .as_mut() + .ok_or(AgentError::Closed { exit: None })?; + let mut buf = serde_json::to_vec(value).expect("serializable"); + buf.push(b'\n'); + match stdin.write_all(&buf).await { + Ok(()) => stdin.flush().await.map_err(Into::into), + Err(e) => { + // Broken pipe means the CLI is gone; surface its stderr, which is + // far more useful than the io error. + self.stdin = None; + if let Some(tail) = self.stderr_tail.get() { + Err(AgentError::protocol("claude", tail, None)) + } else { + Err(AgentError::Io(e)) + } + } + } + } + + async fn respond( + &mut self, + request_id: &str, + response: serde_json::Value, + ) -> Result<(), AgentError> { + self.write_line(&serde_json::json!({ + "type": "control_response", + "response": {"subtype": "success", "request_id": request_id, "response": response} + })) + .await + } + + async fn send_text(&mut self, text: &str) -> Result<(), AgentError> { + self.write_line(&serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": [{"type": "text", "text": text}] + } + })) + .await + } +} + +#[async_trait] +impl AgentSession for ClaudeSession { + async fn send(&mut self, turn: UserTurn) -> Result<(), AgentError> { + let mut content = Vec::new(); + if !turn.text.is_empty() { + content.push(serde_json::json!({"type": "text", "text": turn.text})); + } + // Images the model can see go in as images, so it looks at them + // without a tool call; anything else (or too big to inline) is passed + // by path and the CLI reads it itself. + for att in &turn.attachments { + match inline_image(att) { + Some(block) => content.push(block), + None => content.push(serde_json::json!({ + "type": "text", + "text": format!("[attached file: {}]", att.path.display()) + })), + } + } + let mut line = serde_json::json!({ + "type": "user", + "parent_tool_use_id": null, + "message": {"role": "user", "content": content} + }); + // Sent mid-turn, the CLI queues it and reads it at the next step; + // `now` asks it to stop or background the current work instead. + if let Some(id) = &turn.id { + line["uuid"] = serde_json::json!(id); + } + if turn.now { + line["priority"] = serde_json::json!("now"); + } + self.write_line(&line).await + } + + fn events(&mut self) -> BoxStream<'_, AgentEvent> { + let rx = &mut self.rx; + Box::pin(futures::stream::poll_fn(move |cx| rx.poll_recv(cx))) + } + + async fn interrupt(&mut self) -> Result<(), AgentError> { + self.control_seq += 1; + let req = serde_json::json!({ + "type": "control_request", + "request_id": format!("int-{}", self.control_seq), + "request": {"subtype": "interrupt"} + }); + // If the control channel is not honoured, killing the child is the only + // reliable stop. The supervisor restarts it for the next turn. + if self.write_line(&req).await.is_err() { + let _ = self.child.start_kill(); + } + Ok(()) + } + + async fn decide(&mut self, id: RequestId, decision: Decision) -> Result<(), AgentError> { + let Some(ask) = self.asks.lock().remove(&id) else { + return Ok(()); // already answered or cancelled + }; + let response = match decision { + Decision::Allow => serde_json::json!({"behavior": "allow", "updatedInput": ask.input}), + // "Always" means for this chat: the CLI suggests rules saved to + // the project's settings file, which deck must not write behind + // the user's back, so they are kept to the session. + Decision::AllowAlways => serde_json::json!({ + "behavior": "allow", + "updatedInput": ask.input, + "updatedPermissions": session_rules(&ask.suggestions), + }), + Decision::Deny => serde_json::json!({ + "behavior": "deny", + "message": "The user declined this. Do not retry it; ask them, or try another way." + }), + Decision::Abort => serde_json::json!({ + "behavior": "deny", + "message": "The user declined this and stopped the turn.", + "interrupt": true + }), + }; + self.respond(&ask.request_id, response).await + } + + async fn answer( + &mut self, + id: RequestId, + answers: Vec<(String, String)>, + ) -> Result<(), AgentError> { + let Some(ask) = self.asks.lock().remove(&id) else { + return Ok(()); + }; + let mut input = ask.input; + let map: serde_json::Map = answers + .into_iter() + .map(|(q, a)| (q, serde_json::Value::String(a))) + .collect(); + if let Some(obj) = input.as_object_mut() { + obj.insert("answers".into(), serde_json::Value::Object(map)); + } + self.respond( + &ask.request_id, + serde_json::json!({"behavior": "allow", "updatedInput": input}), + ) + .await + } + + async fn hydrate(&mut self, transcript: &Transcript) -> Result { + // Resume was requested at spawn time via `--resume`, so the backend + // already holds this history; replaying it would duplicate context. + if self.resumed_from.is_some() { + return Ok(HydrateMode::NativeResume); + } + if transcript.messages.is_empty() { + return Ok(HydrateMode::Fresh); + } + let replay = replay::render(transcript, REPLAY_BUDGET); + self.send_text(&replay.text).await?; + Ok(if replay.dropped > 0 { + HydrateMode::ReplayedPartial { + dropped_messages: replay.dropped, + } + } else { + HydrateMode::Replayed + }) + } + + fn native_session_id(&self) -> Option { + self.native_id.get() + } +} + +/// The CLI's "always allow" suggestions, kept to this session: new rules and +/// directories only (not a mode switch), never written to a settings file. +fn session_rules(suggestions: &[serde_json::Value]) -> Vec { + suggestions + .iter() + .filter(|s| { + matches!( + s.get("type").and_then(|t| t.as_str()), + Some("addRules" | "addDirectories") + ) + }) + .map(|s| { + let mut s = s.clone(); + s["destination"] = serde_json::json!("session"); + s + }) + .collect() +} + +/// Largest image sent inline. The API's ceiling is 5 MB of base64, which a +/// 3.75 MB file just fits; bigger files go by path. +const INLINE_IMAGE_MAX: u64 = 3_750_000; + +fn inline_image(att: &agent_core::Attachment) -> Option { + use base64::Engine; + if !matches!( + att.mime.as_str(), + "image/png" | "image/jpeg" | "image/gif" | "image/webp" + ) { + return None; + } + if std::fs::metadata(&att.path).ok()?.len() > INLINE_IMAGE_MAX { + return None; + } + let bytes = std::fs::read(&att.path).ok()?; + Some(serde_json::json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": att.mime.as_str(), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + } + })) +} + +fn to_json_map(pairs: &[(String, String)]) -> serde_json::Map { + pairs + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect() +} + +/// A string cell shared between a background reader task and the session handle. +#[derive(Default)] +struct Slot(parking_lot::Mutex>); + +impl Slot { + fn set(&self, value: String) { + *self.0.lock() = Some(value); + } + + fn get(&self) -> Option { + self.0.lock().clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_core::McpServerSpec; + use std::sync::Arc; + + fn ctx() -> SessionCtx { + SessionCtx { + session_id: agent_core::SessionId::new(), + cwd: PathBuf::from("/tmp"), + system_prompt: Arc::from("HARNESS"), + model: Some("claude-opus-5".into()), + mcp: Arc::new(vec![]), + skills_dir: None, + runtime_dir: PathBuf::from("/tmp/does-not-matter"), + resume: None, + permission_mode: PermissionMode::Ask, + env: Vec::new(), + allowed_tools: Vec::new(), + extra_args: Vec::new(), + } + } + + #[test] + fn argv_requests_streaming_json_both_directions() { + let a = ClaudeAdapter::new().args(&ctx(), None); + let joined = a.join(" "); + assert!(joined.contains("--output-format stream-json")); + assert!(joined.contains("--input-format stream-json")); + assert!(joined.contains("--include-partial-messages")); + // The CLI rejects stream-json output without --verbose. + assert!(a.iter().any(|x| x == "--verbose")); + } + + #[test] + fn argv_appends_rather_than_replaces_the_vendor_prompt() { + let a = ClaudeAdapter::new().args(&ctx(), None); + assert!(a.iter().any(|x| x == "--append-system-prompt")); + assert!( + !a.iter().any(|x| x == "--system-prompt"), + "replacing the vendor prompt must be opt-in, not the default" + ); + let i = a + .iter() + .position(|x| x == "--append-system-prompt") + .unwrap(); + assert_eq!(a[i + 1], "HARNESS"); + } + + #[test] + fn argv_passes_resume_id_when_reattaching() { + let mut c = ctx(); + c.resume = Some("sess-123".into()); + let a = ClaudeAdapter::new().args(&c, None); + let i = a.iter().position(|x| x == "--resume").unwrap(); + assert_eq!(a[i + 1], "sess-123"); + } + + #[test] + fn permission_modes_map_to_cli_flags() { + for (mode, expected) in [ + (PermissionMode::Ask, "default"), + (PermissionMode::Edit, "acceptEdits"), + (PermissionMode::Yolo, "bypassPermissions"), + ] { + let mut c = ctx(); + c.permission_mode = mode; + let a = ClaudeAdapter::new().args(&c, None); + let i = a.iter().position(|x| x == "--permission-mode").unwrap(); + assert_eq!(a[i + 1], expected, "for {mode:?}"); + } + } + + #[tokio::test] + async fn mcp_config_is_projected_into_the_cli_format() { + let dir = std::env::temp_dir().join(format!("yourcode-test-{}", uuid_like())); + let mut c = ctx(); + c.runtime_dir = dir.clone(); + c.mcp = Arc::new(vec![ + McpServerSpec { + name: "azure".into(), + transport: McpTransport::Stdio { + command: "npx".into(), + args: vec!["-y".into(), "azure-mcp".into()], + env: vec![("TOKEN".into(), "x".into())], + }, + enabled: true, + }, + McpServerSpec { + name: "disabled".into(), + transport: McpTransport::Http { + url: "https://example.com".into(), + headers: vec![], + }, + enabled: false, + }, + ]); + + let path = ClaudeAdapter::new() + .write_mcp_config(&c) + .await + .unwrap() + .expect("config written"); + let doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(doc["mcpServers"]["azure"]["command"], "npx"); + assert_eq!(doc["mcpServers"]["azure"]["env"]["TOKEN"], "x"); + assert!( + doc["mcpServers"].get("disabled").is_none(), + "disabled servers must not be projected" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn no_mcp_config_written_when_registry_is_empty() { + assert!(ClaudeAdapter::new() + .write_mcp_config(&ctx()) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn probe_reports_missing_binary_instead_of_erroring() { + let a = ClaudeAdapter::with_bin("definitely-not-a-real-binary-xyz"); + match a.probe().await.unwrap() { + Availability::Missing { .. } => {} + other => panic!("expected Missing, got {other:?}"), + } + } + + fn uuid_like() -> u128 { + // Test-only unique suffix without pulling in uuid here. + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + } + + #[test] + fn always_allow_stays_in_the_session() { + let suggestions = vec![ + serde_json::json!({"type":"addRules","rules":[{"toolName":"Bash","ruleContent":"touch a"}],"behavior":"allow","destination":"localSettings"}), + serde_json::json!({"type":"addDirectories","directories":["/tmp/x"],"destination":"session"}), + serde_json::json!({"type":"setMode","mode":"acceptEdits","destination":"session"}), + ]; + let kept = super::session_rules(&suggestions); + assert_eq!(kept.len(), 2); + assert!(kept.iter().all(|s| s["destination"] == "session")); + assert_eq!(kept[0]["type"], "addRules"); + } +} diff --git a/src-tauri/crates/agent-claude/src/replay.rs b/src-tauri/crates/agent-claude/src/replay.rs new file mode 100644 index 00000000..868679e2 --- /dev/null +++ b/src-tauri/crates/agent-claude/src/replay.rs @@ -0,0 +1,199 @@ +//! Rendering a canonical transcript back into prose, so a *different* backend +//! can pick up a conversation another one started. +//! +//! This is what makes mid-thread agent switching work, so it is deliberately +//! plain text: no backend-specific structure, no tool-call replay (the new agent +//! must not think it already ran those tools — it is being told what happened). + +use agent_core::{Block, Role, Transcript}; + +/// How much of the replay we spend on a single tool result. +const TOOL_PREVIEW: usize = 600; + +pub struct Replay { + pub text: String, + /// Oldest messages dropped to fit the budget. + pub dropped: usize, +} + +/// Render the transcript as a handoff briefing, newest-biased. +/// +/// `budget_tokens` is approximate; we drop whole messages from the front until +/// the estimate fits, because a truncated first message reads as corruption. +pub fn render(transcript: &Transcript, budget_tokens: usize) -> Replay { + let mut start = 0; + // Cheap loop: recompute the estimate as we drop from the front. Transcripts + // are small enough (thousands, not millions) that this stays trivial. + loop { + let est = estimate(transcript, start); + if est <= budget_tokens || start >= transcript.messages.len() { + break; + } + start += 1; + } + + let mut text = String::from( + "You are taking over an in-progress conversation from another coding agent. \ + The transcript below is what happened so far. Tools listed were already \ + executed — do not re-run them unless you need fresh results.\n\n\ + \n", + ); + if start > 0 { + text.push_str("[earlier turns omitted for length]\n\n"); + } + + for msg in &transcript.messages[start..] { + let who = match msg.role { + Role::User => "User".to_string(), + Role::Assistant => match &msg.agent { + Some(a) => format!("Assistant ({a})"), + None => "Assistant".to_string(), + }, + }; + text.push_str(&who); + text.push_str(":\n"); + for block in &msg.blocks { + match block { + Block::Text { text: t, .. } => { + text.push_str(t.trim()); + text.push('\n'); + } + // Reasoning is intentionally dropped: it is another model's + // private scratchpad and mostly confuses the successor. + Block::Reasoning { .. } => {} + Block::Tool(t) => { + text.push_str(&format!("[ran {} — {}]\n", t.name, t.title)); + if let Some(out) = &t.result { + let body = out.preview(TOOL_PREVIEW); + if !body.trim().is_empty() { + text.push_str(" → "); + text.push_str(body.trim()); + text.push('\n'); + } + } + } + Block::Attachment { path, .. } => { + text.push_str(&format!("[attached {path}]\n")); + } + Block::Handoff { to, .. } => { + text.push_str(&format!("[handed off to {to}]\n")); + } + } + } + text.push('\n'); + } + text.push_str("\n"); + + Replay { + text, + dropped: start, + } +} + +fn estimate(t: &Transcript, start: usize) -> usize { + let chars: usize = t.messages[start..] + .iter() + .flat_map(|m| m.blocks.iter()) + .map(|b| match b { + Block::Text { text, .. } => text.len(), + Block::Reasoning { .. } => 0, + Block::Tool(t) => { + t.name.len() + + t.title.len() + + t.result + .as_ref() + .map_or(0, |r| r.preview(TOOL_PREVIEW).len()) + } + _ => 32, + }) + .sum(); + chars / 4 +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_core::{AgentId, ToolInvocation, ToolOutput}; + + fn transcript() -> Transcript { + let mut t = Transcript::default(); + t.push_user("does this project use gpui".into(), vec![], 0); + t.apply( + &AgentId::new("claude"), + &agent_core::AgentEvent::Reasoning { + delta: "private thoughts".into(), + }, + 0, + ); + t.apply( + &AgentId::new("claude"), + &agent_core::AgentEvent::Text { + delta: "Yes, gpui 0.2.2.".into(), + }, + 0, + ); + if let Some(m) = t.messages.last_mut() { + m.blocks.push(Block::Tool(ToolInvocation { + id: agent_core::ToolId::new("t1"), + name: "Bash".into(), + input: serde_json::Value::Null, + title: "grep gpui Cargo.toml".into(), + progress: String::new(), + result: Some(ToolOutput::Terminal { + text: "gpui = \"0.2.2\"".into(), + exit_code: Some(0), + }), + is_error: false, + })); + } + t + } + + #[test] + fn replay_includes_turns_and_tool_summaries() { + let r = render(&transcript(), 10_000); + assert_eq!(r.dropped, 0); + assert!(r.text.contains("does this project use gpui")); + assert!(r.text.contains("Assistant (claude)")); + assert!(r.text.contains("[ran Bash — grep gpui Cargo.toml]")); + assert!(r.text.contains("gpui = \"0.2.2\"")); + } + + #[test] + fn replay_omits_other_models_reasoning() { + let r = render(&transcript(), 10_000); + assert!( + !r.text.contains("private thoughts"), + "another model's scratchpad must not leak into the handoff" + ); + } + + #[test] + fn replay_tells_successor_not_to_rerun_tools() { + let r = render(&transcript(), 10_000); + assert!(r.text.contains("do not re-run")); + } + + #[test] + fn oversized_transcript_drops_oldest_and_reports_it() { + let mut t = Transcript::default(); + for i in 0..50 { + t.push_user(format!("message {i} {}", "padding ".repeat(50)), vec![], 0); + } + let r = render(&t, 200); + assert!(r.dropped > 0, "should have dropped something"); + assert!(r.text.contains("earlier turns omitted")); + assert!( + r.text.contains("message 49"), + "must keep the newest turn: {}", + &r.text[..200.min(r.text.len())] + ); + } + + #[test] + fn empty_transcript_renders_without_panicking() { + let r = render(&Transcript::default(), 100); + assert_eq!(r.dropped, 0); + assert!(r.text.contains("")); + } +} diff --git a/src-tauri/crates/agent-claude/src/wire.rs b/src-tauri/crates/agent-claude/src/wire.rs new file mode 100644 index 00000000..556bbc5e --- /dev/null +++ b/src-tauri/crates/agent-claude/src/wire.rs @@ -0,0 +1,237 @@ +//! Serde types for Claude Code's `--output-format stream-json` protocol. +//! +//! Every enum has an `Unknown` catch-all: the CLI adds event types over time, +//! and an unrecognized line must be ignored, never fatal. + +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Line { + System(SystemLine), + Assistant(MessageLine), + User(MessageLine), + Result(ResultLine), + /// Emitted only with `--include-partial-messages`; wraps a raw Anthropic + /// streaming event. + StreamEvent(StreamEventLine), + /// What became of a message sent on stdin: queued, started, completed, + /// cancelled. + CommandLifecycle(CommandLifecycleLine), + /// The CLI asks us something and waits for a `control_response`: with + /// `--permission-prompt-tool stdio`, whether a tool may run. + ControlRequest(ControlRequestLine), + /// A `control_request` that no longer needs an answer. + ControlCancelRequest(ControlCancelLine), + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +pub struct SystemLine { + #[serde(default)] + pub subtype: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub model: Option, +} + +#[derive(Debug, Deserialize)] +pub struct MessageLine { + pub message: InnerMessage, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub uuid: Option, + /// A message we sent on stdin, echoed back as the model reads it + /// (`--replay-user-messages`). + #[serde(default, rename = "isReplay")] + pub is_replay: bool, + /// Set on a sub-agent's messages, whose context is its own. + #[serde(default)] + pub parent_tool_use_id: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ControlRequestLine { + pub request_id: String, + #[serde(default)] + pub request: Value, +} + +#[derive(Debug, Deserialize)] +pub struct ControlCancelLine { + pub request_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct CommandLifecycleLine { + #[serde(default)] + pub command_uuid: Option, + #[serde(default)] + pub state: Option, +} + +#[derive(Debug, Deserialize)] +pub struct InnerMessage { + #[serde(default)] + pub content: ContentField, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub stop_reason: Option, +} + +/// `content` is a string on some user messages and an array of blocks on +/// assistant messages. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum ContentField { + Text(String), + Blocks(Vec), +} + +impl Default for ContentField { + fn default() -> Self { + ContentField::Blocks(Vec::new()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentBlock { + Text { + #[serde(default)] + text: String, + }, + Thinking { + #[serde(default)] + thinking: String, + }, + ToolUse { + id: String, + name: String, + #[serde(default)] + input: Value, + }, + ToolResult { + tool_use_id: String, + #[serde(default)] + content: Value, + #[serde(default)] + is_error: bool, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +pub struct ResultLine { + #[serde(default)] + pub subtype: Option, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub total_cost_usd: Option, + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub is_error: bool, + #[serde(default)] + pub result: Option, + /// The HTTP status of the API call that failed the turn, when one did. + #[serde(default)] + pub api_error_status: Option, + /// Per model, since this process started. + #[serde(default, rename = "modelUsage")] + pub model_usage: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct ModelUsage { + #[serde(default, rename = "contextWindow")] + pub context_window: Option, +} + +#[derive(Debug, Deserialize)] +pub struct StreamEventLine { + pub event: RawEvent, + #[serde(default)] + pub parent_tool_use_id: Option, +} + +/// Raw Anthropic Messages-API streaming events, as forwarded by the CLI. +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RawEvent { + ContentBlockStart { + index: u32, + content_block: ContentBlock, + }, + ContentBlockDelta { + index: u32, + delta: BlockDelta, + }, + ContentBlockStop { + index: u32, + }, + MessageDelta { + #[serde(default)] + usage: Option, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BlockDelta { + TextDelta { + text: String, + }, + ThinkingDelta { + thinking: String, + }, + /// Tool input arrives as incrementally-concatenated JSON fragments. + InputJsonDelta { + partial_json: String, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Default, Deserialize)] +pub struct WireUsage { + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub cache_read_input_tokens: u64, + #[serde(default)] + pub cache_creation_input_tokens: u64, +} + +impl WireUsage { + pub fn to_core(&self, cost_usd: Option) -> agent_core::Usage { + agent_core::Usage { + input: self.input_tokens, + output: self.output_tokens, + cache_read: self.cache_read_input_tokens, + cache_write: self.cache_creation_input_tokens, + cost_usd, + ..Default::default() + } + } + + /// What the context held at this call: the whole prompt, cached or + /// not, and the reply. `None` when only output was reported. + pub fn context(&self) -> Option { + let prompt = + self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens; + (prompt > 0).then_some(prompt + self.output_tokens) + } +} diff --git a/src-tauri/crates/agent-claude/tests/permissions.rs b/src-tauri/crates/agent-claude/tests/permissions.rs new file mode 100644 index 00000000..3904f48a --- /dev/null +++ b/src-tauri/crates/agent-claude/tests/permissions.rs @@ -0,0 +1,89 @@ +//! The permission round trip against a stand-in `claude`: it asks to run a +//! command over stdout, and records what deck answers on stdin. + +use agent_claude::ClaudeAdapter; +use agent_core::{AgentAdapter, AgentEvent, Decision, PermissionMode, SessionCtx, UserTurn}; +use futures::StreamExt; +use std::time::Duration; + +#[tokio::test] +async fn an_approval_goes_back_as_a_control_response() { + let dir = std::env::temp_dir().join(format!("deck-perm-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("stdin.log"); + let args = dir.join("args.log"); + let fake = dir.join("claude"); + std::fs::write( + &fake, + format!( + r#"#!/bin/sh +echo "$@" > '{args}' +read first +echo "$first" >> '{log}' +echo '{{"type":"system","subtype":"init","session_id":"s-1"}}' +echo '{{"type":"control_request","request_id":"req-7","request":{{"subtype":"can_use_tool","tool_name":"Bash","input":{{"command":"touch a"}},"permission_suggestions":[{{"type":"addRules","rules":[{{"toolName":"Bash","ruleContent":"touch a"}}],"behavior":"allow","destination":"localSettings"}}],"tool_use_id":"tu_1"}}}}' +read answer +echo "$answer" >> '{log}' +echo '{{"type":"result","subtype":"success","result":"ok"}}' +sleep 1 +"#, + args = args.display(), + log = log.display() + ), + ) + .unwrap(); + std::process::Command::new("chmod") + .arg("+x") + .arg(&fake) + .status() + .unwrap(); + + let adapter = ClaudeAdapter::with_bin(&fake); + let ctx = SessionCtx { + session_id: agent_core::SessionId::new(), + cwd: dir.clone(), + system_prompt: "".into(), + model: None, + mcp: Default::default(), + skills_dir: None, + runtime_dir: dir.join("rt"), + resume: None, + permission_mode: PermissionMode::Ask, + env: Vec::new(), + allowed_tools: Vec::new(), + extra_args: Vec::new(), + }; + let mut session = adapter.spawn(ctx).await.unwrap(); + session.send(UserTurn::text("go")).await.unwrap(); + + let id = tokio::time::timeout(Duration::from_secs(5), async { + let mut events = session.events(); + while let Some(ev) = events.next().await { + if let AgentEvent::Permission { id, .. } = ev { + return id; + } + } + panic!("no permission request"); + }) + .await + .unwrap(); + session.decide(id, Decision::AllowAlways).await.unwrap(); + tokio::time::sleep(Duration::from_millis(500)).await; + + let args = std::fs::read_to_string(&args).unwrap(); + assert!(args.contains("--permission-prompt-tool stdio"), "{args}"); + assert!(args.contains("--permission-mode default"), "{args}"); + let lines: Vec = std::fs::read_to_string(&log) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + let answer = &lines[1]; + assert_eq!(answer["type"], "control_response"); + assert_eq!(answer["response"]["request_id"], "req-7"); + let r = &answer["response"]["response"]; + assert_eq!(r["behavior"], "allow"); + assert_eq!(r["updatedInput"]["command"], "touch a"); + assert_eq!(r["updatedPermissions"][0]["destination"], "session"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/src-tauri/crates/agent-cli/Cargo.toml b/src-tauri/crates/agent-cli/Cargo.toml new file mode 100644 index 00000000..c1235844 --- /dev/null +++ b/src-tauri/crates/agent-cli/Cargo.toml @@ -0,0 +1,19 @@ +# Vendored from deck (github.com/broisnees/deck, crates/agent-cli). +[package] +name = "agent-cli" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +agent-core = { path = "../agent-core" } +agent-claude = { path = "../agent-claude" } +async-trait = "0.1" +futures = "0.3" +parking_lot = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +smol_str = { version = "0.3", features = ["serde"] } +# `net`: the local `opencode serve` is driven over HTTP. +tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync", "time", "fs", "net"] } +tracing = "0.1" diff --git a/src-tauri/crates/agent-cli/src/decode.rs b/src-tauri/crates/agent-cli/src/decode.rs new file mode 100644 index 00000000..b2008895 --- /dev/null +++ b/src-tauri/crates/agent-cli/src/decode.rs @@ -0,0 +1,1932 @@ +//! Decoders for the CLI backends. +//! +//! Each turn of these backends is one process, and each decoder reads that +//! process's stdout a line at a time. A decoder never ends the turn itself: +//! [`Decode::end`] does, once the process has exited, because only then is it +//! known whether the turn failed (a non-zero exit, an error event, or a CLI +//! that printed its complaint to stderr and nothing at all to stdout). That is +//! also where a failure is turned into something a person can read and act on, +//! instead of an empty reply. + +use crate::spec::{CliSpec, Wire}; +use agent_core::{AgentEvent, StopReason, ToolId, ToolOutput, Usage}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +/// How the turn's process ended. +#[derive(Debug, Clone, Default)] +pub struct Exit { + /// `None` when it was killed by a signal. + pub code: Option, + /// We killed it because the user pressed stop. + pub interrupted: bool, + /// The last lines it wrote to stderr. + pub stderr: Vec, +} + +impl Exit { + pub fn clean() -> Self { + Self { + code: Some(0), + ..Default::default() + } + } +} + +/// A failed turn, as the backend reported it. +#[derive(Debug, Clone, PartialEq)] +pub struct Failure { + pub status: Option, + pub message: String, +} + +/// One backend's stdout, decoded line by line. +pub trait Decode: Send { + fn decode_line(&mut self, line: &str) -> Vec; + /// The process exited: close the turn, as a failure if it was one. + fn end(&mut self, exit: &Exit) -> Vec; +} + +/// The decoder for a backend's wire format. +pub fn for_spec(spec: &'static CliSpec) -> Box { + match spec.wire { + Wire::OpenCodeJson => Box::new(OpenCodeDecoder::for_spec(spec)), + Wire::CodexJson => Box::new(CodexDecoder::new(spec)), + Wire::GeminiJson => Box::new(GeminiDecoder::new(spec)), + Wire::GrokJson => Box::new(GrokDecoder::new(spec)), + Wire::PlainText => Box::new(PlainDecoder::for_spec(spec)), + } +} + +/// What every decoder tracks about the turn in flight. +#[derive(Debug, Default)] +pub(crate) struct Turn { + /// Visible reply text went out. + text: bool, + /// Anything at all went out: text, reasoning or a tool. + any: bool, + failure: Option, + stop: Option, +} + +impl Turn { + pub(crate) fn note(&mut self, events: &[AgentEvent]) { + for e in events { + match e { + AgentEvent::Text { .. } => { + self.text = true; + self.any = true; + } + AgentEvent::Reasoning { .. } + | AgentEvent::ToolCall { .. } + | AgentEvent::ToolResult { .. } => self.any = true, + _ => {} + } + } + } + + fn fail(&mut self, raw: &str) { + let (status, message) = readable_error(raw); + self.fail_with(status, message); + } + + pub(crate) fn failed(&self) -> bool { + self.failure.is_some() + } + + pub(crate) fn fail_with(&mut self, status: Option, message: String) { + // The first error is the cause; later ones are usually its echo. + if self.failure.is_none() && !message.is_empty() { + self.failure = Some(Failure { status, message }); + } + } + + /// The turn's closing events, given how its process ended. + pub(crate) fn conclude(&mut self, exit: &Exit, spec: Option<&CliSpec>) -> Vec { + let turn = std::mem::take(self); + if exit.interrupted { + return vec![AgentEvent::TurnEnd { + stop: StopReason::Interrupted, + }]; + } + let failure = turn.failure.or_else(|| { + let said = from_stderr(&exit.stderr); + match exit.code { + Some(0) if turn.any || said.is_none() => None, + Some(0) => said.map(|message| Failure { + status: None, + message, + }), + code => Some(Failure { + status: None, + message: said.unwrap_or_else(|| { + let who = spec.map_or("the agent", |s| s.display_name); + match code { + Some(c) => format!("{who} exited with code {c} and said nothing."), + None => format!("{who} was stopped by a signal."), + } + }), + }), + } + }); + let Some(mut f) = failure else { + return vec![AgentEvent::TurnEnd { + stop: turn.stop.unwrap_or(StopReason::EndTurn), + }]; + }; + if let Some(spec) = spec { + f.message = explain(spec, &f.message); + } + let mut out = Vec::new(); + // The reply would otherwise be empty, and the error easy to miss. + if !turn.text { + out.push(AgentEvent::Text { + delta: f.message.clone(), + }); + } + out.push(AgentEvent::TurnFailed { + status: f.status.filter(|s| retryable_status(*s)), + message: f.message, + }); + out.push(AgentEvent::TurnEnd { + stop: StopReason::Error, + }); + out + } +} + +/// Statuses worth reporting as such. For the rest (400, 401, 404…) the CLI's +/// own words say more than a generic label for the code would, so the status +/// is left out and the message is what gets read. +fn retryable_status(s: u16) -> bool { + matches!(s, 408 | 429 | 500..=599) +} + +/// Say what fixes a failure the user can fix: signing in, or another model. +pub fn explain(spec: &CliSpec, message: &str) -> String { + const SIGNED_OUT: &[&str] = &[ + "not signed in", + "not logged in", + "please set an auth method", + "login required", + "please log in", + "please login", + "unauthenticated", + // vibe: "Missing MISTRAL_API_KEY environment variable for mistral provider." + "_api_key environment variable", + ]; + // codex: "The 'gpt-5.5' model is not supported when using Codex with a + // ChatGPT account." + const MODEL_REFUSED: &[&str] = &[ + "model is not supported", + "unknown model", + "model not found", + "model_not_found", + ]; + let lower = message.to_lowercase(); + let pickable = !spec.models.is_empty() || spec.list_models.is_some(); + match spec.login { + Some(login) if SIGNED_OUT.iter().any(|n| lower.contains(n)) => format!( + "{} is not signed in: run `{login}` in a terminal.", + spec.display_name + ), + // All vibe's preview TUI says when it cannot open a session, most + // often for want of an API key. + Some(login) if lower == "session/start" => format!( + "{} could not start a session. If it is not signed in, run `{login}` in a terminal.", + spec.display_name + ), + // Short enough that the app's 80-character reason keeps the fix. + _ if pickable && MODEL_REFUSED.iter().any(|n| lower.contains(n)) => match quoted(message) { + Some(model) => format!( + "{} cannot use {model} with this account: pick a model in the composer.", + spec.display_name + ), + None => format!("{message} Pick a model in the composer."), + }, + _ => message.to_string(), + } +} + +/// The first word in quotes: `gpt-5.5` from "The 'gpt-5.5' model is…". +fn quoted(s: &str) -> Option<&str> { + let (i, q) = s + .char_indices() + .find(|(_, c)| matches!(c, '\'' | '`' | '"'))?; + let rest = &s[i + 1..]; + let word = &rest[..rest.find(q)?]; + (!word.is_empty() && word.len() <= 60 && !word.contains(char::is_whitespace)).then_some(word) +} + +/// The readable part of an error a CLI reported, and its HTTP status if it +/// gave one. These arrive as JSON inside a string inside JSON (codex wraps the +/// API's error body; gemini wraps it twice), so layers are peeled until the +/// innermost `message` is reached. +pub fn readable_error(raw: &str) -> (Option, String) { + let mut status = None; + let mut msg = raw.trim().to_string(); + for _ in 0..6 { + let Some(v) = embedded_json(&msg) else { break }; + let (s, m) = dig(&v); + status = s.or(status); + match m { + Some(m) if m.trim() != msg => msg = m.trim().to_string(), + _ => break, + } + } + let flat = msg.split_whitespace().collect::>().join(" "); + (status, clip(&flat, 400)) +} + +fn embedded_json(s: &str) -> Option { + let start = s.find('{')?; + let end = s.rfind('}')?; + if end <= start { + return None; + } + serde_json::from_str::(&s[start..=end]) + .ok() + .filter(Value::is_object) +} + +fn dig(v: &Value) -> (Option, Option) { + let status = [ + "status", + "code", + "http_status", + "statusCode", + "raw_status_code", + ] + .iter() + .find_map(|k| v.get(k).and_then(Value::as_u64)) + .and_then(|n| u16::try_from(n).ok()) + .filter(|n| (100..600).contains(n)); + let own = v.get("message").and_then(Value::as_str).map(str::to_string); + for k in ["error", "data"] { + match v.get(k) { + Some(inner) if inner.is_object() => { + let (s, m) = dig(inner); + if m.is_some() { + return (s.or(status), m); + } + } + Some(Value::String(s)) if !s.is_empty() => return (status, Some(s.clone())), + _ => {} + } + } + (status, own) +} + +fn clip(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max).collect(); + out.push('…'); + out +} + +/// What a CLI said on stderr, minus the chatter these CLIs print on every +/// run. `None` when nothing meaningful is left. +pub fn from_stderr(lines: &[String]) -> Option { + const NOISE: &[&str] = &[ + "yolo mode is enabled", + "reading additional input from stdin", + "approval mode overridden", + "skill conflict detected", + "[startup]", + "256-color support", + "loaded cached credentials", + "opencode_server_password", + "duplicate skill name", + "data collection is disabled", + "level=info", + "level=debug", + "level=warn", + // vibe's preview TUI, on every failure. + "you are using the new vibe tui", + ]; + let mut kept: Vec = Vec::new(); + for line in lines { + let t = strip_ansi(line).trim().to_string(); + if t.is_empty() || t.starts_with("at ") || t.starts_with("file://") { + continue; + } + let lower = t.to_lowercase(); + if NOISE.iter().any(|n| lower.contains(n)) { + continue; + } + // grok prints its error twice, once behind "Error: ". + let t = t.strip_prefix("Error: ").unwrap_or(&t).to_string(); + if !kept.contains(&t) { + kept.push(t); + } + } + if kept.is_empty() { + return None; + } + let tail = kept[kept.len().saturating_sub(6)..].join(" "); + let (_, msg) = readable_error(&tail); + Some(msg) +} + +/// Joins separate text blocks of one reply with a blank line, so two messages +/// do not run into each other. +#[derive(Default)] +pub(crate) struct Joiner { + /// The block the last text came from. + last: Option, +} + +impl Joiner { + pub(crate) fn text(&mut self, block: &str, delta: &str) -> String { + let out = match &self.last { + Some(prev) if prev != block => format!("\n\n{delta}"), + _ => delta.to_string(), + }; + self.last = Some(block.to_string()); + out + } + + /// A tool ran: the next text starts a block of its own anyway. + pub(crate) fn break_(&mut self) { + self.last = None; + } +} + +/// Only what `full` adds to what was already sent. A value that is not an +/// extension was rewritten, so it is sent whole. +pub(crate) fn suffix(seen: &mut String, full: &str) -> String { + let delta = if full.len() >= seen.len() && full.starts_with(seen.as_str()) { + full[seen.len()..].to_string() + } else { + full.to_string() + }; + *seen = full.to_string(); + delta +} + +/// Decoder for `opencode run --format json`. +/// +/// Written against events captured from the real CLI: +/// ```text +/// {"type":"step_start","sessionID":"ses_…","part":{…,"type":"step-start"}} +/// {"type":"text","sessionID":"ses_…","part":{"id":"prt_…","type":"text","text":"Hi",…}} +/// {"type":"step_finish","sessionID":"ses_…","part":{"reason":"stop","tokens":{…},"cost":0,…}} +/// ``` +/// +/// `run` prints a text part only once it is complete, and a tool only once it +/// finished, which is why deck drives `opencode serve` instead and only falls +/// back to this. Text is still diffed per `part.id`, so a part that does +/// arrive twice is not duplicated. +#[derive(Default)] +pub struct OpenCodeDecoder { + session_id: Option, + /// Last text seen per part id, so repeated snapshots become deltas. + parts: HashMap, + tools: HashMap, + join: Joiner, + turn: Turn, + spec: Option<&'static CliSpec>, +} + +impl OpenCodeDecoder { + pub fn new() -> Self { + Self::default() + } + + fn for_spec(spec: &'static CliSpec) -> Self { + Self { + spec: Some(spec), + ..Self::default() + } + } + + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + pub fn decode_line(&mut self, line: &str) -> Vec { + let out = self.decode(line); + self.turn.note(&out); + out + } + + fn decode(&mut self, line: &str) -> Vec { + let line = line.trim(); + if line.is_empty() || !line.starts_with('{') { + return Vec::new(); + } + let Ok(v) = serde_json::from_str::(line) else { + tracing::debug!(line = %&line[..line.len().min(160)], "undecodable opencode line"); + return Vec::new(); + }; + + let mut out = Vec::new(); + if let Some(sid) = v.get("sessionID").and_then(Value::as_str) { + if self.session_id.is_none() { + self.session_id = Some(sid.to_string()); + out.push(AgentEvent::Started { + native_session_id: Some(sid.to_string()), + model: None, + }); + } + } + + let part = v.get("part"); + match v.get("type").and_then(Value::as_str).unwrap_or("") { + "text" => { + if let Some(part) = part { + let id = part.get("id").and_then(Value::as_str).unwrap_or(""); + let full = part.get("text").and_then(Value::as_str).unwrap_or(""); + let seen = self.parts.entry(id.to_string()).or_default(); + let delta = suffix(seen, full); + if !delta.is_empty() { + out.push(AgentEvent::Text { + delta: self.join.text(id, &delta), + }); + } + } + } + + "reasoning" => { + if let Some(part) = part { + let id = format!("r{}", part.get("id").and_then(Value::as_str).unwrap_or("")); + let full = part + .get("text") + .or_else(|| part.get("reasoning")) + .and_then(Value::as_str) + .unwrap_or(""); + let seen = self.parts.entry(id).or_default(); + let delta = suffix(seen, full); + if !delta.is_empty() { + out.push(AgentEvent::Reasoning { delta }); + } + } + } + + "tool" | "tool_start" | "tool_use" => { + if let Some(part) = part { + self.join.break_(); + let id = part + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let name = part + .get("tool") + .or_else(|| part.get("name")) + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(); + let state = part.get("state"); + let input = state + .and_then(|s| s.get("input")) + .or_else(|| part.get("input")) + .cloned() + .unwrap_or(Value::Null); + let status = state + .and_then(|s| s.get("status")) + .and_then(Value::as_str) + .unwrap_or(""); + + if !self.tools.contains_key(&id) { + self.tools.insert(id.clone(), name.clone()); + out.push(AgentEvent::ToolCall { + id: ToolId::new(id.as_str()), + title: tool_title(&name, &input), + name: name.into(), + input, + }); + } + + // A completed tool carries its output on the same event. + if matches!(status, "completed" | "error") { + let text = state + .and_then(|s| s.get("output").or_else(|| s.get("error"))) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + out.push(AgentEvent::ToolResult { + id: ToolId::new(id.as_str()), + output: ToolOutput::Terminal { + text, + exit_code: state + .and_then(|s| s.pointer("/metadata/exit")) + .and_then(Value::as_i64) + .map(|c| c as i32), + }, + is_error: status == "error", + }); + } + } + } + + "step_finish" => { + if let Some(u) = part.and_then(opencode_usage) { + out.push(AgentEvent::Usage(u)); + } + } + + "error" => { + let e = v.get("error").unwrap_or(&v); + if e.get("name").and_then(Value::as_str) == Some("MessageAbortedError") { + self.turn.stop = Some(StopReason::Interrupted); + } else { + let (status, message) = opencode_failure(e); + self.turn.fail_with(status, message); + } + } + + _ => {} + } + out + } + + /// Called when the process exits; a one-shot run ends its turn there. + pub fn finish(&mut self) -> Vec { + self.end(&Exit::clean()) + } + + fn reset(&mut self) { + self.parts.clear(); + self.tools.clear(); + self.join = Joiner::default(); + } +} + +impl Decode for OpenCodeDecoder { + fn decode_line(&mut self, line: &str) -> Vec { + OpenCodeDecoder::decode_line(self, line) + } + + fn end(&mut self, exit: &Exit) -> Vec { + self.reset(); + self.turn.conclude(exit, self.spec) + } +} + +/// Token counts from an opencode `step-finish` part. +pub(crate) fn opencode_usage(part: &Value) -> Option { + let t = part.get("tokens")?; + let n = |k: &str| t.get(k).and_then(Value::as_u64).unwrap_or(0); + let cn = |k: &str| { + t.pointer(&format!("/cache/{k}")) + .and_then(Value::as_u64) + .unwrap_or(0) + }; + let (input, output) = (n("input"), n("output")); + let (read, write) = (cn("read"), cn("write")); + Some(Usage { + input, + output, + cache_read: read, + cache_write: write, + cost_usd: part.get("cost").and_then(Value::as_f64), + // Each step sends the whole thread again. + context: input + read + write + output + n("reasoning"), + ..Default::default() + }) +} + +/// The status and words of an opencode error object: +/// `{"name":"APIError","data":{"message":"…","statusCode":429}}`. +pub(crate) fn opencode_failure(e: &Value) -> (Option, String) { + let raw = e + .pointer("/data/message") + .or_else(|| e.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| e.get("name").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_else(|| "opencode reported an error".into()); + let (inner, message) = readable_error(&raw); + let status = e + .pointer("/data/statusCode") + .and_then(Value::as_u64) + .and_then(|n| u16::try_from(n).ok()); + (status.or(inner), message) +} + +/// Decoder for `codex exec --json`. +/// +/// Captured from codex-cli 0.146.0: +/// ```text +/// {"type":"thread.started","thread_id":"01a14317-…"} +/// {"type":"turn.started"} +/// {"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"/bin/zsh -lc ls",…,"status":"in_progress"}} +/// {"type":"item.completed","item":{"id":"item_0","type":"command_execution",…,"aggregated_output":"a.txt\nb.rs\n","exit_code":0,"status":"completed"}} +/// {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"There are 2 files"}} +/// {"type":"turn.completed","usage":{"input_tokens":29189,"cached_input_tokens":0,…,"output_tokens":149}} +/// ``` +/// and on failure `{"type":"turn.failed","error":{"message":""}}`. +pub struct CodexDecoder { + spec: &'static CliSpec, + /// Last text seen per item, so an item updated in place sends a delta. + seen: HashMap, + /// Commands whose output was already streamed, by item id. + output: HashMap, + tools: HashSet, + join: Joiner, + turn: Turn, + /// The last top-level `error`, for a run that dies without `turn.failed`. + last_error: Option, +} + +impl CodexDecoder { + pub fn new(spec: &'static CliSpec) -> Self { + Self { + spec, + seen: HashMap::new(), + output: HashMap::new(), + tools: HashSet::new(), + join: Joiner::default(), + turn: Turn::default(), + last_error: None, + } + } + + fn item(&mut self, item: &Value, done: bool, out: &mut Vec) { + let id = item + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let s = |k: &str| item.get(k).and_then(Value::as_str).unwrap_or(""); + match s("type") { + "agent_message" => { + let seen = self.seen.entry(id.clone()).or_default(); + let delta = suffix(seen, s("text")); + if !delta.is_empty() { + out.push(AgentEvent::Text { + delta: self.join.text(&id, &delta), + }); + } + } + "reasoning" => { + let seen = self.seen.entry(format!("r{id}")).or_default(); + let delta = suffix(seen, s("text")); + if !delta.is_empty() { + out.push(AgentEvent::Reasoning { delta }); + } + } + "command_execution" => { + let command = s("command").to_string(); + self.call(&id, "bash", serde_json::json!({ "command": command }), out); + let text = s("aggregated_output"); + let seen = self.output.entry(id.clone()).or_default(); + if done { + let code = item.get("exit_code").and_then(Value::as_i64); + out.push(AgentEvent::ToolResult { + id: ToolId::new(id.as_str()), + output: ToolOutput::Terminal { + text: text.to_string(), + exit_code: code.map(|c| c as i32), + }, + is_error: s("status") == "failed" || code.is_some_and(|c| c != 0), + }); + } else { + let chunk = suffix(seen, text); + if !chunk.is_empty() { + out.push(AgentEvent::ToolProgress { + id: ToolId::new(id.as_str()), + chunk, + }); + } + } + } + "file_change" => { + let changes = item.get("changes").cloned().unwrap_or(Value::Null); + let paths: Vec = changes + .as_array() + .map(|a| { + a.iter() + .filter_map(|c| { + let path = c.get("path")?.as_str()?; + let kind = + c.get("kind").and_then(Value::as_str).unwrap_or("update"); + Some(format!("{kind} {path}")) + }) + .collect() + }) + .unwrap_or_default(); + let first = changes + .pointer("/0/path") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + self.call( + &id, + "apply_patch", + serde_json::json!({ "path": first, "changes": changes }), + out, + ); + if done { + out.push(AgentEvent::ToolResult { + id: ToolId::new(id.as_str()), + output: ToolOutput::Text(paths.join("\n")), + is_error: s("status") == "failed", + }); + } + } + "mcp_tool_call" => { + let name = format!("mcp__{}__{}", s("server"), s("tool")); + let input = item.get("arguments").cloned().unwrap_or(Value::Null); + self.call(&id, &name, input, out); + if done { + let error = item.pointer("/error/message").and_then(Value::as_str); + out.push(AgentEvent::ToolResult { + id: ToolId::new(id.as_str()), + output: match error { + Some(e) => ToolOutput::Text(e.to_string()), + None => ToolOutput::Structured( + item.get("result").cloned().unwrap_or(Value::Null), + ), + }, + is_error: error.is_some() || s("status") == "failed", + }); + } + } + "web_search" => { + let query = s("query").to_string(); + self.call( + &id, + "web_search", + serde_json::json!({ "query": query }), + out, + ); + if done { + out.push(AgentEvent::ToolResult { + id: ToolId::new(id.as_str()), + output: ToolOutput::Text(String::new()), + is_error: false, + }); + } + } + "todo_list" => { + let items = item.get("items").cloned().unwrap_or(Value::Null); + let n = items.as_array().map_or(0, Vec::len); + if self.tools.insert(id.clone()) { + out.push(AgentEvent::ToolCall { + id: ToolId::new(id.as_str()), + name: "TodoWrite".into(), + input: serde_json::json!({ "todos": items }), + title: format!("{n} items"), + }); + } + if done { + out.push(AgentEvent::ToolResult { + id: ToolId::new(id.as_str()), + output: ToolOutput::Text(String::new()), + is_error: false, + }); + } + } + // A non-fatal notice ("model metadata not found"): not part of + // the reply. + "error" => tracing::debug!(message = s("message"), "codex notice"), + other => tracing::debug!(kind = other, "unhandled codex item"), + } + } + + fn call(&mut self, id: &str, name: &str, input: Value, out: &mut Vec) { + if self.tools.insert(id.to_string()) { + self.join.break_(); + out.push(AgentEvent::ToolCall { + id: ToolId::new(id), + title: tool_title(name, &input), + name: name.into(), + input, + }); + } + } +} + +impl Decode for CodexDecoder { + fn decode_line(&mut self, line: &str) -> Vec { + let Some(v) = json_line(line) else { + return Vec::new(); + }; + let mut out = Vec::new(); + match v.get("type").and_then(Value::as_str).unwrap_or("") { + "thread.started" => { + if let Some(id) = v.get("thread_id").and_then(Value::as_str) { + out.push(AgentEvent::Started { + native_session_id: Some(id.to_string()), + model: None, + }); + } + } + "item.started" | "item.updated" => { + if let Some(item) = v.get("item") { + self.item(item, false, &mut out); + } + } + "item.completed" => { + if let Some(item) = v.get("item") { + self.item(item, true, &mut out); + } + } + "turn.completed" => { + if let Some(u) = v.get("usage") { + let n = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0); + // OpenAI counts cached tokens inside `input_tokens`. + let cached = n("cached_input_tokens"); + out.push(AgentEvent::Usage(Usage { + input: n("input_tokens").saturating_sub(cached), + output: n("output_tokens"), + cache_read: cached, + cache_write: n("cache_write_input_tokens"), + cost_usd: None, + ..Default::default() + })); + } + } + "turn.failed" => { + let msg = v + .pointer("/error/message") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| self.last_error.clone()) + .unwrap_or_else(|| "codex reported a failed turn".into()); + self.turn.fail(&msg); + } + "error" => { + self.last_error = v.get("message").and_then(Value::as_str).map(str::to_string); + } + _ => {} + } + self.turn.note(&out); + out + } + + fn end(&mut self, exit: &Exit) -> Vec { + if exit.code != Some(0) && !exit.interrupted { + if let Some(e) = self.last_error.take() { + self.turn.fail(&e); + } + } + self.seen.clear(); + self.output.clear(); + self.tools.clear(); + self.join = Joiner::default(); + self.last_error = None; + self.turn.conclude(exit, Some(self.spec)) + } +} + +/// Decoder for `gemini --output-format stream-json`. +/// +/// The schema is gemini-cli 0.53.1's `StreamJsonFormatter`; these lines were +/// captured from it: +/// ```text +/// {"type":"init","timestamp":"…","session_id":"cfd4f06c-…","model":"auto"} +/// {"type":"message","timestamp":"…","role":"user","content":"Reply with the word ok."} +/// {"type":"result","timestamp":"…","status":"error","error":{"type":"unknown","message":"[API Error: …]"},"stats":{…}} +/// ``` +/// Replies come as `{"type":"message","role":"assistant","content":"…","delta":true}`, +/// tools as `tool_use` {tool_name, tool_id, parameters} and `tool_result` +/// {tool_id, status, output, error}. +pub struct GeminiDecoder { + spec: &'static CliSpec, + tools: HashMap, + turn: Turn, +} + +impl GeminiDecoder { + pub fn new(spec: &'static CliSpec) -> Self { + Self { + spec, + tools: HashMap::new(), + turn: Turn::default(), + } + } +} + +impl Decode for GeminiDecoder { + fn decode_line(&mut self, line: &str) -> Vec { + let Some(v) = json_line(line) else { + return Vec::new(); + }; + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or(""); + let mut out = Vec::new(); + match s("type") { + "init" => out.push(AgentEvent::Started { + native_session_id: Some(s("session_id").to_string()).filter(|s| !s.is_empty()), + model: Some(s("model").into()).filter(|m: &smol_str::SmolStr| !m.is_empty()), + }), + // The prompt is echoed back as a user message. + "message" if s("role") == "assistant" => { + let delta = s("content"); + if !delta.is_empty() { + out.push(AgentEvent::Text { + delta: delta.to_string(), + }); + } + } + "tool_use" => { + let id = s("tool_id").to_string(); + let name = s("tool_name").to_string(); + let input = v.get("parameters").cloned().unwrap_or(Value::Null); + self.tools.insert(id.clone(), name.clone()); + out.push(AgentEvent::ToolCall { + id: ToolId::new(id.as_str()), + title: tool_title(&name, &input), + name: name.into(), + input, + }); + } + "tool_result" => { + let failed = s("status") == "error"; + let text = if failed { + v.pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or(s("output")) + } else { + s("output") + }; + out.push(AgentEvent::ToolResult { + id: ToolId::new(s("tool_id")), + output: ToolOutput::Terminal { + text: text.to_string(), + exit_code: None, + }, + is_error: failed, + }); + } + "error" => { + // Warnings (a loop detected, a blocked tool) let the turn go + // on; a `result` with status "error" follows a real failure. + if s("severity") == "error" { + self.turn.fail(s("message")); + } else { + tracing::debug!(message = s("message"), "gemini warning"); + } + } + "result" => { + if let Some(st) = v.get("stats") { + let n = |k: &str| st.get(k).and_then(Value::as_u64); + let cached = n("cached").unwrap_or(0); + let input = n("input") + .unwrap_or_else(|| n("input_tokens").unwrap_or(0).saturating_sub(cached)); + if n("total_tokens").unwrap_or(0) > 0 { + out.push(AgentEvent::Usage(Usage { + input, + output: n("output_tokens").unwrap_or(0), + cache_read: cached, + cache_write: 0, + cost_usd: None, + ..Default::default() + })); + } + } + if s("status") == "error" { + let msg = v + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("gemini reported an error"); + // The detailed reason beats the generic one before it. + self.turn.failure = None; + self.turn.fail(msg); + } + } + _ => {} + } + self.turn.note(&out); + out + } + + fn end(&mut self, exit: &Exit) -> Vec { + self.tools.clear(); + self.turn.conclude(exit, Some(self.spec)) + } +} + +/// Decoder for `grok --output-format streaming-json`. +/// +/// The schema is the one documented inside grok 0.2.103 itself: +/// ```text +/// {"type":"text","data":"Here's"} +/// {"type":"thought","data":"Analyzing the directory structure..."} +/// {"type":"end","stopReason":"EndTurn","sessionId":"abc123","requestId":"xyz789","usage":{…}} +/// {"type":"error","message":"Couldn't start session: ..."} +/// ``` +/// Tool calls are not part of this stream. A line that is not JSON is passed +/// through as reply text, so if the format ever changes the reply still shows. +pub struct GrokDecoder { + spec: &'static CliSpec, + plain: PlainDecoder, + turn: Turn, +} + +impl GrokDecoder { + pub fn new(spec: &'static CliSpec) -> Self { + Self { + spec, + plain: PlainDecoder::new(), + turn: Turn::default(), + } + } +} + +impl Decode for GrokDecoder { + fn decode_line(&mut self, line: &str) -> Vec { + let Some(v) = json_line(line) else { + let out = if line.trim_start().starts_with('{') { + Vec::new() + } else { + self.plain.decode_line(line) + }; + self.turn.note(&out); + return out; + }; + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or(""); + let mut out = Vec::new(); + match s("type") { + "text" if !s("data").is_empty() => out.push(AgentEvent::Text { + delta: s("data").to_string(), + }), + "thought" if !s("data").is_empty() => out.push(AgentEvent::Reasoning { + delta: s("data").to_string(), + }), + "end" => { + if !s("sessionId").is_empty() { + out.push(AgentEvent::Started { + native_session_id: Some(s("sessionId").to_string()), + model: None, + }); + } + if let Some(u) = v.get("usage") { + let n = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0); + out.push(AgentEvent::Usage(Usage { + input: n("input_tokens"), + output: n("output_tokens"), + cache_read: n("cache_read_input_tokens"), + cache_write: 0, + cost_usd: v.get("total_cost_usd").and_then(Value::as_f64), + ..Default::default() + })); + } + self.turn.stop = Some(match s("stopReason") { + "MaxTurns" | "MaxTurnRequests" | "max_turns" => StopReason::MaxTurns, + "MaxTokens" | "max_tokens" => StopReason::MaxTokens, + "Cancelled" | "cancelled" => StopReason::Interrupted, + "Refusal" | "refusal" => StopReason::Refused, + _ => StopReason::EndTurn, + }); + } + "max_turns_reached" => self.turn.stop = Some(StopReason::MaxTurns), + "error" => self.turn.fail(s("message")), + _ => {} + } + self.turn.note(&out); + out + } + + fn end(&mut self, exit: &Exit) -> Vec { + self.plain = PlainDecoder::new(); + self.turn.conclude(exit, Some(self.spec)) + } +} + +fn json_line(line: &str) -> Option { + let line = line.trim(); + if !line.starts_with('{') { + return None; + } + match serde_json::from_str::(line) { + Ok(v) => Some(v), + Err(_) => { + tracing::debug!(line = %clip(line, 160), "undecodable agent line"); + None + } + } +} + +pub(crate) fn tool_title(name: &str, input: &Value) -> String { + let s = |k: &str| input.get(k).and_then(Value::as_str).unwrap_or(""); + let first = |keys: &[&str]| { + keys.iter() + .map(|k| s(k)) + .find(|v| !v.is_empty()) + .unwrap_or("") + .to_string() + }; + let pick = match name.to_ascii_lowercase().as_str() { + "bash" | "shell" | "run_shell_command" => shell_command(s("command")), + "read" | "write" | "edit" | "read_file" | "write_file" | "replace" | "apply_patch" => { + first(&["filePath", "file_path", "absolute_path", "path"]) + } + "grep" | "glob" | "search_file_content" => first(&["pattern", "query"]), + "list_directory" | "ls" | "list" => first(&["path", "dir_path"]), + "webfetch" | "web_fetch" => first(&["url", "prompt"]), + "websearch" | "web_search" | "google_web_search" => first(&["query"]), + "task" => first(&["description", "prompt"]), + _ => first(&[ + "command", + "filePath", + "file_path", + "path", + "pattern", + "query", + ]), + }; + pick.chars().take(160).collect() +} + +/// `/bin/zsh -lc 'ls -la'` → `ls -la`: the wrapper codex runs every command +/// through is not what the model asked for. +fn shell_command(cmd: &str) -> String { + for wrapper in [ + "/bin/zsh -lc ", + "/bin/bash -lc ", + "bash -lc ", + "zsh -lc ", + "/bin/sh -c ", + ] { + if let Some(rest) = cmd.strip_prefix(wrapper) { + let rest = rest.trim(); + for q in ['\'', '"'] { + if let Some(inner) = rest.strip_prefix(q).and_then(|r| r.strip_suffix(q)) { + return inner.to_string(); + } + } + return rest.to_string(); + } + } + cmd.to_string() +} + +/// Decoder for CLIs we drive in plain-text mode. +/// +/// Everything on stdout is the reply. ANSI escapes are stripped, because several +/// of these colour their output even when not attached to a TTY, and raw escape +/// bytes in a chat bubble look like corruption. +/// +/// Except a failure printed there: vibe's preview TUI ends a failed run with +/// `Error: ` on stdout, not stderr. From a line starting `Error: ` on, +/// output is held until the exit says which it was: the reply after a clean +/// exit, the failure after any other. +#[derive(Default)] +pub struct PlainDecoder { + seen_any: bool, + held: Vec, + turn: Turn, + spec: Option<&'static CliSpec>, +} + +impl PlainDecoder { + pub fn new() -> Self { + Self::default() + } + + fn for_spec(spec: &'static CliSpec) -> Self { + Self { + spec: Some(spec), + ..Self::default() + } + } + + pub fn decode_line(&mut self, line: &str) -> Vec { + let clean = strip_ansi(line); + // Leading blank lines are banner padding, not content. + if !self.seen_any && clean.trim().is_empty() { + return Vec::new(); + } + self.seen_any = true; + if !self.held.is_empty() || clean.starts_with("Error: ") { + self.held.push(clean); + return Vec::new(); + } + let out = vec![AgentEvent::Text { + delta: format!("{clean}\n"), + }]; + self.turn.note(&out); + out + } + + pub fn finish(&mut self) -> Vec { + self.end(&Exit::clean()) + } +} + +impl Decode for PlainDecoder { + fn decode_line(&mut self, line: &str) -> Vec { + PlainDecoder::decode_line(self, line) + } + + fn end(&mut self, exit: &Exit) -> Vec { + self.seen_any = false; + let held = std::mem::take(&mut self.held); + let mut out = Vec::new(); + if !held.is_empty() { + if exit.code == Some(0) || exit.interrupted { + out = held + .into_iter() + .map(|l| AgentEvent::Text { + delta: format!("{l}\n"), + }) + .collect(); + self.turn.note(&out); + } else { + let said = held.join(" "); + self.turn + .fail(said.strip_prefix("Error: ").unwrap_or(&said)); + } + } + out.extend(self.turn.conclude(exit, self.spec)); + out + } +} + +/// Remove CSI/OSC escape sequences. +pub fn strip_ansi(s: &str) -> String { + let b = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < b.len() { + if b[i] == 0x1b { + match b.get(i + 1) { + // CSI: ends at the first byte in 0x40..=0x7e + Some(b'[') => { + i += 2; + while i < b.len() && !(0x40..=0x7e).contains(&b[i]) { + i += 1; + } + i += 1; + } + // OSC: ends at BEL or ST + Some(b']') => { + i += 2; + while i < b.len() && b[i] != 0x07 { + if b[i] == 0x1b && b.get(i + 1) == Some(&b'\\') { + i += 1; + break; + } + i += 1; + } + i += 1; + } + _ => i += 2, + } + continue; + } + // Copy one whole char so multi-byte sequences survive. + let ch_len = s[i..].chars().next().map(char::len_utf8).unwrap_or(1); + out.push_str(&s[i..i + ch_len]); + i += ch_len; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec; + + fn text_of(events: &[AgentEvent]) -> String { + events + .iter() + .filter_map(|e| match e { + AgentEvent::Text { delta } => Some(delta.as_str()), + _ => None, + }) + .collect() + } + + fn run(d: &mut dyn Decode, lines: &[&str], exit: Exit) -> Vec { + let mut all = Vec::new(); + for l in lines { + all.extend(d.decode_line(l)); + } + all.extend(d.end(&exit)); + all + } + + fn failed(code: i32, stderr: &[&str]) -> Exit { + Exit { + code: Some(code), + interrupted: false, + stderr: stderr.iter().map(|s| s.to_string()).collect(), + } + } + + fn failure_of(events: &[AgentEvent]) -> Option<(Option, String)> { + events.iter().find_map(|e| match e { + AgentEvent::TurnFailed { status, message } => Some((*status, message.clone())), + _ => None, + }) + } + + fn stop_of(events: &[AgentEvent]) -> Option { + events.iter().rev().find_map(|e| match e { + AgentEvent::TurnEnd { stop } => Some(*stop), + _ => None, + }) + } + + /// Verbatim capture from `opencode run --format json "say hi"`. + const REAL_STEP_START: &str = r#"{"type":"step_start","timestamp":1785475426548,"sessionID":"ses_0495f1f9bffesDc1ALe3Uyksmu","part":{"id":"prt_fb6a0e8f00013Dea2biNnVs0yE","messageID":"msg_fb6a0e1da001CKyXMxQWEHotVD","sessionID":"ses_0495f1f9bffesDc1ALe3Uyksmu","type":"step-start"}}"#; + const REAL_TEXT: &str = r#"{"type":"text","timestamp":1785475427417,"sessionID":"ses_0495f1f9bffesDc1ALe3Uyksmu","part":{"id":"prt_fb6a0ec24001EPU2P6RaG9Uu3h","messageID":"msg_fb6a0e1da001CKyXMxQWEHotVD","sessionID":"ses_0495f1f9bffesDc1ALe3Uyksmu","type":"text","text":"Hi","time":{"start":1785475427364,"end":1785475427401}}}"#; + const REAL_STEP_FINISH: &str = r#"{"type":"step_finish","timestamp":1785475427417,"sessionID":"ses_0495f1f9bffesDc1ALe3Uyksmu","part":{"id":"prt_fb6a0ec4e001zSKN3n7BGEHUMq","reason":"stop","messageID":"msg_fb6a0e1da001CKyXMxQWEHotVD","sessionID":"ses_0495f1f9bffesDc1ALe3Uyksmu","type":"step-finish","tokens":{"total":12843,"input":12828,"output":2,"reasoning":13,"cache":{"write":0,"read":0}},"cost":0}}"#; + + #[test] + fn real_opencode_session_decodes_end_to_end() { + let mut d = OpenCodeDecoder::new(); + let start = d.decode_line(REAL_STEP_START); + assert!(matches!( + start.first(), + Some(AgentEvent::Started { native_session_id: Some(s), .. }) + if s == "ses_0495f1f9bffesDc1ALe3Uyksmu" + )); + assert_eq!(d.session_id(), Some("ses_0495f1f9bffesDc1ALe3Uyksmu")); + + assert_eq!(text_of(&d.decode_line(REAL_TEXT)), "Hi"); + + let fin = d.decode_line(REAL_STEP_FINISH); + match fin.first() { + Some(AgentEvent::Usage(u)) => { + assert_eq!((u.input, u.output), (12828, 2)); + assert_eq!(u.context, 12843); + assert_eq!(u.cost_usd, Some(0.0)); + } + other => panic!("expected usage, got {other:?}"), + } + + assert!(matches!( + d.finish().first(), + Some(AgentEvent::TurnEnd { + stop: StopReason::EndTurn + }) + )); + } + + #[test] + fn repeated_part_snapshots_become_deltas_not_duplicates() { + // The bug this guards: opencode re-sends the whole part as it grows. + let mut d = OpenCodeDecoder::new(); + let mut all = Vec::new(); + for full in ["Hi", "Hi there", "Hi there!"] { + let line = serde_json::json!({ + "type": "text", + "sessionID": "ses_1", + "part": {"id": "prt_1", "type": "text", "text": full} + }); + all.extend(d.decode_line(&line.to_string())); + } + assert_eq!(text_of(&all), "Hi there!", "text must not be duplicated"); + } + + #[test] + fn distinct_parts_are_separated_by_a_blank_line() { + let mut d = OpenCodeDecoder::new(); + let mut all = Vec::new(); + for (id, t) in [("p1", "one"), ("p2", "two")] { + let line = serde_json::json!({ + "type": "text", "sessionID": "s", + "part": {"id": id, "type": "text", "text": t} + }); + all.extend(d.decode_line(&line.to_string())); + } + assert_eq!(text_of(&all), "one\n\ntwo"); + } + + #[test] + fn a_rewritten_part_is_re_emitted_whole() { + let mut d = OpenCodeDecoder::new(); + let mk = |t: &str| { + serde_json::json!({ + "type": "text", "sessionID": "s", + "part": {"id": "p", "type": "text", "text": t} + }) + .to_string() + }; + d.decode_line(&mk("long original text")); + // Shorter and not a prefix: treat as a replacement rather than silently + // emitting nothing. + let ev = d.decode_line(&mk("new")); + assert_eq!(text_of(&ev), "new"); + } + + #[test] + fn tool_events_emit_a_call_once_then_a_result() { + let mut d = OpenCodeDecoder::new(); + let running = serde_json::json!({ + "type": "tool", "sessionID": "s", + "part": {"id": "t1", "type": "tool", "tool": "bash", + "state": {"status": "running", "input": {"command": "ls -la"}}} + }); + let ev = d.decode_line(&running.to_string()); + // `Started` is emitted first in this same batch, so search rather than + // assuming a position. + match ev.iter().find(|e| matches!(e, AgentEvent::ToolCall { .. })) { + Some(AgentEvent::ToolCall { name, title, .. }) => { + assert_eq!(name, "bash"); + assert_eq!(title, "ls -la"); + } + other => panic!("{other:?}"), + } + + let done = serde_json::json!({ + "type": "tool", "sessionID": "s", + "part": {"id": "t1", "type": "tool", "tool": "bash", + "state": {"status": "completed", "input": {"command": "ls -la"}, + "output": "a\nb\n"}} + }); + let ev = d.decode_line(&done.to_string()); + assert!( + !ev.iter().any(|e| matches!(e, AgentEvent::ToolCall { .. })), + "the call must not be emitted twice" + ); + match ev + .iter() + .find(|e| matches!(e, AgentEvent::ToolResult { .. })) + { + Some(AgentEvent::ToolResult { + output, is_error, .. + }) => { + assert!(!is_error); + assert!(matches!(output, ToolOutput::Terminal { text, .. } if text == "a\nb\n")); + } + other => panic!("{other:?}"), + } + } + + #[test] + fn an_opencode_error_event_fails_the_turn_with_its_message() { + // `run` prints `{"type":"error","error":}`, whose + // words are in `error.data.message`; this used to fall back to a + // generic "opencode reported an error" and kill the session. + let mut d = OpenCodeDecoder::for_spec(&spec::OPENCODE); + let line = r#"{"type":"error","timestamp":1,"sessionID":"ses_1","error":{"name":"APIError","data":{"message":"Rate limit exceeded","statusCode":429}}}"#; + let ev = run(&mut d, &[line], failed(1, &[])); + assert_eq!( + failure_of(&ev), + Some((Some(429), "Rate limit exceeded".to_string())) + ); + assert!(!ev.iter().any(|e| matches!(e, AgentEvent::Fatal { .. }))); + assert_eq!(stop_of(&ev), Some(StopReason::Error)); + } + + #[test] + fn garbage_and_banner_lines_are_ignored() { + let mut d = OpenCodeDecoder::new(); + assert!(d.decode_line("█▀▀█ █▀▀█").is_empty()); + assert!(d.decode_line("").is_empty()); + assert!(d.decode_line("not json").is_empty()); + assert!(d.decode_line(r#"{"type":"brand_new"}"#).is_empty()); + // Still functional afterwards. + assert_eq!(text_of(&d.decode_line(REAL_TEXT)), "Hi"); + } + + // codex-cli 0.146.0, `codex exec --json … "Run the shell command ls…"`. + const CODEX_TOOL_RUN: &[&str] = &[ + r#"{"type":"thread.started","thread_id":"01a14317-28ae-7d32-b04b-8b3a1a31c050"}"#, + r#"{"type":"turn.started"}"#, + r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#, + r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"a.txt\nb.rs\n","exit_code":0,"status":"completed"}}"#, + r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"There are 2 files: `a.txt` and `b.rs`."}}"#, + r#"{"type":"turn.completed","usage":{"input_tokens":29189,"cached_input_tokens":0,"cache_write_input_tokens":0,"output_tokens":149,"reasoning_output_tokens":38}}"#, + ]; + + #[test] + fn real_codex_tool_turn_decodes_to_a_call_a_result_and_the_reply() { + let mut d = CodexDecoder::new(&spec::CODEX); + let ev = run(&mut d, CODEX_TOOL_RUN, Exit::clean()); + assert!(matches!( + &ev[0], + AgentEvent::Started { native_session_id: Some(id), .. } + if id == "01a14317-28ae-7d32-b04b-8b3a1a31c050" + )); + match ev.iter().find(|e| matches!(e, AgentEvent::ToolCall { .. })) { + Some(AgentEvent::ToolCall { name, title, .. }) => { + assert_eq!(name, "bash"); + assert_eq!(title, "ls", "the zsh -lc wrapper is not the command"); + } + other => panic!("{other:?}"), + } + match ev + .iter() + .find(|e| matches!(e, AgentEvent::ToolResult { .. })) + { + Some(AgentEvent::ToolResult { + output: ToolOutput::Terminal { text, exit_code }, + is_error, + .. + }) => { + assert_eq!(text, "a.txt\nb.rs\n"); + assert_eq!(*exit_code, Some(0)); + assert!(!is_error); + } + other => panic!("{other:?}"), + } + assert_eq!(text_of(&ev), "There are 2 files: `a.txt` and `b.rs`."); + assert!(ev.iter().any(|e| matches!( + e, + AgentEvent::Usage(u) if u.input == 29189 && u.output == 149 + ))); + assert_eq!(stop_of(&ev), Some(StopReason::EndTurn)); + } + + #[test] + fn codex_cached_tokens_are_not_counted_twice() { + let mut d = CodexDecoder::new(&spec::CODEX); + let ev = d.decode_line(r#"{"type":"turn.completed","usage":{"input_tokens":14502,"cached_input_tokens":6400,"cache_write_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0}}"#); + match ev.first() { + Some(AgentEvent::Usage(u)) => assert_eq!((u.input, u.cache_read), (8102, 6400)), + other => panic!("{other:?}"), + } + } + + // The user's own config named a model their ChatGPT plan does not offer: + // codex exits 1 and, in plain mode, said so only on stderr. + const CODEX_BAD_MODEL: &[&str] = &[ + r#"{"type":"thread.started","thread_id":"01a14317-8213-78c1-b07b-a010aa5632f6"}"#, + r#"{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Model metadata for `gpt-5.5` not found. Defaulting to fallback metadata; this can degrade performance and cause issues."}}"#, + r#"{"type":"turn.started"}"#, + r#"{"type":"error","message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-5.5' model is not supported when using Codex with a ChatGPT account.\"}}"}"#, + r#"{"type":"turn.failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'gpt-5.5' model is not supported when using Codex with a ChatGPT account.\"}}"}}"#, + ]; + + #[test] + fn a_failed_codex_turn_says_why_instead_of_ending_silently() { + let mut d = CodexDecoder::new(&spec::CODEX); + let ev = run( + &mut d, + CODEX_BAD_MODEL, + failed(1, &["Reading additional input from stdin..."]), + ); + let want = "Codex cannot use gpt-5.5 with this account: pick a model in the composer."; + // A 400 is left out so the app reads these words, not "rejected". + assert_eq!(failure_of(&ev), Some((None, want.to_string()))); + assert!(want.len() <= 80, "the app clips longer reasons"); + assert_eq!(text_of(&ev), want, "the reply shows the reason"); + assert_eq!(stop_of(&ev), Some(StopReason::Error)); + } + + #[test] + fn a_codex_notice_item_is_not_part_of_the_reply() { + let mut d = CodexDecoder::new(&spec::CODEX); + assert!(text_of(&d.decode_line(CODEX_BAD_MODEL[1])).is_empty()); + } + + // gemini 0.53.1, `gemini --output-format stream-json …` with an invalid + // GEMINI_API_KEY: the CLI exits 144 after these three lines. + const GEMINI_BAD_KEY: &[&str] = &[ + r#"{"type":"init","timestamp":"2026-10-16T05:02:10.880Z","session_id":"cfd4f06c-8109-4663-a13f-e622f46dc7fa","model":"auto"}"#, + r#"{"type":"message","timestamp":"2026-10-16T05:02:10.881Z","role":"user","content":"Reply with the word ok."}"#, + r#"{"type":"result","timestamp":"2026-10-16T05:02:11.919Z","status":"error","error":{"type":"unknown","message":"[API Error: {\"error\":{\"message\":\"{\\n \\\"error\\\": {\\n \\\"code\\\": 400,\\n \\\"message\\\": \\\"API key not valid. Please pass a valid API key.\\\",\\n \\\"status\\\": \\\"INVALID_ARGUMENT\\\"\\n }\\n}\\n\",\"code\":400,\"status\":\"Bad Request\"}}]"},"stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":0,"tool_calls":0,"models":{}}}"#, + ]; + + #[test] + fn real_gemini_error_is_unwrapped_to_its_innermost_message() { + let mut d = GeminiDecoder::new(&spec::GEMINI); + let ev = run(&mut d, GEMINI_BAD_KEY, failed(144, &[])); + assert!(matches!( + &ev[0], + AgentEvent::Started { native_session_id: Some(id), .. } + if id == "cfd4f06c-8109-4663-a13f-e622f46dc7fa" + )); + assert_eq!( + failure_of(&ev).unwrap().1, + "API key not valid. Please pass a valid API key." + ); + // The echoed prompt is not the reply; the error is. + assert_eq!( + text_of(&ev), + "API key not valid. Please pass a valid API key." + ); + } + + #[test] + fn gemini_reply_tools_and_usage_stream_in_order() { + // Shapes from gemini-cli 0.53.1's non-interactive loop. + let lines = [ + r#"{"type":"init","timestamp":"t","session_id":"s1","model":"gemini-3-flash"}"#, + r#"{"type":"message","timestamp":"t","role":"user","content":"list files"}"#, + r#"{"type":"tool_use","timestamp":"t","tool_name":"run_shell_command","tool_id":"run_shell_command-1","parameters":{"command":"ls","description":"List files"}}"#, + r#"{"type":"tool_result","timestamp":"t","tool_id":"run_shell_command-1","status":"success","output":"a.txt\nb.rs"}"#, + r#"{"type":"message","timestamp":"t","role":"assistant","content":"Two ","delta":true}"#, + r#"{"type":"message","timestamp":"t","role":"assistant","content":"files.","delta":true}"#, + r#"{"type":"result","timestamp":"t","status":"success","stats":{"total_tokens":120,"input_tokens":100,"output_tokens":20,"cached":40,"input":60,"duration_ms":900,"tool_calls":1,"models":{}}}"#, + ]; + let mut d = GeminiDecoder::new(&spec::GEMINI); + let ev = run(&mut d, &lines, Exit::clean()); + match ev.iter().find(|e| matches!(e, AgentEvent::ToolCall { .. })) { + Some(AgentEvent::ToolCall { title, .. }) => assert_eq!(title, "ls"), + other => panic!("{other:?}"), + } + assert!(ev.iter().any(|e| matches!( + e, + AgentEvent::ToolResult { + is_error: false, + .. + } + ))); + assert_eq!(text_of(&ev), "Two files."); + assert!(ev.iter().any(|e| matches!( + e, + AgentEvent::Usage(u) if u.input == 60 && u.cache_read == 40 && u.output == 20 + ))); + assert_eq!(stop_of(&ev), Some(StopReason::EndTurn)); + } + + #[test] + fn gemini_without_sign_in_names_the_fix() { + // Real: exit 41, nothing on stdout. + let mut d = GeminiDecoder::new(&spec::GEMINI); + let ev = run( + &mut d, + &[], + failed( + 41, + &[ + "YOLO mode is enabled. All tool calls will be automatically approved.", + "Please set an Auth method in your /Users/nees/.gemini/settings.json or specify one of the following environment variables before running: GEMINI_API_KEY, GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_GENAI_USE_GCA", + ], + ), + ); + assert_eq!( + failure_of(&ev).unwrap().1, + "Gemini is not signed in: run `gemini` in a terminal." + ); + } + + // grok 0.2.103 with no credentials: exit 1, and the same words on stdout + // (as JSON) and twice on stderr. + const GROK_SIGNED_OUT: &str = r#"{"type":"error","message":"Not signed in. To authenticate without a browser, run:\n grok login --device-code\n\nAlternatively, set the XAI_API_KEY environment variable or run `grok login` on a machine with a browser."}"#; + + #[test] + fn grok_signed_out_is_reported_with_the_login_command() { + let mut d = GrokDecoder::new(&spec::GROK); + let ev = run( + &mut d, + &[GROK_SIGNED_OUT], + failed( + 1, + &["Error: Not signed in. To authenticate without a browser, run:"], + ), + ); + let (status, message) = failure_of(&ev).unwrap(); + assert_eq!(status, None); + assert_eq!( + message, + "Grok is not signed in: run `grok login` in a terminal." + ); + assert!(message.len() < 80, "the app clips longer reasons"); + assert_eq!(stop_of(&ev), Some(StopReason::Error)); + } + + #[test] + fn grok_api_errors_are_unwrapped() { + // Real, with a bogus XAI_API_KEY. + let line = r#"{"type":"error","message":"Internal error: {\n \"message\": \"API error (status 400 Bad Request): invalid-argument: Incorrect API key provided. You can obtain an API key from https://console.x.ai.\\n\\nRequest URL: https://api.x.ai/v1/responses\",\n \"http_status\": 400\n}"}"#; + let mut d = GrokDecoder::new(&spec::GROK); + let ev = run(&mut d, &[line], failed(1, &[])); + let (_, message) = failure_of(&ev).unwrap(); + assert!( + message.starts_with( + "API error (status 400 Bad Request): invalid-argument: Incorrect API key provided." + ), + "{message}" + ); + } + + #[test] + fn grok_stream_decodes_text_thought_and_end() { + // The example in grok 0.2.103's own docs for `streaming-json`. + let lines = [ + r#"{"type":"text","data":"Here's"}"#, + r#"{"type":"text","data":" a summary"}"#, + r#"{"type":"thought","data":"Analyzing the directory structure..."}"#, + r#"{"type":"end","stopReason":"EndTurn","sessionId":"abc123","requestId":"xyz789","usage":{"input_tokens":7210,"cache_read_input_tokens":41000,"output_tokens":1893},"total_cost_usd":0.01268905}"#, + ]; + let mut d = GrokDecoder::new(&spec::GROK); + let ev = run(&mut d, &lines, Exit::clean()); + assert_eq!(text_of(&ev), "Here's a summary"); + assert!(ev.iter().any(|e| matches!(e, AgentEvent::Reasoning { .. }))); + assert!(ev.iter().any(|e| matches!( + e, + AgentEvent::Started { native_session_id: Some(id), .. } if id == "abc123" + ))); + assert!(ev.iter().any(|e| matches!( + e, + AgentEvent::Usage(u) if u.cache_read == 41000 && u.cost_usd == Some(0.01268905) + ))); + assert_eq!(stop_of(&ev), Some(StopReason::EndTurn)); + } + + #[test] + fn grok_text_that_is_not_json_still_shows() { + let mut d = GrokDecoder::new(&spec::GROK); + assert_eq!(text_of(&d.decode_line("plain reply")), "plain reply\n"); + } + + #[test] + fn an_interrupted_turn_ends_as_interrupted_not_failed() { + let mut d = CodexDecoder::new(&spec::CODEX); + d.decode_line(CODEX_TOOL_RUN[0]); + let ev = d.end(&Exit { + code: None, + interrupted: true, + stderr: vec![], + }); + assert!(failure_of(&ev).is_none()); + assert_eq!(stop_of(&ev), Some(StopReason::Interrupted)); + } + + #[test] + fn a_crash_with_nothing_said_is_still_reported() { + let mut d = PlainDecoder::for_spec(&spec::VIBE); + let ev = d.end(&failed(2, &[])); + assert_eq!( + failure_of(&ev).unwrap().1, + "Vibe exited with code 2 and said nothing." + ); + } + + #[test] + fn a_rejected_flag_is_reported_from_stderr() { + // Real: vibe 2.14.1 given the old `--auto-approve`. + let mut d = PlainDecoder::for_spec(&spec::VIBE); + let ev = d.end(&failed( + 2, + &[ + "usage: vibe [-h] [-v] [-p [TEXT]] [--max-turns N] [--max-price DOLLARS]", + "vibe: error: unrecognized arguments: --auto-approve", + ], + )); + assert!(failure_of(&ev) + .unwrap() + .1 + .ends_with("vibe: error: unrecognized arguments: --auto-approve")); + } + + /// vibe 2.26.1's preview TUI, `vibe --output text … --prompt=…` with a + /// key whose plan allows 0 requests a minute, once its retries were spent: + /// exit 1, the failure on stdout and a notice on stderr. + const VIBE_429_STDOUT: &str = r#"Error: API error occurred: Status 429. Body: {"object":"error","message":"Rate limit exceeded","type":"rate_limited","param":null,"code":"1300","raw_status_code":429}"#; + const VIBE_PREVIEW_NOTICE: &str = "You are using the new Vibe TUI, currently in preview. Please report this error at https://github.com/mistralai/mistral-vibe/issues. To switch back to the classic TUI, set VIBE_CLI=python."; + + #[test] + fn vibe_failure_on_stdout_fails_the_turn_instead_of_being_the_reply() { + let mut d = PlainDecoder::for_spec(&spec::VIBE); + let ev = run( + &mut d, + &[VIBE_429_STDOUT], + failed(1, &[VIBE_PREVIEW_NOTICE]), + ); + assert_eq!( + failure_of(&ev), + Some((Some(429), "Rate limit exceeded".into())) + ); + assert_eq!(text_of(&ev), "Rate limit exceeded", "not the raw error"); + assert_eq!(stop_of(&ev), Some(StopReason::Error)); + } + + #[test] + fn vibe_classic_failure_on_stderr_reads_the_same() { + // VIBE_CLI=python: the same words, on stderr, exit 2. + let mut d = PlainDecoder::for_spec(&spec::VIBE); + let ev = run(&mut d, &[], failed(2, &[VIBE_429_STDOUT])); + assert_eq!(failure_of(&ev).unwrap().1, "Rate limit exceeded"); + } + + #[test] + fn vibe_without_an_api_key_names_the_fix() { + // Real, with an empty VIBE_HOME: the classic CLI says why on stderr... + let mut d = PlainDecoder::for_spec(&spec::VIBE); + let ev = run( + &mut d, + &[], + failed(1, &["Error: Missing MISTRAL_API_KEY environment variable for mistral provider. Set the environment variable (e.g. in ~/.vibe/.env or your shell), or run `vibe --setup` once interactively."]), + ); + assert_eq!( + failure_of(&ev).unwrap().1, + "Vibe is not signed in: run `vibe --setup` in a terminal." + ); + // ...the preview TUI only that it could not start. + let ev = run( + &mut d, + &["Error: session/start"], + failed(1, &[VIBE_PREVIEW_NOTICE]), + ); + assert_eq!( + failure_of(&ev).unwrap().1, + "Vibe could not start a session. If it is not signed in, run `vibe --setup` in a terminal." + ); + } + + #[test] + fn a_reply_line_that_starts_with_error_is_still_the_reply() { + let mut d = PlainDecoder::for_spec(&spec::VIBE); + let ev = run( + &mut d, + &["The log says:", "Error: disk full", "so free some space."], + Exit::clean(), + ); + assert!(failure_of(&ev).is_none()); + assert_eq!( + text_of(&ev), + "The log says:\nError: disk full\nso free some space.\n" + ); + } + + #[test] + fn real_grok_1_0_stream_decodes() { + // grok 1.0.60, `--output-format streaming-json --single=Reply with ok`, + // with the tool list and the repeated thought deltas cut. + let lines = [ + r#"{"type":"available_commands","tools":["run_terminal_command","read_file"]}"#, + r#"{"type":"thought","data":"The user wants me to reply with \"ok\"."}"#, + r#"{"type":"text","data":"ok"}"#, + r#"{"type":"usage","usage":{"input_tokens":11883,"output_tokens":25,"cache_read_input_tokens":8576,"cache_creation_input_tokens":0,"reasoning_tokens":24},"signature":"yFZE"}"#, + r#"{"type":"end","stopReason":"end_turn","sessionId":"01a14416-6906-70a1-bfd5-fceab713bc69","requestId":"b5b830ae-24c8-4dc9-9f22-e18de1f0a4ed","cwd":"/tmp/grok-probe","usage":{"input_tokens":11883,"cache_read_input_tokens":8576,"cache_creation_input_tokens":0,"output_tokens":25,"reasoning_tokens":24,"total_tokens":20484}}"#, + ]; + let mut d = GrokDecoder::new(&spec::GROK); + let ev = run(&mut d, &lines, Exit::clean()); + assert_eq!(text_of(&ev), "ok"); + assert!(ev.iter().any(|e| matches!( + e, + AgentEvent::Started { native_session_id: Some(id), .. } + if id == "01a14416-6906-70a1-bfd5-fceab713bc69" + ))); + assert_eq!(stop_of(&ev), Some(StopReason::EndTurn)); + } + + #[test] + fn grok_unknown_model_is_reported_in_its_words() { + // Real: `--model grok-nope`. Grok has no model list, so no picker to + // point at. + let line = r#"{"type":"error","message":"Couldn't set model 'grok-nope': Invalid params: \"unknown model id\". Run 'grok models' to see available models."}"#; + let mut d = GrokDecoder::new(&spec::GROK); + let ev = run(&mut d, &[line], failed(1, &[])); + assert!(failure_of(&ev) + .unwrap() + .1 + .starts_with("Couldn't set model 'grok-nope'")); + } + + #[test] + fn a_clean_exit_with_only_chatter_on_stderr_is_not_a_failure() { + let mut d = GeminiDecoder::new(&spec::GEMINI); + d.decode_line(r#"{"type":"message","role":"assistant","content":"ok","delta":true}"#); + let ev = d.end(&Exit { + code: Some(0), + interrupted: false, + stderr: vec![ + "YOLO mode is enabled. All tool calls will be automatically approved.".into(), + ], + }); + assert!(failure_of(&ev).is_none()); + assert_eq!(stop_of(&ev), Some(StopReason::EndTurn)); + } + + #[test] + fn retryable_statuses_are_kept_and_others_dropped() { + let mut d = GrokDecoder::new(&spec::GROK); + let ev = run( + &mut d, + &[r#"{"type":"error","message":"{\"message\":\"overloaded\",\"http_status\":529}"}"#], + failed(1, &[]), + ); + assert_eq!(failure_of(&ev), Some((Some(529), "overloaded".into()))); + } + + #[test] + fn plain_decoder_passes_lines_through_and_drops_leading_blanks() { + let mut d = PlainDecoder::new(); + assert!(d.decode_line(" ").is_empty(), "banner padding dropped"); + assert_eq!(text_of(&d.decode_line("hello")), "hello\n"); + // Blank lines *inside* the reply are content. + assert_eq!(text_of(&d.decode_line("")), "\n"); + } + + #[test] + fn plain_decoder_strips_ansi_colour() { + let mut d = PlainDecoder::new(); + let ev = d.decode_line("\x1b[1;32mgreen\x1b[0m text"); + assert_eq!(text_of(&ev), "green text\n"); + } + + #[test] + fn strip_ansi_handles_osc_and_preserves_unicode() { + assert_eq!(strip_ansi("\x1b]0;title\x07body"), "body"); + assert_eq!(strip_ansi("héllo 🚀"), "héllo 🚀"); + assert_eq!(strip_ansi("\x1b[Kclean"), "clean"); + // Truncated escape at end of buffer must not panic. + let _ = strip_ansi("text\x1b["); + let _ = strip_ansi("text\x1b"); + } + + #[test] + fn readable_error_leaves_plain_words_alone() { + assert_eq!( + readable_error(" just words "), + (None, "just words".into()) + ); + assert_eq!( + readable_error("weird {not json} here"), + (None, "weird {not json} here".into()) + ); + } +} diff --git a/src-tauri/crates/agent-cli/src/http.rs b/src-tauri/crates/agent-cli/src/http.rs new file mode 100644 index 00000000..3916ee3a --- /dev/null +++ b/src-tauri/crates/agent-cli/src/http.rs @@ -0,0 +1,414 @@ +//! Just enough HTTP/1.1 to drive a local `opencode serve`: one request per +//! connection with a JSON body, and one long-lived server-sent-event stream. +//! It only ever talks to a loopback server we started, so there is no TLS, no +//! proxy and no redirect handling. + +use std::io; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpStream; + +#[derive(Debug, Clone)] +pub struct Endpoint { + pub host: String, + pub port: u16, + /// A ready `Authorization` header value. + pub auth: Option, +} + +impl Endpoint { + /// From `http://127.0.0.1:4096` (anything after the port is ignored). + pub fn parse(url: &str, auth: Option) -> Option { + let rest = url.trim().strip_prefix("http://")?; + let hostport = rest.split('/').next()?; + let (host, port) = hostport.rsplit_once(':')?; + Some(Self { + host: host.to_string(), + port: port.parse().ok()?, + auth, + }) + } + + fn head(&self, method: &str, path: &str, accept: &str, body_len: Option) -> String { + let mut h = format!( + "{method} {path} HTTP/1.1\r\nHost: {}:{}\r\nAccept: {accept}\r\nConnection: close\r\n", + self.host, self.port + ); + if let Some(a) = &self.auth { + h.push_str(&format!("Authorization: {a}\r\n")); + } + if let Some(n) = body_len { + h.push_str(&format!( + "Content-Type: application/json\r\nContent-Length: {n}\r\n" + )); + } + h.push_str("\r\n"); + h + } +} + +#[derive(Debug)] +pub struct Response { + pub status: u16, + pub body: Vec, +} + +impl Response { + pub fn ok(&self) -> bool { + (200..300).contains(&self.status) + } + + pub fn json(&self) -> Option { + serde_json::from_slice(&self.body).ok() + } + + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.body).trim().to_string() + } +} + +/// One request. `body` makes it carry JSON. +pub async fn request( + ep: &Endpoint, + method: &str, + path: &str, + body: Option<&serde_json::Value>, + timeout: Duration, +) -> io::Result { + let work = async { + let mut stream = TcpStream::connect((ep.host.as_str(), ep.port)).await?; + let body = match body { + Some(b) => Some(serde_json::to_vec(b).map_err(io::Error::other)?), + None if method == "GET" => None, + None => Some(Vec::new()), + }; + let head = ep.head( + method, + path, + "application/json", + body.as_ref().map(Vec::len), + ); + stream.write_all(head.as_bytes()).await?; + if let Some(b) = &body { + stream.write_all(b).await?; + } + let mut reader = BufReader::new(stream); + let head = read_head(&mut reader).await?; + let mut raw = Vec::new(); + if head.chunked { + let mut body = Body::new(true); + while let Some(chunk) = body.next(&mut reader).await? { + raw.extend_from_slice(&chunk); + } + } else if let Some(n) = head.length { + raw.resize(n, 0); + reader.read_exact(&mut raw).await?; + } else { + reader.read_to_end(&mut raw).await?; + } + Ok(Response { + status: head.status, + body: raw, + }) + }; + tokio::time::timeout(timeout, work).await.map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + format!("{method} {path} timed out"), + ) + })? +} + +struct Head { + status: u16, + chunked: bool, + length: Option, +} + +async fn read_head(r: &mut R) -> io::Result { + let mut line = String::new(); + if r.read_line(&mut line).await? == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "connection closed before a response", + )); + } + let status = line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("bad status line {line:?}"), + ) + })?; + let mut head = Head { + status, + chunked: false, + length: None, + }; + loop { + line.clear(); + if r.read_line(&mut line).await? == 0 { + break; + } + let l = line.trim_end(); + if l.is_empty() { + break; + } + if let Some((k, v)) = l.split_once(':') { + let (k, v) = (k.trim().to_ascii_lowercase(), v.trim()); + if k == "transfer-encoding" && v.to_ascii_lowercase().contains("chunked") { + head.chunked = true; + } else if k == "content-length" { + head.length = v.parse().ok(); + } + } + } + Ok(head) +} + +/// A response body read a piece at a time, chunked or not. +struct Body { + chunked: bool, + done: bool, +} + +impl Body { + fn new(chunked: bool) -> Self { + Self { + chunked, + done: false, + } + } + + async fn next(&mut self, r: &mut R) -> io::Result>> { + if self.done { + return Ok(None); + } + if !self.chunked { + let mut buf = vec![0u8; 16 * 1024]; + let n = r.read(&mut buf).await?; + if n == 0 { + self.done = true; + return Ok(None); + } + buf.truncate(n); + return Ok(Some(buf)); + } + let mut size = String::new(); + loop { + size.clear(); + if r.read_line(&mut size).await? == 0 { + self.done = true; + return Ok(None); + } + if !size.trim().is_empty() { + break; + } + } + let hex = size.trim().split(';').next().unwrap_or("").trim(); + let n = usize::from_str_radix(hex, 16).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("bad chunk size {hex:?}"), + ) + })?; + if n == 0 { + self.done = true; + return Ok(None); + } + let mut buf = vec![0u8; n]; + r.read_exact(&mut buf).await?; + // The CRLF after the chunk data. + let mut crlf = [0u8; 2]; + r.read_exact(&mut crlf).await?; + Ok(Some(buf)) + } +} + +/// A `text/event-stream`, yielding each event's `data` payload. +pub struct EventStream { + reader: BufReader, + body: Body, + pending: Vec, + data: String, +} + +impl EventStream { + pub async fn connect(ep: &Endpoint, path: &str, timeout: Duration) -> io::Result { + let work = async { + let mut stream = TcpStream::connect((ep.host.as_str(), ep.port)).await?; + stream + .write_all(ep.head("GET", path, "text/event-stream", None).as_bytes()) + .await?; + let mut reader = BufReader::new(stream); + let head = read_head(&mut reader).await?; + if !(200..300).contains(&head.status) { + return Err(io::Error::other(format!( + "event stream refused with HTTP {}", + head.status + ))); + } + Ok(Self { + reader, + body: Body::new(head.chunked), + pending: Vec::new(), + data: String::new(), + }) + }; + tokio::time::timeout(timeout, work) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "event stream did not open"))? + } + + /// The next event's data; `None` once the server closes the stream. + pub async fn next(&mut self) -> io::Result> { + loop { + while let Some(i) = self.pending.iter().position(|&b| b == b'\n') { + let raw: Vec = self.pending.drain(..=i).collect(); + let line = String::from_utf8_lossy(&raw); + let line = line.trim_end_matches(['\n', '\r']); + if line.is_empty() { + if !self.data.is_empty() { + return Ok(Some(std::mem::take(&mut self.data))); + } + } else if let Some(d) = line.strip_prefix("data:") { + if !self.data.is_empty() { + self.data.push('\n'); + } + self.data.push_str(d.strip_prefix(' ').unwrap_or(d)); + } + // `event:`, `id:`, `retry:` and `:` comments carry nothing we use. + } + match self.body.next(&mut self.reader).await? { + Some(chunk) => self.pending.extend_from_slice(&chunk), + None => return Ok(None), + } + } + } +} + +/// `Basic …` for a user and password. +pub fn basic_auth(user: &str, password: &str) -> String { + format!("Basic {}", base64(format!("{user}:{password}").as_bytes())) +} + +fn base64(input: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for c in input.chunks(3) { + let b = [c[0], *c.get(1).unwrap_or(&0), *c.get(2).unwrap_or(&0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + out.push(T[(n >> 18) as usize & 63] as char); + out.push(T[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { + T[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if c.len() > 2 { + T[n as usize & 63] as char + } else { + '=' + }); + } + out +} + +/// A query-string value: everything but unreserved characters and `/` +/// percent-encoded, so a folder with spaces, `+` or non-ASCII survives. +pub fn query_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + use tokio::net::TcpListener; + + #[test] + fn base64_matches_known_vectors() { + assert_eq!(base64(b""), ""); + assert_eq!(base64(b"f"), "Zg=="); + assert_eq!(base64(b"fo"), "Zm8="); + assert_eq!(base64(b"foo"), "Zm9v"); + assert_eq!( + basic_auth("opencode", "pw123"), + "Basic b3BlbmNvZGU6cHcxMjM=" + ); + } + + #[test] + fn query_escape_keeps_paths_readable_and_safe() { + assert_eq!(query_escape("/Users/me/my app+1"), "/Users/me/my%20app%2B1"); + assert_eq!(query_escape("/tmp/é"), "/tmp/%C3%A9"); + } + + #[test] + fn endpoint_parses_the_url_opencode_prints() { + let ep = Endpoint::parse("http://127.0.0.1:4096", None).unwrap(); + assert_eq!((ep.host.as_str(), ep.port), ("127.0.0.1", 4096)); + assert!(Endpoint::parse("https://x:1", None).is_none()); + } + + async fn serve_once(reply: &'static [u8]) -> Endpoint { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut s, _) = l.accept().await.unwrap(); + let mut buf = [0u8; 4096]; + let _ = s.read(&mut buf).await; + s.write_all(reply).await.unwrap(); + }); + Endpoint { + host: "127.0.0.1".into(), + port, + auth: None, + } + } + + #[tokio::test] + async fn reads_a_content_length_body() { + let ep = serve_once( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 36\r\n\r\n{\"healthy\":true,\"version\":\"1.18.10\"}", + ) + .await; + let r = request(&ep, "GET", "/global/health", None, Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(r.status, 200); + assert_eq!(r.json().unwrap()["version"], "1.18.10"); + } + + #[tokio::test] + async fn reads_a_chunked_event_stream_split_mid_event() { + // As `opencode serve` frames `/event`: chunked, one `data:` per event, + // and here a chunk boundary in the middle of a line. + let ep = serve_once( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n\ +14\r\ndata: {\"type\":\"a\"}\n\n\r\n\ +6\r\ndata: \r\n\ +e\r\n{\"type\":\"b\"}\n\n\r\n\ +0\r\n\r\n", + ) + .await; + let mut s = EventStream::connect(&ep, "/event", Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(s.next().await.unwrap().as_deref(), Some(r#"{"type":"a"}"#)); + assert_eq!(s.next().await.unwrap().as_deref(), Some(r#"{"type":"b"}"#)); + assert_eq!(s.next().await.unwrap(), None); + } +} diff --git a/src-tauri/crates/agent-cli/src/lib.rs b/src-tauri/crates/agent-cli/src/lib.rs new file mode 100644 index 00000000..59d9a5df --- /dev/null +++ b/src-tauri/crates/agent-cli/src/lib.rs @@ -0,0 +1,889 @@ +//! Adapters for coding CLIs driven one turn at a time. +//! +//! Unlike the Claude adapter, these CLIs have no bidirectional streaming input +//! mode: each turn is a fresh process invocation. Continuity comes from the +//! session id each CLI reports (`--resume `, `exec resume `, …) or, on +//! a handoff, from replaying our transcript — which is exactly what +//! `AgentSession::hydrate` was designed for. +//! +//! OpenCode is the exception: it has a real server (`opencode serve`), and +//! driving that instead of `opencode run` is what makes it stream (see +//! [`opencode`]). + +pub mod decode; +mod http; +pub mod models; +pub mod opencode; +pub mod spec; + +use agent_core::{ + AgentAdapter, AgentError, AgentEvent, AgentId, AgentSession, Availability, Caps, Decision, + HydrateMode, ModelSpec, RequestId, SessionCtx, Transcript, UserTurn, +}; +use async_trait::async_trait; +use decode::{Decode, Exit}; +use futures::stream::BoxStream; +use parking_lot::Mutex; +use spec::{CliSpec, Resume, Wire}; +use std::collections::VecDeque; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; + +const EVENT_BUFFER: usize = 1024; +const REPLAY_BUDGET: usize = 40_000; +/// Stderr lines kept for the error message of a failed turn. +const STDERR_TAIL: usize = 40; +/// After the CLI exits, how long something it started (a server it ran in +/// the background) may keep its stdout open before the turn is called over. +const DRAIN_GRACE: Duration = Duration::from_millis(1500); +/// How long a probe waits for the CLI's model list. A slower list still +/// arrives, for the next time the models are asked for. +const PROBE_MODELS_WAIT: Duration = Duration::from_secs(5); + +pub struct CliAdapter { + spec: &'static CliSpec, + /// The models the CLI listed, for one that can list them. + listed: Arc>, +} + +impl CliAdapter { + pub fn new(spec: &'static CliSpec) -> Self { + Self { + spec, + listed: Arc::default(), + } + } + + /// Read the CLI's model list on a thread of its own, if it has one and a + /// read is due. The receiver fires once the list is stored. + fn refresh_models(&self) -> Option> { + let list = self.spec.list_models?; + if !self.listed.lock().claim() { + return None; + } + let (done, rx) = oneshot::channel(); + let cache = self.listed.clone(); + let bin = self.spec.bin; + let read = move || { + let found = models::read(bin, &list); + cache.lock().store(found); + let _ = done.send(()); + }; + if let Err(e) = std::thread::Builder::new() + .name(format!("{bin} models")) + .spawn(read) + { + tracing::debug!(error = %e, "could not start the model list read"); + self.listed.lock().store(None); + return None; + } + Some(rx) + } + + /// Every installed CLI backend, ready to register. + pub fn all() -> Vec> { + spec::ALL + .iter() + .map(|s| Arc::new(CliAdapter::new(s)) as Arc) + .collect() + } +} + +#[async_trait] +impl AgentAdapter for CliAdapter { + fn id(&self) -> AgentId { + AgentId::new(self.spec.id) + } + + fn display_name(&self) -> &str { + self.spec.display_name + } + + fn caps(&self) -> Caps { + self.spec.caps + } + + /// Cheap enough to call on every frame: the list is read off the CLI in + /// the background, at most once a minute, and the last one returned. + fn models(&self) -> &'static [ModelSpec] { + if self.spec.list_models.is_none() { + return self.spec.models; + } + self.refresh_models(); + self.listed.lock().get().unwrap_or(self.spec.models) + } + + fn setup(&self) -> Option { + self.spec.setup + } + + async fn probe(&self) -> Result { + // Alongside, so the list is there by the time the picker is. + let listing = self.refresh_models(); + // `--version` is the cheapest liveness check that does not hit the + // network or consume credits. + let version = Command::new(self.spec.bin) + .arg("--version") + .stdin(Stdio::null()) + .output() + .await; + if let (Some(rx), Ok(_)) = (listing, &version) { + let _ = tokio::time::timeout(PROBE_MODELS_WAIT, rx).await; + } + match version { + Ok(o) if o.status.success() => Ok(Availability::Ready { + version: Some( + String::from_utf8_lossy(&o.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(), + ), + }), + Ok(_) => Ok(Availability::Ready { version: None }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Availability::Missing { + hint: format!("`{}` is not on PATH", self.spec.bin), + }), + Err(e) => Err(AgentError::Spawn(e)), + } + } + + async fn spawn(&self, mut ctx: SessionCtx) -> Result, AgentError> { + if self.spec.wire == Wire::OpenCodeJson && !opencode::serve_api_moved(self.spec.bin).await { + return Ok(Box::new(opencode::OpenCodeSession::start(self.spec, ctx))); + } + if ctx.model.is_none() && self.spec.wire == Wire::CodexJson { + ctx.model = models::codex_default(self.spec.bin).await.map(Into::into); + } + Ok(Box::new(CliSession::new(self.spec, ctx))) + } +} + +/// The text actually sent to the CLI for a turn. +/// +/// These backends take a single prompt string per invocation, so the harness +/// prompt and any handoff briefing have to ride along inside the first one — +/// there is no system-prompt flag to put them in. +pub(crate) fn compose( + turns: u32, + system_prompt: &str, + primer: &mut Option, + turn: &UserTurn, +) -> String { + let mut prompt = String::new(); + if turns == 0 { + if !system_prompt.is_empty() { + prompt.push_str(system_prompt); + prompt.push_str("\n\n"); + } + if let Some(primer) = primer.take() { + prompt.push_str(&primer); + prompt.push_str("\n\n"); + } + } + prompt.push_str(&turn.text); + for att in &turn.attachments { + prompt.push_str(&format!("\n[attached file: {}]", att.path.display())); + } + prompt +} + +/// The briefing a fresh backend gets for a thread begun elsewhere. +pub(crate) fn primer_for( + transcript: &Transcript, +) -> Result<(Option, HydrateMode), AgentError> { + if transcript.messages.is_empty() { + return Ok((None, HydrateMode::Fresh)); + } + // Reuse the Claude adapter's briefing renderer: it is backend-agnostic + // by design and already drops the previous model's private reasoning. + let replay = agent_claude::replay::render(transcript, REPLAY_BUDGET); + let dropped = replay.dropped; + Ok(( + Some(replay.text), + if dropped > 0 { + HydrateMode::ReplayedPartial { + dropped_messages: dropped, + } + } else { + HydrateMode::Replayed + }, + )) +} + +/// A running turn's process. +pub(crate) struct TurnHandle { + kill: Option>, + task: JoinHandle<()>, +} + +impl TurnHandle { + /// Stop the process; its turn then ends as interrupted. + pub(crate) fn interrupt(&mut self) { + if let Some(k) = self.kill.take() { + let _ = k.send(()); + } + } + + /// Stop it if it still runs, and wait until its last event is out, so it + /// cannot be mistaken for the next turn's. + pub(crate) async fn finish(mut self) { + if !self.task.is_finished() { + self.interrupt(); + let _ = tokio::time::timeout(Duration::from_secs(5), &mut self.task).await; + } + } +} + +/// Run one turn of a CLI: spawn it, stream its stdout through `decoder` into +/// `tx`, and end the turn — interrupted, failed with what it said, or done — +/// once it exits. +/// +/// `resuming` marks a turn that re-attaches to a session saved by an earlier +/// run of deck. If that fails before the CLI produced anything, the session +/// is reported dead (`Fatal`): that is what makes the app replay the thread +/// into a fresh one, instead of retrying a session that is gone. +pub(crate) fn spawn_turn( + spec: &'static CliSpec, + ctx: &SessionCtx, + args: Vec, + mut decoder: Box, + tx: mpsc::Sender, + native: Arc>>, + resuming: bool, +) -> Result { + let mut cmd = Command::new(spec.bin); + cmd.args(&args) + .envs(spec.env.iter().copied()) + .envs(ctx.env.iter().map(|(k, v)| (k, v))) + .current_dir(&ctx.cwd) + // None of these may wait on a terminal: codex reads stdin when it is + // not one ("Reading additional input from stdin..."), and gets EOF. + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + // Its own process group, so stop also reaches what it started (gemini + // relaunches itself under a second node process; tools run in shells). + #[cfg(unix)] + cmd.process_group(0); + let mut child = cmd.spawn().map_err(AgentError::Spawn)?; + tracing::debug!(bin = spec.bin, ?args, "agent turn started"); + + let stdout = child.stdout.take().expect("piped"); + let stderr = child.stderr.take().expect("piped"); + let (kill_tx, mut kill_rx) = oneshot::channel::<()>(); + + let task = tokio::spawn(async move { + let mut out = BufReader::new(stdout).lines(); + let mut err = BufReader::new(stderr).lines(); + let (mut out_open, mut err_open) = (true, true); + let mut tail: VecDeque = VecDeque::new(); + let mut status: Option> = None; + let mut interrupted = false; + let mut armed = true; + let mut produced = false; + let grace = tokio::time::sleep(Duration::from_secs(365 * 24 * 3600)); + tokio::pin!(grace); + + loop { + if !out_open && !err_open && status.is_some() { + break; + } + tokio::select! { + line = out.next_line(), if out_open => match line { + Ok(Some(line)) => { + for ev in decoder.decode_line(&line) { + match &ev { + AgentEvent::Started { native_session_id: Some(id), .. } => { + *native.lock() = Some(id.clone()); + } + AgentEvent::Text { .. } + | AgentEvent::Reasoning { .. } + | AgentEvent::ToolCall { .. } => produced = true, + _ => {} + } + if tx.send(ev).await.is_err() { + kill_tree(&mut child); + return; + } + } + } + _ => out_open = false, + }, + line = err.next_line(), if err_open => match line { + Ok(Some(line)) => { + tracing::debug!(target: "agent.cli.stderr", "{line}"); + tail.push_back(line); + if tail.len() > STDERR_TAIL { + tail.pop_front(); + } + } + _ => err_open = false, + }, + st = child.wait(), if status.is_none() => { + status = Some(st.ok().and_then(|s| s.code())); + grace.as_mut().reset(tokio::time::Instant::now() + DRAIN_GRACE); + } + r = &mut kill_rx, if armed => { + armed = false; + // A dropped sender means the session itself went away. + interrupted = r.is_ok(); + kill_tree(&mut child); + } + _ = &mut grace, if status.is_some() => { + tracing::debug!("agent exited but its stdout stayed open; ending the turn"); + break; + } + } + } + + let exit = Exit { + code: status.flatten(), + interrupted, + stderr: tail.into(), + }; + if exit.code != Some(0) && !exit.interrupted { + tracing::info!( + code = ?exit.code, + stderr = %exit.stderr.join(" | "), + "agent turn failed" + ); + } + let tail = decoder.end(&exit); + if resuming && !produced { + let failed = tail.iter().find_map(|e| match e { + AgentEvent::TurnFailed { message, .. } => Some(message.clone()), + _ => None, + }); + if let Some(message) = failed { + *native.lock() = None; + let _ = tx + .send(AgentEvent::Fatal { + message: format!( + "could not resume the {} session: {message}", + spec.display_name + ), + retryable: false, + }) + .await; + return; + } + } + for ev in tail { + if tx.send(ev).await.is_err() { + break; + } + } + }); + + Ok(TurnHandle { + kill: Some(kill_tx), + task, + }) +} + +/// Kill a CLI and everything in its process group. +pub(crate) fn kill_tree(child: &mut Child) { + #[cfg(unix)] + if let Some(pid) = child.id() { + // The group was made by `process_group(0)`, so its id is the pid. + let _ = Command::new("kill") + .args(["-TERM", &format!("-{pid}")]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } + let _ = child.start_kill(); +} + +pub struct CliSession { + spec: &'static CliSpec, + ctx: SessionCtx, + /// Every turn's events go out on this one channel; the session keeps a + /// sender, so the stream never ends between turns. + tx: mpsc::Sender, + rx: mpsc::Receiver, + turn: Option, + /// This chat's session id as the CLI reported it, to resume next turn. + native_session: Arc>>, + /// Transcript briefing prepended to the first prompt, set by `hydrate`. + primer: Option, + /// Turns the CLI has had in this conversation, counting the ones before + /// a resume (so the harness prompt is sent once). + turns: u32, + /// Turns this session has sent. + sent: u32, +} + +impl CliSession { + pub fn new(spec: &'static CliSpec, ctx: SessionCtx) -> Self { + let (tx, rx) = mpsc::channel(EVENT_BUFFER); + // Re-attaching to a session from an earlier run: it already has the + // harness prompt. + let turns = u32::from(ctx.resume.is_some()); + Self { + spec, + native_session: Arc::new(Mutex::new(ctx.resume.clone())), + ctx, + tx, + rx, + turn: None, + primer: None, + turns, + sent: 0, + } + } + + fn compose(&mut self, turn: &UserTurn) -> String { + compose(self.turns, &self.ctx.system_prompt, &mut self.primer, turn) + } + + /// What to resume, if anything: this chat's own session id once the CLI + /// has reported one. A CLI that reports none can only continue "the + /// latest" session, and only after a turn of ours has made it ours. + fn resume_arg(&self) -> Option { + match self.spec.resume { + Resume::Never => None, + Resume::Latest(_) => (self.turns > 0).then(String::new), + Resume::Flag(_) | Resume::Subcommand(_) => self.native_session.lock().clone(), + } + } +} + +#[async_trait] +impl AgentSession for CliSession { + async fn send(&mut self, turn: UserTurn) -> Result<(), AgentError> { + let prompt = self.compose(&turn); + let resume = self.resume_arg(); + let mut args = self.spec.args( + &prompt, + &self.ctx.cwd.to_string_lossy(), + self.ctx.model.as_deref(), + resume.as_deref(), + self.ctx.permission_mode, + ); + // Options must come before a resume subcommand and the prompt. + let at = self.spec.base_args.len(); + args.splice(at..at, self.ctx.extra_args.iter().cloned()); + self.turns += 1; + let resuming = self.sent == 0 && self.ctx.resume.is_some() && resume.is_some(); + self.sent += 1; + // The app waits for a turn to end before the next; if one is somehow + // still running, stop it first so its end is not taken for this one's. + if let Some(prev) = self.turn.take() { + prev.finish().await; + } + self.turn = Some(spawn_turn( + self.spec, + &self.ctx, + args, + decode::for_spec(self.spec), + self.tx.clone(), + self.native_session.clone(), + resuming, + )?); + Ok(()) + } + + fn events(&mut self) -> BoxStream<'_, AgentEvent> { + // Never ends: this session holds a sender. Ending would make the + // driver exit between turns. + Box::pin(futures::stream::poll_fn(move |cx| self.rx.poll_recv(cx))) + } + + async fn interrupt(&mut self) -> Result<(), AgentError> { + // No control channel exists for a one-shot process, so killing it is the + // only real stop. The next turn spawns a fresh one. + if let Some(t) = &mut self.turn { + t.interrupt(); + } + Ok(()) + } + + async fn decide(&mut self, _id: RequestId, _d: Decision) -> Result<(), AgentError> { + Err(AgentError::Unsupported( + "this CLI has no per-call permission hook; approval is set at spawn", + )) + } + + async fn hydrate(&mut self, transcript: &Transcript) -> Result { + let (primer, mode) = primer_for(transcript)?; + if primer.is_some() { + self.primer = primer; + } + Ok(mode) + } + + fn native_session_id(&self) -> Option { + self.native_session.lock().clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + use std::path::PathBuf; + + fn ctx() -> SessionCtx { + SessionCtx { + session_id: agent_core::SessionId::new(), + cwd: PathBuf::from("/tmp"), + system_prompt: Arc::from("HARNESS"), + model: None, + mcp: Arc::new(vec![]), + skills_dir: None, + runtime_dir: PathBuf::from("/tmp"), + resume: None, + permission_mode: agent_core::PermissionMode::Ask, + env: Vec::new(), + allowed_tools: Vec::new(), + extra_args: Vec::new(), + } + } + + fn session(spec: &'static CliSpec) -> CliSession { + CliSession::new(spec, ctx()) + } + + #[test] + fn the_first_prompt_carries_the_harness_and_later_ones_do_not() { + let mut s = session(&spec::CRUSH); + let first = s.compose(&UserTurn::text("hello")); + assert!(first.starts_with("HARNESS")); + assert!(first.ends_with("hello")); + + s.turns = 1; + let second = s.compose(&UserTurn::text("again")); + assert_eq!(second, "again", "harness must not be resent every turn"); + } + + #[test] + fn a_resumed_session_does_not_get_the_harness_again() { + let mut c = ctx(); + c.resume = Some("thread-1".into()); + let mut s = CliSession::new(&spec::CODEX, c); + assert_eq!(s.compose(&UserTurn::text("hi")), "hi"); + assert_eq!(s.resume_arg().as_deref(), Some("thread-1")); + } + + #[tokio::test] + async fn a_hydrated_transcript_rides_along_in_the_first_prompt() { + let mut s = session(&spec::CRUSH); + let mut t = Transcript::default(); + t.push_user("earlier question".into(), vec![], 0); + + let mode = s.hydrate(&t).await.unwrap(); + assert_eq!(mode, HydrateMode::Replayed); + + let prompt = s.compose(&UserTurn::text("follow up")); + assert!(prompt.contains("earlier question"), "briefing missing"); + assert!(prompt.contains("do not re-run")); + assert!(prompt.ends_with("follow up")); + } + + #[tokio::test] + async fn an_empty_transcript_hydrates_as_fresh_with_no_primer() { + let mut s = session(&spec::CRUSH); + assert_eq!( + s.hydrate(&Transcript::default()).await.unwrap(), + HydrateMode::Fresh + ); + assert!(s.primer.is_none()); + } + + static MISSING: CliSpec = CliSpec { + id: "nope", + display_name: "Nope", + bin: "definitely-not-installed-xyz", + wire: Wire::PlainText, + base_args: &[], + cwd_flag: None, + model_flag: None, + resume: Resume::Never, + yolo_args: &[], + prompt_flag: None, + login: None, + caps: Caps::DEGRADED, + models: &[], + list_models: None, + env: &[], + setup: None, + }; + + #[tokio::test] + async fn probe_reports_missing_binaries_rather_than_erroring() { + let a = CliAdapter::new(&MISSING); + match a.probe().await.unwrap() { + Availability::Missing { hint } => assert!(hint.contains("PATH")), + other => panic!("{other:?}"), + } + } + + #[test] + fn all_registers_every_spec() { + let all = CliAdapter::all(); + assert_eq!(all.len(), spec::ALL.len()); + let ids: Vec = all.iter().map(|a| a.id().to_string()).collect(); + for want in ["opencode", "vibe", "grok", "crush", "gemini", "codex"] { + assert!(ids.contains(&want.to_string()), "{want} not registered"); + } + } + + #[test] + fn resume_is_not_used_on_the_very_first_turn() { + // Otherwise `--continue` attaches to an unrelated prior session that + // happens to exist in this directory. + let s = session(&spec::CRUSH); + assert_eq!(s.resume_arg(), None); + let s = session(&spec::GROK); + assert_eq!(s.resume_arg(), None); + } + + #[test] + fn the_session_id_a_turn_reports_is_resumed_next_turn() { + let s = session(&spec::GEMINI); + *s.native_session.lock() = Some("cfd4f06c".into()); + assert_eq!(s.resume_arg().as_deref(), Some("cfd4f06c")); + assert_eq!(s.native_session_id().as_deref(), Some("cfd4f06c")); + } + + /// A fake CLI: a shell script standing in for the binary. + static SH: CliSpec = CliSpec { + id: "sh", + display_name: "Shell", + bin: "/bin/sh", + wire: Wire::PlainText, + base_args: &["-c"], + cwd_flag: None, + model_flag: None, + resume: Resume::Never, + yolo_args: &[], + prompt_flag: None, + login: Some("sh-login"), + caps: Caps::DEGRADED, + models: &[], + list_models: None, + env: &[], + setup: None, + }; + + async fn turn_of(script: &str) -> Vec { + turn_on(&SH, script).await + } + + async fn turn_on(spec: &'static CliSpec, script: &str) -> Vec { + let mut c = ctx(); + c.system_prompt = Arc::from(""); + let mut s = CliSession::new(spec, c); + // `sh -c -- + +{#if AGENTS_WITH_EFFORT.has(agent)} + + + + {effortLabel} + + + + agentPrefs.update((p) => ({ ...p, effort: /** @type {any} */ (v) }))} + > + {#each EFFORTS as e (e.id)} + + +
+ {e.label} + {EFFORT_NOTES[e.id]} +
+
+ {/each} +
+
+
+{/if} + + + + + + {access.label} + + + + {#if $readOnlyMode} +

+ + This connection is open read-only, so the agent is too. Turn read-only off for the connection to allow more. +

+ {/if} + agentPrefs.update((p) => ({ ...p, access: /** @type {any} */ (ACCESS.some((a) => a.id === v) ? v : 'read') }))} + > + {#each ACCESS as a (a.id)} + + +
+ {a.label} + {a.note} +
+
+ {/each} +
+
+
diff --git a/src/lib/components/AiChat.svelte b/src/lib/components/AiChat.svelte index 9de55ef5..7914b549 100644 --- a/src/lib/components/AiChat.svelte +++ b/src/lib/components/AiChat.svelte @@ -2,6 +2,8 @@ import { tick, onMount, onDestroy } from "svelte"; import Sparkles from "@lucide/svelte/icons/sparkles"; import ArrowUp from "@lucide/svelte/icons/arrow-up"; + import ArrowDown from "@lucide/svelte/icons/arrow-down"; + import CornerDownRight from "@lucide/svelte/icons/corner-down-right"; import Reply from "@lucide/svelte/icons/reply"; import Square from "@lucide/svelte/icons/square"; import Settings2 from "@lucide/svelte/icons/settings-2"; @@ -28,6 +30,8 @@ import Cpu from "@lucide/svelte/icons/cpu"; import Maximize2 from "@lucide/svelte/icons/maximize-2"; import Minimize2 from "@lucide/svelte/icons/minimize-2"; + import ResizeHandle from "$lib/components/ResizeHandle.svelte"; + import { getAppScale } from "$lib/app-zoom.js"; import ArrowDownToLine from "@lucide/svelte/icons/arrow-down-to-line"; import ZoomIn from "@lucide/svelte/icons/zoom-in"; import ZoomOut from "@lucide/svelte/icons/zoom-out"; @@ -36,7 +40,19 @@ import { Input } from "$lib/components/ui/input/index.js"; import { Label } from "$lib/components/ui/label/index.js"; import { cn } from "$lib/utils.js"; - import { executeSql, aiWebSearch, aiFetchPage, saveExportAs } from "$lib/api.js"; + import { executeSql, aiWebSearch, aiFetchPage, saveExportAs, agentStart, agentSend, agentInterrupt, agentStop } from "$lib/api.js"; + import { + profileAgent, + strokeToolName, + toolOutputText, + parseSqlResult, + agentSystemPrompt, + agentPrefs, + AGENTS_WITH_EFFORT, + stepLabel, + cleanToolError, + } from "$lib/local-agents.js"; + import AgentComposerChips from "$lib/components/AgentComposerChips.svelte"; import { isReadOnly, guardWrite } from "$lib/stores/read-only.js"; import { isWriteSql, stripSqlComments } from "$lib/sql-write.js"; import { @@ -68,7 +84,8 @@ filterSchemaForQuery, detectPromptTopics, toolsForTurn, - titleFromMessage, + conversationTitle, + isPlaceholderTitle, historyBudget, repairToolPairs, extractTextToolCalls, @@ -103,8 +120,12 @@ import Save from "@lucide/svelte/icons/save"; import Check from "@lucide/svelte/icons/check"; import Search from "@lucide/svelte/icons/search"; - import TrendingUp from "@lucide/svelte/icons/trending-up"; import Layers from "@lucide/svelte/icons/layers"; + import Hash from "@lucide/svelte/icons/hash"; + import Clock from "@lucide/svelte/icons/clock"; + import HardDrive from "@lucide/svelte/icons/hard-drive"; + import Link2 from "@lucide/svelte/icons/link-2"; + import LoaderCircle from "@lucide/svelte/icons/loader-circle"; import GitBranch from "@lucide/svelte/icons/git-branch"; import Brain from "@lucide/svelte/icons/brain"; import AiModelPicker from "./AiModelPicker.svelte"; @@ -131,9 +152,16 @@ * | { id: string, kind: 'executing', sql: string, op: 'query' | 'schema' | 'describe' | 'run' | 'diagram' } * | { id: string, kind: 'diagram', code: string, title: string } * | { id: string, kind: 'web', mode: 'search'|'fetch', label: string, hits: {title:string,url:string,snippet:string}[], error: string|null, loading: boolean } + * | { id: string, kind: 'work', startedAt: number, endedAt: number|null, steps: WorkStep[] } * } ChatItem */ + /** + * One step of a local agent's turn, folded under "Worked for 4s": a tool + * call, or a `note` (what the agent said between calls). + * @typedef {{ id: string, tool: string, op: 'query' | 'schema' | 'describe' | 'note', detail: string, status: 'running' | 'done' | 'error', error?: string }} WorkStep + */ + /** * Leading keyword of a statement, for the SQL block badge. More useful than a * generic "SQL" chip: at a glance you know whether the model is about to read or @@ -414,6 +442,11 @@ const conv = convList.find((c) => c.id === id); if (!conv) return; activeConvId = id; + clearAnchor(); + queued = []; + enterFrom = Number.MAX_SAFE_INTEGER; + stopAgentSession(); + agentNative = /** @type {any} */ (conv).agentNative ?? null; // Restore - filter out ephemeral items (including any stuck streaming items) items = /** @type {ChatItem[]} */ ( (conv.items ?? []).filter( @@ -431,6 +464,7 @@ conv.apiHistory ?? [] ); syncSeq(items); + enterFrom = items.length; error = ""; await scrollBottom(); } @@ -439,7 +473,12 @@ abortCurrentRequest(); await persistCurrent(); activeConvId = null; + clearAnchor(); + queued = []; + stopAgentSession(); + agentNative = null; items = []; + enterFrom = 0; apiHistory = []; rawApiHistory = []; error = ""; @@ -463,6 +502,9 @@ } if (activeConvId === id) { activeConvId = null; + clearAnchor(); + stopAgentSession(); + agentNative = null; items = []; apiHistory = []; rawApiHistory = []; @@ -511,6 +553,7 @@ await updateConversation(activeConvId, { items: plainItems, apiHistory: plainHistory, + agentNative: agentNative ? { ...agentNative } : null, }); } else { const conv = await createConversation({ @@ -519,6 +562,7 @@ connectionId, items: plainItems, apiHistory: plainHistory, + agentNative: agentNative ? { ...agentNative } : null, }); activeConvId = conv.id; // Prepend new conversation at the top (it is the newest) @@ -530,13 +574,17 @@ /** Generate a short title from the conversation and update the stored title. */ async function generateAiTitle() { if (!activeConvId) return; - const userMsg = rawApiHistory.find((m) => m.role === "user"); - if (!userMsg) return; - // From the first message, not from a second model call: on the free tier - // that extra request per turn is what tripped the rate limit and the - // retry wait that followed. - const title = titleFromMessage(String(userMsg.content ?? "")); - if (title.length < 3) return; + const conv = convList.find((c) => c.id === activeConvId); + // A real title stays: one the user typed, or one already taken from a + // message with content. Only a placeholder ("hi") is replaced. + if (conv?.title && !isPlaceholderTitle(conv.title)) return; + const userTexts = rawApiHistory.filter((m) => m.role === "user").map((m) => String(m.content ?? "")); + if (!userTexts.length) return; + // From the messages, not from a second model call: on the free tier that + // extra request per turn is what tripped the rate limit and the retry wait + // that followed. + const title = conversationTitle(userTexts, conv?.createdAt); + if (title.length < 3 || title === conv?.title) return; try { await updateConversation(activeConvId, { title }); convList = convList.map((c) => @@ -790,7 +838,7 @@ /** Scroll to bottom on the next animation frame (throttled; skipped if user scrolled up). */ function scrollBottomSoon() { - if (userScrolledUp) return; + if (userScrolledUp || anchorId) return; if (rafId !== null) return; rafId = requestAnimationFrame(() => { rafId = null; @@ -816,6 +864,59 @@ pinToBottom(); } + // ── Anchored turn ───────────────────────────────────────────────────────── + // On send, the user's message moves to the top of the view and the answer + // grows beneath it, as in T3 and ChatGPT, instead of the view chasing the + // bottom on every token. A spacer under the turn gives it room until the + // answer is tall enough to fill the view on its own. + /** The user message the latest turn is pinned under. */ + let anchorId = $state(/** @type {string | null} */ (null)); + /** Room under the anchored turn, in px. */ + let tailSpace = $state(0); + + /** + * Size the spacer from measurements, not guesses: just enough for the + * message to scroll up to its stop (its scroll-margin under the floating + * buttons). A guessed constant overshot, so a one-turn chat that fit on + * screen still got a scrollbar. + */ + function measureTail() { + if (!anchorId || !scrollEl || !msgListEl) return; + const anchor = msgListEl.querySelector(`[data-item-id="${anchorId}"]`); + if (!(anchor instanceof HTMLElement)) return; + const box = scrollEl.getBoundingClientRect(); + const anchorTop = anchor.getBoundingClientRect().top - box.top + scrollEl.scrollTop; + const margin = parseFloat(getComputedStyle(anchor).scrollMarginTop) || 0; + const needed = anchorTop - margin + scrollEl.clientHeight; + const withoutSpacer = scrollEl.scrollHeight - tailSpace; + const next = Math.max(0, Math.floor(needed - withoutSpacer)); + if (next !== tailSpace) tailSpace = next; + } + + $effect(() => { + if (!anchorId || !msgListEl || typeof ResizeObserver !== "function") return; + const ro = new ResizeObserver(() => measureTail()); + ro.observe(msgListEl); + return () => ro.disconnect(); + }); + + /** @param {string} id */ + async function anchorTurn(id) { + anchorId = id; + userScrolledUp = false; + await tick(); + measureTail(); + await tick(); + const anchor = msgListEl?.querySelector(`[data-item-id="${id}"]`); + const still = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; + anchor?.scrollIntoView({ block: "start", behavior: still ? "auto" : "smooth" }); + } + + function clearAnchor() { + anchorId = null; + tailSpace = 0; + } + /** * End the running turn on screen, now, whatever it is waiting on. * @@ -827,6 +928,7 @@ */ function stop() { if (!loading && !abortController) return; + interruptAgentTurn(); turnSeq++; const ctrl = abortController; abortController = null; @@ -874,6 +976,7 @@ for (const i of items.filter((i) => i.kind === "confirm")) { i.resolve(false); } + interruptAgentTurn(); turnSeq++; if (abortController) { abortController.abort(); @@ -1223,7 +1326,8 @@ const content = msgListEl; if (!content) return; const ro = new ResizeObserver(() => { - if (!userScrolledUp) pinToBottom(); + // An anchored turn keeps its place; the answer grows below it. + if (!userScrolledUp && !anchorId) pinToBottom(); }); ro.observe(content); return () => ro.disconnect(); @@ -1347,7 +1451,9 @@ (i) => i.kind === "thinking" || i.kind === "streaming" || - i.kind === "executing", + i.kind === "executing" || + // A local agent's fold already says it is working. + (i.kind === "work" && i.endedAt === null), ), ); @@ -1415,6 +1521,33 @@ // ── AI sidebar visibility (persisted) ───────────────────────────────────── const AI_SIDEBAR_KEY = "stroke:ai-sidebar-open"; + // ── Chat list width ─────────────────────────────────────────────────────── + const AI_SIDEBAR_W_KEY = "stroke:ai-sidebar-width"; + const SIDEBAR_MIN_W = 160; + const SIDEBAR_MAX_W = 420; + const SIDEBAR_DEFAULT_W = 208; + + function loadSidebarWidth() { + try { + const w = Number(localStorage.getItem(AI_SIDEBAR_W_KEY) || SIDEBAR_DEFAULT_W); + return Math.min(SIDEBAR_MAX_W, Math.max(SIDEBAR_MIN_W, w || SIDEBAR_DEFAULT_W)); + } catch { + return SIDEBAR_DEFAULT_W; + } + } + + let sidebarWidth = $state(loadSidebarWidth()); + let sidebarResizeStart = 0; + let sidebarResizeScale = 1; + + function saveSidebarWidth() { + try { + localStorage.setItem(AI_SIDEBAR_W_KEY, String(Math.round(sidebarWidth))); + } catch { + /* ignore */ + } + } + function loadSidebarPref() { try { return localStorage.getItem(AI_SIDEBAR_KEY) !== "false"; @@ -1489,6 +1622,8 @@ async function scrollBottom() { await tick(); + // An anchored turn stays where it is; the answer grows into the space below. + if (anchorId) return; userScrolledUp = false; pinToBottom(); } @@ -1669,9 +1804,39 @@ return schema ? `${q(schema)}.${q(table)}` : q(table); } + /** + * Messages sent while a turn runs. They wait above the composer and go out + * one at a time as turns end, instead of Enter doing nothing mid-turn. + * Stop leaves them waiting; switching chats drops them. + */ + let queued = $state(/** @type {{ id: string, text: string }[]} */ ([])); + + /** Send the next queued message, once nothing is running. */ + function drainQueue() { + if (loading || hasPendingConfirm || queued.length === 0) return; + const [next, ...rest] = queued; + queued = rest; + void send([next.text]); + } + + /** @param {string} id */ + function unqueue(id) { + queued = queued.filter((q) => q.id !== id); + } + async function send(/** @type {string} */ [overrideText] = []) { const text = (overrideText ?? inputText).trim(); - if (!text || loading || hasPendingConfirm) return; + if (!text || hasPendingConfirm) return; + if (loading) { + // Typed while the agent works: hold it for when the turn ends. + if (overrideText === undefined) { + queued = [...queued, { id: uid(), text }]; + inputText = ""; + resetInputHeight(); + resetHistory(); + } + return; + } error = ""; aiStatusHint = ""; if (!overrideText) { @@ -1680,12 +1845,13 @@ resetHistory(); } + const userItemId = uid(); items.push( - /** @type {ChatItem} */ ({ id: uid(), kind: "user", text, ts: Date.now() }), + /** @type {ChatItem} */ ({ id: userItemId, kind: "user", text, ts: Date.now() }), ); apiHistory.push({ role: "user", content: text }); rawApiHistory.push({ role: "user", content: text }); - await scrollBottom(); + await anchorTurn(userItemId); const thinkingId = uid(); items.push(/** @type {ChatItem} */ ({ id: thinkingId, kind: "thinking" })); @@ -1700,6 +1866,27 @@ executedCalls = new Set(); failureTracker = new Map(); + // A local agent CLI reads the schema through its own tools, so none of the + // prompt building below applies to it. + const agent = profileAgent($aiSettings); + if (agent) { + try { + const reply = await runAgentTurn(agent, $aiSettings.model ?? "", text, disowned); + if (disowned()) return; + if (reply) { + apiHistory.push({ role: "assistant", content: reply }); + rawApiHistory.push({ role: "assistant", content: reply }); + } + await persistCurrent(); + void generateAiTitle(); + } catch (e) { + if (!disowned()) error = String(/** @type {any} */ (e)?.message ?? e); + } finally { + if (!disowned()) await finishTurn(); + } + return; + } + // Proactively fetch column definitions for all tables into session cache // only when the query looks like it's asking about data (not a trivial message). const looksLikeDataQuery = @@ -1788,8 +1975,6 @@ apiHistory = managedHistory; } - const isFirstTurn = - rawApiHistory.filter((m) => m.role === "user").length === 1; try { await runAiTurn(0, ctrl); if (disowned()) return; @@ -1797,7 +1982,7 @@ rawApiHistory.push(...apiHistory.slice(managedLen)); await persistCurrent(); // Generate AI title after the first turn, in the background - if (isFirstTurn) void generateAiTitle(); + void generateAiTitle(); } catch (e) { if (/** @type {any} */ (e)?.name !== "AbortError" && !disowned()) error = String(e); } finally { @@ -1843,6 +2028,367 @@ } await tick(); inputRef?.focus(); + drainQueue(); + } + + // ── Local agent CLIs ────────────────────────────────────────────────────── + /** Wall clock for a live "Working for 12s"; ticks only while a turn runs. */ + let nowMs = $state(Date.now()); + $effect(() => { + if (!loading) return; + nowMs = Date.now(); + const timer = setInterval(() => (nowMs = Date.now()), 1000); + return () => clearInterval(timer); + }); + /** + * What the list shows for a chat. Chats saved before titles skipped small + * talk are all called "hi"; they get a title from their messages here, + * without rewriting what is stored. + * @param {{ title?: string, createdAt?: number, items?: unknown[] }} conv + */ + function displayTitle(conv) { + if (conv.title && !isPlaceholderTitle(conv.title)) return conv.title; + const texts = (conv.items ?? []) + .filter((/** @type {any} */ i) => i?.kind === "user") + .map((/** @type {any} */ i) => String(i.text ?? "")); + return texts.length ? conversationTitle(texts, conv.createdAt) : conv.title || "New chat"; + } + + /** + * An icon that says what a suggestion does. They used to be handed out by + * position, so "Schema overview" wore a bar chart and duplicates a book. + * @param {string} label + */ + function suggestionIcon(label) { + const l = label.toLowerCase(); + if (/duplicate/.test(l)) return Copy; + if (/foreign|relationship/.test(l)) return Link2; + if (/size/.test(l)) return HardDrive; + if (/count/.test(l)) return Hash; + if (/recent|activity|updated/.test(l)) return Clock; + if (/group|distribution|breakdown/.test(l)) return BarChart2; + if (/schema|overview/.test(l)) return Layers; + if (/browse|explore|rows/.test(l)) return Table2; + if (/find|search/.test(l)) return Search; + return Sparkles; + } + + /** Docked chats keep a tab strip; full mode lists chats in its sidebar. */ + const showTabBar = $derived(mode !== "full" && (openTabs.length > 0 || !activeConvId)); + + /** + * Items from this index on arrived during this visit and slide in; the ones + * a saved conversation opened with appear at once, so switching chats does + * not replay forty entrances. + */ + let enterFrom = $state(0); + + /** Folds the user opened. */ + let openWork = $state(/** @type {Set} */ (new Set())); + + /** @param {string} id */ + function toggleWork(id) { + const next = new Set(openWork); + if (next.has(id)) next.delete(id); + else next.add(id); + openWork = next; + } + + /** @param {number} ms */ + function formatElapsed(ms) { + const s = Math.max(1, Math.round(ms / 1000)); + return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; + } + + /** "2 queries · 1 schema read". @param {WorkStep[]} steps */ + function workSummary(steps) { + const queries = steps.filter((s) => s.op === "query").length; + const reads = steps.filter((s) => s.op !== "query" && s.op !== "note").length; + return [ + queries ? `${queries} ${queries === 1 ? "query" : "queries"}` : "", + reads ? `${reads} schema ${reads === 1 ? "read" : "reads"}` : "", + ].filter(Boolean).join(" · "); + } + + /** The agent session behind this chat: its backend key and what started it. */ + let agentSession = /** @type {{ key: string, agent: string, model: string, effort: string, access: string, ready: Promise } | null} */ (null); + /** The CLI's own session id for the open conversation, to resume it later. */ + let agentNative = /** @type {{ agent: string, id: string } | null} */ (null); + /** Receives the running turn's events; null between turns. */ + let agentOnEvent = /** @type {((ev: any) => void) | null} */ (null); + /** Ends the running turn early (Stop, or a switch of conversation). */ + let agentSettle = /** @type {(() => void) | null} */ (null); + + function stopAgentSession() { + if (agentSession) void agentStop(agentSession.key).catch(() => {}); + agentSession = null; + } + + function interruptAgentTurn() { + if (agentSession && agentOnEvent) void agentInterrupt(agentSession.key).catch(() => {}); + agentOnEvent = null; + agentSettle?.(); + agentSettle = null; + } + + /** + * The session to send to, started when there is none or the agent or model + * changed. A restart resumes the CLI's own session, so the agent keeps the + * conversation it had. + * @param {string} agent @param {string} model + */ + async function ensureAgentSession(agent, model) { + const effort = AGENTS_WITH_EFFORT.has(agent) ? $agentPrefs.effort : ""; + // A connection opened read-only stays read-only for the agent too. + /** @type {import('$lib/local-agents.js').AgentAccess} */ + const access = isReadOnly() ? "read" : $agentPrefs.access; + const s = agentSession; + if (s?.agent === agent && s.model === model && s.effort === effort && s.access === access) { + // Started already, or still starting from the composer's focus: wait for + // that start rather than racing it with a second one. + await s.ready; + return s.key; + } + stopAgentSession(); + const key = activeConvId ?? `draft-${uid()}`; + /** @type {(v?: unknown) => void} */ + let started = () => {}; + /** @type {(e: unknown) => void} */ + let failed = () => {}; + /** @type {Promise} */ + const ready = new Promise((res, rej) => { started = res; failed = rej; }); + // A failed start is reported by the send that awaits it, not as unhandled. + ready.catch(() => {}); + const session = { key, agent, model, effort, access, ready }; + agentSession = session; + const tables = (schemaContext.tables ?? []) + .map((/** @type {any} */ t) => (typeof t === "string" ? t : t?.name)) + .filter(Boolean); + try { + await agentStart( + { + conversationId: key, + agent, + model, + systemPrompt: agentSystemPrompt({ + dbType: schemaContext.dbType, + activeSchema: schemaContext.activeSchema, + activeTable: schemaContext.activeTable, + tables, + access, + }), + resume: agentNative?.agent === agent ? agentNative.id : null, + effort: effort || null, + access, + }, + (ev) => { + if (agentSession !== session) return; + const native = ev.Started?.native_session_id; + if (native) agentNative = { agent, id: native }; + if (ev.Fatal) agentSession = null; + agentOnEvent?.(ev); + }, + ); + started(); + } catch (e) { + if (agentSession === session) agentSession = null; + failed(e); + throw e; + } + return key; + } + + /** + * Start the agent while the user is still typing, as T3 and deck do: the + * CLI's boot (a second or two for Claude Code) is paid before Send, not after. + */ + function prewarmAgent() { + const agent = profileAgent($aiSettings); + if (!agent || loading) return; + ensureAgentSession(agent, $aiSettings.model ?? "").catch(() => {}); + } + + /** + * One turn with a local agent, drawn with the same items as the HTTP path: + * streamed text, a query card per Stroke tool call, result tables. + * Resolves with the reply's text. + * @param {string} agent @param {string} model @param {string} text + * @param {() => boolean} disowned + * @returns {Promise} + */ + async function runAgentTurn(agent, model, text, disowned) { + const key = await ensureAgentSession(agent, model); + let content = ""; + let reply = ""; + let itemId = /** @type {string | null} */ (null); + /** The turn's one "Worked for" fold, made at its first tool call or note. */ + let workId = /** @type {string | null} */ (null); + /** @type {Map} */ + const tools = new Map(); + /** @param {string} id @param {(w: any) => any} fn */ + const patchWork = (id, fn) => { + items = items.map((i) => (i.id === id && i.kind === "work" ? fn(i) : i)); + }; + /** Stop the fold's clock while the agent writes; the next call resumes it. */ + const closeWork = () => { + if (!workId) return; + patchWork(workId, (w) => (w.endedAt === null ? { ...w, endedAt: Date.now() } : w)); + }; + const dropThinking = () => { + const i = items.findIndex((x) => x.kind === "thinking"); + if (i >= 0) items.splice(i, 1); + }; + /** Finish the streamed text so a tool card can follow it. */ + const closeText = () => { + closeWork(); + if (!itemId) return; + flushStreamingContent(); + const id = itemId; + const parts = parseAssistantMessage(content); + items = items.map((i) => + i.id === id ? /** @type {ChatItem} */ ({ id, kind: "assistant", parts, ts: Date.now() }) : i, + ); + reply += (reply ? "\n\n" : "") + content; + itemId = null; + streamingId = null; + streamingContent = ""; + _pendingStreamContent = ""; + content = ""; + }; + /** + * Text written before a tool call was narration ("I'll check the schema"), + * not the answer: it moves into the fold, so only the reply that ends the + * turn stays in the transcript, as T3 does. + */ + const foldText = () => { + if (!itemId) return; + flushStreamingContent(); + const id = itemId; + const note = content.trim(); + itemId = null; + streamingId = null; + streamingContent = ""; + _pendingStreamContent = ""; + content = ""; + if (!workId) { + // The fold takes the narration's place in the transcript. + const wid = uid(); + workId = wid; + items = items.map((i) => + i.id === id ? /** @type {ChatItem} */ ({ id: wid, kind: "work", startedAt: Date.now(), endedAt: null, steps: [] }) : i, + ); + } else { + items = items.filter((i) => i.id !== id); + } + if (note) { + /** @type {WorkStep} */ + const step = { id: uid(), tool: "note", op: "note", detail: note, status: "done" }; + patchWork(workId, (w) => ({ ...w, steps: [...w.steps, step] })); + } + }; + /** @param {any} call */ + const onToolCall = (call) => { + const tool = strokeToolName(String(call.name ?? "")); + const input = call.input ?? {}; + /** @type {WorkStep['op']} */ + const op = tool === "execute_sql" || tool === "explain_query" ? "query" + : tool === "describe_table" ? "describe" : "schema"; + const detail = op === "query" ? String(input.sql ?? "") + : op === "describe" ? String(input.table ?? "") : tool.replace(/_/g, " "); + const known = tools.get(call.id); + if (known) { + // The same call again with its full input. + known.detail = detail || known.detail; + patchWork(known.workId, (w) => ({ + ...w, + steps: w.steps.map((/** @type {WorkStep} */ s) => (s.id === call.id ? { ...s, detail: known.detail } : s)), + })); + return; + } + foldText(); + dropThinking(); + if (!workId) { + workId = uid(); + items.push(/** @type {ChatItem} */ ({ id: workId, kind: "work", startedAt: Date.now(), endedAt: null, steps: [] })); + } else { + // Working again after a note: the clock runs on from where it paused. + patchWork(workId, (w) => (w.endedAt === null ? w : { ...w, startedAt: w.startedAt + (Date.now() - w.endedAt), endedAt: null })); + } + const wid = workId; + tools.set(call.id, { workId: wid, name: tool, detail }); + /** @type {WorkStep} */ + const step = { id: String(call.id), tool, op, detail, status: "running" }; + patchWork(wid, (w) => ({ ...w, steps: [...w.steps, step] })); + scrollBottomSoon(); + }; + /** @param {any} res */ + const onToolResult = (res) => { + const t = tools.get(res.id); + if (!t) return; + const out = toolOutputText(res.output); + patchWork(t.workId, (w) => ({ + ...w, + steps: w.steps.map((/** @type {WorkStep} */ s) => + s.id === String(res.id) + ? { ...s, status: res.is_error ? "error" : "done", ...(res.is_error ? { error: cleanToolError(out).slice(0, 400) } : {}) } + : s, + ), + })); + const parsed = t.name === "execute_sql" && !res.is_error ? parseSqlResult(out) : null; + // Rows still get their table; a failure is already shown in the fold. + if (parsed && parsed.total > 0) { + const id = uid(); + items.push(/** @type {ChatItem} */ ({ id, kind: "result", sql: t.detail, columns: parsed.columns, rows: parsed.rows.slice(0, AI_DISPLAY_ROWS), total: parsed.total, error: null })); + autoOpenResult(id); + } + }; + + await new Promise((resolve, reject) => { + const settle = (/** @type {Error | null} */ err) => { + agentOnEvent = null; + agentSettle = null; + if (err) reject(err); + else resolve(undefined); + }; + agentSettle = () => settle(null); + agentOnEvent = (ev) => { + if (disowned()) return settle(null); + if (ev.Text) { + aiStatusHint = ""; + content += ev.Text.delta; + if (!itemId) { + closeWork(); + dropThinking(); + itemId = uid(); + streamingId = itemId; + items.push(/** @type {ChatItem} */ ({ id: itemId, kind: "streaming" })); + } + scheduleStreamingUpdate(content); + } else if (ev.ToolCall) onToolCall(ev.ToolCall); + else if (ev.ToolResult) onToolResult(ev.ToolResult); + else if (ev.TurnFailed) error = ev.TurnFailed.message; + else if (ev.TurnEnd) { + closeText(); + closeWork(); + settle(null); + } else if (ev.Fatal) { + closeText(); + closeWork(); + settle(new Error(ev.Fatal.message)); + } + }; + agentSend(key, text).catch(async (e) => { + if (String(e) !== "NO_SESSION") return settle(e instanceof Error ? e : new Error(String(e))); + // The CLI exited between turns: start it again, resuming, and resend. + agentSession = null; + try { + await agentSend(await ensureAgentSession(agent, model), text); + } catch (e2) { + settle(e2 instanceof Error ? e2 : new Error(String(e2))); + } + }); + }); + return reply + (content ? (reply ? "\n\n" : "") + content : ""); } /** Max rows fetched from DB per AI tool call - prevents OOM on large tables */ @@ -2934,15 +3480,32 @@ loadConvList(); }); + /** + * Ctrl+B hides and shows the chat list while the chat is on screen. The app + * binds it to its own sidebar, so this listens in the capture phase and + * keeps the key from reaching that binding. + * @param {KeyboardEvent} e + */ + function onChatListKey(e) { + if (!(isActive || mode === "full")) return; + if (!(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey || e.key.toLowerCase() !== "b") return; + e.preventDefault(); + e.stopImmediatePropagation(); + toggleAiSidebar(); + } + onMount(() => { void tick().then(() => inputRef?.focus()); document.addEventListener("keydown", handleGlobalKey); + window.addEventListener("keydown", onChatListKey, true); // Pre-warm schema cache so the first message doesn't pay the fetch cost if (schemaContext.tables?.length) void ensureFullSchemaCache(); }); onDestroy(() => { document.removeEventListener("keydown", handleGlobalKey); + window.removeEventListener("keydown", onChatListKey, true); + stopAgentSession(); // Cancel any pending rAF handles to avoid callbacks on destroyed DOM if (rafId !== null) { cancelAnimationFrame(rafId); @@ -2963,22 +3526,10 @@ both the in-flow "thinking" placeholder and the bottom "working" state so they are pixel-identical and never visually drift or double up. --> {#snippet agentIndicator()} - {@const Icon = loadingIcon} -
- -
- {#if activeAiProfile && hasBrand(activeAiProfile.provider)} - - {:else} - - {/if} -
+
+ + {#if sidebarVisible} -