From 5dac22a76ecee5265a3ad3733afa95a88222afb2 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 14 May 2026 16:52:17 +0800 Subject: [PATCH] feat(desktop/chat): Claude Code adapter via anthropic-agent-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of the per-CLI ChatProvider adapters that replace the hand-rolled stream-JSON parser in chat_subprocess.rs. This one wires `anthropic_agent_sdk::query` (the in-workspace fork of bartolli/anthropic-agent-sdk) into the OP chat-panel plumbing. `crates/openpencil-desktop/src/chat_claude.rs`: - `ClaudeCodeProvider` impls `ChatProvider`. Constructs trivially via `new()` (SDK defaults) or `with_options(ClaudeAgentOptions)` when the settings modal has user overrides (system prompt, model pick, allowed-tools list, MCP servers, sandbox config — all 30+ SDK option fields). - `send()` spawns the shared tokio runtime task, calls `anthropic_agent_sdk::query(prompt, options)`, drains its async `Stream>`, and dispatches each Message through `handle_message`: - `Message::Assistant.content` Vec is unpacked per block: `Text { text }` → `ChatDelta::TextDelta`, `Thinking { thinking, .. }` → `Thinking`, `ToolUse { name, input, .. }` → `ToolUse { name, args = input.to_string() }`, `ToolResult` swallowed (already part of conversation history the CLI tracks). - `Message::Result { subtype, is_error, .. }` is the turn terminator. `is_error` → `StopReason::Aborted`; otherwise `map_result_subtype` maps "success" → EndTurn, "error_max_turns" → MaxTokens, error variants → Aborted, unknown → EndTurn. - `System` / `User` / `StreamEvent` swallowed (init / context / partial-stream payloads the chat widget doesn't surface yet). - Receiver-drop short-circuit: every iteration checks `tx.is_closed()` so chat-panel teardown stops the SDK stream promptly without waiting for the CLI to flush more output. - Always emits a terminal `Done` — `Result` message → mapped stop reason; stream EOF without a Result → `EndTurn` fallback. `crates/openpencil-desktop/Cargo.toml`: - Adds `anthropic-agent-sdk = { path = "../anthropic-agent-sdk" }` + `copilot-sdk = { path = "../copilot-sdk" }`. Copilot dep declared now even though `chat_copilot.rs` lands in a follow-up, so Cargo.lock resolves the whole graph in one pass. `crates/openpencil-desktop/src/main.rs`: - `mod chat_claude;` between `mod chat_runtime` and `mod chat_subprocess` so the alphabetical mod-list rule holds. Tests (3 added, all pass): - `map_result_subtype_table` covers the success / error_max_turns / error_during_execution / error / unknown table. - `provider_label_is_human_readable` asserts the chat widget gets "Claude Code" as the displayed label. - `provider_constructs_as_chat_provider_trait_object` is the compile-time type-check that `ClaudeCodeProvider` satisfies the `Send + Sync` bounds so it can live behind `Arc` in the widget host. End-to-end smoke testing requires an actual `claude` binary on PATH. The 3 tests here verify the wiring + type contracts but not the live CLI interaction; that lands when the settings modal exposes the "connect" button + we have a real session to drive. 46 openpencil-desktop tests pass (was 43 before this commit). Next: chat_copilot.rs over `copilot_sdk::Client + Session`, then chat_http_server.rs for Codex / OpenCode `serve` mode per the user's "opencode 和 codex 我们调用 http server, 通过 ipc 启动本地的 server 模式". --- Cargo.lock | 2 + Cargo.toml | 7 +- crates/openpencil-desktop/Cargo.toml | 11 + crates/openpencil-desktop/src/chat_claude.rs | 262 +++++++++++++++++++ crates/openpencil-desktop/src/main.rs | 1 + 5 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 crates/openpencil-desktop/src/chat_claude.rs diff --git a/Cargo.lock b/Cargo.lock index 5236a52e1..87fd14dc6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2375,7 +2375,9 @@ name = "openpencil-desktop" version = "0.1.0" dependencies = [ "agent", + "anthropic-agent-sdk", "async-trait", + "copilot-sdk", "dirs 5.0.1", "futures", "glam", diff --git a/Cargo.toml b/Cargo.toml index 9c4a4c13c..a7edada8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,7 @@ resolver = "2" # Phase 1 batch 2 的 workspace masking 反馈:列出未存在的 crate 会让 `cargo build -p X` # 在 resolve 阶段就挂掉)。glob 自动包含 crates/ 下所有 manifest,自然支持增量创建。 members = ["crates/*"] -exclude = [ - "vendor/agent", - "vendor/jian", - "vendor/skia-safe-op", - "node_modules", -] +exclude = ["vendor/agent", "vendor/jian", "vendor/skia-safe-op", "node_modules"] [workspace.package] version = "0.1.0" diff --git a/crates/openpencil-desktop/Cargo.toml b/crates/openpencil-desktop/Cargo.toml index a42cd5ac3..1d38325d0 100644 --- a/crates/openpencil-desktop/Cargo.toml +++ b/crates/openpencil-desktop/Cargo.toml @@ -77,6 +77,17 @@ dirs = "5" # Provider impls (Anthropic / OpenAI-compat / Ollama) flip on once the # workspace rust-toolchain bumps. agent = { path = "../../../agent-rs/crates/agent", default-features = false } +# Forked from bartolli/anthropic-agent-sdk — lives under +# `crates/anthropic-agent-sdk/` so OP owns the source and can diverge +# from upstream. Provides the Claude Code CLI subprocess transport + +# stream-json message parser that `src/chat_claude.rs` wraps as a +# `ChatProvider`. Default features (no rmcp) keep the dep set tight. +anthropic-agent-sdk = { path = "../anthropic-agent-sdk" } +# Forked from copilot-community-sdk/copilot-sdk-rust — same story: +# in-workspace fork, OP-owned, evolves separately from upstream. +# Powers the GitHub Copilot CLI bridge via Content-Length-framed +# JSON-RPC. +copilot-sdk = { path = "../copilot-sdk" } async-trait = "0.1" # Async runtime — agent-rs is async; the BuiltIn ChatProvider impl # bridges its `EventStream` into the sync `Iterator` diff --git a/crates/openpencil-desktop/src/chat_claude.rs b/crates/openpencil-desktop/src/chat_claude.rs new file mode 100644 index 000000000..32f7ae3c6 --- /dev/null +++ b/crates/openpencil-desktop/src/chat_claude.rs @@ -0,0 +1,262 @@ +//! Claude Code IPC bridge — adapts `anthropic_agent_sdk::query` to +//! the shell-core [`ChatProvider`] trait. +//! +//! The vendored-then-promoted `crates/anthropic-agent-sdk` carries +//! the full Claude Code CLI subprocess transport: spawn the `claude` +//! binary with `--print --verbose --output-format stream-json --`, +//! parse the line-delimited stream-JSON envelope, surface +//! `Message::Assistant` / `Result` / `System` / `User` / +//! `StreamEvent` shapes. This module is the thin mapping layer that +//! collapses those into the OP chat panel's `ChatDelta` vocabulary. +//! +//! Why a separate adapter instead of inlining into chat_subprocess.rs: +//! the SDK owns ~30 fields of CLI options (system prompts, MCP +//! servers, allowed tools, sandbox config, ...) that the bridge +//! eventually wires through. Keeping each provider in its own file +//! gives that surface room to grow without busting the 800-line cap. + +use std::sync::Arc; + +use anthropic_agent_sdk::{ + types::{ContentBlock, Message}, + ClaudeAgentOptions, StreamExt, +}; +use openpencil_shell_core::chat_provider::{ + ChatDelta, ChatProvider, ChatRequest, StopReason, +}; +use tokio::sync::mpsc; + +use crate::chat_runtime::{shared_runtime, BlockingRecvIter}; + +/// `ChatProvider` impl that drives Claude Code via the +/// `anthropic-agent-sdk` Rust client. Single-shot per send today +/// (each call spawns a fresh `claude --print` subprocess); multi-turn +/// via `--resume ` lands in a follow-up once the OP +/// settings panel exposes session-pinning. +pub struct ClaudeCodeProvider { + /// Optional CLI-options bundle the SDK forwards to `claude`. + /// Cloned per-`send`; `None` falls through to the SDK's defaults. + options: Option, + label: String, +} + +impl ClaudeCodeProvider { + /// Build a Claude Code provider with no extra options (SDK + /// defaults). The CLI is discovered via the SDK's own `find_cli` + /// (PATH + npm-global / yarn / Linux package locations) — no + /// binary path needed up front. + #[allow(dead_code)] + pub fn new() -> Self { + Self { + options: None, + label: "Claude Code".into(), + } + } + + /// Build a Claude Code provider with a pre-configured + /// `ClaudeAgentOptions` (system prompt, model selection, tool + /// allowlist, MCP servers, sandbox config, etc.). The settings + /// modal calls this when the user has tuned options away from + /// defaults. + #[allow(dead_code)] + pub fn with_options(options: ClaudeAgentOptions) -> Self { + Self { + options: Some(options), + label: "Claude Code".into(), + } + } +} + +impl Default for ClaudeCodeProvider { + fn default() -> Self { + Self::new() + } +} + +impl ChatProvider for ClaudeCodeProvider { + fn provider_label(&self) -> &str { + &self.label + } + + fn send( + &self, + request: ChatRequest, + ) -> Box + Send> { + let prompt = request.user_message; + let options = self.options.clone(); + let (tx, rx) = mpsc::channel::(64); + shared_runtime().spawn(async move { + let stream = match anthropic_agent_sdk::query(prompt, options).await { + Ok(s) => s, + Err(e) => { + let _ = tx + .send(ChatDelta::Error(format!("claude query: {e}"))) + .await; + let _ = tx + .send(ChatDelta::Done { + stop_reason: StopReason::Aborted, + }) + .await; + return; + } + }; + let mut stream = Box::pin(stream); + let mut emitted_done = false; + while let Some(msg_result) = stream.next().await { + // Drop-out the moment the chat panel goes away. + if tx.is_closed() { + break; + } + let msg = match msg_result { + Ok(m) => m, + Err(e) => { + let _ = tx + .send(ChatDelta::Error(format!("claude stream: {e}"))) + .await; + let _ = tx + .send(ChatDelta::Done { + stop_reason: StopReason::Aborted, + }) + .await; + emitted_done = true; + break; + } + }; + if let Some((stop, last)) = handle_message(msg, &tx).await { + emitted_done = true; + if stop { + let _ = tx.send(ChatDelta::Done { stop_reason: last }).await; + break; + } + } + } + if !emitted_done { + let _ = tx + .send(ChatDelta::Done { + stop_reason: StopReason::EndTurn, + }) + .await; + } + }); + Box::new(BlockingRecvIter::new(rx)) + } +} + +/// Dispatch one SDK `Message` into `ChatDelta`s sent over `tx`. +/// Returns `Some((true, reason))` when this message is the turn- +/// terminating `Result` (caller should emit terminal Done + +/// break), `Some((false, _))` when emitted-but-not-terminal, +/// `None` when the message was ignored. Marking these explicitly +/// keeps the caller's loop centralized. +async fn handle_message( + msg: Message, + tx: &mpsc::Sender, +) -> Option<(bool, StopReason)> { + match msg { + Message::Assistant { message, .. } => { + // Stream each ContentBlock as the right ChatDelta variant. + // Claude Code groups multiple blocks (text + tool_use + + // thinking) into one assistant message; we surface each + // individually so the chat panel can render them in turn. + for block in &message.content { + match block { + ContentBlock::Text { text } => { + let _ = tx.send(ChatDelta::TextDelta(text.clone())).await; + } + ContentBlock::Thinking { thinking, .. } => { + let _ = tx.send(ChatDelta::Thinking(thinking.clone())).await; + } + ContentBlock::ToolUse { name, input, .. } => { + let _ = tx + .send(ChatDelta::ToolUse { + name: name.clone(), + args: input.to_string(), + }) + .await; + } + ContentBlock::ToolResult { .. } => { + // Tool results are part of the conversation + // history the CLI already shows the model; + // the chat widget doesn't render them + // separately today. + } + } + } + Some((false, StopReason::EndTurn)) + } + Message::Result { + subtype, is_error, .. + } => { + let reason = if is_error { + StopReason::Aborted + } else { + map_result_subtype(&subtype) + }; + Some((true, reason)) + } + Message::System { .. } | Message::User { .. } | Message::StreamEvent { .. } => { + // Init / context / partial-stream events — the chat + // widget doesn't surface them today; silent. + None + } + } +} + +fn map_result_subtype(s: &str) -> StopReason { + match s { + "success" => StopReason::EndTurn, + "error_max_turns" => StopReason::MaxTokens, + "error_during_execution" | "error" => StopReason::Aborted, + _ => StopReason::EndTurn, + } +} + +// Keep the import surface alive so callers from main.rs can +// reach the SDK without re-importing. +#[allow(unused_imports)] +pub use anthropic_agent_sdk::{ClaudeAgentOptions as ClaudeOptions, ClaudeSDKClient}; + +/// Returned by `ClaudeCodeProvider::new` / `with_options` chain +/// for the rare smoke test where we want to confirm the wiring +/// compiles end-to-end without a live `claude` binary on PATH. +#[doc(hidden)] +#[allow(dead_code)] +pub fn _smoke_sdk_arc_send() -> Arc { + Arc::new(ClaudeCodeProvider::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_result_subtype_table() { + assert!(matches!(map_result_subtype("success"), StopReason::EndTurn)); + assert!(matches!( + map_result_subtype("error_max_turns"), + StopReason::MaxTokens + )); + assert!(matches!( + map_result_subtype("error_during_execution"), + StopReason::Aborted + )); + assert!(matches!(map_result_subtype("error"), StopReason::Aborted)); + // Unknown subtypes default to EndTurn so a future SDK addition + // doesn't break the chat widget. + assert!(matches!(map_result_subtype("rocket"), StopReason::EndTurn)); + } + + #[test] + fn provider_label_is_human_readable() { + let p = ClaudeCodeProvider::new(); + assert_eq!(p.provider_label(), "Claude Code"); + } + + #[test] + fn provider_constructs_as_chat_provider_trait_object() { + // Type-system check: ClaudeCodeProvider satisfies the + // ChatProvider trait bounds (Send + Sync) so it can live + // behind an `Arc` in the widget host. + let _: Arc = Arc::new(ClaudeCodeProvider::new()); + } +} diff --git a/crates/openpencil-desktop/src/main.rs b/crates/openpencil-desktop/src/main.rs index 84e1103c7..a1d50bc56 100644 --- a/crates/openpencil-desktop/src/main.rs +++ b/crates/openpencil-desktop/src/main.rs @@ -3,6 +3,7 @@ #![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +mod chat_claude; mod chat_runtime; mod chat_subprocess; mod export;