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, HostedToolCallCompleted, HostedToolCallStarted,
15 InferenceCapabilities, InferenceEngine, InferenceEvent, InferenceEventStream, InferenceFailure,
16 InferenceProviderContext, InferenceProviderMetadata, InferenceTurnContext, MessageDelta,
17 ModelDescriptor, ProviderAuthType, ReasoningDelta, TokenUsage, ToolCallCompleted,
18 ToolCallDelta,
19};
20use roder_api::transcript::TranscriptItem;
21use serde_json::json;
22use tokio::sync::mpsc;
23
24use crate::options::build_options;
25
26#[derive(Debug, Clone, Default)]
27pub struct ClaudeCodeConfig {
28 pub cli_path: Option<String>,
29 pub permission_mode: Option<String>,
30 pub setting_sources: Option<Vec<String>>,
31 pub workspace: Option<PathBuf>,
32 pub reuse_cli_session: Option<bool>,
37 pub enable_claude_in_chrome: Option<bool>,
52}
53
54impl ClaudeCodeConfig {
55 fn session_reuse_enabled(&self) -> bool {
56 self.reuse_cli_session.unwrap_or(true)
57 }
58}
59
60#[async_trait::async_trait]
61pub trait ClaudeCodeRunner: Send + Sync {
62 async fn stream(
63 &self,
64 options: ClaudeAgentOptions,
65 content: UserMessageInput,
66 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>>;
67}
68
69#[derive(Debug, Default)]
70pub struct SdkClaudeCodeRunner;
71
72#[async_trait::async_trait]
73impl ClaudeCodeRunner for SdkClaudeCodeRunner {
74 async fn stream(
75 &self,
76 options: ClaudeAgentOptions,
77 content: UserMessageInput,
78 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
79 Ok(ClaudeAgentClient::spawn_stream_message(options, content))
80 }
81}
82
83#[derive(Debug, Default)]
91struct SessionContinuity {
92 session_id: Option<String>,
93 synced: Vec<u64>,
94}
95
96pub struct ClaudeCodeEngine {
97 config: ClaudeCodeConfig,
98 runner: Arc<dyn ClaudeCodeRunner>,
99 continuity: Arc<Mutex<SessionContinuity>>,
100}
101
102impl ClaudeCodeEngine {
103 pub fn new(config: ClaudeCodeConfig) -> Self {
104 Self::new_with_runner(config, Arc::new(SdkClaudeCodeRunner))
105 }
106
107 pub fn new_with_runner(config: ClaudeCodeConfig, runner: Arc<dyn ClaudeCodeRunner>) -> Self {
108 Self {
109 config,
110 runner,
111 continuity: Arc::new(Mutex::new(SessionContinuity::default())),
112 }
113 }
114
115 fn auth_configured(&self) -> bool {
116 self.config
117 .cli_path
118 .as_deref()
119 .map(|value| !value.trim().is_empty())
120 .unwrap_or_else(|| which::which("claude").is_ok())
121 }
122}
123
124#[async_trait::async_trait]
125impl InferenceEngine for ClaudeCodeEngine {
126 fn id(&self) -> InferenceEngineId {
127 PROVIDER_CLAUDE_CODE.to_string()
128 }
129
130 fn capabilities(&self) -> InferenceCapabilities {
131 InferenceCapabilities {
132 streaming: true,
133 tool_calls: true,
134 parallel_tool_calls: false,
135 reasoning_summaries: true,
136 structured_output: false,
137 image_input: true,
138 prompt_cache: false,
139 provider_metadata: true,
140 tool_search: false,
141 }
142 }
143
144 fn metadata(&self) -> InferenceProviderMetadata {
145 InferenceProviderMetadata {
146 name: "Claude Code".to_string(),
147 description: Some("Local Claude Code CLI harness via claude-agent-sdk".to_string()),
148 auth_type: ProviderAuthType::None,
149 auth_label: Some("Authenticated local Claude Code CLI".to_string()),
150 auth_configured: Some(self.auth_configured()),
151 recommended: true,
152 sort_order: 19,
153 }
154 }
155
156 async fn list_models(
157 &self,
158 _ctx: InferenceProviderContext<'_>,
159 ) -> anyhow::Result<Vec<ModelDescriptor>> {
160 Ok(models_for_provider(PROVIDER_CLAUDE_CODE, false))
161 }
162
163 async fn stream_turn(
164 &self,
165 _ctx: InferenceTurnContext<'_>,
166 request: AgentInferenceRequest,
167 ) -> anyhow::Result<InferenceEventStream> {
168 validate_request(&request)?;
169 let current_dir = std::env::current_dir().ok();
170 let cwd = self
171 .config
172 .workspace
173 .as_deref()
174 .or_else(|| current_dir.as_deref());
175 let plan = self.plan_turn(&request);
176 let options = build_options(
177 &self.config,
178 &request,
179 _ctx.tool_executor.clone(),
180 cwd,
181 plan.resume_session_id.as_deref(),
182 )?;
183 let events = self.runner.stream(options, plan.content).await?;
184 Ok(Box::pin(map_stream_events(
185 events,
186 Arc::clone(&self.continuity),
187 plan.synced,
188 )))
189 }
190}
191
192struct TurnPlan {
196 resume_session_id: Option<String>,
197 content: UserMessageInput,
198 synced: Vec<u64>,
202}
203
204impl ClaudeCodeEngine {
205 fn plan_turn(&self, request: &AgentInferenceRequest) -> TurnPlan {
206 let fingerprints = transcript_fingerprints(&request.transcript);
207 let boundary = delta_boundary(&request.transcript);
211
212 if self.config.session_reuse_enabled()
213 && let Ok(state) = self.continuity.lock()
214 {
215 let can_resume = state.session_id.is_some()
216 && boundary > 0
217 && is_prefix(&state.synced, &fingerprints)
218 && boundary <= request.transcript.len();
219 if can_resume {
220 let delta = prompt_from_delta(request, boundary);
221 if input_has_content(&delta) {
222 return TurnPlan {
223 resume_session_id: state.session_id.clone(),
224 content: delta,
225 synced: fingerprints,
228 };
229 }
230 }
231 }
232
233 TurnPlan {
237 resume_session_id: None,
238 content: prompt_from_request(request),
239 synced: fingerprints,
240 }
241 }
242}
243
244fn validate_request(request: &AgentInferenceRequest) -> anyhow::Result<()> {
245 if request.output.response_format.is_some() {
246 anyhow::bail!("Claude Code provider does not support structured response_format yet");
247 }
248 Ok(())
249}
250
251fn prompt_from_request(request: &AgentInferenceRequest) -> UserMessageInput {
252 prompt_from_items(request, &request.transcript)
253}
254
255fn prompt_from_delta(request: &AgentInferenceRequest, boundary: usize) -> UserMessageInput {
258 let tail = request.transcript.get(boundary..).unwrap_or(&[]);
259 prompt_from_items(request, tail)
260}
261
262fn prompt_from_items(request: &AgentInferenceRequest, items: &[TranscriptItem]) -> UserMessageInput {
267 let mut parts = Vec::new();
268 let mut images = Vec::new();
269 for item in items {
270 if matches!(item, TranscriptItem::ProviderMetadata(_)) {
273 continue;
274 }
275 if let TranscriptItem::UserMessage(message) = item
276 && !message.images.is_empty()
277 {
278 for image in &message.images {
279 if let Some(block) = image_block_from_url(&image.image_url) {
280 images.push(block);
281 }
282 }
283 let text_only =
286 TranscriptItem::UserMessage(roder_api::transcript::UserMessage::text(
287 message.text.clone(),
288 ));
289 parts.push(format!("{text_only:?}"));
290 continue;
291 }
292 parts.push(format!("{item:?}"));
293 }
294 if let Some(value) = request
295 .metadata
296 .get("prompt")
297 .and_then(|value| value.as_str())
298 {
299 parts.push(value.to_string());
300 }
301 let text = if parts.is_empty() {
302 "Continue the current Roder turn.".to_string()
303 } else {
304 parts.join("\n\n")
305 };
306
307 if images.is_empty() {
308 UserMessageInput::Text(text)
309 } else {
310 let mut blocks = Vec::with_capacity(images.len() + 1);
311 blocks.push(InputContentBlock::text(text));
312 blocks.extend(images);
313 UserMessageInput::Blocks(blocks)
314 }
315}
316
317fn image_block_from_url(url: &str) -> Option<InputContentBlock> {
323 if let Some(source) = ImageSource::from_data_url(url) {
324 return Some(InputContentBlock::image(source));
325 }
326 if url.starts_with("http://") || url.starts_with("https://") {
327 return Some(InputContentBlock::image(ImageSource::Url {
328 url: url.to_string(),
329 }));
330 }
331 None
332}
333
334fn input_has_content(input: &UserMessageInput) -> bool {
336 match input {
337 UserMessageInput::Text(text) => !text.trim().is_empty(),
338 UserMessageInput::Blocks(blocks) => !blocks.is_empty(),
339 }
340}
341
342fn delta_boundary(transcript: &[TranscriptItem]) -> usize {
346 transcript
347 .iter()
348 .rposition(|item| matches!(item, TranscriptItem::AssistantMessage(_)))
349 .map(|index| index + 1)
350 .unwrap_or(0)
351}
352
353fn transcript_fingerprints(transcript: &[TranscriptItem]) -> Vec<u64> {
354 transcript.iter().map(fingerprint_item).collect()
355}
356
357fn fingerprint_item(item: &TranscriptItem) -> u64 {
358 let mut hasher = DefaultHasher::new();
359 format!("{item:?}").hash(&mut hasher);
360 hasher.finish()
361}
362
363fn is_prefix(prefix: &[u64], full: &[u64]) -> bool {
365 prefix.len() <= full.len() && full[..prefix.len()] == *prefix
366}
367
368fn map_stream_events(
369 mut events: mpsc::UnboundedReceiver<StreamEvent>,
370 continuity: Arc<Mutex<SessionContinuity>>,
371 synced: Vec<u64>,
372) -> InferenceEventStream {
373 Box::pin(try_stream! {
374 let mut saw_partial_text = false;
375 let mut accumulated_text = String::new();
376 let mut echo_match_pos = 0usize;
385 let mut completed = false;
386 let mut last_session_id: Option<String> = None;
387 let mut last_stop_reason: Option<String> = None;
388 let mut mcp_tool_ids: std::collections::HashSet<String> =
396 std::collections::HashSet::new();
397 let mut hosted_pending: std::collections::HashMap<String, String> =
404 std::collections::HashMap::new();
405 let mut hosted_done: std::collections::HashSet<String> =
406 std::collections::HashSet::new();
407 while let Some(event) = events.recv().await {
408 match event {
409 StreamEvent::ContentChunk(text) => {
410 if completed {
411 continue;
412 }
413 if !accumulated_text.is_empty()
414 && echo_match_pos < accumulated_text.len()
415 && accumulated_text[echo_match_pos..].starts_with(&text)
416 {
417 echo_match_pos += text.len();
418 continue;
419 }
420 saw_partial_text = true;
421 echo_match_pos = 0;
422 accumulated_text.push_str(&text);
423 yield InferenceEvent::MessageDelta(MessageDelta { text, phase: None });
424 }
425 StreamEvent::ThinkingChunk { thinking, .. } => {
426 yield InferenceEvent::ReasoningDelta(ReasoningDelta { text: thinking });
427 }
428 StreamEvent::ToolUseStart { id, name, input } => {
429 if name.starts_with("mcp__roder__") {
430 mcp_tool_ids.insert(id);
433 continue;
434 }
435 if name.starts_with("mcp__") {
436 mcp_tool_ids.insert(id.clone());
445 if hosted_done.contains(&id) {
446 continue;
447 }
448 if !hosted_pending.contains_key(&id) {
449 hosted_pending.insert(id.clone(), name.clone());
450 yield InferenceEvent::HostedToolCallStarted(HostedToolCallStarted {
451 id: id.clone(),
452 name: name.clone(),
453 });
454 }
455 if !input.is_empty() {
456 hosted_pending.remove(&id);
457 hosted_done.insert(id.clone());
458 yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
459 id,
460 name,
461 arguments: serde_json::Value::Object(input).to_string(),
462 });
463 }
464 continue;
465 }
466 if !input.is_empty() {
467 yield InferenceEvent::ToolCallCompleted(ToolCallCompleted {
468 id,
469 name,
470 arguments: serde_json::Value::Object(input).to_string(),
471 });
472 }
473 }
474 StreamEvent::ToolUseDelta { id, partial_input } => {
475 if mcp_tool_ids.contains(&id) {
476 continue;
477 }
478 yield InferenceEvent::ToolCallDelta(ToolCallDelta { id, arguments_delta: partial_input });
479 }
480 StreamEvent::ToolResult { tool_use_id, content, is_error } => {
481 if let Some(name) = hosted_pending.remove(&tool_use_id) {
485 hosted_done.insert(tool_use_id.clone());
486 yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
487 id: tool_use_id,
488 name,
489 arguments: "{}".to_string(),
490 });
491 continue;
492 }
493 yield InferenceEvent::ProviderMetadata(json!({
494 "provider": PROVIDER_CLAUDE_CODE,
495 "toolResult": {
496 "toolUseId": tool_use_id,
497 "isError": is_error.unwrap_or(false),
498 "content": content.unwrap_or(serde_json::Value::Null),
499 }
500 }));
501 }
502 StreamEvent::RateLimit(info) => {
503 yield InferenceEvent::ProviderMetadata(json!({
504 "provider": PROVIDER_CLAUDE_CODE,
505 "rateLimit": info,
506 }));
507 }
508 StreamEvent::Complete(response) => {
509 if completed {
513 continue;
514 }
515 if let Some(usage) = usage_from_response(&response) {
516 yield InferenceEvent::Usage(usage);
517 }
518 if response.content.is_empty() {
519 continue;
520 }
521 if !saw_partial_text {
522 yield InferenceEvent::MessageDelta(MessageDelta { text: response.content.clone(), phase: None });
523 }
524 last_session_id = Some(response.session_id);
525 last_stop_reason = response.stop_reason;
526 accumulated_text.clear();
529 echo_match_pos = 0;
530 }
531 StreamEvent::TurnComplete(response) => {
532 if completed {
533 continue;
534 }
535 for (id, name) in std::mem::take(&mut hosted_pending) {
536 hosted_done.insert(id.clone());
537 yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
538 id,
539 name,
540 arguments: "{}".to_string(),
541 });
542 }
543 if let Some(usage) = usage_from_response(&response) {
544 yield InferenceEvent::Usage(usage);
545 }
546 completed = true;
547 let session_id = (!response.session_id.trim().is_empty())
548 .then_some(response.session_id)
549 .or(last_session_id.take());
550 commit_session(&continuity, session_id.as_deref(), &synced);
551 yield InferenceEvent::Completed(CompletionMetadata {
552 stop_reason: response.stop_reason.or(last_stop_reason.take()),
553 provider_response_id: session_id,
554 });
555 }
556 StreamEvent::Error(message) => {
557 clear_session(&continuity);
560 yield InferenceEvent::Failed(InferenceFailure { message: redact_error(&message) });
561 }
562 }
563 }
564 if !completed {
567 for (id, name) in std::mem::take(&mut hosted_pending) {
568 yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
569 id,
570 name,
571 arguments: "{}".to_string(),
572 });
573 }
574 }
575 if !completed && (last_session_id.is_some() || last_stop_reason.is_some()) {
579 commit_session(&continuity, last_session_id.as_deref(), &synced);
580 yield InferenceEvent::Completed(CompletionMetadata {
581 stop_reason: last_stop_reason,
582 provider_response_id: last_session_id,
583 });
584 }
585 })
586}
587
588fn commit_session(
591 continuity: &Arc<Mutex<SessionContinuity>>,
592 session_id: Option<&str>,
593 synced: &[u64],
594) {
595 let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) else {
596 clear_session(continuity);
599 return;
600 };
601 if let Ok(mut state) = continuity.lock() {
602 state.session_id = Some(session_id.to_string());
603 state.synced = synced.to_vec();
604 }
605}
606
607fn clear_session(continuity: &Arc<Mutex<SessionContinuity>>) {
608 if let Ok(mut state) = continuity.lock() {
609 state.session_id = None;
610 state.synced.clear();
611 }
612}
613
614fn usage_from_response(response: &MessageResponse) -> Option<TokenUsage> {
615 let usage = response.usage.as_ref()?;
616 let prompt_tokens = number_from_usage(
617 usage,
618 &[
619 "input_tokens",
620 "inputTokens",
621 "prompt_tokens",
622 "promptTokens",
623 "cache_creation_input_tokens",
624 "cacheCreationInputTokens",
625 "cache_read_input_tokens",
626 "cacheReadInputTokens",
627 ],
628 )
629 .unwrap_or(0);
630 let cached_prompt_tokens =
631 number_from_usage(usage, &["cache_read_input_tokens", "cacheReadInputTokens"]);
632 let cache_creation_prompt_tokens = number_from_usage(
633 usage,
634 &["cache_creation_input_tokens", "cacheCreationInputTokens"],
635 );
636 let completion_tokens = number_from_usage(
637 usage,
638 &[
639 "output_tokens",
640 "outputTokens",
641 "completion_tokens",
642 "completionTokens",
643 ],
644 )
645 .unwrap_or(0);
646 let total_tokens = number_from_usage(usage, &["total_tokens", "totalTokens"])
647 .unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens));
648 if prompt_tokens == 0 && completion_tokens == 0 && total_tokens == 0 {
649 return None;
650 }
651 Some(
652 TokenUsage::new(prompt_tokens, completion_tokens, total_tokens)
653 .with_cached_prompt_tokens(cached_prompt_tokens.unwrap_or(0))
654 .with_cache_creation_prompt_tokens(cache_creation_prompt_tokens.unwrap_or(0)),
655 )
656}
657
658fn number_from_usage(
659 usage: &std::collections::HashMap<String, serde_json::Value>,
660 keys: &[&str],
661) -> Option<u32> {
662 let total = usage.iter().fold(0u32, |total, (key, value)| {
663 total.saturating_add(number_from_usage_value(key, value, keys))
664 });
665 (total > 0).then_some(total)
666}
667
668fn number_from_usage_value(key: &str, value: &serde_json::Value, keys: &[&str]) -> u32 {
669 let direct = if keys.iter().any(|candidate| candidate == &key) {
670 json_u32(value).unwrap_or(0)
671 } else {
672 0
673 };
674 let nested = value
675 .as_object()
676 .map(|object| {
677 object.iter().fold(0u32, |total, (key, value)| {
678 total.saturating_add(number_from_usage_value(key, value, keys))
679 })
680 })
681 .unwrap_or(0);
682 direct.saturating_add(nested)
683}
684
685fn json_u32(value: &serde_json::Value) -> Option<u32> {
686 value
687 .as_u64()
688 .and_then(|value| u32::try_from(value).ok())
689 .or_else(|| value.as_i64().and_then(|value| u32::try_from(value).ok()))
690 .or_else(|| {
691 value
692 .as_f64()
693 .filter(|value| value.is_finite() && *value >= 0.0)
694 .and_then(|value| u32::try_from(value as u64).ok())
695 })
696 .or_else(|| value.as_str().and_then(|value| value.parse::<u32>().ok()))
697}
698
699fn redact_error(message: &str) -> String {
700 let mut redacted = message.to_string();
701 for marker in ["ANTHROPIC_API_KEY", "CLAUDE_CODE", "Bearer "] {
702 if redacted.contains(marker) {
703 redacted = redacted.replace(marker, "[redacted]");
704 }
705 }
706 redacted
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use claude_code_sdk_rust::types::ContentBlock;
713 use futures::StreamExt;
714 use roder_api::inference::{
715 HostedWebSearchConfig, InstructionBundle, ModelSelection, OutputConfig, ReasoningConfig,
716 RuntimeHints,
717 };
718 use roder_api::tools::ToolChoice;
719
720 #[derive(Default)]
721 struct FakeRunner {
722 events: Vec<StreamEvent>,
723 }
724
725 #[async_trait::async_trait]
726 impl ClaudeCodeRunner for FakeRunner {
727 async fn stream(
728 &self,
729 _options: ClaudeAgentOptions,
730 _content: UserMessageInput,
731 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
732 let (tx, rx) = mpsc::unbounded_channel();
733 for event in self.events.clone() {
734 tx.send(event).unwrap();
735 }
736 Ok(rx)
737 }
738 }
739
740 fn request() -> AgentInferenceRequest {
741 AgentInferenceRequest {
742 model: ModelSelection {
743 provider: PROVIDER_CLAUDE_CODE.to_string(),
744 model: "sonnet".to_string(),
745 },
746 instructions: InstructionBundle::default(),
747 transcript: Vec::new(),
748 tools: Vec::new(),
749 tool_choice: ToolChoice::Auto,
750 reasoning: ReasoningConfig::default(),
751 output: OutputConfig::default(),
752 runtime: RuntimeHints {
753 hosted_web_search: HostedWebSearchConfig::disabled(),
754 ..RuntimeHints::default()
755 },
756 metadata: json!({"prompt": "hello"}),
757 }
758 }
759
760 #[tokio::test]
761 async fn provider_streams_text_and_completion() {
762 let mut usage = std::collections::HashMap::new();
763 usage.insert("input_tokens".to_string(), json!(3));
764 usage.insert("output_tokens".to_string(), json!(5));
765 let engine = ClaudeCodeEngine::new_with_runner(
766 ClaudeCodeConfig::default(),
767 Arc::new(FakeRunner {
768 events: vec![
769 StreamEvent::ContentChunk("hello".to_string()),
770 StreamEvent::Complete(MessageResponse {
771 content: "hello".to_string(),
772 blocks: vec![ContentBlock::Text {
773 text: "hello".to_string(),
774 }],
775 model: "sonnet".to_string(),
776 stop_reason: Some("end_turn".to_string()),
777 session_id: "session-1".to_string(),
778 usage: Some(usage),
779 }),
780 StreamEvent::TurnComplete(MessageResponse {
781 content: String::new(),
782 blocks: Vec::new(),
783 model: String::new(),
784 stop_reason: Some("end_turn".to_string()),
785 session_id: "session-1".to_string(),
786 usage: None,
787 }),
788 ],
789 }),
790 );
791 let events = engine
792 .stream_turn(
793 InferenceTurnContext {
794 thread_id: "thread",
795 turn_id: "turn",
796 tool_executor: None,
797 },
798 request(),
799 )
800 .await
801 .unwrap()
802 .collect::<Vec<_>>()
803 .await
804 .into_iter()
805 .collect::<Result<Vec<_>, _>>()
806 .unwrap();
807 assert!(matches!(events[0], InferenceEvent::MessageDelta(_)));
808 assert!(
809 events
810 .iter()
811 .any(|event| matches!(event, InferenceEvent::Usage(_)))
812 );
813 assert!(
814 events
815 .iter()
816 .any(|event| matches!(event, InferenceEvent::Completed(_)))
817 );
818 }
819
820 #[tokio::test]
821 async fn provider_keeps_partial_text_and_uses_later_usage_completion() {
822 let mut usage = std::collections::HashMap::new();
823 usage.insert("input_tokens".to_string(), json!(4));
824 usage.insert("cache_read_input_tokens".to_string(), json!(6));
825 usage.insert("output_tokens".to_string(), json!(8));
826 let engine = ClaudeCodeEngine::new_with_runner(
827 ClaudeCodeConfig::default(),
828 Arc::new(FakeRunner {
829 events: vec![
830 StreamEvent::ContentChunk("one ".to_string()),
831 StreamEvent::ContentChunk("two".to_string()),
832 StreamEvent::Complete(MessageResponse {
833 content: String::new(),
834 blocks: Vec::new(),
835 model: String::new(),
836 stop_reason: Some("end_turn".to_string()),
837 session_id: "session-usage".to_string(),
838 usage: Some(usage),
839 }),
840 StreamEvent::ContentChunk("one two".to_string()),
841 StreamEvent::Complete(MessageResponse {
842 content: "one two".to_string(),
843 blocks: Vec::new(),
844 model: "sonnet".to_string(),
845 stop_reason: Some("end_turn".to_string()),
846 session_id: "session-final".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-final".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!(rendered, "one two");
884 assert!(events.iter().any(|event| matches!(event, InferenceEvent::Usage(usage) if usage.prompt_tokens == 10 && usage.completion_tokens == 8)));
885 assert!(
886 events
887 .iter()
888 .any(|event| matches!(event, InferenceEvent::Completed(_)))
889 );
890 }
891
892 #[tokio::test]
898 async fn provider_does_not_duplicate_multi_block_commentary() {
899 let engine = ClaudeCodeEngine::new_with_runner(
900 ClaudeCodeConfig::default(),
901 Arc::new(FakeRunner {
902 events: vec![
903 StreamEvent::ContentChunk("First ".to_string()),
905 StreamEvent::ContentChunk("block.".to_string()),
906 StreamEvent::ContentChunk("Second ".to_string()),
907 StreamEvent::ContentChunk("block.".to_string()),
908 StreamEvent::ContentChunk("First block.".to_string()),
910 StreamEvent::ContentChunk("Second block.".to_string()),
911 StreamEvent::Complete(MessageResponse {
912 content: "First block.Second block.".to_string(),
913 blocks: Vec::new(),
914 model: "sonnet".to_string(),
915 stop_reason: Some("end_turn".to_string()),
916 session_id: "session-final".to_string(),
917 usage: None,
918 }),
919 StreamEvent::TurnComplete(MessageResponse {
920 content: String::new(),
921 blocks: Vec::new(),
922 model: String::new(),
923 stop_reason: Some("end_turn".to_string()),
924 session_id: "session-final".to_string(),
925 usage: None,
926 }),
927 ],
928 }),
929 );
930 let events = engine
931 .stream_turn(
932 InferenceTurnContext {
933 thread_id: "thread",
934 turn_id: "turn",
935 tool_executor: None,
936 },
937 request(),
938 )
939 .await
940 .unwrap()
941 .collect::<Vec<_>>()
942 .await
943 .into_iter()
944 .collect::<Result<Vec<_>, _>>()
945 .unwrap();
946 let rendered = events
947 .iter()
948 .filter_map(|event| match event {
949 InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
950 _ => None,
951 })
952 .collect::<String>();
953 assert_eq!(rendered, "First block.Second block.");
954 }
955
956 #[tokio::test]
962 async fn provider_streams_text_after_tool_use_until_turn_complete() {
963 let engine = ClaudeCodeEngine::new_with_runner(
964 ClaudeCodeConfig::default(),
965 Arc::new(FakeRunner {
966 events: vec![
967 StreamEvent::ContentChunk("I'll read the file.".to_string()),
969 StreamEvent::ContentChunk("I'll read the file.".to_string()),
971 StreamEvent::Complete(MessageResponse {
972 content: "I'll read the file.".to_string(),
973 blocks: Vec::new(),
974 model: "claude-fable-5".to_string(),
975 stop_reason: None,
976 session_id: "session-1".to_string(),
977 usage: None,
978 }),
979 StreamEvent::ToolUseStart {
981 id: "toolu_1".to_string(),
982 name: "mcp__roder__read_file".to_string(),
983 input: serde_json::Map::new(),
984 },
985 StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
987 StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
988 StreamEvent::Complete(MessageResponse {
989 content: "The magic word is pomegranate.".to_string(),
990 blocks: Vec::new(),
991 model: "claude-fable-5".to_string(),
992 stop_reason: Some("end_turn".to_string()),
993 session_id: "session-1".to_string(),
994 usage: None,
995 }),
996 StreamEvent::TurnComplete(MessageResponse {
997 content: String::new(),
998 blocks: Vec::new(),
999 model: String::new(),
1000 stop_reason: Some("end_turn".to_string()),
1001 session_id: "session-1".to_string(),
1002 usage: None,
1003 }),
1004 ],
1005 }),
1006 );
1007 let events = engine
1008 .stream_turn(
1009 InferenceTurnContext {
1010 thread_id: "thread",
1011 turn_id: "turn",
1012 tool_executor: None,
1013 },
1014 request(),
1015 )
1016 .await
1017 .unwrap()
1018 .collect::<Vec<_>>()
1019 .await
1020 .into_iter()
1021 .collect::<Result<Vec<_>, _>>()
1022 .unwrap();
1023 let rendered = events
1024 .iter()
1025 .filter_map(|event| match event {
1026 InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
1027 _ => None,
1028 })
1029 .collect::<String>();
1030 assert_eq!(
1031 rendered, "I'll read the file.The magic word is pomegranate.",
1032 "post-tool assistant text must stream and echoes must dedupe"
1033 );
1034 let completions = events
1035 .iter()
1036 .filter(|event| matches!(event, InferenceEvent::Completed(_)))
1037 .count();
1038 assert_eq!(completions, 1, "turn must complete exactly once");
1039 }
1040
1041 #[tokio::test]
1045 async fn surfaces_claude_in_chrome_tool_use_as_hosted_calls() {
1046 let engine = ClaudeCodeEngine::new_with_runner(
1047 ClaudeCodeConfig::default(),
1048 Arc::new(FakeRunner {
1049 events: vec![
1050 StreamEvent::ToolUseStart {
1052 id: "toolu_chrome".to_string(),
1053 name: "mcp__claude-in-chrome__navigate".to_string(),
1054 input: serde_json::Map::new(),
1055 },
1056 StreamEvent::ToolUseStart {
1057 id: "toolu_chrome".to_string(),
1058 name: "mcp__claude-in-chrome__navigate".to_string(),
1059 input: {
1060 let mut input = serde_json::Map::new();
1061 input.insert("url".to_string(), json!("https://example.com"));
1062 input
1063 },
1064 },
1065 StreamEvent::ContentChunk("Opened the page.".to_string()),
1066 StreamEvent::ContentChunk("Opened the page.".to_string()),
1067 StreamEvent::Complete(MessageResponse {
1068 content: "Opened the page.".to_string(),
1069 blocks: Vec::new(),
1070 model: "opus".to_string(),
1071 stop_reason: Some("end_turn".to_string()),
1072 session_id: "session-1".to_string(),
1073 usage: None,
1074 }),
1075 StreamEvent::TurnComplete(MessageResponse {
1076 content: String::new(),
1077 blocks: Vec::new(),
1078 model: String::new(),
1079 stop_reason: Some("end_turn".to_string()),
1080 session_id: "session-1".to_string(),
1081 usage: None,
1082 }),
1083 ],
1084 }),
1085 );
1086 let events = engine
1087 .stream_turn(
1088 InferenceTurnContext {
1089 thread_id: "thread",
1090 turn_id: "turn",
1091 tool_executor: None,
1092 },
1093 request(),
1094 )
1095 .await
1096 .unwrap()
1097 .collect::<Vec<_>>()
1098 .await
1099 .into_iter()
1100 .collect::<Result<Vec<_>, _>>()
1101 .unwrap();
1102
1103 assert!(
1105 !events
1106 .iter()
1107 .any(|event| matches!(event, InferenceEvent::ToolCallCompleted(_))),
1108 "Claude-in-Chrome tools must not surface as runtime tool calls"
1109 );
1110
1111 let started = events
1112 .iter()
1113 .filter(|event| matches!(event, InferenceEvent::HostedToolCallStarted(_)))
1114 .count();
1115 assert_eq!(started, 1, "browser tool must surface as one hosted start");
1116
1117 let completed = events
1118 .iter()
1119 .filter_map(|event| match event {
1120 InferenceEvent::HostedToolCallCompleted(call) => Some(call),
1121 _ => None,
1122 })
1123 .collect::<Vec<_>>();
1124 assert_eq!(completed.len(), 1, "browser tool must complete exactly once");
1125 assert_eq!(completed[0].name, "mcp__claude-in-chrome__navigate");
1126 assert!(
1127 completed[0].arguments.contains("example.com"),
1128 "completion must carry the full tool input"
1129 );
1130 }
1131
1132 #[tokio::test]
1133 async fn provider_completes_turn_when_stream_ends_without_result() {
1134 let engine = ClaudeCodeEngine::new_with_runner(
1135 ClaudeCodeConfig::default(),
1136 Arc::new(FakeRunner {
1137 events: vec![
1138 StreamEvent::ContentChunk("partial".to_string()),
1139 StreamEvent::Complete(MessageResponse {
1140 content: "partial".to_string(),
1141 blocks: Vec::new(),
1142 model: "claude-fable-5".to_string(),
1143 stop_reason: Some("end_turn".to_string()),
1144 session_id: "session-dead".to_string(),
1145 usage: None,
1146 }),
1147 ],
1149 }),
1150 );
1151 let events = engine
1152 .stream_turn(
1153 InferenceTurnContext {
1154 thread_id: "thread",
1155 turn_id: "turn",
1156 tool_executor: None,
1157 },
1158 request(),
1159 )
1160 .await
1161 .unwrap()
1162 .collect::<Vec<_>>()
1163 .await
1164 .into_iter()
1165 .collect::<Result<Vec<_>, _>>()
1166 .unwrap();
1167 assert!(
1168 events
1169 .iter()
1170 .any(|event| matches!(event, InferenceEvent::Completed(_))),
1171 "stream end without a result message must still complete the turn"
1172 );
1173 }
1174
1175 #[tokio::test]
1176 async fn provider_maps_tool_events() {
1177 let engine = ClaudeCodeEngine::new_with_runner(
1178 ClaudeCodeConfig::default(),
1179 Arc::new(FakeRunner {
1180 events: vec![
1181 StreamEvent::ToolUseStart {
1182 id: "toolu_1".to_string(),
1183 name: "Read".to_string(),
1184 input: serde_json::Map::from_iter([(
1185 "path".to_string(),
1186 json!("crates/roder-ext-claude-code"),
1187 )]),
1188 },
1189 StreamEvent::ToolUseDelta {
1190 id: "toolu_1".to_string(),
1191 partial_input: "{\"file_path\"".to_string(),
1192 },
1193 ],
1194 }),
1195 );
1196 let events = engine
1197 .stream_turn(
1198 InferenceTurnContext {
1199 thread_id: "thread",
1200 turn_id: "turn",
1201 tool_executor: None,
1202 },
1203 request(),
1204 )
1205 .await
1206 .unwrap()
1207 .collect::<Vec<_>>()
1208 .await
1209 .into_iter()
1210 .collect::<Result<Vec<_>, _>>()
1211 .unwrap();
1212 assert!(
1213 matches!(&events[0], InferenceEvent::ToolCallCompleted(call) if call.arguments.contains("roder-ext-claude-code"))
1214 );
1215 assert!(matches!(events[1], InferenceEvent::ToolCallDelta(_)));
1216 }
1217
1218 #[tokio::test]
1224 async fn provider_suppresses_mcp_tool_lifecycle_events() {
1225 let engine = ClaudeCodeEngine::new_with_runner(
1226 ClaudeCodeConfig::default(),
1227 Arc::new(FakeRunner {
1228 events: vec![
1229 StreamEvent::ToolUseStart {
1230 id: "toolu_mcp".to_string(),
1231 name: "mcp__roder__read_file".to_string(),
1232 input: serde_json::Map::from_iter([(
1233 "path".to_string(),
1234 json!("README.md"),
1235 )]),
1236 },
1237 StreamEvent::ToolUseDelta {
1238 id: "toolu_mcp".to_string(),
1239 partial_input: "{\"path\"".to_string(),
1240 },
1241 StreamEvent::Complete(MessageResponse {
1242 content: "done".to_string(),
1243 blocks: Vec::new(),
1244 model: "sonnet".to_string(),
1245 stop_reason: Some("end_turn".to_string()),
1246 session_id: "session".to_string(),
1247 usage: None,
1248 }),
1249 ],
1250 }),
1251 );
1252 let events = engine
1253 .stream_turn(
1254 InferenceTurnContext {
1255 thread_id: "thread",
1256 turn_id: "turn",
1257 tool_executor: None,
1258 },
1259 request(),
1260 )
1261 .await
1262 .unwrap()
1263 .collect::<Vec<_>>()
1264 .await
1265 .into_iter()
1266 .collect::<Result<Vec<_>, _>>()
1267 .unwrap();
1268 assert!(
1269 !events.iter().any(|event| matches!(
1270 event,
1271 InferenceEvent::ToolCallCompleted(_) | InferenceEvent::ToolCallDelta(_)
1272 )),
1273 "mcp__ tool lifecycle events must not be surfaced from the CLI stream"
1274 );
1275 }
1276
1277 #[test]
1278 fn usage_parser_reports_cached_prompt_tokens_as_subset() {
1279 let usage = std::collections::HashMap::from([
1280 ("input_tokens".to_string(), json!(123)),
1281 ("cache_creation_input_tokens".to_string(), json!(7)),
1282 ("cache_read_input_tokens".to_string(), json!(11)),
1283 ("output_tokens".to_string(), json!(13)),
1284 ]);
1285 let response = MessageResponse {
1286 content: String::new(),
1287 blocks: Vec::new(),
1288 model: "sonnet".to_string(),
1289 stop_reason: Some("end_turn".to_string()),
1290 session_id: "session".to_string(),
1291 usage: Some(usage),
1292 };
1293
1294 let usage = usage_from_response(&response).unwrap();
1295 assert_eq!(usage.prompt_tokens, 141);
1296 assert_eq!(usage.cached_prompt_tokens, 11);
1297 assert_eq!(usage.cache_creation_prompt_tokens, 7);
1298 assert_eq!(usage.completion_tokens, 13);
1299 assert_eq!(usage.total_tokens, 154);
1300 }
1301
1302 #[test]
1303 fn usage_parser_includes_cache_only_input_tokens_in_prompt_total() {
1304 let usage = std::collections::HashMap::from([
1305 ("cache_creation_input_tokens".to_string(), json!(7)),
1306 ("cache_read_input_tokens".to_string(), json!(11)),
1307 ("output_tokens".to_string(), json!(13)),
1308 ]);
1309 let response = MessageResponse {
1310 content: String::new(),
1311 blocks: Vec::new(),
1312 model: "sonnet".to_string(),
1313 stop_reason: Some("end_turn".to_string()),
1314 session_id: "session".to_string(),
1315 usage: Some(usage),
1316 };
1317
1318 let usage = usage_from_response(&response).unwrap();
1319 assert_eq!(usage.prompt_tokens, 18);
1320 assert_eq!(usage.cached_prompt_tokens, 11);
1321 assert_eq!(usage.cache_creation_prompt_tokens, 7);
1322 assert_eq!(usage.completion_tokens, 13);
1323 assert_eq!(usage.total_tokens, 31);
1324 }
1325
1326 #[test]
1327 fn usage_parser_accepts_output_only_delta_usage() {
1328 let response = MessageResponse {
1329 content: String::new(),
1330 blocks: Vec::new(),
1331 model: "sonnet".to_string(),
1332 stop_reason: Some("end_turn".to_string()),
1333 session_id: "session".to_string(),
1334 usage: Some(std::collections::HashMap::from([(
1335 "output_tokens".to_string(),
1336 json!(42),
1337 )])),
1338 };
1339
1340 let usage = usage_from_response(&response).unwrap();
1341 assert_eq!(usage.prompt_tokens, 0);
1342 assert_eq!(usage.completion_tokens, 42);
1343 assert_eq!(usage.total_tokens, 42);
1344 }
1345
1346 #[test]
1347 fn usage_parser_accepts_nested_camel_case_model_usage() {
1348 let response = MessageResponse {
1349 content: String::new(),
1350 blocks: Vec::new(),
1351 model: "sonnet".to_string(),
1352 stop_reason: Some("end_turn".to_string()),
1353 session_id: "session".to_string(),
1354 usage: Some(std::collections::HashMap::from([(
1355 "modelUsage".to_string(),
1356 json!({
1357 "inputTokens": "100",
1358 "cacheReadInputTokens": 25.0,
1359 "outputTokens": 7,
1360 }),
1361 )])),
1362 };
1363
1364 let usage = usage_from_response(&response).unwrap();
1365 assert_eq!(usage.prompt_tokens, 125);
1366 assert_eq!(usage.cached_prompt_tokens, 25);
1367 assert_eq!(usage.completion_tokens, 7);
1368 assert_eq!(usage.total_tokens, 132);
1369 }
1370
1371 use roder_api::transcript::{AssistantMessage, UserMessage};
1372 use std::sync::Mutex as StdMutex;
1373
1374 type RecordedCall = (Option<String>, String);
1376
1377 fn capture_text(content: &UserMessageInput) -> String {
1380 match content {
1381 UserMessageInput::Text(text) => text.clone(),
1382 UserMessageInput::Blocks(blocks) => blocks
1383 .iter()
1384 .filter_map(|block| match block {
1385 InputContentBlock::Text { text } => Some(text.clone()),
1386 InputContentBlock::Image { .. } => None,
1387 })
1388 .collect::<Vec<_>>()
1389 .join("\n\n"),
1390 }
1391 }
1392
1393 #[derive(Default)]
1396 struct RecordingRunner {
1397 session_id: String,
1398 calls: Arc<StdMutex<Vec<RecordedCall>>>,
1399 }
1400
1401 #[async_trait::async_trait]
1402 impl ClaudeCodeRunner for RecordingRunner {
1403 async fn stream(
1404 &self,
1405 options: ClaudeAgentOptions,
1406 content: UserMessageInput,
1407 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
1408 self.calls
1409 .lock()
1410 .unwrap()
1411 .push((options.resume.clone(), capture_text(&content)));
1412 let (tx, rx) = mpsc::unbounded_channel();
1413 tx.send(StreamEvent::Complete(MessageResponse {
1414 content: "ok".to_string(),
1415 blocks: Vec::new(),
1416 model: "sonnet".to_string(),
1417 stop_reason: Some("end_turn".to_string()),
1418 session_id: self.session_id.clone(),
1419 usage: None,
1420 }))
1421 .unwrap();
1422 tx.send(StreamEvent::TurnComplete(MessageResponse {
1423 content: String::new(),
1424 blocks: Vec::new(),
1425 model: String::new(),
1426 stop_reason: Some("end_turn".to_string()),
1427 session_id: self.session_id.clone(),
1428 usage: None,
1429 }))
1430 .unwrap();
1431 Ok(rx)
1432 }
1433 }
1434
1435 fn request_with_transcript(transcript: Vec<TranscriptItem>) -> AgentInferenceRequest {
1436 AgentInferenceRequest {
1437 transcript,
1438 metadata: json!({}),
1439 ..request()
1440 }
1441 }
1442
1443 async fn drain(engine: &ClaudeCodeEngine, request: AgentInferenceRequest) {
1444 let _ = engine
1445 .stream_turn(
1446 InferenceTurnContext {
1447 thread_id: "thread",
1448 turn_id: "turn",
1449 tool_executor: None,
1450 },
1451 request,
1452 )
1453 .await
1454 .unwrap()
1455 .collect::<Vec<_>>()
1456 .await;
1457 }
1458
1459 #[tokio::test]
1460 async fn second_turn_resumes_session_and_sends_only_the_new_tail() {
1461 let calls = Arc::new(StdMutex::new(Vec::new()));
1462 let engine = ClaudeCodeEngine::new_with_runner(
1463 ClaudeCodeConfig::default(),
1464 Arc::new(RecordingRunner {
1465 session_id: "session-1".to_string(),
1466 calls: Arc::clone(&calls),
1467 }),
1468 );
1469
1470 drain(
1472 &engine,
1473 request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text(
1474 "first question",
1475 ))]),
1476 )
1477 .await;
1478
1479 drain(
1483 &engine,
1484 request_with_transcript(vec![
1485 TranscriptItem::UserMessage(UserMessage::text("first question")),
1486 TranscriptItem::AssistantMessage(AssistantMessage {
1487 text: "first answer".to_string(),
1488 phase: None,
1489 }),
1490 TranscriptItem::UserMessage(UserMessage::text("second question")),
1491 ]),
1492 )
1493 .await;
1494
1495 let calls = calls.lock().unwrap();
1496 assert_eq!(calls.len(), 2);
1497 assert_eq!(calls[0].0, None);
1499 assert!(calls[0].1.contains("first question"));
1500 assert_eq!(calls[1].0.as_deref(), Some("session-1"));
1502 assert!(calls[1].1.contains("second question"));
1503 assert!(
1504 !calls[1].1.contains("first answer"),
1505 "resumed turn must not replay prior assistant output: {}",
1506 calls[1].1
1507 );
1508 assert!(
1509 !calls[1].1.contains("first question"),
1510 "resumed turn must not replay the prior user message: {}",
1511 calls[1].1
1512 );
1513 }
1514
1515 #[tokio::test]
1516 async fn compacted_transcript_falls_back_to_a_fresh_send() {
1517 let calls = Arc::new(StdMutex::new(Vec::new()));
1518 let engine = ClaudeCodeEngine::new_with_runner(
1519 ClaudeCodeConfig::default(),
1520 Arc::new(RecordingRunner {
1521 session_id: "session-1".to_string(),
1522 calls: Arc::clone(&calls),
1523 }),
1524 );
1525
1526 drain(
1527 &engine,
1528 request_with_transcript(vec![
1529 TranscriptItem::UserMessage(UserMessage::text("original question")),
1530 TranscriptItem::AssistantMessage(AssistantMessage {
1531 text: "original answer".to_string(),
1532 phase: None,
1533 }),
1534 ]),
1535 )
1536 .await;
1537
1538 drain(
1541 &engine,
1542 request_with_transcript(vec![
1543 TranscriptItem::ContextCompaction(roder_api::transcript::ContextCompactionRecord {
1544 summary: "summary of earlier turns".to_string(),
1545 }),
1546 TranscriptItem::UserMessage(UserMessage::text("next question")),
1547 ]),
1548 )
1549 .await;
1550
1551 let calls = calls.lock().unwrap();
1552 assert_eq!(calls[1].0, None, "compacted transcript must send fresh");
1553 assert!(calls[1].1.contains("summary of earlier turns"));
1554 }
1555
1556 #[tokio::test]
1557 async fn session_reuse_can_be_disabled_via_config() {
1558 let calls = Arc::new(StdMutex::new(Vec::new()));
1559 let engine = ClaudeCodeEngine::new_with_runner(
1560 ClaudeCodeConfig {
1561 reuse_cli_session: Some(false),
1562 ..ClaudeCodeConfig::default()
1563 },
1564 Arc::new(RecordingRunner {
1565 session_id: "session-1".to_string(),
1566 calls: Arc::clone(&calls),
1567 }),
1568 );
1569
1570 drain(
1571 &engine,
1572 request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text("one"))]),
1573 )
1574 .await;
1575 drain(
1576 &engine,
1577 request_with_transcript(vec![
1578 TranscriptItem::UserMessage(UserMessage::text("one")),
1579 TranscriptItem::AssistantMessage(AssistantMessage {
1580 text: "answer".to_string(),
1581 phase: None,
1582 }),
1583 TranscriptItem::UserMessage(UserMessage::text("two")),
1584 ]),
1585 )
1586 .await;
1587
1588 let calls = calls.lock().unwrap();
1589 assert!(
1590 calls.iter().all(|(resume, _)| resume.is_none()),
1591 "reuse disabled: no turn should resume a session"
1592 );
1593 assert!(calls[1].1.contains("answer"));
1595 }
1596
1597 #[test]
1598 fn claude_code_engine_supports_images() {
1599 let engine = ClaudeCodeEngine::new(ClaudeCodeConfig::default());
1600 assert!(engine.capabilities().image_input);
1601
1602 let models = roder_api::catalog::models_for_provider(PROVIDER_CLAUDE_CODE, false);
1603 assert!(!models.is_empty());
1604 for model in models {
1605 let entry = roder_api::catalog::lookup_model_for_provider(PROVIDER_CLAUDE_CODE, &model.id).unwrap();
1606 assert!(entry.supports_images, "model {} must support images", model.id);
1607 }
1608 }
1609
1610 #[derive(Default)]
1613 struct ContentCapturingRunner {
1614 contents: Arc<StdMutex<Vec<UserMessageInput>>>,
1615 }
1616
1617 #[async_trait::async_trait]
1618 impl ClaudeCodeRunner for ContentCapturingRunner {
1619 async fn stream(
1620 &self,
1621 _options: ClaudeAgentOptions,
1622 content: UserMessageInput,
1623 ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
1624 self.contents.lock().unwrap().push(content);
1625 let (tx, rx) = mpsc::unbounded_channel();
1626 tx.send(StreamEvent::TurnComplete(MessageResponse {
1627 content: String::new(),
1628 blocks: Vec::new(),
1629 model: String::new(),
1630 stop_reason: Some("end_turn".to_string()),
1631 session_id: "session-1".to_string(),
1632 usage: None,
1633 }))
1634 .unwrap();
1635 Ok(rx)
1636 }
1637 }
1638
1639 #[tokio::test]
1640 async fn image_user_message_is_delivered_as_image_block() {
1641 use roder_api::transcript::{InputImage, UserMessage};
1642
1643 let contents = Arc::new(StdMutex::new(Vec::new()));
1644 let engine = ClaudeCodeEngine::new_with_runner(
1645 ClaudeCodeConfig::default(),
1646 Arc::new(ContentCapturingRunner {
1647 contents: Arc::clone(&contents),
1648 }),
1649 );
1650
1651 drain(
1652 &engine,
1653 request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::with_images(
1654 "describe this",
1655 vec![InputImage {
1656 image_url: "data:image/png;base64,QUJD".to_string(),
1657 }],
1658 ))]),
1659 )
1660 .await;
1661
1662 let contents = contents.lock().unwrap();
1663 let blocks = match &contents[0] {
1664 UserMessageInput::Blocks(blocks) => blocks,
1665 other => panic!("expected image content blocks, got {other:?}"),
1666 };
1667 assert!(matches!(blocks.first(), Some(InputContentBlock::Text { text }) if text.contains("describe this")));
1669 assert!(blocks.iter().any(|block| matches!(
1670 block,
1671 InputContentBlock::Image {
1672 source: ImageSource::Base64 { media_type, data }
1673 } if media_type == "image/png" && data == "QUJD"
1674 )));
1675 for block in blocks {
1677 if let InputContentBlock::Text { text } = block {
1678 assert!(!text.contains("QUJD"), "base64 leaked into text: {text}");
1679 }
1680 }
1681 }
1682}