Skip to content

Commit fb4fd6b

Browse files
feat(test): add real Claude Code integration tests
Tests that spawn actual claude CLI, send messages, and verify streaming events (SessionReady, TurnStarted, ContentDelta, TurnCompleted). Includes multi-turn context preservation and incremental streaming verification.
1 parent 2a14974 commit fb4fd6b

1 file changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
//! Integration test: actually spawns Claude Code, sends a message, and verifies
2+
//! the full streaming event lifecycle (TurnStarted → ContentDelta(s) → TurnCompleted).
3+
//!
4+
//! Requires `claude` to be installed and authenticated on the system.
5+
//! Run with: cargo test -p codeforge-session --test claude_integration -- --nocapture
6+
7+
use std::time::Duration;
8+
9+
use codeforge_session::claude::ClaudeSession;
10+
use codeforge_session::AgentEvent;
11+
use tokio::time::timeout;
12+
13+
/// Send a single message to Claude Code and verify we get streaming events back.
14+
#[tokio::test]
15+
async fn claude_code_send_and_receive() {
16+
let cwd = std::env::temp_dir();
17+
18+
let (session, mut rx) = ClaudeSession::start(&cwd, None)
19+
.await
20+
.expect("Failed to start Claude session — is `claude` installed?");
21+
22+
// Send a message immediately — with -p mode, the init event comes after
23+
// the first message is sent, not before.
24+
session
25+
.send_message("Reply with exactly the word 'pong' and nothing else.")
26+
.expect("Failed to send message");
27+
28+
// Collect events until TurnCompleted or timeout
29+
let mut got_session_ready = false;
30+
let mut got_turn_started = false;
31+
let mut got_content = false;
32+
let mut content_delta_count: usize = 0;
33+
let mut accumulated_text = String::new();
34+
let mut got_turn_completed = false;
35+
let mut got_usage = false;
36+
37+
let response_timeout = Duration::from_secs(90);
38+
let deadline = tokio::time::Instant::now() + response_timeout;
39+
40+
while tokio::time::Instant::now() < deadline {
41+
match timeout(Duration::from_secs(30), rx.recv()).await {
42+
Ok(Some(event)) => {
43+
match &event {
44+
AgentEvent::SessionReady => {
45+
println!("[OK] SessionReady");
46+
got_session_ready = true;
47+
}
48+
AgentEvent::TurnStarted { turn_id } => {
49+
println!("[OK] TurnStarted: {turn_id}");
50+
got_turn_started = true;
51+
}
52+
AgentEvent::ContentDelta { text } => {
53+
print!("{text}");
54+
accumulated_text.push_str(text);
55+
content_delta_count += 1;
56+
got_content = true;
57+
}
58+
AgentEvent::TurnCompleted { turn_id } => {
59+
println!("\n[OK] TurnCompleted: {turn_id}");
60+
got_turn_completed = true;
61+
break;
62+
}
63+
AgentEvent::UsageReport {
64+
input_tokens,
65+
output_tokens,
66+
cost_usd,
67+
model,
68+
..
69+
} => {
70+
println!(
71+
"[OK] UsageReport: model={model}, in={input_tokens}, out={output_tokens}, cost=${cost_usd:.6}"
72+
);
73+
got_usage = true;
74+
}
75+
AgentEvent::SessionError { message } => {
76+
panic!("SessionError: {message}");
77+
}
78+
other => {
79+
println!("[..] Event: {:?}", other);
80+
}
81+
}
82+
}
83+
Ok(None) => {
84+
println!("[!!] Channel closed");
85+
break;
86+
}
87+
Err(_) => {
88+
println!("[!!] Timeout waiting for event");
89+
break;
90+
}
91+
}
92+
}
93+
94+
// Assertions
95+
assert!(got_session_ready, "Never received SessionReady");
96+
assert!(got_turn_started, "Never received TurnStarted");
97+
assert!(got_content, "Never received any ContentDelta");
98+
assert!(
99+
!accumulated_text.is_empty(),
100+
"Accumulated response text is empty"
101+
);
102+
assert!(got_turn_completed, "Never received TurnCompleted");
103+
104+
println!("\n--- Full response ---");
105+
println!("{accumulated_text}");
106+
println!("--- End ---");
107+
println!(
108+
"Response length: {} chars, delta count: {}, got usage report: {}",
109+
accumulated_text.len(),
110+
content_delta_count,
111+
got_usage
112+
);
113+
114+
// The response should contain "pong" since we asked for it
115+
let lower = accumulated_text.to_lowercase();
116+
assert!(
117+
lower.contains("pong"),
118+
"Expected response to contain 'pong', got: {accumulated_text}"
119+
);
120+
121+
// No duplicate content: the response for "pong" should be very short.
122+
// If stream_event and assistant both emit, we'd see ~2x the expected length.
123+
assert!(
124+
accumulated_text.len() < 50,
125+
"Response suspiciously long — possible duplicate streaming. Got {} chars: {accumulated_text}",
126+
accumulated_text.len()
127+
);
128+
}
129+
130+
/// Test multi-turn conversation: send two messages, verify context is preserved.
131+
#[tokio::test]
132+
async fn claude_code_multi_turn() {
133+
let cwd = std::env::temp_dir();
134+
135+
let (session, mut rx) = ClaudeSession::start(&cwd, None)
136+
.await
137+
.expect("Failed to start Claude session");
138+
139+
// First turn: send message immediately
140+
session
141+
.send_message("Remember the number 42. Reply only with 'noted'.")
142+
.expect("Failed to send first message");
143+
144+
let response1 = collect_response(&mut rx).await;
145+
println!("[Turn 1] {response1}");
146+
assert!(
147+
response1.to_lowercase().contains("noted"),
148+
"Expected 'noted' in first response, got: {response1}"
149+
);
150+
151+
// Second turn: verify context is preserved
152+
session
153+
.send_message("What number did I ask you to remember? Reply with just the number.")
154+
.expect("Failed to send second message");
155+
156+
let response2 = collect_response(&mut rx).await;
157+
println!("[Turn 2] {response2}");
158+
assert!(
159+
response2.contains("42"),
160+
"Expected '42' in second response, got: {response2}"
161+
);
162+
}
163+
164+
/// Verify that responses actually stream incrementally (multiple ContentDelta events),
165+
/// not as a single dump.
166+
#[tokio::test]
167+
async fn claude_code_streams_incrementally() {
168+
let cwd = std::env::temp_dir();
169+
170+
let (session, mut rx) = ClaudeSession::start(&cwd, None)
171+
.await
172+
.expect("Failed to start Claude session");
173+
174+
// Ask for a longer response to ensure multiple streaming chunks
175+
session
176+
.send_message("Count from 1 to 10, one number per line.")
177+
.expect("Failed to send message");
178+
179+
let mut delta_count: usize = 0;
180+
let mut text = String::new();
181+
let deadline = tokio::time::Instant::now() + Duration::from_secs(90);
182+
183+
while tokio::time::Instant::now() < deadline {
184+
match timeout(Duration::from_secs(30), rx.recv()).await {
185+
Ok(Some(AgentEvent::ContentDelta { text: t })) => {
186+
delta_count += 1;
187+
text.push_str(&t);
188+
}
189+
Ok(Some(AgentEvent::TurnCompleted { .. })) => break,
190+
Ok(Some(AgentEvent::SessionError { message })) => panic!("SessionError: {message}"),
191+
Ok(Some(_)) => continue,
192+
Ok(None) | Err(_) => break,
193+
}
194+
}
195+
196+
println!("Got {delta_count} content deltas for response:\n{text}");
197+
198+
// A counting response should produce multiple streaming chunks
199+
assert!(
200+
delta_count > 1,
201+
"Expected multiple ContentDelta events for incremental streaming, got {delta_count}. \
202+
Response may be arriving as a single dump instead of streaming."
203+
);
204+
205+
// Verify the content is reasonable
206+
assert!(text.contains("1"), "Response should contain '1'");
207+
assert!(text.contains("10"), "Response should contain '10'");
208+
}
209+
210+
// ── Helpers ──
211+
212+
async fn collect_response(
213+
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AgentEvent>,
214+
) -> String {
215+
let mut text = String::new();
216+
let deadline = tokio::time::Instant::now() + Duration::from_secs(90);
217+
218+
while tokio::time::Instant::now() < deadline {
219+
match timeout(Duration::from_secs(30), rx.recv()).await {
220+
Ok(Some(AgentEvent::ContentDelta { text: t })) => text.push_str(&t),
221+
Ok(Some(AgentEvent::TurnCompleted { .. })) => break,
222+
Ok(Some(AgentEvent::SessionError { message })) => panic!("SessionError: {message}"),
223+
Ok(Some(_)) => continue,
224+
Ok(None) => break,
225+
Err(_) => break,
226+
}
227+
}
228+
229+
text
230+
}

0 commit comments

Comments
 (0)