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 }