notedeck

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

claude_integration.rs (18753B)


      1 //! Integration tests for Claude Code SDK
      2 //!
      3 //! These tests require Claude Code CLI to be installed and authenticated.
      4 //! Run with: cargo test -p notedeck_dave --test claude_integration -- --ignored
      5 //!
      6 //! The SDK spawns the Claude Code CLI as a subprocess and communicates via JSON streaming.
      7 //! The CLAUDE_API_KEY environment variable is read by the CLI subprocess.
      8 
      9 use claude_agent_sdk_rs::{
     10     get_claude_code_version, query_stream, ClaudeAgentOptions, ClaudeClient, ContentBlock,
     11     Message as ClaudeMessage, PermissionMode, PermissionResult, PermissionResultAllow,
     12     PermissionResultDeny, TextBlock, ToolPermissionContext,
     13 };
     14 use futures::future::BoxFuture;
     15 use futures::StreamExt;
     16 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
     17 use std::sync::Arc;
     18 
     19 /// Check if Claude CLI is available
     20 fn cli_available() -> bool {
     21     get_claude_code_version().is_some()
     22 }
     23 
     24 /// Build test options with cost controls.
     25 /// Uses BypassPermissions to avoid interactive prompts in automated testing.
     26 /// Includes a stderr callback to prevent subprocess blocking.
     27 fn test_options() -> ClaudeAgentOptions {
     28     // A stderr callback is needed to prevent the subprocess from blocking
     29     // when stderr buffer fills up. We just discard the output.
     30     let stderr_callback = |_msg: String| {};
     31 
     32     ClaudeAgentOptions::builder()
     33         .permission_mode(PermissionMode::BypassPermissions)
     34         .max_turns(1)
     35         .skip_version_check(true)
     36         .stderr_callback(Arc::new(stderr_callback))
     37         .build()
     38 }
     39 
     40 /// Non-ignored test that checks CLI availability without failing.
     41 /// This test always passes - it just reports whether the CLI is present.
     42 #[test]
     43 fn test_cli_version_available() {
     44     let version = get_claude_code_version();
     45     match version {
     46         Some(v) => println!("Claude Code CLI version: {}", v),
     47         None => println!("Claude Code CLI not installed - integration tests will be skipped"),
     48     }
     49 }
     50 
     51 /// Test that the Claude Code SDK returns a text response.
     52 /// Validates that we receive actual text content from Claude.
     53 #[tokio::test]
     54 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
     55 async fn test_simple_query_returns_text() {
     56     if !cli_available() {
     57         println!("Skipping: Claude CLI not available");
     58         return;
     59     }
     60 
     61     let prompt = "Respond with exactly: Hello";
     62     let options = test_options();
     63 
     64     let mut stream = match query_stream(prompt.to_string(), Some(options)).await {
     65         Ok(s) => s,
     66         Err(e) => {
     67             panic!("Failed to create stream: {}", e);
     68         }
     69     };
     70 
     71     let mut received_text = String::new();
     72 
     73     while let Some(result) = stream.next().await {
     74         match result {
     75             Ok(message) => {
     76                 if let ClaudeMessage::Assistant(assistant_msg) = message {
     77                     for block in &assistant_msg.message.content {
     78                         if let ContentBlock::Text(TextBlock { text }) = block {
     79                             received_text.push_str(text);
     80                         }
     81                     }
     82                 }
     83             }
     84             Err(e) => {
     85                 panic!("Stream error: {}", e);
     86             }
     87         }
     88     }
     89 
     90     assert!(
     91         !received_text.is_empty(),
     92         "Should receive text response from Claude"
     93     );
     94 }
     95 
     96 /// Test that the Result message is received to mark completion.
     97 #[tokio::test]
     98 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
     99 async fn test_result_message_received() {
    100     if !cli_available() {
    101         println!("Skipping: Claude CLI not available");
    102         return;
    103     }
    104 
    105     let prompt = "Say hi";
    106     let options = test_options();
    107 
    108     let mut stream = match query_stream(prompt.to_string(), Some(options)).await {
    109         Ok(s) => s,
    110         Err(e) => {
    111             panic!("Failed to create stream: {}", e);
    112         }
    113     };
    114 
    115     let mut received_result = false;
    116 
    117     while let Some(result) = stream.next().await {
    118         match result {
    119             Ok(message) => {
    120                 if let ClaudeMessage::Result(_) = message {
    121                     received_result = true;
    122                     break;
    123                 }
    124             }
    125             Err(e) => {
    126                 panic!("Stream error: {}", e);
    127             }
    128         }
    129     }
    130 
    131     assert!(
    132         received_result,
    133         "Should receive Result message marking completion"
    134     );
    135 }
    136 
    137 /// Test that empty prompt is handled gracefully (no panic).
    138 #[tokio::test]
    139 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
    140 async fn test_empty_prompt_handled() {
    141     if !cli_available() {
    142         println!("Skipping: Claude CLI not available");
    143         return;
    144     }
    145 
    146     let prompt = "";
    147     let options = test_options();
    148 
    149     let result = query_stream(prompt.to_string(), Some(options)).await;
    150 
    151     // Empty prompt should either work or fail gracefully - either is acceptable
    152     if let Ok(mut stream) = result {
    153         // Consume the stream - we just care it doesn't panic
    154         while let Some(_) = stream.next().await {}
    155     }
    156     // If result is Err, that's also fine - as long as we didn't panic
    157 }
    158 
    159 /// Verify that our prompt formatting produces substantial output.
    160 /// This is a pure unit test that doesn't require Claude CLI.
    161 #[test]
    162 fn test_prompt_formatting_is_substantial() {
    163     // Simulate what messages_to_prompt should produce
    164     let system = "You are Dave, a helpful Nostr assistant.";
    165     let user_msg = "Hi";
    166 
    167     // Build a proper prompt like messages_to_prompt should
    168     let prompt = format!("{}\n\nHuman: {}\n\n", system, user_msg);
    169 
    170     // The prompt should be much longer than just "Hi" (2 chars)
    171     // If only the user message was sent (the bug), length would be ~2
    172     // With system message, it should be ~60+
    173     assert!(
    174         prompt.len() > 50,
    175         "Prompt with system message should be substantial. Got {} chars: {:?}",
    176         prompt.len(),
    177         prompt
    178     );
    179 
    180     // Verify the prompt contains what we expect
    181     assert!(
    182         prompt.contains(system),
    183         "Prompt should contain system message"
    184     );
    185     assert!(
    186         prompt.contains("Human: Hi"),
    187         "Prompt should contain formatted user message"
    188     );
    189 }
    190 
    191 /// Test that the can_use_tool callback is invoked when Claude tries to use a tool.
    192 #[tokio::test]
    193 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
    194 async fn test_can_use_tool_callback_invoked() {
    195     if !cli_available() {
    196         println!("Skipping: Claude CLI not available");
    197         return;
    198     }
    199 
    200     let callback_count = Arc::new(AtomicUsize::new(0));
    201     let callback_count_clone = callback_count.clone();
    202 
    203     // Create a callback that counts invocations and always allows
    204     let can_use_tool: Arc<
    205         dyn Fn(
    206                 String,
    207                 serde_json::Value,
    208                 ToolPermissionContext,
    209             ) -> BoxFuture<'static, PermissionResult>
    210             + Send
    211             + Sync,
    212     > = Arc::new(move |tool_name: String, _tool_input, _context| {
    213         let count = callback_count_clone.clone();
    214         Box::pin(async move {
    215             count.fetch_add(1, Ordering::SeqCst);
    216             println!("Permission requested for tool: {}", tool_name);
    217             PermissionResult::Allow(PermissionResultAllow::default())
    218         })
    219     });
    220 
    221     let stderr_callback = |_msg: String| {};
    222 
    223     let options = ClaudeAgentOptions::builder()
    224         .tools(["Read"])
    225         .permission_mode(PermissionMode::Default)
    226         .max_turns(3)
    227         .skip_version_check(true)
    228         .stderr_callback(Arc::new(stderr_callback))
    229         .can_use_tool(can_use_tool)
    230         .build();
    231 
    232     // Ask Claude to read a file - this should trigger the Read tool
    233     let prompt = "Read the file /etc/hostname";
    234 
    235     // Use ClaudeClient which wires up the control protocol for can_use_tool callbacks
    236     let mut client = ClaudeClient::new(options);
    237     client.connect().await.expect("Failed to connect");
    238     client.query(prompt).await.expect("Failed to send query");
    239 
    240     // Consume the stream
    241     let mut stream = client.receive_response();
    242     while let Some(result) = stream.next().await {
    243         match result {
    244             Ok(msg) => println!("Stream message: {:?}", msg),
    245             Err(e) => println!("Stream error: {:?}", e),
    246         }
    247     }
    248 
    249     let count = callback_count.load(Ordering::SeqCst);
    250     assert!(
    251         count > 0,
    252         "can_use_tool callback should have been invoked at least once, but was invoked {} times",
    253         count
    254     );
    255     println!("can_use_tool callback was invoked {} time(s)", count);
    256 }
    257 
    258 /// Test session management - sending multiple queries with session context maintained.
    259 /// The ClaudeClient must be kept connected to maintain session context.
    260 #[tokio::test]
    261 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
    262 async fn test_session_context_maintained() {
    263     if !cli_available() {
    264         println!("Skipping: Claude CLI not available");
    265         return;
    266     }
    267 
    268     let stderr_callback = |_msg: String| {};
    269 
    270     let options = ClaudeAgentOptions::builder()
    271         .permission_mode(PermissionMode::BypassPermissions)
    272         .max_turns(1)
    273         .skip_version_check(true)
    274         .stderr_callback(Arc::new(stderr_callback))
    275         .build();
    276 
    277     let mut client = ClaudeClient::new(options);
    278     client.connect().await.expect("Failed to connect");
    279 
    280     // First query - tell Claude a secret
    281     let session_id = "test-session-context";
    282     println!("Sending first query to session: {}", session_id);
    283     client
    284         .query_with_session(
    285             "Remember this secret code: BANANA42. Just acknowledge.",
    286             session_id,
    287         )
    288         .await
    289         .expect("Failed to send first query");
    290 
    291     // Consume first response
    292     let mut first_response = String::new();
    293     {
    294         let mut stream = client.receive_response();
    295         while let Some(result) = stream.next().await {
    296             if let Ok(ClaudeMessage::Assistant(msg)) = result {
    297                 for block in &msg.message.content {
    298                     if let ContentBlock::Text(TextBlock { text }) = block {
    299                         first_response.push_str(text);
    300                     }
    301                 }
    302             }
    303         }
    304     }
    305     println!("First response: {}", first_response);
    306 
    307     // Second query - ask about the secret (should remember within same session)
    308     println!("Sending second query to same session");
    309     client
    310         .query_with_session("What was the secret code I told you?", session_id)
    311         .await
    312         .expect("Failed to send second query");
    313 
    314     // Check if second response mentions the secret
    315     let mut second_response = String::new();
    316     {
    317         let mut stream = client.receive_response();
    318         while let Some(result) = stream.next().await {
    319             if let Ok(ClaudeMessage::Assistant(msg)) = result {
    320                 for block in &msg.message.content {
    321                     if let ContentBlock::Text(TextBlock { text }) = block {
    322                         second_response.push_str(text);
    323                     }
    324                 }
    325             }
    326         }
    327     }
    328     println!("Second response: {}", second_response);
    329 
    330     client.disconnect().await.expect("Failed to disconnect");
    331 
    332     // The second response should contain the secret code if context is maintained
    333     assert!(
    334         second_response.to_uppercase().contains("BANANA42"),
    335         "Claude should remember the secret code from the same session. Got: {}",
    336         second_response
    337     );
    338 }
    339 
    340 /// Test that different session IDs maintain separate contexts.
    341 #[tokio::test]
    342 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
    343 async fn test_separate_sessions_have_separate_context() {
    344     if !cli_available() {
    345         println!("Skipping: Claude CLI not available");
    346         return;
    347     }
    348 
    349     let stderr_callback = |_msg: String| {};
    350 
    351     let options = ClaudeAgentOptions::builder()
    352         .permission_mode(PermissionMode::BypassPermissions)
    353         .max_turns(1)
    354         .skip_version_check(true)
    355         .stderr_callback(Arc::new(stderr_callback))
    356         .build();
    357 
    358     let mut client = ClaudeClient::new(options);
    359     client.connect().await.expect("Failed to connect");
    360 
    361     // First session - tell a secret
    362     println!("Session A: Setting secret");
    363     client
    364         .query_with_session(
    365             "Remember: The password is APPLE123. Just acknowledge.",
    366             "session-A",
    367         )
    368         .await
    369         .expect("Failed to send to session A");
    370 
    371     {
    372         let mut stream = client.receive_response();
    373         while let Some(_) = stream.next().await {}
    374     }
    375 
    376     // Different session - should NOT know the secret
    377     println!("Session B: Asking about secret");
    378     client
    379         .query_with_session(
    380             "What password did I tell you? If you don't know, just say 'I don't know any password'.",
    381             "session-B",
    382         )
    383         .await
    384         .expect("Failed to send to session B");
    385 
    386     let mut response_b = String::new();
    387     {
    388         let mut stream = client.receive_response();
    389         while let Some(result) = stream.next().await {
    390             if let Ok(ClaudeMessage::Assistant(msg)) = result {
    391                 for block in &msg.message.content {
    392                     if let ContentBlock::Text(TextBlock { text }) = block {
    393                         response_b.push_str(text);
    394                     }
    395                 }
    396             }
    397         }
    398     }
    399     println!("Session B response: {}", response_b);
    400 
    401     client.disconnect().await.expect("Failed to disconnect");
    402 
    403     // Session B should NOT know the password from Session A
    404     assert!(
    405         !response_b.to_uppercase().contains("APPLE123"),
    406         "Session B should NOT know the password from Session A. Got: {}",
    407         response_b
    408     );
    409 }
    410 
    411 /// Test --continue flag for resuming the last conversation.
    412 /// This tests the simpler approach of continuing the most recent conversation.
    413 #[tokio::test]
    414 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
    415 async fn test_continue_conversation_flag() {
    416     if !cli_available() {
    417         println!("Skipping: Claude CLI not available");
    418         return;
    419     }
    420 
    421     let stderr_callback = |_msg: String| {};
    422 
    423     // First: Start a fresh conversation
    424     let options1 = ClaudeAgentOptions::builder()
    425         .permission_mode(PermissionMode::BypassPermissions)
    426         .max_turns(1)
    427         .skip_version_check(true)
    428         .stderr_callback(Arc::new(stderr_callback))
    429         .build();
    430 
    431     let mut stream1 = query_stream(
    432         "Remember this code: ZEBRA999. Just acknowledge.".to_string(),
    433         Some(options1),
    434     )
    435     .await
    436     .expect("First query failed");
    437 
    438     let mut first_response = String::new();
    439     while let Some(result) = stream1.next().await {
    440         if let Ok(ClaudeMessage::Assistant(msg)) = result {
    441             for block in &msg.message.content {
    442                 if let ContentBlock::Text(TextBlock { text }) = block {
    443                     first_response.push_str(text);
    444                 }
    445             }
    446         }
    447     }
    448     println!("First response: {}", first_response);
    449 
    450     // Second: Use --continue to resume and ask about the code
    451     let stderr_callback2 = |_msg: String| {};
    452     let options2 = ClaudeAgentOptions::builder()
    453         .permission_mode(PermissionMode::BypassPermissions)
    454         .max_turns(1)
    455         .skip_version_check(true)
    456         .stderr_callback(Arc::new(stderr_callback2))
    457         .continue_conversation(true)
    458         .build();
    459 
    460     let mut stream2 = query_stream("What was the code I told you?".to_string(), Some(options2))
    461         .await
    462         .expect("Second query failed");
    463 
    464     let mut second_response = String::new();
    465     while let Some(result) = stream2.next().await {
    466         if let Ok(ClaudeMessage::Assistant(msg)) = result {
    467             for block in &msg.message.content {
    468                 if let ContentBlock::Text(TextBlock { text }) = block {
    469                     second_response.push_str(text);
    470                 }
    471             }
    472         }
    473     }
    474     println!("Second response (with --continue): {}", second_response);
    475 
    476     // Claude should remember the code when using --continue
    477     assert!(
    478         second_response.to_uppercase().contains("ZEBRA999"),
    479         "Claude should remember the code with --continue. Got: {}",
    480         second_response
    481     );
    482 }
    483 
    484 /// Test that denying a tool permission prevents the tool from executing.
    485 #[tokio::test]
    486 #[ignore = "Requires Claude Code CLI to be installed and authenticated"]
    487 async fn test_can_use_tool_deny_prevents_execution() {
    488     if !cli_available() {
    489         println!("Skipping: Claude CLI not available");
    490         return;
    491     }
    492 
    493     let was_denied = Arc::new(AtomicBool::new(false));
    494     let was_denied_clone = was_denied.clone();
    495 
    496     // Create a callback that always denies
    497     let can_use_tool: Arc<
    498         dyn Fn(
    499                 String,
    500                 serde_json::Value,
    501                 ToolPermissionContext,
    502             ) -> BoxFuture<'static, PermissionResult>
    503             + Send
    504             + Sync,
    505     > = Arc::new(move |tool_name: String, _tool_input, _context| {
    506         let denied = was_denied_clone.clone();
    507         Box::pin(async move {
    508             denied.store(true, Ordering::SeqCst);
    509             println!("Denying permission for tool: {}", tool_name);
    510             PermissionResult::Deny(PermissionResultDeny {
    511                 message: "Test denial - permission not granted".to_string(),
    512                 interrupt: false,
    513             })
    514         })
    515     });
    516 
    517     let stderr_callback = |_msg: String| {};
    518 
    519     let options = ClaudeAgentOptions::builder()
    520         .tools(["Read"])
    521         .permission_mode(PermissionMode::Default)
    522         .max_turns(3)
    523         .skip_version_check(true)
    524         .stderr_callback(Arc::new(stderr_callback))
    525         .can_use_tool(can_use_tool)
    526         .build();
    527 
    528     // Ask Claude to read a file
    529     let prompt = "Read the file /etc/hostname";
    530 
    531     // Use ClaudeClient which wires up the control protocol for can_use_tool callbacks
    532     let mut client = ClaudeClient::new(options);
    533     client.connect().await.expect("Failed to connect");
    534     client.query(prompt).await.expect("Failed to send query");
    535 
    536     let mut response_text = String::new();
    537     let mut stream = client.receive_response();
    538     while let Some(result) = stream.next().await {
    539         match result {
    540             Ok(ClaudeMessage::Assistant(msg)) => {
    541                 for block in &msg.message.content {
    542                     if let ContentBlock::Text(TextBlock { text }) = block {
    543                         response_text.push_str(text);
    544                     }
    545                 }
    546             }
    547             _ => {}
    548         }
    549     }
    550 
    551     assert!(
    552         was_denied.load(Ordering::SeqCst),
    553         "The can_use_tool callback should have been invoked and denied"
    554     );
    555     println!("Response after denial: {}", response_text);
    556 }