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