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