1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5
6use async_stream::try_stream;
7use claude_code_sdk_rust::{ClaudeAgentClient, ClaudeAgentOptions, MessageResponse, StreamEvent};
8use roder_api::catalog::{PROVIDER_CLAUDE_CODE, models_for_provider};
9use roder_api::extension::InferenceEngineId;
10use roder_api::inference::{
11 AgentInferenceRequest, CompletionMetadata, InferenceCapabilities, InferenceEngine,
12 InferenceEvent, InferenceEventStream, InferenceFailure, InferenceProviderContext,
13 InferenceProviderMetadata, InferenceTurnContext, MessageDelta, ModelDescriptor,
14 ProviderAuthType, ReasoningDelta, TokenUsage, ToolCallCompleted, ToolCallDelta,
15};
16use roder_api::transcript::TranscriptItem;
17use serde_json::json;
18use tokio::sync::mpsc;
19
20use crate::options::build_options;
21
22#[derive(Debug, Clone, Default)]
23pub struct ClaudeCodeConfig {
24 pub cli_path: Option<String>,
25 pub permission_mode: Option<String>,
26 pub setting_sources: Option<Vec<String>>,
27 pub workspace: Option<PathBuf>,
28 pub reuse_cli_session: Option<bool>,
33}
34
35impl ClaudeCodeConfig {
36 fn session_reuse_enabled(&self) -> bool {
37 self.reuse_cli_session.unwrap_or(true)
38 }
39}
40
41#[async_trait::async_trait]
42pub trait ClaudeCodeRunner: Send + Sync {
43 async fn stream(
44 &self,
45 options: ClaudeAgentOptions,
46 prompt: String,
47 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>>;
48}
49
50#[derive(Debug, Default)]
51pub struct SdkClaudeCodeRunner;
52
53#[async_trait::async_trait]
54impl ClaudeCodeRunner for SdkClaudeCodeRunner {
55 async fn stream(
56 &self,
57 options: ClaudeAgentOptions,
58 prompt: String,
59 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
60 Ok(ClaudeAgentClient::spawn_stream_message(options, prompt))
61 }
62}
63
64#[derive(Debug, Default)]
72struct SessionContinuity {
73 session_id: Option<String>,
74 synced: Vec<u64>,
75}
76
77pub struct ClaudeCodeEngine {
78 config: ClaudeCodeConfig,
79 runner: Arc<dyn ClaudeCodeRunner>,
80 continuity: Arc<Mutex<SessionContinuity>>,
81}
82
83impl ClaudeCodeEngine {
84 pub fn new(config: ClaudeCodeConfig) -> Self {
85 Self::new_with_runner(config, Arc::new(SdkClaudeCodeRunner))
86 }
87
88 pub fn new_with_runner(config: ClaudeCodeConfig, runner: Arc<dyn ClaudeCodeRunner>) -> Self {
89 Self {
90 config,
91 runner,
92 continuity: Arc::new(Mutex::new(SessionContinuity::default())),
93 }
94 }
95
96 fn auth_configured(&self) -> bool {
97 self.config
98 .cli_path
99 .as_deref()
100 .map(|value| !value.trim().is_empty())
101 .unwrap_or_else(|| which::which("claude").is_ok())
102 }
103}
104
105#[async_trait::async_trait]
106impl InferenceEngine for ClaudeCodeEngine {
107 fn id(&self) -> InferenceEngineId {
108 PROVIDER_CLAUDE_CODE.to_string()
109 }
110
111 fn capabilities(&self) -> InferenceCapabilities {
112 InferenceCapabilities {
113 streaming: true,
114 tool_calls: true,
115 parallel_tool_calls: false,
116 reasoning_summaries: true,
117 structured_output: false,
118 image_input: false,
119 prompt_cache: false,
120 provider_metadata: true,
121 tool_search: false,
122 }
123 }
124
125 fn metadata(&self) -> InferenceProviderMetadata {
126 InferenceProviderMetadata {
127 name: "Claude Code".to_string(),
128 description: Some("Local Claude Code CLI harness via claude-agent-sdk".to_string()),
129 auth_type: ProviderAuthType::None,
130 auth_label: Some("Authenticated local Claude Code CLI".to_string()),
131 auth_configured: Some(self.auth_configured()),
132 recommended: true,
133 sort_order: 19,
134 }
135 }
136
137 async fn list_models(
138 &self,
139 _ctx: InferenceProviderContext<'_>,
140 ) -> anyhow::Result<Vec<ModelDescriptor>> {
141 Ok(models_for_provider(PROVIDER_CLAUDE_CODE, false))
142 }
143
144 async fn stream_turn(
145 &self,
146 _ctx: InferenceTurnContext<'_>,
147 request: AgentInferenceRequest,
148 ) -> anyhow::Result<InferenceEventStream> {
149 validate_request(&request)?;
150 let current_dir = std::env::current_dir().ok();
151 let cwd = self
152 .config
153 .workspace
154 .as_deref()
155 .or_else(|| current_dir.as_deref());
156 let plan = self.plan_turn(&request);
157 let options = build_options(
158 &self.config,
159 &request,
160 _ctx.tool_executor.clone(),
161 cwd,
162 plan.resume_session_id.as_deref(),
163 )?;
164 let events = self.runner.stream(options, plan.prompt).await?;
165 Ok(Box::pin(map_stream_events(
166 events,
167 Arc::clone(&self.continuity),
168 plan.synced,
169 )))
170 }
171}
172
173struct TurnPlan {
177 resume_session_id: Option<String>,
178 prompt: String,
179 synced: Vec<u64>,
183}
184
185impl ClaudeCodeEngine {
186 fn plan_turn(&self, request: &AgentInferenceRequest) -> TurnPlan {
187 let fingerprints = transcript_fingerprints(&request.transcript);
188 let boundary = delta_boundary(&request.transcript);
192
193 if self.config.session_reuse_enabled()
194 && let Ok(state) = self.continuity.lock()
195 {
196 let can_resume = state.session_id.is_some()
197 && boundary > 0
198 && is_prefix(&state.synced, &fingerprints)
199 && boundary <= request.transcript.len();
200 if can_resume {
201 let delta = prompt_from_delta(request, boundary);
202 if !delta.trim().is_empty() {
203 return TurnPlan {
204 resume_session_id: state.session_id.clone(),
205 prompt: delta,
206 synced: fingerprints,
209 };
210 }
211 }
212 }
213
214 TurnPlan {
218 resume_session_id: None,
219 prompt: prompt_from_request(request),
220 synced: fingerprints,
221 }
222 }
223}
224
225fn validate_request(request: &AgentInferenceRequest) -> anyhow::Result<()> {
226 if request.output.response_format.is_some() {
227 anyhow::bail!("Claude Code provider does not support structured response_format yet");
228 }
229 Ok(())
230}
231
232fn prompt_from_request(request: &AgentInferenceRequest) -> String {
233 prompt_from_items(request, &request.transcript)
234}
235
236fn prompt_from_delta(request: &AgentInferenceRequest, boundary: usize) -> String {
239 let tail = request.transcript.get(boundary..).unwrap_or(&[]);
240 prompt_from_items(request, tail)
241}
242
243fn prompt_from_items(request: &AgentInferenceRequest, items: &[TranscriptItem]) -> String {
244 let mut parts = Vec::new();
245 for item in items {
246 if matches!(item, TranscriptItem::ProviderMetadata(_)) {
249 continue;
250 }
251 parts.push(format!("{item:?}"));
252 }
253 if let Some(value) = request
254 .metadata
255 .get("prompt")
256 .and_then(|value| value.as_str())
257 {
258 parts.push(value.to_string());
259 }
260 if parts.is_empty() {
261 "Continue the current Roder turn.".to_string()
262 } else {
263 parts.join("\n\n")
264 }
265}
266
267fn delta_boundary(transcript: &[TranscriptItem]) -> usize {
271 transcript
272 .iter()
273 .rposition(|item| matches!(item, TranscriptItem::AssistantMessage(_)))
274 .map(|index| index + 1)
275 .unwrap_or(0)
276}
277
278fn transcript_fingerprints(transcript: &[TranscriptItem]) -> Vec<u64> {
279 transcript.iter().map(fingerprint_item).collect()
280}
281
282fn fingerprint_item(item: &TranscriptItem) -> u64 {
283 let mut hasher = DefaultHasher::new();
284 format!("{item:?}").hash(&mut hasher);
285 hasher.finish()
286}
287
288fn is_prefix(prefix: &[u64], full: &[u64]) -> bool {
290 prefix.len() <= full.len() && full[..prefix.len()] == *prefix
291}
292
293fn map_stream_events(
294 mut events: mpsc::UnboundedReceiver<StreamEvent>,
295 continuity: Arc<Mutex<SessionContinuity>>,
296 synced: Vec<u64>,
297) -> InferenceEventStream {
298 Box::pin(try_stream! {
299 let mut saw_partial_text = false;
300 let mut accumulated_text = String::new();
301 let mut echo_match_pos = 0usize;
310 let mut completed = false;
311 let mut last_session_id: Option<String> = None;
312 let mut last_stop_reason: Option<String> = None;
313 let mut mcp_tool_ids: std::collections::HashSet<String> =
321 std::collections::HashSet::new();
322 while let Some(event) = events.recv().await {
323 match event {
324 StreamEvent::ContentChunk(text) => {
325 if completed {
326 continue;
327 }
328 if !accumulated_text.is_empty()
329 && echo_match_pos < accumulated_text.len()
330 && accumulated_text[echo_match_pos..].starts_with(&text)
331 {
332 echo_match_pos += text.len();
333 continue;
334 }
335 saw_partial_text = true;
336 echo_match_pos = 0;
337 accumulated_text.push_str(&text);
338 yield InferenceEvent::MessageDelta(MessageDelta { text, phase: None });
339 }
340 StreamEvent::ThinkingChunk { thinking, .. } => {
341 yield InferenceEvent::ReasoningDelta(ReasoningDelta { text: thinking });
342 }
343 StreamEvent::ToolUseStart { id, name, input } => {
344 if name.starts_with("mcp__") {
345 mcp_tool_ids.insert(id);
348 continue;
349 }
350 if !input.is_empty() {
351 yield InferenceEvent::ToolCallCompleted(ToolCallCompleted {
352 id,
353 name,
354 arguments: serde_json::Value::Object(input).to_string(),
355 });
356 }
357 }
358 StreamEvent::ToolUseDelta { id, partial_input } => {
359 if mcp_tool_ids.contains(&id) {
360 continue;
361 }
362 yield InferenceEvent::ToolCallDelta(ToolCallDelta { id, arguments_delta: partial_input });
363 }
364 StreamEvent::ToolResult { tool_use_id, content, is_error } => {
365 yield InferenceEvent::ProviderMetadata(json!({
366 "provider": PROVIDER_CLAUDE_CODE,
367 "toolResult": {
368 "toolUseId": tool_use_id,
369 "isError": is_error.unwrap_or(false),
370 "content": content.unwrap_or(serde_json::Value::Null),
371 }
372 }));
373 }
374 StreamEvent::RateLimit(info) => {
375 yield InferenceEvent::ProviderMetadata(json!({
376 "provider": PROVIDER_CLAUDE_CODE,
377 "rateLimit": info,
378 }));
379 }
380 StreamEvent::Complete(response) => {
381 if completed {
385 continue;
386 }
387 if let Some(usage) = usage_from_response(&response) {
388 yield InferenceEvent::Usage(usage);
389 }
390 if response.content.is_empty() {
391 continue;
392 }
393 if !saw_partial_text {
394 yield InferenceEvent::MessageDelta(MessageDelta { text: response.content.clone(), phase: None });
395 }
396 last_session_id = Some(response.session_id);
397 last_stop_reason = response.stop_reason;
398 accumulated_text.clear();
401 echo_match_pos = 0;
402 }
403 StreamEvent::TurnComplete(response) => {
404 if completed {
405 continue;
406 }
407 if let Some(usage) = usage_from_response(&response) {
408 yield InferenceEvent::Usage(usage);
409 }
410 completed = true;
411 let session_id = (!response.session_id.trim().is_empty())
412 .then_some(response.session_id)
413 .or(last_session_id.take());
414 commit_session(&continuity, session_id.as_deref(), &synced);
415 yield InferenceEvent::Completed(CompletionMetadata {
416 stop_reason: response.stop_reason.or(last_stop_reason.take()),
417 provider_response_id: session_id,
418 });
419 }
420 StreamEvent::Error(message) => {
421 clear_session(&continuity);
424 yield InferenceEvent::Failed(InferenceFailure { message: redact_error(&message) });
425 }
426 }
427 }
428 if !completed && (last_session_id.is_some() || last_stop_reason.is_some()) {
432 commit_session(&continuity, last_session_id.as_deref(), &synced);
433 yield InferenceEvent::Completed(CompletionMetadata {
434 stop_reason: last_stop_reason,
435 provider_response_id: last_session_id,
436 });
437 }
438 })
439}
440
441fn commit_session(
444 continuity: &Arc<Mutex<SessionContinuity>>,
445 session_id: Option<&str>,
446 synced: &[u64],
447) {
448 let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) else {
449 clear_session(continuity);
452 return;
453 };
454 if let Ok(mut state) = continuity.lock() {
455 state.session_id = Some(session_id.to_string());
456 state.synced = synced.to_vec();
457 }
458}
459
460fn clear_session(continuity: &Arc<Mutex<SessionContinuity>>) {
461 if let Ok(mut state) = continuity.lock() {
462 state.session_id = None;
463 state.synced.clear();
464 }
465}
466
467fn usage_from_response(response: &MessageResponse) -> Option<TokenUsage> {
468 let usage = response.usage.as_ref()?;
469 let prompt_tokens = number_from_usage(
470 usage,
471 &[
472 "input_tokens",
473 "inputTokens",
474 "prompt_tokens",
475 "promptTokens",
476 "cache_creation_input_tokens",
477 "cacheCreationInputTokens",
478 "cache_read_input_tokens",
479 "cacheReadInputTokens",
480 ],
481 )
482 .unwrap_or(0);
483 let cached_prompt_tokens =
484 number_from_usage(usage, &["cache_read_input_tokens", "cacheReadInputTokens"]);
485 let cache_creation_prompt_tokens = number_from_usage(
486 usage,
487 &["cache_creation_input_tokens", "cacheCreationInputTokens"],
488 );
489 let completion_tokens = number_from_usage(
490 usage,
491 &[
492 "output_tokens",
493 "outputTokens",
494 "completion_tokens",
495 "completionTokens",
496 ],
497 )
498 .unwrap_or(0);
499 let total_tokens = number_from_usage(usage, &["total_tokens", "totalTokens"])
500 .unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens));
501 if prompt_tokens == 0 && completion_tokens == 0 && total_tokens == 0 {
502 return None;
503 }
504 Some(
505 TokenUsage::new(prompt_tokens, completion_tokens, total_tokens)
506 .with_cached_prompt_tokens(cached_prompt_tokens.unwrap_or(0))
507 .with_cache_creation_prompt_tokens(cache_creation_prompt_tokens.unwrap_or(0)),
508 )
509}
510
511fn number_from_usage(
512 usage: &std::collections::HashMap<String, serde_json::Value>,
513 keys: &[&str],
514) -> Option<u32> {
515 let total = usage.iter().fold(0u32, |total, (key, value)| {
516 total.saturating_add(number_from_usage_value(key, value, keys))
517 });
518 (total > 0).then_some(total)
519}
520
521fn number_from_usage_value(key: &str, value: &serde_json::Value, keys: &[&str]) -> u32 {
522 let direct = if keys.iter().any(|candidate| candidate == &key) {
523 json_u32(value).unwrap_or(0)
524 } else {
525 0
526 };
527 let nested = value
528 .as_object()
529 .map(|object| {
530 object.iter().fold(0u32, |total, (key, value)| {
531 total.saturating_add(number_from_usage_value(key, value, keys))
532 })
533 })
534 .unwrap_or(0);
535 direct.saturating_add(nested)
536}
537
538fn json_u32(value: &serde_json::Value) -> Option<u32> {
539 value
540 .as_u64()
541 .and_then(|value| u32::try_from(value).ok())
542 .or_else(|| value.as_i64().and_then(|value| u32::try_from(value).ok()))
543 .or_else(|| {
544 value
545 .as_f64()
546 .filter(|value| value.is_finite() && *value >= 0.0)
547 .and_then(|value| u32::try_from(value as u64).ok())
548 })
549 .or_else(|| value.as_str().and_then(|value| value.parse::<u32>().ok()))
550}
551
552fn redact_error(message: &str) -> String {
553 let mut redacted = message.to_string();
554 for marker in ["ANTHROPIC_API_KEY", "CLAUDE_CODE", "Bearer "] {
555 if redacted.contains(marker) {
556 redacted = redacted.replace(marker, "[redacted]");
557 }
558 }
559 redacted
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use claude_code_sdk_rust::types::ContentBlock;
566 use futures::StreamExt;
567 use roder_api::inference::{
568 HostedWebSearchConfig, InstructionBundle, ModelSelection, OutputConfig, ReasoningConfig,
569 RuntimeHints,
570 };
571 use roder_api::tools::ToolChoice;
572
573 #[derive(Default)]
574 struct FakeRunner {
575 events: Vec<StreamEvent>,
576 }
577
578 #[async_trait::async_trait]
579 impl ClaudeCodeRunner for FakeRunner {
580 async fn stream(
581 &self,
582 _options: ClaudeAgentOptions,
583 _prompt: String,
584 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
585 let (tx, rx) = mpsc::unbounded_channel();
586 for event in self.events.clone() {
587 tx.send(event).unwrap();
588 }
589 Ok(rx)
590 }
591 }
592
593 fn request() -> AgentInferenceRequest {
594 AgentInferenceRequest {
595 model: ModelSelection {
596 provider: PROVIDER_CLAUDE_CODE.to_string(),
597 model: "sonnet".to_string(),
598 },
599 instructions: InstructionBundle::default(),
600 transcript: Vec::new(),
601 tools: Vec::new(),
602 tool_choice: ToolChoice::Auto,
603 reasoning: ReasoningConfig::default(),
604 output: OutputConfig::default(),
605 runtime: RuntimeHints {
606 hosted_web_search: HostedWebSearchConfig::disabled(),
607 ..RuntimeHints::default()
608 },
609 metadata: json!({"prompt": "hello"}),
610 }
611 }
612
613 #[tokio::test]
614 async fn provider_streams_text_and_completion() {
615 let mut usage = std::collections::HashMap::new();
616 usage.insert("input_tokens".to_string(), json!(3));
617 usage.insert("output_tokens".to_string(), json!(5));
618 let engine = ClaudeCodeEngine::new_with_runner(
619 ClaudeCodeConfig::default(),
620 Arc::new(FakeRunner {
621 events: vec![
622 StreamEvent::ContentChunk("hello".to_string()),
623 StreamEvent::Complete(MessageResponse {
624 content: "hello".to_string(),
625 blocks: vec![ContentBlock::Text {
626 text: "hello".to_string(),
627 }],
628 model: "sonnet".to_string(),
629 stop_reason: Some("end_turn".to_string()),
630 session_id: "session-1".to_string(),
631 usage: Some(usage),
632 }),
633 StreamEvent::TurnComplete(MessageResponse {
634 content: String::new(),
635 blocks: Vec::new(),
636 model: String::new(),
637 stop_reason: Some("end_turn".to_string()),
638 session_id: "session-1".to_string(),
639 usage: None,
640 }),
641 ],
642 }),
643 );
644 let events = engine
645 .stream_turn(
646 InferenceTurnContext {
647 thread_id: "thread",
648 turn_id: "turn",
649 tool_executor: None,
650 },
651 request(),
652 )
653 .await
654 .unwrap()
655 .collect::<Vec<_>>()
656 .await
657 .into_iter()
658 .collect::<Result<Vec<_>, _>>()
659 .unwrap();
660 assert!(matches!(events[0], InferenceEvent::MessageDelta(_)));
661 assert!(
662 events
663 .iter()
664 .any(|event| matches!(event, InferenceEvent::Usage(_)))
665 );
666 assert!(
667 events
668 .iter()
669 .any(|event| matches!(event, InferenceEvent::Completed(_)))
670 );
671 }
672
673 #[tokio::test]
674 async fn provider_keeps_partial_text_and_uses_later_usage_completion() {
675 let mut usage = std::collections::HashMap::new();
676 usage.insert("input_tokens".to_string(), json!(4));
677 usage.insert("cache_read_input_tokens".to_string(), json!(6));
678 usage.insert("output_tokens".to_string(), json!(8));
679 let engine = ClaudeCodeEngine::new_with_runner(
680 ClaudeCodeConfig::default(),
681 Arc::new(FakeRunner {
682 events: vec![
683 StreamEvent::ContentChunk("one ".to_string()),
684 StreamEvent::ContentChunk("two".to_string()),
685 StreamEvent::Complete(MessageResponse {
686 content: String::new(),
687 blocks: Vec::new(),
688 model: String::new(),
689 stop_reason: Some("end_turn".to_string()),
690 session_id: "session-usage".to_string(),
691 usage: Some(usage),
692 }),
693 StreamEvent::ContentChunk("one two".to_string()),
694 StreamEvent::Complete(MessageResponse {
695 content: "one two".to_string(),
696 blocks: Vec::new(),
697 model: "sonnet".to_string(),
698 stop_reason: Some("end_turn".to_string()),
699 session_id: "session-final".to_string(),
700 usage: None,
701 }),
702 StreamEvent::TurnComplete(MessageResponse {
703 content: String::new(),
704 blocks: Vec::new(),
705 model: String::new(),
706 stop_reason: Some("end_turn".to_string()),
707 session_id: "session-final".to_string(),
708 usage: None,
709 }),
710 ],
711 }),
712 );
713 let events = engine
714 .stream_turn(
715 InferenceTurnContext {
716 thread_id: "thread",
717 turn_id: "turn",
718 tool_executor: None,
719 },
720 request(),
721 )
722 .await
723 .unwrap()
724 .collect::<Vec<_>>()
725 .await
726 .into_iter()
727 .collect::<Result<Vec<_>, _>>()
728 .unwrap();
729 let rendered = events
730 .iter()
731 .filter_map(|event| match event {
732 InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
733 _ => None,
734 })
735 .collect::<String>();
736 assert_eq!(rendered, "one two");
737 assert!(events.iter().any(|event| matches!(event, InferenceEvent::Usage(usage) if usage.prompt_tokens == 10 && usage.completion_tokens == 8)));
738 assert!(
739 events
740 .iter()
741 .any(|event| matches!(event, InferenceEvent::Completed(_)))
742 );
743 }
744
745 #[tokio::test]
751 async fn provider_does_not_duplicate_multi_block_commentary() {
752 let engine = ClaudeCodeEngine::new_with_runner(
753 ClaudeCodeConfig::default(),
754 Arc::new(FakeRunner {
755 events: vec![
756 StreamEvent::ContentChunk("First ".to_string()),
758 StreamEvent::ContentChunk("block.".to_string()),
759 StreamEvent::ContentChunk("Second ".to_string()),
760 StreamEvent::ContentChunk("block.".to_string()),
761 StreamEvent::ContentChunk("First block.".to_string()),
763 StreamEvent::ContentChunk("Second block.".to_string()),
764 StreamEvent::Complete(MessageResponse {
765 content: "First block.Second block.".to_string(),
766 blocks: Vec::new(),
767 model: "sonnet".to_string(),
768 stop_reason: Some("end_turn".to_string()),
769 session_id: "session-final".to_string(),
770 usage: None,
771 }),
772 StreamEvent::TurnComplete(MessageResponse {
773 content: String::new(),
774 blocks: Vec::new(),
775 model: String::new(),
776 stop_reason: Some("end_turn".to_string()),
777 session_id: "session-final".to_string(),
778 usage: None,
779 }),
780 ],
781 }),
782 );
783 let events = engine
784 .stream_turn(
785 InferenceTurnContext {
786 thread_id: "thread",
787 turn_id: "turn",
788 tool_executor: None,
789 },
790 request(),
791 )
792 .await
793 .unwrap()
794 .collect::<Vec<_>>()
795 .await
796 .into_iter()
797 .collect::<Result<Vec<_>, _>>()
798 .unwrap();
799 let rendered = events
800 .iter()
801 .filter_map(|event| match event {
802 InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
803 _ => None,
804 })
805 .collect::<String>();
806 assert_eq!(rendered, "First block.Second block.");
807 }
808
809 #[tokio::test]
815 async fn provider_streams_text_after_tool_use_until_turn_complete() {
816 let engine = ClaudeCodeEngine::new_with_runner(
817 ClaudeCodeConfig::default(),
818 Arc::new(FakeRunner {
819 events: vec![
820 StreamEvent::ContentChunk("I'll read the file.".to_string()),
822 StreamEvent::ContentChunk("I'll read the file.".to_string()),
824 StreamEvent::Complete(MessageResponse {
825 content: "I'll read the file.".to_string(),
826 blocks: Vec::new(),
827 model: "claude-fable-5".to_string(),
828 stop_reason: None,
829 session_id: "session-1".to_string(),
830 usage: None,
831 }),
832 StreamEvent::ToolUseStart {
834 id: "toolu_1".to_string(),
835 name: "mcp__roder__read_file".to_string(),
836 input: serde_json::Map::new(),
837 },
838 StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
840 StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
841 StreamEvent::Complete(MessageResponse {
842 content: "The magic word is pomegranate.".to_string(),
843 blocks: Vec::new(),
844 model: "claude-fable-5".to_string(),
845 stop_reason: Some("end_turn".to_string()),
846 session_id: "session-1".to_string(),
847 usage: None,
848 }),
849 StreamEvent::TurnComplete(MessageResponse {
850 content: String::new(),
851 blocks: Vec::new(),
852 model: String::new(),
853 stop_reason: Some("end_turn".to_string()),
854 session_id: "session-1".to_string(),
855 usage: None,
856 }),
857 ],
858 }),
859 );
860 let events = engine
861 .stream_turn(
862 InferenceTurnContext {
863 thread_id: "thread",
864 turn_id: "turn",
865 tool_executor: None,
866 },
867 request(),
868 )
869 .await
870 .unwrap()
871 .collect::<Vec<_>>()
872 .await
873 .into_iter()
874 .collect::<Result<Vec<_>, _>>()
875 .unwrap();
876 let rendered = events
877 .iter()
878 .filter_map(|event| match event {
879 InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
880 _ => None,
881 })
882 .collect::<String>();
883 assert_eq!(
884 rendered, "I'll read the file.The magic word is pomegranate.",
885 "post-tool assistant text must stream and echoes must dedupe"
886 );
887 let completions = events
888 .iter()
889 .filter(|event| matches!(event, InferenceEvent::Completed(_)))
890 .count();
891 assert_eq!(completions, 1, "turn must complete exactly once");
892 }
893
894 #[tokio::test]
895 async fn provider_completes_turn_when_stream_ends_without_result() {
896 let engine = ClaudeCodeEngine::new_with_runner(
897 ClaudeCodeConfig::default(),
898 Arc::new(FakeRunner {
899 events: vec![
900 StreamEvent::ContentChunk("partial".to_string()),
901 StreamEvent::Complete(MessageResponse {
902 content: "partial".to_string(),
903 blocks: Vec::new(),
904 model: "claude-fable-5".to_string(),
905 stop_reason: Some("end_turn".to_string()),
906 session_id: "session-dead".to_string(),
907 usage: None,
908 }),
909 ],
911 }),
912 );
913 let events = engine
914 .stream_turn(
915 InferenceTurnContext {
916 thread_id: "thread",
917 turn_id: "turn",
918 tool_executor: None,
919 },
920 request(),
921 )
922 .await
923 .unwrap()
924 .collect::<Vec<_>>()
925 .await
926 .into_iter()
927 .collect::<Result<Vec<_>, _>>()
928 .unwrap();
929 assert!(
930 events
931 .iter()
932 .any(|event| matches!(event, InferenceEvent::Completed(_))),
933 "stream end without a result message must still complete the turn"
934 );
935 }
936
937 #[tokio::test]
938 async fn provider_maps_tool_events() {
939 let engine = ClaudeCodeEngine::new_with_runner(
940 ClaudeCodeConfig::default(),
941 Arc::new(FakeRunner {
942 events: vec![
943 StreamEvent::ToolUseStart {
944 id: "toolu_1".to_string(),
945 name: "Read".to_string(),
946 input: serde_json::Map::from_iter([(
947 "path".to_string(),
948 json!("crates/roder-ext-claude-code"),
949 )]),
950 },
951 StreamEvent::ToolUseDelta {
952 id: "toolu_1".to_string(),
953 partial_input: "{\"file_path\"".to_string(),
954 },
955 ],
956 }),
957 );
958 let events = engine
959 .stream_turn(
960 InferenceTurnContext {
961 thread_id: "thread",
962 turn_id: "turn",
963 tool_executor: None,
964 },
965 request(),
966 )
967 .await
968 .unwrap()
969 .collect::<Vec<_>>()
970 .await
971 .into_iter()
972 .collect::<Result<Vec<_>, _>>()
973 .unwrap();
974 assert!(
975 matches!(&events[0], InferenceEvent::ToolCallCompleted(call) if call.arguments.contains("roder-ext-claude-code"))
976 );
977 assert!(matches!(events[1], InferenceEvent::ToolCallDelta(_)));
978 }
979
980 #[tokio::test]
986 async fn provider_suppresses_mcp_tool_lifecycle_events() {
987 let engine = ClaudeCodeEngine::new_with_runner(
988 ClaudeCodeConfig::default(),
989 Arc::new(FakeRunner {
990 events: vec![
991 StreamEvent::ToolUseStart {
992 id: "toolu_mcp".to_string(),
993 name: "mcp__roder__read_file".to_string(),
994 input: serde_json::Map::from_iter([(
995 "path".to_string(),
996 json!("README.md"),
997 )]),
998 },
999 StreamEvent::ToolUseDelta {
1000 id: "toolu_mcp".to_string(),
1001 partial_input: "{\"path\"".to_string(),
1002 },
1003 StreamEvent::Complete(MessageResponse {
1004 content: "done".to_string(),
1005 blocks: Vec::new(),
1006 model: "sonnet".to_string(),
1007 stop_reason: Some("end_turn".to_string()),
1008 session_id: "session".to_string(),
1009 usage: None,
1010 }),
1011 ],
1012 }),
1013 );
1014 let events = engine
1015 .stream_turn(
1016 InferenceTurnContext {
1017 thread_id: "thread",
1018 turn_id: "turn",
1019 tool_executor: None,
1020 },
1021 request(),
1022 )
1023 .await
1024 .unwrap()
1025 .collect::<Vec<_>>()
1026 .await
1027 .into_iter()
1028 .collect::<Result<Vec<_>, _>>()
1029 .unwrap();
1030 assert!(
1031 !events.iter().any(|event| matches!(
1032 event,
1033 InferenceEvent::ToolCallCompleted(_) | InferenceEvent::ToolCallDelta(_)
1034 )),
1035 "mcp__ tool lifecycle events must not be surfaced from the CLI stream"
1036 );
1037 }
1038
1039 #[test]
1040 fn usage_parser_reports_cached_prompt_tokens_as_subset() {
1041 let usage = std::collections::HashMap::from([
1042 ("input_tokens".to_string(), json!(123)),
1043 ("cache_creation_input_tokens".to_string(), json!(7)),
1044 ("cache_read_input_tokens".to_string(), json!(11)),
1045 ("output_tokens".to_string(), json!(13)),
1046 ]);
1047 let response = MessageResponse {
1048 content: String::new(),
1049 blocks: Vec::new(),
1050 model: "sonnet".to_string(),
1051 stop_reason: Some("end_turn".to_string()),
1052 session_id: "session".to_string(),
1053 usage: Some(usage),
1054 };
1055
1056 let usage = usage_from_response(&response).unwrap();
1057 assert_eq!(usage.prompt_tokens, 141);
1058 assert_eq!(usage.cached_prompt_tokens, 11);
1059 assert_eq!(usage.cache_creation_prompt_tokens, 7);
1060 assert_eq!(usage.completion_tokens, 13);
1061 assert_eq!(usage.total_tokens, 154);
1062 }
1063
1064 #[test]
1065 fn usage_parser_includes_cache_only_input_tokens_in_prompt_total() {
1066 let usage = std::collections::HashMap::from([
1067 ("cache_creation_input_tokens".to_string(), json!(7)),
1068 ("cache_read_input_tokens".to_string(), json!(11)),
1069 ("output_tokens".to_string(), json!(13)),
1070 ]);
1071 let response = MessageResponse {
1072 content: String::new(),
1073 blocks: Vec::new(),
1074 model: "sonnet".to_string(),
1075 stop_reason: Some("end_turn".to_string()),
1076 session_id: "session".to_string(),
1077 usage: Some(usage),
1078 };
1079
1080 let usage = usage_from_response(&response).unwrap();
1081 assert_eq!(usage.prompt_tokens, 18);
1082 assert_eq!(usage.cached_prompt_tokens, 11);
1083 assert_eq!(usage.cache_creation_prompt_tokens, 7);
1084 assert_eq!(usage.completion_tokens, 13);
1085 assert_eq!(usage.total_tokens, 31);
1086 }
1087
1088 #[test]
1089 fn usage_parser_accepts_output_only_delta_usage() {
1090 let response = MessageResponse {
1091 content: String::new(),
1092 blocks: Vec::new(),
1093 model: "sonnet".to_string(),
1094 stop_reason: Some("end_turn".to_string()),
1095 session_id: "session".to_string(),
1096 usage: Some(std::collections::HashMap::from([(
1097 "output_tokens".to_string(),
1098 json!(42),
1099 )])),
1100 };
1101
1102 let usage = usage_from_response(&response).unwrap();
1103 assert_eq!(usage.prompt_tokens, 0);
1104 assert_eq!(usage.completion_tokens, 42);
1105 assert_eq!(usage.total_tokens, 42);
1106 }
1107
1108 #[test]
1109 fn usage_parser_accepts_nested_camel_case_model_usage() {
1110 let response = MessageResponse {
1111 content: String::new(),
1112 blocks: Vec::new(),
1113 model: "sonnet".to_string(),
1114 stop_reason: Some("end_turn".to_string()),
1115 session_id: "session".to_string(),
1116 usage: Some(std::collections::HashMap::from([(
1117 "modelUsage".to_string(),
1118 json!({
1119 "inputTokens": "100",
1120 "cacheReadInputTokens": 25.0,
1121 "outputTokens": 7,
1122 }),
1123 )])),
1124 };
1125
1126 let usage = usage_from_response(&response).unwrap();
1127 assert_eq!(usage.prompt_tokens, 125);
1128 assert_eq!(usage.cached_prompt_tokens, 25);
1129 assert_eq!(usage.completion_tokens, 7);
1130 assert_eq!(usage.total_tokens, 132);
1131 }
1132
1133 use roder_api::transcript::{AssistantMessage, UserMessage};
1134 use std::sync::Mutex as StdMutex;
1135
1136 type RecordedCall = (Option<String>, String);
1138
1139 #[derive(Default)]
1142 struct RecordingRunner {
1143 session_id: String,
1144 calls: Arc<StdMutex<Vec<RecordedCall>>>,
1145 }
1146
1147 #[async_trait::async_trait]
1148 impl ClaudeCodeRunner for RecordingRunner {
1149 async fn stream(
1150 &self,
1151 options: ClaudeAgentOptions,
1152 prompt: String,
1153 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
1154 self.calls
1155 .lock()
1156 .unwrap()
1157 .push((options.resume.clone(), prompt));
1158 let (tx, rx) = mpsc::unbounded_channel();
1159 tx.send(StreamEvent::Complete(MessageResponse {
1160 content: "ok".to_string(),
1161 blocks: Vec::new(),
1162 model: "sonnet".to_string(),
1163 stop_reason: Some("end_turn".to_string()),
1164 session_id: self.session_id.clone(),
1165 usage: None,
1166 }))
1167 .unwrap();
1168 tx.send(StreamEvent::TurnComplete(MessageResponse {
1169 content: String::new(),
1170 blocks: Vec::new(),
1171 model: String::new(),
1172 stop_reason: Some("end_turn".to_string()),
1173 session_id: self.session_id.clone(),
1174 usage: None,
1175 }))
1176 .unwrap();
1177 Ok(rx)
1178 }
1179 }
1180
1181 fn request_with_transcript(transcript: Vec<TranscriptItem>) -> AgentInferenceRequest {
1182 AgentInferenceRequest {
1183 transcript,
1184 metadata: json!({}),
1185 ..request()
1186 }
1187 }
1188
1189 async fn drain(engine: &ClaudeCodeEngine, request: AgentInferenceRequest) {
1190 let _ = engine
1191 .stream_turn(
1192 InferenceTurnContext {
1193 thread_id: "thread",
1194 turn_id: "turn",
1195 tool_executor: None,
1196 },
1197 request,
1198 )
1199 .await
1200 .unwrap()
1201 .collect::<Vec<_>>()
1202 .await;
1203 }
1204
1205 #[tokio::test]
1206 async fn second_turn_resumes_session_and_sends_only_the_new_tail() {
1207 let calls = Arc::new(StdMutex::new(Vec::new()));
1208 let engine = ClaudeCodeEngine::new_with_runner(
1209 ClaudeCodeConfig::default(),
1210 Arc::new(RecordingRunner {
1211 session_id: "session-1".to_string(),
1212 calls: Arc::clone(&calls),
1213 }),
1214 );
1215
1216 drain(
1218 &engine,
1219 request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text(
1220 "first question",
1221 ))]),
1222 )
1223 .await;
1224
1225 drain(
1229 &engine,
1230 request_with_transcript(vec![
1231 TranscriptItem::UserMessage(UserMessage::text("first question")),
1232 TranscriptItem::AssistantMessage(AssistantMessage {
1233 text: "first answer".to_string(),
1234 phase: None,
1235 }),
1236 TranscriptItem::UserMessage(UserMessage::text("second question")),
1237 ]),
1238 )
1239 .await;
1240
1241 let calls = calls.lock().unwrap();
1242 assert_eq!(calls.len(), 2);
1243 assert_eq!(calls[0].0, None);
1245 assert!(calls[0].1.contains("first question"));
1246 assert_eq!(calls[1].0.as_deref(), Some("session-1"));
1248 assert!(calls[1].1.contains("second question"));
1249 assert!(
1250 !calls[1].1.contains("first answer"),
1251 "resumed turn must not replay prior assistant output: {}",
1252 calls[1].1
1253 );
1254 assert!(
1255 !calls[1].1.contains("first question"),
1256 "resumed turn must not replay the prior user message: {}",
1257 calls[1].1
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn compacted_transcript_falls_back_to_a_fresh_send() {
1263 let calls = Arc::new(StdMutex::new(Vec::new()));
1264 let engine = ClaudeCodeEngine::new_with_runner(
1265 ClaudeCodeConfig::default(),
1266 Arc::new(RecordingRunner {
1267 session_id: "session-1".to_string(),
1268 calls: Arc::clone(&calls),
1269 }),
1270 );
1271
1272 drain(
1273 &engine,
1274 request_with_transcript(vec![
1275 TranscriptItem::UserMessage(UserMessage::text("original question")),
1276 TranscriptItem::AssistantMessage(AssistantMessage {
1277 text: "original answer".to_string(),
1278 phase: None,
1279 }),
1280 ]),
1281 )
1282 .await;
1283
1284 drain(
1287 &engine,
1288 request_with_transcript(vec![
1289 TranscriptItem::ContextCompaction(roder_api::transcript::ContextCompactionRecord {
1290 summary: "summary of earlier turns".to_string(),
1291 }),
1292 TranscriptItem::UserMessage(UserMessage::text("next question")),
1293 ]),
1294 )
1295 .await;
1296
1297 let calls = calls.lock().unwrap();
1298 assert_eq!(calls[1].0, None, "compacted transcript must send fresh");
1299 assert!(calls[1].1.contains("summary of earlier turns"));
1300 }
1301
1302 #[tokio::test]
1303 async fn session_reuse_can_be_disabled_via_config() {
1304 let calls = Arc::new(StdMutex::new(Vec::new()));
1305 let engine = ClaudeCodeEngine::new_with_runner(
1306 ClaudeCodeConfig {
1307 reuse_cli_session: Some(false),
1308 ..ClaudeCodeConfig::default()
1309 },
1310 Arc::new(RecordingRunner {
1311 session_id: "session-1".to_string(),
1312 calls: Arc::clone(&calls),
1313 }),
1314 );
1315
1316 drain(
1317 &engine,
1318 request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text("one"))]),
1319 )
1320 .await;
1321 drain(
1322 &engine,
1323 request_with_transcript(vec![
1324 TranscriptItem::UserMessage(UserMessage::text("one")),
1325 TranscriptItem::AssistantMessage(AssistantMessage {
1326 text: "answer".to_string(),
1327 phase: None,
1328 }),
1329 TranscriptItem::UserMessage(UserMessage::text("two")),
1330 ]),
1331 )
1332 .await;
1333
1334 let calls = calls.lock().unwrap();
1335 assert!(
1336 calls.iter().all(|(resume, _)| resume.is_none()),
1337 "reuse disabled: no turn should resume a session"
1338 );
1339 assert!(calls[1].1.contains("answer"));
1341 }
1342}