From 43026bbb17ff360cdfc0b8b6fb5f92cf1c44aa6e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 2 Nov 2026 15:21:07 +0545 Subject: [PATCH 01/45] Vendor agent-core from deck --- src-tauri/crates/agent-core/Cargo.toml | 16 + src-tauri/crates/agent-core/src/adapter.rs | 291 +++++++++ src-tauri/crates/agent-core/src/error.rs | 53 ++ src-tauri/crates/agent-core/src/event.rs | 304 +++++++++ src-tauri/crates/agent-core/src/ids.rs | 84 +++ src-tauri/crates/agent-core/src/lib.rs | 25 + src-tauri/crates/agent-core/src/transcript.rs | 612 ++++++++++++++++++ 7 files changed, 1385 insertions(+) create mode 100644 src-tauri/crates/agent-core/Cargo.toml create mode 100644 src-tauri/crates/agent-core/src/adapter.rs create mode 100644 src-tauri/crates/agent-core/src/error.rs create mode 100644 src-tauri/crates/agent-core/src/event.rs create mode 100644 src-tauri/crates/agent-core/src/ids.rs create mode 100644 src-tauri/crates/agent-core/src/lib.rs create mode 100644 src-tauri/crates/agent-core/src/transcript.rs diff --git a/src-tauri/crates/agent-core/Cargo.toml b/src-tauri/crates/agent-core/Cargo.toml new file mode 100644 index 00000000..2bab8c86 --- /dev/null +++ b/src-tauri/crates/agent-core/Cargo.toml @@ -0,0 +1,16 @@ +# Vendored from deck (github.com/broisnees/deck, crates/agent-core). +[package] +name = "agent-core" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +thiserror = "2" +async-trait = "0.1" +futures = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "serde"] } +smol_str = { version = "0.3", features = ["serde"] } diff --git a/src-tauri/crates/agent-core/src/adapter.rs b/src-tauri/crates/agent-core/src/adapter.rs new file mode 100644 index 00000000..2eec0a55 --- /dev/null +++ b/src-tauri/crates/agent-core/src/adapter.rs @@ -0,0 +1,291 @@ +use crate::event::{AgentEvent, Decision}; +use crate::ids::{AgentId, RequestId, SessionId}; +use crate::transcript::Transcript; +use async_trait::async_trait; +use futures::stream::BoxStream; +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; +use std::path::PathBuf; +use std::sync::Arc; + +/// What a backend can actually do. The UI reads this to disable affordances up +/// front rather than discovering failure mid-turn. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Caps { + /// Accepts an MCP server list natively (vs. needing our proxy tool). + pub native_mcp: bool, + /// Has a skills mechanism we can project into. + pub native_skills: bool, + /// Can resume its own session by id — enables lossless re-attach. + pub native_resume: bool, + /// Surfaces permission requests to us before acting. + pub permission_hook: bool, + /// Accepts image attachments. + pub images: bool, + /// Streams tool stdout incrementally. + pub streaming_tools: bool, + /// Reports token usage. + pub usage: bool, + /// Can be interrupted mid-turn without killing the process. + pub soft_interrupt: bool, + /// Takes messages while a turn runs, queues them itself and reads them + /// at the next step (reported with `UserDelivered`), as Claude Code does. + pub queues_input: bool, + /// Emits structured events rather than being screen-scraped. A `false` here + /// makes the UI mark the session as degraded. + pub structured: bool, +} + +impl Caps { + /// Everything a first-class, protocol-native backend supports. + pub const FULL: Caps = Caps { + native_mcp: true, + native_skills: true, + native_resume: true, + permission_hook: true, + images: true, + streaming_tools: true, + usage: true, + soft_interrupt: true, + queues_input: true, + structured: true, + }; + + /// Floor for a PTY-scraped backend. + pub const DEGRADED: Caps = Caps { + native_mcp: false, + native_skills: false, + native_resume: false, + permission_hook: false, + images: false, + streaming_tools: true, + usage: false, + soft_interrupt: false, + queues_input: false, + structured: false, + }; +} + +/// How faithfully a backend absorbed an existing transcript on handoff. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HydrateMode { + /// Backend resumed its own native session — nothing was lost. + NativeResume, + /// Canonical transcript was replayed into a fresh session as context. + Replayed, + /// Transcript exceeded the context budget, so the oldest turns were dropped + /// from the replay. Lossy in a way the user must be told about. + ReplayedPartial { dropped_messages: usize }, + /// Transcript exceeded the context budget and was summarized first. + Summarized, + /// Fresh session with no prior context (first turn, or handoff declined). + Fresh, +} + +impl HydrateMode { + pub fn is_lossless(self) -> bool { + matches!(self, HydrateMode::NativeResume | HydrateMode::Fresh) + } +} + +/// Everything the harness resolved for one session, handed to the adapter at +/// spawn time. Adapters project the shared substrate into their own config +/// format from this — they never read global config themselves. +#[derive(Debug, Clone)] +pub struct SessionCtx { + pub session_id: SessionId, + pub cwd: PathBuf, + /// Composed system prompt. Stable prefix first, volatile blocks last, so + /// backends with prompt caching keep hitting cache. + pub system_prompt: Arc, + pub model: Option, + /// MCP servers to expose, already resolved from the shared registry. + pub mcp: Arc>, + /// Directory of shared skills to project in. + pub skills_dir: Option, + /// Per-session scratch dir for generated config. Deleted on session close. + pub runtime_dir: PathBuf, + /// Native session id to resume, if we are re-attaching. + pub resume: Option, + pub permission_mode: PermissionMode, + /// Extra environment for the agent process: how a host app hands the + /// agent credentials to call back into it (deck's `DECK_SOCKET`/`DECK_TOKEN`). + pub env: Vec<(String, String)>, + /// Tools pre-approved regardless of `permission_mode`, in the backend's own + /// syntax (Claude: `Bash(deck:*)`). Lets a gated session still drive the host. + pub allowed_tools: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionMode { + /// Ask for everything not on the allowlist. + Ask, + /// Reads free, writes and commands prompted. + Edit, + /// Allowlist only; anything else auto-denied (for unattended runs). + Auto, + /// No prompting at all. Caller is responsible for sandboxing. + Yolo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerSpec { + pub name: SmolStr, + pub transport: McpTransport, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum McpTransport { + Stdio { + command: String, + args: Vec, + env: Vec<(String, String)>, + }, + Http { + url: String, + headers: Vec<(String, String)>, + }, +} + +/// A backend the app can talk to. One instance per installed agent; it is a +/// factory, so it must stay cheap to hold and `Sync`. +#[async_trait] +pub trait AgentAdapter: Send + Sync + 'static { + fn id(&self) -> AgentId; + + /// Human-facing name for the picker. + fn display_name(&self) -> &str; + + fn caps(&self) -> Caps; + + /// Models this backend can be asked for, most capable first. Empty means + /// the backend chooses and the picker is hidden rather than showing a + /// single fake entry. + fn models(&self) -> &'static [ModelSpec] { + &[] + } + + /// Whether the backend is installed and usable right now. Runs at startup + /// to decide what appears in the agent picker. + async fn probe(&self) -> Result; + + async fn spawn(&self, ctx: SessionCtx) -> Result, crate::AgentError>; + + /// How a user would install or sign in to this backend, if we know. + /// + /// Returned as a shell command rather than run for them: these commands + /// install software and open browsers, so the user gets to read the thing + /// before it happens. The UI puts it in the terminal with the cursor after + /// it — one keystroke away, still their decision. + fn setup(&self) -> Option { + None + } +} + +/// A command that makes an unavailable backend available. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Setup { + /// What the button says: "Install", "Sign in". + pub verb: &'static str, + pub command: &'static str, + /// Where the command came from, so a user can check it before running it. + pub source: &'static str, +} + +/// One selectable model. `id` is what the backend expects on the wire; `label` +/// is what the picker shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModelSpec { + pub id: &'static str, + pub label: &'static str, + /// Short qualifier shown under the label, e.g. "fastest". + pub note: &'static str, +} + +#[derive(Debug, Clone)] +pub enum Availability { + Ready { + version: Option, + }, + /// Installed but unusable, e.g. not logged in. + NeedsAuth { + hint: String, + }, + Missing { + hint: String, + }, +} + +impl Availability { + pub fn is_ready(&self) -> bool { + matches!(self, Availability::Ready { .. }) + } +} + +/// One live conversation with one backend. +/// +/// Implementations own a child process or HTTP connection. Dropping this must +/// terminate the backend — the supervisor relies on it for cleanup. +#[async_trait] +pub trait AgentSession: Send { + /// Submit a user turn. Returns once the backend has accepted it; the + /// response arrives on the event stream. + async fn send(&mut self, turn: UserTurn) -> Result<(), crate::AgentError>; + + /// The event stream. Borrowing `&mut self` keeps a single consumer, which is + /// what lets adapters own the decode buffer without locking. + fn events(&mut self) -> BoxStream<'_, AgentEvent>; + + async fn interrupt(&mut self) -> Result<(), crate::AgentError>; + + /// Answer a pending `AgentEvent::Permission`. + async fn decide(&mut self, id: RequestId, decision: Decision) -> Result<(), crate::AgentError>; + + /// Answer a pending `AgentEvent::Question`: (question, chosen answer) + /// pairs, several answers to one question joined with ", ". + async fn answer( + &mut self, + _id: RequestId, + _answers: Vec<(String, String)>, + ) -> Result<(), crate::AgentError> { + Err(crate::AgentError::Unsupported( + "this agent does not ask questions", + )) + } + + /// Seed this session with an existing canonical transcript. Called on + /// mid-thread agent switches, before the first `send`. + async fn hydrate(&mut self, transcript: &Transcript) -> Result; + + /// Native session id, once known, for later lossless resume. Returns an + /// owned string because adapters learn it from a background reader task. + fn native_session_id(&self) -> Option; +} + +#[derive(Debug, Clone, Default)] +pub struct UserTurn { + pub text: String, + pub attachments: Vec, + /// Lets a backend that queues input report when it read this message. + pub id: Option, + /// Sent mid-turn to be read now: the backend interrupts, or moves work + /// that can run in the background aside, rather than waiting for the + /// current step to finish. + pub now: bool, +} + +impl UserTurn { + pub fn text(s: impl Into) -> Self { + Self { + text: s.into(), + ..Default::default() + } + } +} + +#[derive(Debug, Clone)] +pub struct Attachment { + pub path: PathBuf, + pub mime: SmolStr, +} diff --git a/src-tauri/crates/agent-core/src/error.rs b/src-tauri/crates/agent-core/src/error.rs new file mode 100644 index 00000000..bea3a4aa --- /dev/null +++ b/src-tauri/crates/agent-core/src/error.rs @@ -0,0 +1,53 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AgentError { + #[error("agent `{0}` is not installed")] + NotInstalled(String), + + #[error("agent `{agent}` needs authentication: {hint}")] + NeedsAuth { agent: String, hint: String }, + + #[error("failed to spawn agent process: {0}")] + Spawn(#[source] std::io::Error), + + /// The backend produced bytes we could not decode. Carries the offending + /// line so adapter bugs are reportable rather than silent. + #[error("protocol error from `{agent}`: {detail}")] + Protocol { + agent: String, + detail: String, + line: Option, + }, + + #[error("agent session closed unexpectedly (exit: {exit:?})")] + Closed { exit: Option }, + + #[error("operation unsupported by this agent: {0}")] + Unsupported(&'static str), + + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl AgentError { + /// Whether the supervisor should try restarting rather than failing the + /// session outright. + pub fn retryable(&self) -> bool { + matches!( + self, + AgentError::Closed { .. } | AgentError::Io(_) | AgentError::Spawn(_) + ) + } + + pub fn protocol(agent: &str, detail: impl Into, line: Option<&str>) -> Self { + AgentError::Protocol { + agent: agent.to_string(), + detail: detail.into(), + line: line.map(|l| l.chars().take(512).collect()), + } + } +} diff --git a/src-tauri/crates/agent-core/src/event.rs b/src-tauri/crates/agent-core/src/event.rs new file mode 100644 index 00000000..68c28d20 --- /dev/null +++ b/src-tauri/crates/agent-core/src/event.rs @@ -0,0 +1,304 @@ +use crate::ids::{RequestId, ToolId}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use smol_str::SmolStr; + +/// The normalized event stream every adapter emits. +/// +/// This is the narrow waist of the whole app: adapters translate their native +/// protocol into these, and the UI, transcript, and persistence layers only +/// ever see these. Adding a variant here is a cross-cutting change — prefer +/// extending an existing payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AgentEvent { + /// Backend accepted the session. `native_session_id` is a *hint* used to + /// attempt lossless resume later; the canonical transcript is ours. + Started { + native_session_id: Option, + model: Option, + }, + + /// Extended-thinking / reasoning delta. Rendered collapsed by default. + Reasoning { + delta: String, + }, + + /// Assistant-visible text delta. + Text { + delta: String, + }, + + /// A tool invocation began. `title` is a short human label the UI shows on + /// the collapsed card (e.g. `ls vendor/gpui-component`). A backend may send + /// it early with partial `input` and again with the same `id` once all of + /// the input has arrived; the repeat updates the same call. + ToolCall { + id: ToolId, + name: SmolStr, + input: Value, + title: String, + }, + + /// Incremental output from a still-running tool (bash stdout, etc.). + /// Adapters that cannot stream tool output simply never emit this. + ToolProgress { + id: ToolId, + chunk: String, + }, + + ToolResult { + id: ToolId, + output: ToolOutput, + is_error: bool, + }, + + /// Backend is blocked waiting on the user to allow/deny an action. + Permission { + id: RequestId, + action: Action, + suggestions: Vec, + /// The tool call waiting on this, when the backend says which. + tool_use: Option, + /// The tool's full input, to show what exactly would happen. + input: Value, + /// The agent's own one-line account of the action, if it gave one. + reason: Option, + }, + + /// The agent asks the user to choose (Claude's AskUserQuestion). Answer + /// with `AgentSession::answer`. + Question { + id: RequestId, + tool_use: Option, + questions: Vec, + }, + + /// A pending `Permission` or `Question` no longer needs an answer. + AskCancelled { + id: RequestId, + }, + + Usage(Usage), + + /// The turn failed: the API's HTTP status when the backend reports it, + /// and what it said. Comes just before its `TurnEnd`. + TurnFailed { + status: Option, + message: String, + }, + + TurnEnd { + stop: StopReason, + }, + + /// Session is dead. The supervisor decides whether to restart. + Fatal { + message: String, + retryable: bool, + }, + + /// A message sent while a turn ran (`UserTurn::id`) has reached the + /// model: the backend queued it and has now read it. + UserDelivered { + id: String, + }, + + /// A message sent while a turn ran was dropped by the backend unread. + UserDropped { + id: String, + }, +} + +/// Structured tool output so the UI can pick a renderer instead of dumping text. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ToolOutput { + /// Terminal-ish output, rendered through the alacritty grid (keeps ANSI). + Terminal { + text: String, + exit_code: Option, + }, + /// A file excerpt, rendered with syntax highlighting + jump-to-line. + FileExcerpt { + path: String, + text: String, + start_line: u32, + }, + /// A unified diff, rendered as an inline diff view. + Diff { path: String, unified: String }, + /// Structured payload (typically MCP), rendered by a typed renderer keyed + /// on tool name with a JSON tree fallback. + Structured(Value), + /// Anything else. + Text(String), + /// Output exceeded the byte budget and was spilled to disk. + Truncated { + preview: String, + total_bytes: u64, + spill_path: Option, + }, +} + +impl ToolOutput { + /// Cheap preview for the collapsed card. Never allocates the full body. + pub fn preview(&self, max: usize) -> &str { + let s = match self { + ToolOutput::Terminal { text, .. } => text.as_str(), + ToolOutput::FileExcerpt { text, .. } => text.as_str(), + ToolOutput::Diff { unified, .. } => unified.as_str(), + ToolOutput::Text(t) => t.as_str(), + ToolOutput::Truncated { preview, .. } => preview.as_str(), + ToolOutput::Structured(_) => return "{…}", + }; + let end = s + .char_indices() + .take(max) + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + &s[..end] + } +} + +/// One question in an `AgentEvent::Question`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Question { + /// A short label for it ("Color"). + pub header: String, + pub question: String, + pub options: Vec, + /// Several options may be picked. + pub multi: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct QuestionOption { + pub label: String, + pub description: String, +} + +/// An action the backend wants to take that our permission engine gates. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Action { + Bash { + command: String, + cwd: Option, + }, + WriteFile { + path: String, + }, + EditFile { + path: String, + }, + ReadFile { + path: String, + }, + Fetch { + url: String, + }, + McpCall { + server: SmolStr, + tool: SmolStr, + input: Value, + }, + Other { + name: SmolStr, + input: Value, + }, +} + +impl Action { + pub fn summary(&self) -> String { + match self { + Action::Bash { command, .. } => command.clone(), + Action::WriteFile { path } => format!("write {path}"), + Action::EditFile { path } => format!("edit {path}"), + Action::ReadFile { path } => format!("read {path}"), + Action::Fetch { url } => format!("fetch {url}"), + Action::McpCall { server, tool, .. } => format!("{server}/{tool}"), + Action::Other { name, .. } => name.to_string(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Decision { + /// Allow this one invocation. + Allow, + /// Allow and remember for the rest of the session. + AllowAlways, + /// Deny this one invocation; the agent may try something else. + Deny, + /// Deny and abort the turn. + Abort, +} + +// No `Eq`: `cost_usd` is an f64. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +pub struct Usage { + pub input: u64, + pub output: u64, + pub cache_read: u64, + pub cache_write: u64, + /// Backends that report cost directly; otherwise the UI prices it locally. + pub cost_usd: Option, + /// Tokens the model's context held at its latest call. A reading, not a + /// count: the newest wins, and 0 means none came. + #[serde(default)] + pub context: u64, + /// How many tokens that context can hold, when the backend says. + #[serde(default)] + pub context_window: Option, +} + +impl Usage { + /// Adapters report tokens and cost per turn; this folds a turn into a + /// session total. Readings (context and its window) replace the last. + pub fn accumulate(&mut self, other: &Usage) { + self.input += other.input; + self.output += other.output; + self.cache_read += other.cache_read; + self.cache_write += other.cache_write; + match (self.cost_usd, other.cost_usd) { + (Some(a), Some(b)) => self.cost_usd = Some(a + b), + (None, Some(b)) => self.cost_usd = Some(b), + _ => {} + } + if other.context > 0 { + self.context = other.context; + } + if other.context_window.is_some() { + self.context_window = other.context_window; + } + } + + /// Tokens through the model, cached reads included. + pub fn tokens(&self) -> u64 { + self.input + self.output + self.cache_read + self.cache_write + } + + /// How full the context is, 0.0 to 1.0, when both sides are known. + pub fn context_used(&self) -> Option { + let window = self.context_window.filter(|w| *w > 0)?; + (self.context > 0).then(|| (self.context as f32 / window as f32).min(1.)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum StopReason { + /// Agent finished its turn normally. + EndTurn, + /// User interrupted. + Interrupted, + /// Hit a token or turn limit. + MaxTokens, + MaxTurns, + /// Permission denial aborted the turn. + Refused, + Error, +} + +impl StopReason { + pub fn is_clean(self) -> bool { + matches!(self, StopReason::EndTurn) + } +} diff --git a/src-tauri/crates/agent-core/src/ids.rs b/src-tauri/crates/agent-core/src/ids.rs new file mode 100644 index 00000000..66ff9241 --- /dev/null +++ b/src-tauri/crates/agent-core/src/ids.rs @@ -0,0 +1,84 @@ +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; +use std::fmt; + +/// Stable identifier for an agent backend, e.g. `claude`, `opencode`, `grok`. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct AgentId(pub SmolStr); + +impl AgentId { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AgentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +macro_rules! uuid_id { + ($(#[$m:meta])* $name:ident) => { + $(#[$m])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] + pub struct $name(pub uuid::Uuid); + + impl $name { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +uuid_id!( + /// Identifies one canonical conversation. Survives agent switches. + SessionId +); +uuid_id!( + /// Identifies one message (a user turn or one assistant turn). + MessageId +); +uuid_id!( + /// Identifies a pending permission request. + RequestId +); + +/// Tool-call identifier. Backends assign these as opaque strings, so we keep +/// theirs verbatim rather than minting our own — correlating `ToolCall` with +/// `ToolResult` depends on matching the backend's spelling exactly. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ToolId(pub SmolStr); + +impl ToolId { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ToolId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} diff --git a/src-tauri/crates/agent-core/src/lib.rs b/src-tauri/crates/agent-core/src/lib.rs new file mode 100644 index 00000000..d28ac4fc --- /dev/null +++ b/src-tauri/crates/agent-core/src/lib.rs @@ -0,0 +1,25 @@ +//! The narrow waist of the app. +//! +//! Every agent backend — Claude Code, OpenCode, Vibe, Grok — is reduced to the +//! [`AgentAdapter`] / [`AgentSession`] pair and the [`AgentEvent`] stream +//! defined here. The UI, persistence, and harness layers depend only on this +//! crate, never on a specific backend. + +pub mod adapter; +pub mod error; +pub mod event; +pub mod ids; +pub mod transcript; + +pub use adapter::{ + AgentAdapter, AgentSession, Attachment, Availability, Caps, HydrateMode, McpServerSpec, + McpTransport, ModelSpec, PermissionMode, SessionCtx, Setup, UserTurn, +}; +pub use error::AgentError; +pub use event::{ + Action, AgentEvent, Decision, Question, QuestionOption, StopReason, ToolOutput, Usage, +}; +pub use ids::{AgentId, MessageId, RequestId, SessionId, ToolId}; +pub use transcript::{ + Applied, AppliedKind, AttachmentKind, Block, Message, Role, ToolInvocation, Transcript, +}; diff --git a/src-tauri/crates/agent-core/src/transcript.rs b/src-tauri/crates/agent-core/src/transcript.rs new file mode 100644 index 00000000..c93edcdb --- /dev/null +++ b/src-tauri/crates/agent-core/src/transcript.rs @@ -0,0 +1,612 @@ +use crate::event::{AgentEvent, StopReason, ToolOutput, Usage}; +use crate::ids::{AgentId, MessageId, ToolId}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use smol_str::SmolStr; + +/// The canonical, agent-agnostic conversation. This — not any backend's +/// session file — is the source of truth, which is what makes mid-thread agent +/// switching possible. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Transcript { + pub messages: Vec, + pub usage: Usage, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub id: MessageId, + pub role: Role, + /// Which agent produced this. `None` for user messages. Rendered as a badge + /// so provenance stays visible in mixed-agent threads. + pub agent: Option, + pub blocks: Vec, + /// Unix millis. Passed in rather than sampled so replay stays deterministic. + pub created_at: i64, + pub stop: Option, +} + +impl Message { + pub fn new(role: Role, agent: Option, created_at: i64) -> Self { + Self { + id: MessageId::new(), + role, + agent, + blocks: Vec::new(), + created_at, + stop: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Role { + User, + Assistant, +} + +/// One renderable unit. Messages are `Vec` rather than a single string +/// so that streaming only invalidates the tail block's layout. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Block { + Text { + text: String, + /// Open blocks are still receiving deltas; closed ones have frozen + /// layout and cached measured height. + open: bool, + }, + Reasoning { + text: String, + open: bool, + }, + Tool(ToolInvocation), + /// Marks a mid-thread agent handoff, rendered as a divider. + Handoff { + from: Option, + to: AgentId, + mode: crate::adapter::HydrateMode, + }, + /// Attachments on a user turn. + Attachment { + path: String, + kind: AttachmentKind, + }, +} + +impl Block { + pub fn is_open(&self) -> bool { + match self { + Block::Text { open, .. } | Block::Reasoning { open, .. } => *open, + Block::Tool(t) => t.result.is_none(), + Block::Handoff { .. } | Block::Attachment { .. } => false, + } + } + + fn close(&mut self) { + match self { + Block::Text { open, .. } | Block::Reasoning { open, .. } => *open = false, + _ => {} + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AttachmentKind { + Image, + File, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolInvocation { + pub id: ToolId, + pub name: SmolStr, + pub input: Value, + pub title: String, + /// Streaming output accumulated before the result lands. + pub progress: String, + pub result: Option, + pub is_error: bool, +} + +impl ToolInvocation { + /// Still running — the card renders a spinner and live `progress`. + pub fn is_open(&self) -> bool { + self.result.is_none() + } +} + +/// Outcome of folding an event into the transcript, so the UI can invalidate +/// exactly one list row instead of re-rendering the thread. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Applied { + pub kind: AppliedKind, + /// Index of the message that changed, when one did. A late tool result can + /// land on a message that is no longer the tail, so the UI must not assume + /// the last row is the only one that needs remeasuring. + pub message: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppliedKind { + /// Nothing changed structurally; the tail block's text grew. + TailGrew, + /// A block was added, closed, or replaced — remeasure that message. + BlockChanged, + /// A message was added, or its stop reason was set. + MessageChanged, + /// Event carried no transcript-visible content (usage, permission prompts). + Ignored, +} + +impl Applied { + const IGNORED: Applied = Applied { + kind: AppliedKind::Ignored, + message: None, + }; + + fn at(kind: AppliedKind, message: usize) -> Applied { + Applied { + kind, + message: Some(message), + } + } +} + +impl Transcript { + /// Fold one adapter event into the canonical transcript. + /// + /// `now` is supplied by the caller (never sampled here) so that replaying a + /// persisted event log reproduces byte-identical state. + pub fn apply(&mut self, agent: &AgentId, event: &AgentEvent, now: i64) -> Applied { + match event { + AgentEvent::Started { .. } => Applied::IGNORED, + // The chat reads it to decide on a retry; the transcript keeps + // the error from the turn's own text. + AgentEvent::TurnFailed { .. } => Applied::IGNORED, + + AgentEvent::Text { delta } => self.push_delta(agent, now, delta, false), + AgentEvent::Reasoning { delta } => self.push_delta(agent, now, delta, true), + + AgentEvent::ToolCall { + id, + name, + input, + title, + } => { + // A call shown before its input finished: fill it in. + if let Some((idx, t)) = self.find_tool_mut(id) { + t.input = input.clone(); + t.title = title.clone(); + return Applied::at(AppliedKind::BlockChanged, idx); + } + let idx = self.assistant_tail_index(agent, now); + let msg = &mut self.messages[idx]; + if let Some(b) = msg.blocks.last_mut() { + if b.is_open() { + b.close(); + } + } + msg.blocks.push(Block::Tool(ToolInvocation { + id: id.clone(), + name: name.clone(), + input: input.clone(), + title: title.clone(), + progress: String::new(), + result: None, + is_error: false, + })); + Applied::at(AppliedKind::BlockChanged, idx) + } + + AgentEvent::ToolProgress { id, chunk } => match self.find_tool_mut(id) { + Some((idx, t)) => { + t.progress.push_str(chunk); + Applied::at(AppliedKind::TailGrew, idx) + } + None => Applied::IGNORED, + }, + + AgentEvent::ToolResult { + id, + output, + is_error, + } => match self.find_tool_mut(id) { + Some((idx, t)) => { + t.result = Some(output.clone()); + t.is_error = *is_error; + Applied::at(AppliedKind::BlockChanged, idx) + } + None => Applied::IGNORED, + }, + + AgentEvent::Usage(u) => { + self.usage.accumulate(u); + Applied::IGNORED + } + + AgentEvent::TurnEnd { stop } => self.finish_tail(Some(*stop)), + + AgentEvent::Permission { .. } + | AgentEvent::Question { .. } + | AgentEvent::AskCancelled { .. } => Applied::IGNORED, + + // The UI places these messages itself, where the model read them. + AgentEvent::UserDelivered { .. } | AgentEvent::UserDropped { .. } => Applied::IGNORED, + + AgentEvent::Fatal { .. } => self.finish_tail(Some(StopReason::Error)), + } + } + + fn push_delta(&mut self, agent: &AgentId, now: i64, delta: &str, reasoning: bool) -> Applied { + let idx = self.assistant_tail_index(agent, now); + let msg = &mut self.messages[idx]; + let appended = match msg.blocks.last_mut() { + Some(Block::Text { text, open: true }) if !reasoning => { + text.push_str(delta); + true + } + Some(Block::Reasoning { text, open: true }) if reasoning => { + text.push_str(delta); + true + } + // An open block of the *other* kind must be closed first, so text and + // reasoning never merge into one run. + Some(b) if b.is_open() => { + b.close(); + false + } + _ => false, + }; + if appended { + return Applied::at(AppliedKind::TailGrew, idx); + } + msg.blocks.push(if reasoning { + Block::Reasoning { + text: delta.to_string(), + open: true, + } + } else { + Block::Text { + text: delta.to_string(), + open: true, + } + }); + Applied::at(AppliedKind::BlockChanged, idx) + } + + fn finish_tail(&mut self, stop: Option) -> Applied { + match self.messages.len().checked_sub(1) { + Some(idx) => { + let msg = &mut self.messages[idx]; + if let Some(b) = msg.blocks.last_mut() { + b.close(); + } + msg.stop = stop; + Applied::at(AppliedKind::MessageChanged, idx) + } + None => Applied::IGNORED, + } + } + + pub fn push_user(&mut self, text: String, attachments: Vec, now: i64) -> MessageId { + let mut msg = Message::new(Role::User, None, now); + if !text.is_empty() { + msg.blocks.push(Block::Text { text, open: false }); + } + msg.blocks.extend(attachments); + let id = msg.id; + self.messages.push(msg); + id + } + + pub fn push_handoff( + &mut self, + from: Option, + to: AgentId, + mode: crate::adapter::HydrateMode, + now: i64, + ) { + let mut msg = Message::new(Role::Assistant, Some(to.clone()), now); + msg.blocks.push(Block::Handoff { from, to, mode }); + msg.stop = Some(StopReason::EndTurn); + self.messages.push(msg); + } + + /// Index of the assistant message currently being streamed into, creating + /// one if the last message is a user turn or a finished assistant turn. + fn assistant_tail_index(&mut self, agent: &AgentId, now: i64) -> usize { + let needs_new = match self.messages.last() { + Some(m) => { + m.role != Role::Assistant || m.stop.is_some() || m.agent.as_ref() != Some(agent) + } + None => true, + }; + if needs_new { + self.messages + .push(Message::new(Role::Assistant, Some(agent.clone()), now)); + } + self.messages.len() - 1 + } + + /// Tool results can arrive out of order and after later blocks were opened, + /// so search backwards rather than assuming the tail. + fn find_tool_mut(&mut self, id: &ToolId) -> Option<(usize, &mut ToolInvocation)> { + self.messages + .iter_mut() + .enumerate() + .rev() + .find_map(|(idx, m)| { + m.blocks.iter_mut().rev().find_map(|b| match b { + Block::Tool(t) if &t.id == id => Some(idx), + _ => None, + }) + }) + .map(|idx| { + let t = self.messages[idx] + .blocks + .iter_mut() + .rev() + .find_map(|b| match b { + Block::Tool(t) if &t.id == id => Some(t), + _ => None, + }) + .expect("located above"); + (idx, t) + }) + } + + /// Rough token estimate, used to decide replay vs. summarized handoff. + pub fn approx_tokens(&self) -> usize { + let chars: usize = self + .messages + .iter() + .flat_map(|m| m.blocks.iter()) + .map(|b| match b { + Block::Text { text, .. } | Block::Reasoning { text, .. } => text.len(), + Block::Tool(t) => { + t.title.len() + t.result.as_ref().map_or(0, |r| r.preview(2048).len()) + } + _ => 0, + }) + .sum(); + chars / 4 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::AgentEvent; + + fn agent() -> AgentId { + AgentId::new("claude") + } + + fn text(s: &str) -> AgentEvent { + AgentEvent::Text { + delta: s.to_string(), + } + } + + #[test] + fn text_deltas_coalesce_into_one_block() { + let mut t = Transcript::default(); + assert_eq!( + t.apply(&agent(), &text("Hel"), 0).kind, + AppliedKind::BlockChanged + ); + assert_eq!( + t.apply(&agent(), &text("lo"), 0).kind, + AppliedKind::TailGrew + ); + assert_eq!(t.messages.len(), 1); + assert_eq!(t.messages[0].blocks.len(), 1); + match &t.messages[0].blocks[0] { + Block::Text { text, open } => { + assert_eq!(text, "Hello"); + assert!(open); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn tool_call_closes_open_text_and_result_attaches() { + let mut t = Transcript::default(); + t.apply(&agent(), &text("checking"), 0); + let id = ToolId::new("t1"); + t.apply( + &agent(), + &AgentEvent::ToolCall { + id: id.clone(), + name: "bash".into(), + input: serde_json::json!({"command":"ls"}), + title: "ls".into(), + }, + 0, + ); + assert!(!t.messages[0].blocks[0].is_open(), "text should be closed"); + + t.apply( + &agent(), + &AgentEvent::ToolProgress { + id: id.clone(), + chunk: "a\nb\n".into(), + }, + 0, + ); + t.apply( + &agent(), + &AgentEvent::ToolResult { + id, + output: ToolOutput::Terminal { + text: "a\nb\n".into(), + exit_code: Some(0), + }, + is_error: false, + }, + 0, + ); + + match &t.messages[0].blocks[1] { + Block::Tool(t) => { + assert_eq!(t.progress, "a\nb\n"); + assert!(t.result.is_some()); + assert!(!t.is_open()); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn a_repeated_tool_call_fills_in_the_same_card() { + let mut t = Transcript::default(); + let call = |input: Value| AgentEvent::ToolCall { + id: ToolId::new("w"), + name: "Write".into(), + input, + title: "/a.py".into(), + }; + t.apply( + &agent(), + &call(serde_json::json!({"file_path": "/a.py"})), + 0, + ); + let applied = t.apply( + &agent(), + &call(serde_json::json!({"file_path": "/a.py", "content": "x"})), + 0, + ); + assert_eq!(applied.kind, AppliedKind::BlockChanged); + assert_eq!(applied.message, Some(0)); + assert_eq!(t.messages.len(), 1); + match t.messages[0].blocks.as_slice() { + [Block::Tool(tool)] => assert_eq!(tool.input["content"], "x"), + other => panic!("expected one card, got {other:?}"), + } + } + + #[test] + fn reasoning_and_text_never_merge_into_one_block() { + let mut t = Transcript::default(); + t.apply( + &agent(), + &AgentEvent::Reasoning { + delta: "thinking".into(), + }, + 0, + ); + t.apply(&agent(), &text("answer"), 0); + let blocks = &t.messages[0].blocks; + assert_eq!(blocks.len(), 2, "got {blocks:?}"); + assert!(matches!(&blocks[0], Block::Reasoning { text, open: false } if text == "thinking")); + assert!(matches!(&blocks[1], Block::Text { text, .. } if text == "answer")); + } + + #[test] + fn switching_agent_starts_a_new_message() { + let mut t = Transcript::default(); + t.apply(&agent(), &text("from claude"), 0); + t.apply(&AgentId::new("grok"), &text("from grok"), 0); + assert_eq!(t.messages.len(), 2); + assert_eq!(t.messages[1].agent, Some(AgentId::new("grok"))); + } + + #[test] + fn turn_end_closes_tail_and_next_delta_opens_a_message() { + let mut t = Transcript::default(); + t.apply(&agent(), &text("one"), 0); + t.apply( + &agent(), + &AgentEvent::TurnEnd { + stop: StopReason::EndTurn, + }, + 0, + ); + t.apply(&agent(), &text("two"), 0); + assert_eq!(t.messages.len(), 2); + } + + #[test] + fn preview_never_splits_a_utf8_boundary() { + let out = ToolOutput::Text("héllo wörld".into()); + for n in 0..14 { + let _ = out.preview(n); // must not panic + } + assert_eq!(out.preview(2), "hé"); + } + + #[test] + fn late_tool_result_finds_its_call_behind_newer_blocks() { + let mut t = Transcript::default(); + let a = ToolId::new("a"); + let b = ToolId::new("b"); + for id in [&a, &b] { + t.apply( + &agent(), + &AgentEvent::ToolCall { + id: id.clone(), + name: "bash".into(), + input: Value::Null, + title: String::new(), + }, + 0, + ); + } + // `a` resolves after `b` was opened. + let applied = t.apply( + &agent(), + &AgentEvent::ToolResult { + id: a, + output: ToolOutput::Text("done".into()), + is_error: false, + }, + 0, + ); + assert_eq!(applied.kind, AppliedKind::BlockChanged); + assert_eq!( + applied.message, + Some(0), + "the UI must be told which row to remeasure" + ); + match &t.messages[0].blocks[0] { + Block::Tool(t) => assert!(t.result.is_some()), + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn usage_sums_counts_and_keeps_the_newest_readings() { + let mut t = Transcript::default(); + let turn = |input, context, window| { + AgentEvent::Usage(Usage { + input, + output: 10, + cost_usd: Some(0.5), + context, + context_window: window, + ..Default::default() + }) + }; + t.apply(&agent(), &turn(100, 40_000, Some(200_000)), 0); + t.apply(&agent(), &turn(50, 0, None), 0); + t.apply(&agent(), &turn(0, 30_000, None), 0); + assert_eq!((t.usage.input, t.usage.output), (150, 30)); + assert_eq!(t.usage.cost_usd, Some(1.5)); + // Compaction shrinks the context; the window outlives a turn that + // did not restate it. + assert_eq!(t.usage.context, 30_000); + assert_eq!(t.usage.context_window, Some(200_000)); + assert_eq!(t.usage.context_used(), Some(0.15)); + } + + #[test] + fn usage_from_before_readings_still_loads() { + let u: Usage = serde_json::from_str( + r#"{"input":1,"output":2,"cache_read":3,"cache_write":4,"cost_usd":null}"#, + ) + .unwrap(); + assert_eq!(u.tokens(), 10); + assert_eq!((u.context, u.context_window), (0, None)); + } +} From 6d01ab153b050e616e74d69b0e2aca7e84fe0e16 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 2 Nov 2026 15:21:07 +0545 Subject: [PATCH 02/45] Vendor agent-claude from deck --- src-tauri/crates/agent-claude/Cargo.toml | 19 + src-tauri/crates/agent-claude/src/decode.rs | 1125 +++++++++++++++++ src-tauri/crates/agent-claude/src/lib.rs | 716 +++++++++++ src-tauri/crates/agent-claude/src/replay.rs | 199 +++ src-tauri/crates/agent-claude/src/wire.rs | 237 ++++ .../crates/agent-claude/tests/permissions.rs | 88 ++ 6 files changed, 2384 insertions(+) create mode 100644 src-tauri/crates/agent-claude/Cargo.toml create mode 100644 src-tauri/crates/agent-claude/src/decode.rs create mode 100644 src-tauri/crates/agent-claude/src/lib.rs create mode 100644 src-tauri/crates/agent-claude/src/replay.rs create mode 100644 src-tauri/crates/agent-claude/src/wire.rs create mode 100644 src-tauri/crates/agent-claude/tests/permissions.rs 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..555b8a77 --- /dev/null +++ b/src-tauri/crates/agent-claude/src/lib.rs @@ -0,0 +1,716 @@ +//! 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 + } + + /// 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(), + } + } + + #[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..94ef1b22 --- /dev/null +++ b/src-tauri/crates/agent-claude/tests/permissions.rs @@ -0,0 +1,88 @@ +//! 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(), + }; + 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); +} From 0d17daa8f329c15064bf2220a1f82df1a838d627 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 2 Nov 2026 15:21:07 +0545 Subject: [PATCH 03/45] Vendor agent-cli from deck --- src-tauri/crates/agent-cli/Cargo.toml | 19 + src-tauri/crates/agent-cli/src/decode.rs | 1932 +++++++++++++++++ src-tauri/crates/agent-cli/src/http.rs | 414 ++++ src-tauri/crates/agent-cli/src/lib.rs | 885 ++++++++ src-tauri/crates/agent-cli/src/models.rs | 359 +++ src-tauri/crates/agent-cli/src/opencode.rs | 1380 ++++++++++++ src-tauri/crates/agent-cli/src/spec.rs | 597 +++++ .../testdata/opencode_serve_ask_abort.jsonl | 67 + .../src/testdata/opencode_serve_ls.jsonl | 38 + 9 files changed, 5691 insertions(+) create mode 100644 src-tauri/crates/agent-cli/Cargo.toml create mode 100644 src-tauri/crates/agent-cli/src/decode.rs create mode 100644 src-tauri/crates/agent-cli/src/http.rs create mode 100644 src-tauri/crates/agent-cli/src/lib.rs create mode 100644 src-tauri/crates/agent-cli/src/models.rs create mode 100644 src-tauri/crates/agent-cli/src/opencode.rs create mode 100644 src-tauri/crates/agent-cli/src/spec.rs create mode 100644 src-tauri/crates/agent-cli/src/testdata/opencode_serve_ask_abort.jsonl create mode 100644 src-tauri/crates/agent-cli/src/testdata/opencode_serve_ls.jsonl 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..ec23dffa --- /dev/null +++ b/src-tauri/crates/agent-cli/src/lib.rs @@ -0,0 +1,885 @@ +//! 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 { + 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 args = self.spec.args( + &prompt, + &self.ctx.cwd.to_string_lossy(), + self.ctx.model.as_deref(), + resume.as_deref(), + self.ctx.permission_mode, + ); + 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(), + } + } + + 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 -- - - - + {:else if brand.startsWith('agent:')} + + {:else if FALLBACK_ICONS[brand]} + {@const Icon = FALLBACK_ICONS[brand]} + + {:else} + + {/if} +{/snippet} + + { if (o) openPicker() }}> + - {#if activeProfile && hasBrand(activeProfile.provider)} - - {:else} - - {/if} + {@render mark(activeProfile ? brandOf(activeProfile.provider) : '', 'size-3.5 shrink-0')} {displayName} - - - - pick(v)}> - {#each $aiProfiles as profile (profile.id)} - + + + +
+ + + {#each groups as g (g.key)} + {/each} - +
- +
+ - - - Manage models… - - - +
+ {#if blockedAgent} +
+

+ {blockedAgent.name} {blockedAgent.status === 'missing' ? 'is not installed' : 'needs you to sign in'} +

+ {#if blockedAgent.setup} + +

{copied ? 'Copied. Run it in a terminal, then reopen this menu.' : 'Run this in a terminal, then reopen this menu.'}

+ {:else if blockedAgent.hint} +

{blockedAgent.hint}

+ {/if} +
+ {:else if visible.length === 0} +

+ {query.trim() ? 'No model matches.' : railKey === 'fav' ? 'Star a model to keep it here.' : 'No models.'} +

+ {:else} + {#each visible as e, i (e.key)} +
(highlight = i)} + onclick={() => choose(e)} + onkeydown={(ev) => ev.key === 'Enter' && choose(e)} + > +
+ {e.label} +
+ {@render mark(e.brand, 'size-3 shrink-0')} + {[e.provider, e.note || e.tag].filter(Boolean).join(' · ')} +
+
+ +
+ {/each} + {/if} +
+ +
+
+ From 959bf3ca0b39a9265cba1a5ca0a43a9422713390 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Mon, 2 Nov 2026 15:22:50 +0545 Subject: [PATCH 18/45] Hide agent profiles in Manage models --- src/lib/components/AiSettingsDialog.svelte | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/components/AiSettingsDialog.svelte b/src/lib/components/AiSettingsDialog.svelte index e86c5ec9..454da04d 100644 --- a/src/lib/components/AiSettingsDialog.svelte +++ b/src/lib/components/AiSettingsDialog.svelte @@ -1,4 +1,5 @@ + {:else if item.kind === "work"} + {@const running = item.endedAt === null && loading} + {@const isOpen = openWork.has(item.id)} + {@const failed = item.steps.filter((s) => s.status === "error").length} + {@const summary = workSummary(item.steps)} +
+
+ +
+ +
+
+
    + {#each item.steps as step (step.id)} + {@const meta = stepLabel(step)} + {#if step.op === "note"} +
  1. {step.detail}
  2. + {:else} +
  3. +
    + {#if step.status === "running"} + + {:else if step.op === "query"} + + {:else} + + {/if} + {meta.label} + {#if meta.detail} + {meta.detail} + {/if} +
    + {#if step.op === "query" && step.detail.trim()} +
    {step.detail.trim()}
    + {/if} + {#if step.error} +

    {cleanToolError(step.error)}

    + {/if} +
  4. + {/if} + {/each} +
+
+
+
+ {:else if item.kind === "assistant"}
@@ -4375,7 +4813,10 @@ pushHistory(inputText); }} onkeydown={handleKeydown} - onfocus={() => (inputFocused = true)} + onfocus={() => { + inputFocused = true; + prewarmAgent(); + }} onblur={() => (inputFocused = false)} disabled={hasPendingConfirm} > @@ -4385,6 +4826,9 @@ affordance stays alone on the right. -->
+ {#if profileAgent($aiSettings)} + + {/if} {#if contextStats.messages > 0} Date: Mon, 2 Nov 2026 15:28:32 +0545 Subject: [PATCH 24/45] Queue messages while the AI answers --- src/lib/components/AiChat.svelte | 61 ++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/src/lib/components/AiChat.svelte b/src/lib/components/AiChat.svelte index 7df56bb5..332201ba 100644 --- a/src/lib/components/AiChat.svelte +++ b/src/lib/components/AiChat.svelte @@ -2,6 +2,7 @@ import { tick, onMount, onDestroy } from "svelte"; import Sparkles from "@lucide/svelte/icons/sparkles"; import ArrowUp from "@lucide/svelte/icons/arrow-up"; + 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"; @@ -435,6 +436,7 @@ const conv = convList.find((c) => c.id === id); if (!conv) return; activeConvId = id; + queued = []; stopAgentSession(); agentNative = /** @type {any} */ (conv).agentNative ?? null; // Restore - filter out ephemeral items (including any stuck streaming items) @@ -462,6 +464,7 @@ abortCurrentRequest(); await persistCurrent(); activeConvId = null; + queued = []; stopAgentSession(); agentNative = null; items = []; @@ -1706,9 +1709,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) { @@ -1899,6 +1932,7 @@ } await tick(); inputRef?.focus(); + drainQueue(); } // ── Local agent CLIs ────────────────────────────────────────────────────── @@ -4795,6 +4829,27 @@ hasPendingConfirm && "opacity-50", )} > + {#if queued.length} + +
    + {#each queued as q, qi (q.id)} +
  • + + {q.text} + {qi === 0 ? (loading ? "Next" : "Queued") : `#${qi + 1}`} + +
  • + {/each} +
+ {/if} @@ -4862,7 +4917,9 @@ - {#if inputFocused && !inputText.trim() && !loading} + {#if loading && inputText.trim()} + + {:else if inputFocused && !inputText.trim() && !loading}