Skip to main content

roder_ext_claude_code/
provider.rs

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    /// Reuse the persisted `claude` CLI session across turns (default: on) so
32    /// the CLI keeps history server-side and auto-compacts it, and Roder only
33    /// sends the new transcript tail each turn. Set to `Some(false)` to force
34    /// the legacy behavior of replaying the full transcript every turn.
35    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/// Tracks the persisted `claude` CLI session so successive turns resume it
68/// instead of replaying the whole transcript. `synced` holds a per-item
69/// fingerprint of the transcript prefix the CLI session is already known to
70/// contain (everything up to and including the last assistant message of the
71/// previous turn). If a later request no longer extends that prefix — e.g.
72/// Roder compacted the transcript or a brand-new conversation started — the
73/// fingerprint check fails and the provider falls back to a fresh full send.
74#[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
176/// Outcome of deciding whether the upcoming turn resumes the persisted CLI
177/// session (sending only the new transcript tail) or starts fresh (sending the
178/// whole transcript).
179struct TurnPlan {
180    resume_session_id: Option<String>,
181    content: UserMessageInput,
182    /// Fingerprint of the transcript prefix the session will be known to
183    /// contain once this turn is sent. Committed to `SessionContinuity` only
184    /// after the turn completes successfully.
185    synced: Vec<u64>,
186}
187
188impl ClaudeCodeEngine {
189    fn plan_turn(&self, request: &AgentInferenceRequest) -> TurnPlan {
190        let fingerprints = transcript_fingerprints(&request.transcript);
191        // The CLI session always contains everything up to and including the
192        // previous turn's final assistant message; only items after it are new
193        // input the resumed session has not seen yet.
194        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                        // After this resume the session covers the full current
210                        // transcript (prior items + this new tail).
211                        synced: fingerprints,
212                    };
213                }
214            }
215        }
216
217        // Fresh send: replay the whole transcript and start a new session. The
218        // synced baseline becomes the current transcript so the next turn can
219        // resume from here.
220        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
239/// Builds the prompt from only the transcript items at/after `boundary`, used
240/// when resuming a persisted CLI session that already holds the earlier items.
241fn 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
246/// Renders the transcript `items` into a CLI prompt. Text is replayed as the
247/// Debug form of each item (the historical behavior); any images carried by
248/// user messages are decoded into real image content blocks so Claude actually
249/// receives the pixels instead of a base64 string dumped into the text.
250fn 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        // Provider metadata (rate-limit blobs, tool-result echoes) is internal
255        // bookkeeping, not conversational input. Never replay it as a prompt.
256        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            // Replay only the text; embedding the raw base64 data URL as Debug
268            // text would balloon the prompt and duplicate the image bytes.
269            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
301/// Converts a Roder `InputImage` URL into a Claude image content block.
302///
303/// `data:<mime>;base64,<payload>` URLs become base64 image sources; plain
304/// `http(s)` URLs become URL image sources. Anything else (e.g. an unsupported
305/// scheme) is skipped.
306fn 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
318/// True when the input carries any text or image content worth sending.
319fn 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
326/// Index of the first transcript item the CLI session has NOT seen yet: the
327/// position just after the previous turn's final assistant message. Returns 0
328/// when there is no prior assistant message (first turn -> send everything).
329fn 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
347/// True when `prefix` is a (non-strict) leading slice of `full`.
348fn 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        // Tracks how far into `accumulated_text` the trailing full-message echo
361        // has been re-matched. Under `include_partial_messages`, the SDK streams
362        // incremental `content_block_delta` chunks and then re-emits the full
363        // text of every block from the final `AssistantMsg`. Those echoes replay
364        // the already-streamed text from the start, one block at a time, so we
365        // walk `accumulated_text` and drop each chunk that matches. Reset to 0 on
366        // any genuinely new text, since echoes only ever arrive contiguously at
367        // the end of the message.
368        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        // Tool-use ids for `mcp__roder__*` tools. Those tools are executed
373        // in-process by the SDK MCP handler, which routes through Roder's
374        // TurnToolExecutor and emits the canonical tool-call lifecycle events.
375        // The provider must NOT also surface them from the CLI stream: doing so
376        // makes the runtime try to execute a tool literally named
377        // `mcp__roder__read_file` (which is not registered, so it fails) and
378        // produces duplicate, failing rows that trip the reliability limit.
379        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                        // Executed in-process via the MCP handler; the executor
405                        // owns the canonical tool-call events. Skip emission.
406                        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                    // One assistant message (or a usage-bearing message delta)
441                    // finished. A turn with tool calls produces several of
442                    // these — the turn only ends at TurnComplete below.
443                    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                    // Assistant-message boundary: the next message streams its
458                    // own deltas and echoes, so reset the echo-dedup state.
459                    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                    // The turn failed (possibly a stale/invalid resume). Drop the
481                    // session so the next attempt replays the full transcript.
482                    clear_session(&continuity);
483                    yield InferenceEvent::Failed(InferenceFailure { message: redact_error(&message) });
484                }
485            }
486        }
487        // The CLI stream ended without a result message (e.g. the process
488        // died after its last assistant message). Close the turn with what
489        // we saw so the runtime never hangs waiting for completion.
490        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
500/// Records the resumable session id and the transcript prefix it now covers so
501/// the next turn can resume instead of replaying the full transcript.
502fn 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        // No usable session id: force a fresh send next turn rather than
509        // resuming against a session we cannot name.
510        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    /// Mirrors the real SDK sequence under `include_partial_messages(true)`:
805    /// the CLI first streams incremental `content_block_delta` chunks, then the
806    /// final `AssistantMsg` re-emits the FULL text of every text block. When an
807    /// assistant message contains more than one text block (text -> tool_use ->
808    /// text), the redundant full-block echoes must not be rendered again.
809    #[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                    // Incremental deltas as the message streams in.
816                    StreamEvent::ContentChunk("First ".to_string()),
817                    StreamEvent::ContentChunk("block.".to_string()),
818                    StreamEvent::ContentChunk("Second ".to_string()),
819                    StreamEvent::ContentChunk("block.".to_string()),
820                    // Final AssistantMsg re-emits the full text of each block.
821                    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    /// Fable 5 (and Opus 4.8) narrate before tool calls. A turn then contains
869    /// MULTIPLE assistant messages: "I'll read the file." -> tool_use ->
870    /// "The answer is X." Each assistant message emits its own Complete; the
871    /// turn must only end at TurnComplete, and the post-tool text must not be
872    /// dropped.
873    #[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                    // First assistant message: pre-tool narration.
880                    StreamEvent::ContentChunk("I'll read the file.".to_string()),
881                    // Echo of the full block from the final AssistantMsg.
882                    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                    // Tool runs (mcp tool -- suppressed lifecycle).
892                    StreamEvent::ToolUseStart {
893                        id: "toolu_1".to_string(),
894                        name: "mcp__roder__read_file".to_string(),
895                        input: serde_json::Map::new(),
896                    },
897                    // Second assistant message: the actual answer.
898                    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                    // CLI dies here -- no TurnComplete.
969                ],
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    /// `mcp__roder__*` tools are executed in-process by the SDK MCP handler,
1040    /// which routes through Roder's executor and emits the canonical tool-call
1041    /// events. The provider must NOT re-surface those tool calls from the CLI
1042    /// stream, or the runtime would try to execute an unregistered tool named
1043    /// `mcp__roder__read_file` and record a spurious failure.
1044    #[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    /// `(resume_session_id, prompt)` captured for each CLI invocation.
1196    type RecordedCall = (Option<String>, String);
1197
1198    /// Flattens user-message input back into the text the assertions inspect:
1199    /// the string for text input, or the concatenated text blocks otherwise.
1200    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    /// Records the `resume` id and prompt of every CLI invocation and always
1215    /// completes the turn with the configured session id.
1216    #[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        // Turn 1: only a user message -> fresh send, no resume.
1292        drain(
1293            &engine,
1294            request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text(
1295                "first question",
1296            ))]),
1297        )
1298        .await;
1299
1300        // Turn 2: prior exchange plus a new user message. The session already
1301        // holds everything up to the assistant reply, so only the new user
1302        // message should be sent, and the call must resume session-1.
1303        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        // Turn 1: fresh, replays the whole transcript.
1319        assert_eq!(calls[0].0, None);
1320        assert!(calls[0].1.contains("first question"));
1321        // Turn 2: resumes and sends ONLY the new tail.
1322        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        // Roder compacted the head: the prefix no longer matches what the
1360        // session was synced to, so the next turn must NOT resume.
1361        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        // Full transcript replayed every turn.
1415        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    /// Captures the full [`UserMessageInput`] of each CLI invocation so image
1432    /// delivery can be asserted on the structured content blocks.
1433    #[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        // First a text block, then the decoded base64 image block.
1489        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        // The raw base64 payload must NOT be dumped into the text prompt.
1497        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}