notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 07efe78bb9ceb5741c156380dfa80d19ee74882e
parent 55e7506453879f4a66d5ec694b8d895f3d9c4b83
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 25 Jan 2026 20:49:16 -0800

dave: fix can_use_tool callback by using ClaudeClient

- Use ClaudeClient instead of query_stream to enable control protocol
- Add PermissionMode::Default to ensure tools trigger the callback
- Update tests to use ClaudeClient and remove outdated SDK limitation notes
- Temporarily use local path for claude-agent-sdk-rs with fixes

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

Diffstat:
MCargo.lock | 2--
Mcrates/notedeck_dave/Cargo.toml | 2+-
Mcrates/notedeck_dave/src/backend/claude.rs | 27+++++++++++++++++----------
Mcrates/notedeck_dave/tests/claude_integration.rs | 70++++++++++++++++++++++++----------------------------------------------
4 files changed, 42 insertions(+), 59 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1133,8 +1133,6 @@ dependencies = [ [[package]] name = "claude-agent-sdk-rs" version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47e08b9f18ec1810d0355942b4a2b76b777c4d368ac0d72825e1b45f07b8fe1" dependencies = [ "anyhow", "async-stream", diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -5,7 +5,7 @@ version.workspace = true [dependencies] async-openai = { version = "0.28.0", features = ["rustls-webpki-roots"] } -claude-agent-sdk-rs = "0.6" +claude-agent-sdk-rs = { path = "/home/jb55/dev/github/tyrchen/claude-agent-sdk-rs" } egui = { workspace = true } sha2 = { workspace = true } notedeck = { workspace = true } diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs @@ -3,8 +3,8 @@ use crate::messages::{DaveApiResponse, PendingPermission, PermissionRequest, Per use crate::tools::Tool; use crate::Message; use claude_agent_sdk_rs::{ - query_stream, ClaudeAgentOptions, ContentBlock, Message as ClaudeMessage, PermissionResult, - PermissionResultAllow, PermissionResultDeny, TextBlock, + ClaudeAgentOptions, ClaudeClient, ContentBlock, Message as ClaudeMessage, PermissionMode, + PermissionResult, PermissionResultAllow, PermissionResultDeny, TextBlock, }; use futures::future::BoxFuture; use futures::StreamExt; @@ -164,18 +164,25 @@ impl AiBackend for ClaudeBackend { }); let options = ClaudeAgentOptions::builder() + .permission_mode(PermissionMode::Default) .stderr_callback(Arc::new(stderr_callback)) .can_use_tool(can_use_tool) .build(); - let mut stream = match query_stream(prompt, Some(options)).await { - Ok(stream) => stream, - Err(err) => { - tracing::error!("Claude Code error: {}", err); - let _ = tx.send(DaveApiResponse::Failed(err.to_string())); - return; - } - }; + // Use ClaudeClient instead of query_stream to enable control protocol + // for can_use_tool callbacks + let mut client = ClaudeClient::new(options); + if let Err(err) = client.connect().await { + tracing::error!("Claude Code connection error: {}", err); + let _ = tx.send(DaveApiResponse::Failed(err.to_string())); + return; + } + if let Err(err) = client.query(&prompt).await { + tracing::error!("Claude Code query error: {}", err); + let _ = tx.send(DaveApiResponse::Failed(err.to_string())); + return; + } + let mut stream = client.receive_response(); while let Some(result) = stream.next().await { match result { diff --git a/crates/notedeck_dave/tests/claude_integration.rs b/crates/notedeck_dave/tests/claude_integration.rs @@ -7,7 +7,7 @@ //! The CLAUDE_API_KEY environment variable is read by the CLI subprocess. use claude_agent_sdk_rs::{ - get_claude_code_version, query_stream, ClaudeAgentOptions, ContentBlock, + get_claude_code_version, query_stream, ClaudeAgentOptions, ClaudeClient, ContentBlock, Message as ClaudeMessage, PermissionMode, PermissionResult, PermissionResultAllow, PermissionResultDeny, TextBlock, ToolPermissionContext, }; @@ -16,18 +16,6 @@ use futures::StreamExt; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; -// NOTE: SDK v0.6.3 Limitation -// =========================== -// The SDK defines `can_use_tool` callback in ClaudeAgentOptions but does NOT implement -// the control protocol to invoke it. In query_full.rs, handle_control_request only -// handles "hook_callback" and "mcp_message" subtypes - there's no handler for -// tool permission requests. -// -// For interactive permission handling, either: -// 1. Wait for a new SDK version that implements the permission callback -// 2. Fork the SDK and add the missing control protocol handler -// 3. Use hooks (PreToolUse) for non-interactive permission logic - /// Check if Claude CLI is available fn cli_available() -> bool { get_claude_code_version().is_some() @@ -201,16 +189,8 @@ fn test_prompt_formatting_is_substantial() { } /// Test that the can_use_tool callback is invoked when Claude tries to use a tool. -/// -/// NOTE: As of SDK v0.6.3, the can_use_tool callback is defined in ClaudeAgentOptions -/// but the control protocol to invoke it is NOT implemented. The SDK's handle_control_request -/// only handles "hook_callback" and "mcp_message" subtypes - there's no handler for -/// tool permission requests. -/// -/// This test documents this limitation - it will FAIL until the SDK implements the -/// permission callback control protocol. #[tokio::test] -#[ignore = "SDK v0.6.3 does not implement can_use_tool callback - see test documentation"] +#[ignore = "Requires Claude Code CLI to be installed and authenticated"] async fn test_can_use_tool_callback_invoked() { if !cli_available() { println!("Skipping: Claude CLI not available"); @@ -241,46 +221,43 @@ async fn test_can_use_tool_callback_invoked() { let stderr_callback = |_msg: String| {}; let options = ClaudeAgentOptions::builder() - .max_turns(1) + .tools(["Read"]) + .permission_mode(PermissionMode::Default) + .max_turns(3) .skip_version_check(true) .stderr_callback(Arc::new(stderr_callback)) .can_use_tool(can_use_tool) .build(); // Ask Claude to read a file - this should trigger the Read tool - let prompt = "Read the file /etc/hostname and tell me what it contains"; + let prompt = "Read the file /etc/hostname"; - let mut stream = match query_stream(prompt.to_string(), Some(options)).await { - Ok(s) => s, - Err(e) => { - panic!("Failed to create stream: {}", e); - } - }; + // Use ClaudeClient which wires up the control protocol for can_use_tool callbacks + let mut client = ClaudeClient::new(options); + client.connect().await.expect("Failed to connect"); + client.query(prompt).await.expect("Failed to send query"); // Consume the stream + let mut stream = client.receive_response(); while let Some(result) = stream.next().await { - if let Err(e) = result { - // Some errors are expected if the tool is denied or file doesn't exist - println!("Stream message: {:?}", e); + match result { + Ok(msg) => println!("Stream message: {:?}", msg), + Err(e) => println!("Stream error: {:?}", e), } } let count = callback_count.load(Ordering::SeqCst); assert!( count > 0, - "can_use_tool callback should have been invoked at least once, but was invoked {} times. \ - NOTE: SDK v0.6.3 doesn't implement the permission callback protocol - \ - see handle_control_request in query_full.rs which only handles 'hook_callback' and 'mcp_message'", + "can_use_tool callback should have been invoked at least once, but was invoked {} times", count ); println!("can_use_tool callback was invoked {} time(s)", count); } /// Test that denying a tool permission prevents the tool from executing. -/// -/// NOTE: See test_can_use_tool_callback_invoked for SDK limitation details. #[tokio::test] -#[ignore = "SDK v0.6.3 does not implement can_use_tool callback"] +#[ignore = "Requires Claude Code CLI to be installed and authenticated"] async fn test_can_use_tool_deny_prevents_execution() { if !cli_available() { println!("Skipping: Claude CLI not available"); @@ -314,7 +291,9 @@ async fn test_can_use_tool_deny_prevents_execution() { let stderr_callback = |_msg: String| {}; let options = ClaudeAgentOptions::builder() - .max_turns(2) // Allow a retry turn for Claude to respond to denial + .tools(["Read"]) + .permission_mode(PermissionMode::Default) + .max_turns(3) .skip_version_check(true) .stderr_callback(Arc::new(stderr_callback)) .can_use_tool(can_use_tool) @@ -323,14 +302,13 @@ async fn test_can_use_tool_deny_prevents_execution() { // Ask Claude to read a file let prompt = "Read the file /etc/hostname"; - let mut stream = match query_stream(prompt.to_string(), Some(options)).await { - Ok(s) => s, - Err(e) => { - panic!("Failed to create stream: {}", e); - } - }; + // Use ClaudeClient which wires up the control protocol for can_use_tool callbacks + let mut client = ClaudeClient::new(options); + client.connect().await.expect("Failed to connect"); + client.query(prompt).await.expect("Failed to send query"); let mut response_text = String::new(); + let mut stream = client.receive_response(); while let Some(result) = stream.next().await { match result { Ok(ClaudeMessage::Assistant(msg)) => {