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, HostedToolCallCompleted, HostedToolCallStarted,
15    InferenceCapabilities, InferenceEngine, InferenceEvent, InferenceEventStream, InferenceFailure,
16    InferenceProviderContext, InferenceProviderMetadata, InferenceTurnContext, MessageDelta,
17    ModelDescriptor, ProviderAuthType, ReasoningDelta, TokenUsage, ToolCallCompleted,
18    ToolCallDelta,
19};
20use roder_api::transcript::TranscriptItem;
21use serde_json::json;
22use tokio::sync::mpsc;
23
24use crate::options::build_options;
25
26#[derive(Debug, Clone, Default)]
27pub struct ClaudeCodeConfig {
28    pub cli_path: Option<String>,
29    pub permission_mode: Option<String>,
30    pub setting_sources: Option<Vec<String>>,
31    pub workspace: Option<PathBuf>,
32    /// Reuse the persisted `claude` CLI session across turns (default: on) so
33    /// the CLI keeps history server-side and auto-compacts it, and Roder only
34    /// sends the new transcript tail each turn. Set to `Some(false)` to force
35    /// the legacy behavior of replaying the full transcript every turn.
36    pub reuse_cli_session: Option<bool>,
37    /// Enable the Claude Code "Claude in Chrome" (CFC) browser integration so
38    /// the model can drive the user's real browser through the CLI's
39    /// `mcp__claude-in-chrome__*` tools.
40    ///
41    /// * `Some(true)` / `Some(false)` force the integration on or off.
42    /// * `None` (default) auto-detects: it turns on when the local Claude Code
43    ///   config shows the Chrome extension is paired/enabled, and otherwise
44    ///   off. The `RODER_CLAUDE_CODE_ENABLE_CHROME` / `CLAUDE_CODE_ENABLE_CHROME`
45    ///   environment variables override the auto-detection.
46    ///
47    /// When enabled the provider spawns `claude` with `CLAUDE_CODE_ENABLE_CFC=1`
48    /// and blanks `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` for that child so
49    /// the CLI uses its claude.ai subscription auth — a prerequisite for the
50    /// Chrome integration to connect (it is disabled under API-key auth).
51    pub enable_claude_in_chrome: Option<bool>,
52}
53
54impl ClaudeCodeConfig {
55    fn session_reuse_enabled(&self) -> bool {
56        self.reuse_cli_session.unwrap_or(true)
57    }
58}
59
60#[async_trait::async_trait]
61pub trait ClaudeCodeRunner: Send + Sync {
62    async fn stream(
63        &self,
64        options: ClaudeAgentOptions,
65        content: UserMessageInput,
66    ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>>;
67}
68
69#[derive(Debug, Default)]
70pub struct SdkClaudeCodeRunner;
71
72#[async_trait::async_trait]
73impl ClaudeCodeRunner for SdkClaudeCodeRunner {
74    async fn stream(
75        &self,
76        options: ClaudeAgentOptions,
77        content: UserMessageInput,
78    ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
79        Ok(ClaudeAgentClient::spawn_stream_message(options, content))
80    }
81}
82
83/// Tracks the persisted `claude` CLI session so successive turns resume it
84/// instead of replaying the whole transcript. `synced` holds a per-item
85/// fingerprint of the transcript prefix the CLI session is already known to
86/// contain (everything up to and including the last assistant message of the
87/// previous turn). If a later request no longer extends that prefix — e.g.
88/// Roder compacted the transcript or a brand-new conversation started — the
89/// fingerprint check fails and the provider falls back to a fresh full send.
90#[derive(Debug, Default)]
91struct SessionContinuity {
92    session_id: Option<String>,
93    synced: Vec<u64>,
94}
95
96pub struct ClaudeCodeEngine {
97    config: ClaudeCodeConfig,
98    runner: Arc<dyn ClaudeCodeRunner>,
99    continuity: Arc<Mutex<SessionContinuity>>,
100}
101
102impl ClaudeCodeEngine {
103    pub fn new(config: ClaudeCodeConfig) -> Self {
104        Self::new_with_runner(config, Arc::new(SdkClaudeCodeRunner))
105    }
106
107    pub fn new_with_runner(config: ClaudeCodeConfig, runner: Arc<dyn ClaudeCodeRunner>) -> Self {
108        Self {
109            config,
110            runner,
111            continuity: Arc::new(Mutex::new(SessionContinuity::default())),
112        }
113    }
114
115    fn auth_configured(&self) -> bool {
116        self.config
117            .cli_path
118            .as_deref()
119            .map(|value| !value.trim().is_empty())
120            .unwrap_or_else(|| which::which("claude").is_ok())
121    }
122}
123
124#[async_trait::async_trait]
125impl InferenceEngine for ClaudeCodeEngine {
126    fn id(&self) -> InferenceEngineId {
127        PROVIDER_CLAUDE_CODE.to_string()
128    }
129
130    fn capabilities(&self) -> InferenceCapabilities {
131        InferenceCapabilities {
132            streaming: true,
133            tool_calls: true,
134            parallel_tool_calls: false,
135            reasoning_summaries: true,
136            structured_output: false,
137            image_input: true,
138            prompt_cache: false,
139            provider_metadata: true,
140            tool_search: false,
141        }
142    }
143
144    fn metadata(&self) -> InferenceProviderMetadata {
145        InferenceProviderMetadata {
146            name: "Claude Code".to_string(),
147            description: Some("Local Claude Code CLI harness via claude-agent-sdk".to_string()),
148            auth_type: ProviderAuthType::None,
149            auth_label: Some("Authenticated local Claude Code CLI".to_string()),
150            auth_configured: Some(self.auth_configured()),
151            recommended: true,
152            sort_order: 19,
153        }
154    }
155
156    async fn list_models(
157        &self,
158        _ctx: InferenceProviderContext<'_>,
159    ) -> anyhow::Result<Vec<ModelDescriptor>> {
160        Ok(models_for_provider(PROVIDER_CLAUDE_CODE, false))
161    }
162
163    async fn stream_turn(
164        &self,
165        _ctx: InferenceTurnContext<'_>,
166        request: AgentInferenceRequest,
167    ) -> anyhow::Result<InferenceEventStream> {
168        validate_request(&request)?;
169        let current_dir = std::env::current_dir().ok();
170        let cwd = self
171            .config
172            .workspace
173            .as_deref()
174            .or_else(|| current_dir.as_deref());
175        let plan = self.plan_turn(&request);
176        let options = build_options(
177            &self.config,
178            &request,
179            _ctx.tool_executor.clone(),
180            cwd,
181            plan.resume_session_id.as_deref(),
182        )?;
183        let events = self.runner.stream(options, plan.content).await?;
184        Ok(Box::pin(map_stream_events(
185            events,
186            Arc::clone(&self.continuity),
187            plan.synced,
188        )))
189    }
190}
191
192/// Outcome of deciding whether the upcoming turn resumes the persisted CLI
193/// session (sending only the new transcript tail) or starts fresh (sending the
194/// whole transcript).
195struct TurnPlan {
196    resume_session_id: Option<String>,
197    content: UserMessageInput,
198    /// Fingerprint of the transcript prefix the session will be known to
199    /// contain once this turn is sent. Committed to `SessionContinuity` only
200    /// after the turn completes successfully.
201    synced: Vec<u64>,
202}
203
204impl ClaudeCodeEngine {
205    fn plan_turn(&self, request: &AgentInferenceRequest) -> TurnPlan {
206        let fingerprints = transcript_fingerprints(&request.transcript);
207        // The CLI session always contains everything up to and including the
208        // previous turn's final assistant message; only items after it are new
209        // input the resumed session has not seen yet.
210        let boundary = delta_boundary(&request.transcript);
211
212        if self.config.session_reuse_enabled()
213            && let Ok(state) = self.continuity.lock()
214        {
215            let can_resume = state.session_id.is_some()
216                && boundary > 0
217                && is_prefix(&state.synced, &fingerprints)
218                && boundary <= request.transcript.len();
219            if can_resume {
220                let delta = prompt_from_delta(request, boundary);
221                if input_has_content(&delta) {
222                    return TurnPlan {
223                        resume_session_id: state.session_id.clone(),
224                        content: delta,
225                        // After this resume the session covers the full current
226                        // transcript (prior items + this new tail).
227                        synced: fingerprints,
228                    };
229                }
230            }
231        }
232
233        // Fresh send: replay the whole transcript and start a new session. The
234        // synced baseline becomes the current transcript so the next turn can
235        // resume from here.
236        TurnPlan {
237            resume_session_id: None,
238            content: prompt_from_request(request),
239            synced: fingerprints,
240        }
241    }
242}
243
244fn validate_request(request: &AgentInferenceRequest) -> anyhow::Result<()> {
245    if request.output.response_format.is_some() {
246        anyhow::bail!("Claude Code provider does not support structured response_format yet");
247    }
248    Ok(())
249}
250
251fn prompt_from_request(request: &AgentInferenceRequest) -> UserMessageInput {
252    prompt_from_items(request, &request.transcript)
253}
254
255/// Builds the prompt from only the transcript items at/after `boundary`, used
256/// when resuming a persisted CLI session that already holds the earlier items.
257fn prompt_from_delta(request: &AgentInferenceRequest, boundary: usize) -> UserMessageInput {
258    let tail = request.transcript.get(boundary..).unwrap_or(&[]);
259    prompt_from_items(request, tail)
260}
261
262/// Renders the transcript `items` into a CLI prompt. Text is replayed as the
263/// Debug form of each item (the historical behavior); any images carried by
264/// user messages are decoded into real image content blocks so Claude actually
265/// receives the pixels instead of a base64 string dumped into the text.
266fn prompt_from_items(request: &AgentInferenceRequest, items: &[TranscriptItem]) -> UserMessageInput {
267    let mut parts = Vec::new();
268    let mut images = Vec::new();
269    for item in items {
270        // Provider metadata (rate-limit blobs, tool-result echoes) is internal
271        // bookkeeping, not conversational input. Never replay it as a prompt.
272        if matches!(item, TranscriptItem::ProviderMetadata(_)) {
273            continue;
274        }
275        if let TranscriptItem::UserMessage(message) = item
276            && !message.images.is_empty()
277        {
278            for image in &message.images {
279                if let Some(block) = image_block_from_url(&image.image_url) {
280                    images.push(block);
281                }
282            }
283            // Replay only the text; embedding the raw base64 data URL as Debug
284            // text would balloon the prompt and duplicate the image bytes.
285            let text_only =
286                TranscriptItem::UserMessage(roder_api::transcript::UserMessage::text(
287                    message.text.clone(),
288                ));
289            parts.push(format!("{text_only:?}"));
290            continue;
291        }
292        parts.push(format!("{item:?}"));
293    }
294    if let Some(value) = request
295        .metadata
296        .get("prompt")
297        .and_then(|value| value.as_str())
298    {
299        parts.push(value.to_string());
300    }
301    let text = if parts.is_empty() {
302        "Continue the current Roder turn.".to_string()
303    } else {
304        parts.join("\n\n")
305    };
306
307    if images.is_empty() {
308        UserMessageInput::Text(text)
309    } else {
310        let mut blocks = Vec::with_capacity(images.len() + 1);
311        blocks.push(InputContentBlock::text(text));
312        blocks.extend(images);
313        UserMessageInput::Blocks(blocks)
314    }
315}
316
317/// Converts a Roder `InputImage` URL into a Claude image content block.
318///
319/// `data:<mime>;base64,<payload>` URLs become base64 image sources; plain
320/// `http(s)` URLs become URL image sources. Anything else (e.g. an unsupported
321/// scheme) is skipped.
322fn image_block_from_url(url: &str) -> Option<InputContentBlock> {
323    if let Some(source) = ImageSource::from_data_url(url) {
324        return Some(InputContentBlock::image(source));
325    }
326    if url.starts_with("http://") || url.starts_with("https://") {
327        return Some(InputContentBlock::image(ImageSource::Url {
328            url: url.to_string(),
329        }));
330    }
331    None
332}
333
334/// True when the input carries any text or image content worth sending.
335fn input_has_content(input: &UserMessageInput) -> bool {
336    match input {
337        UserMessageInput::Text(text) => !text.trim().is_empty(),
338        UserMessageInput::Blocks(blocks) => !blocks.is_empty(),
339    }
340}
341
342/// Index of the first transcript item the CLI session has NOT seen yet: the
343/// position just after the previous turn's final assistant message. Returns 0
344/// when there is no prior assistant message (first turn -> send everything).
345fn delta_boundary(transcript: &[TranscriptItem]) -> usize {
346    transcript
347        .iter()
348        .rposition(|item| matches!(item, TranscriptItem::AssistantMessage(_)))
349        .map(|index| index + 1)
350        .unwrap_or(0)
351}
352
353fn transcript_fingerprints(transcript: &[TranscriptItem]) -> Vec<u64> {
354    transcript.iter().map(fingerprint_item).collect()
355}
356
357fn fingerprint_item(item: &TranscriptItem) -> u64 {
358    let mut hasher = DefaultHasher::new();
359    format!("{item:?}").hash(&mut hasher);
360    hasher.finish()
361}
362
363/// True when `prefix` is a (non-strict) leading slice of `full`.
364fn is_prefix(prefix: &[u64], full: &[u64]) -> bool {
365    prefix.len() <= full.len() && full[..prefix.len()] == *prefix
366}
367
368fn map_stream_events(
369    mut events: mpsc::UnboundedReceiver<StreamEvent>,
370    continuity: Arc<Mutex<SessionContinuity>>,
371    synced: Vec<u64>,
372) -> InferenceEventStream {
373    Box::pin(try_stream! {
374        let mut saw_partial_text = false;
375        let mut accumulated_text = String::new();
376        // Tracks how far into `accumulated_text` the trailing full-message echo
377        // has been re-matched. Under `include_partial_messages`, the SDK streams
378        // incremental `content_block_delta` chunks and then re-emits the full
379        // text of every block from the final `AssistantMsg`. Those echoes replay
380        // the already-streamed text from the start, one block at a time, so we
381        // walk `accumulated_text` and drop each chunk that matches. Reset to 0 on
382        // any genuinely new text, since echoes only ever arrive contiguously at
383        // the end of the message.
384        let mut echo_match_pos = 0usize;
385        let mut completed = false;
386        let mut last_session_id: Option<String> = None;
387        let mut last_stop_reason: Option<String> = None;
388        // Tool-use ids for `mcp__roder__*` tools. Those tools are executed
389        // in-process by the SDK MCP handler, which routes through Roder's
390        // TurnToolExecutor and emits the canonical tool-call lifecycle events.
391        // The provider must NOT also surface them from the CLI stream: doing so
392        // makes the runtime try to execute a tool literally named
393        // `mcp__roder__read_file` (which is not registered, so it fails) and
394        // produces duplicate, failing rows that trip the reliability limit.
395        let mut mcp_tool_ids: std::collections::HashSet<String> =
396            std::collections::HashSet::new();
397        // Tool-use ids for non-Roder MCP servers the CLI executes itself (e.g.
398        // Claude-in-Chrome browser tools). Roder never runs these, so they are
399        // surfaced as hosted tool calls for visibility instead of runtime tool
400        // calls -- emitting `ToolCallCompleted` would make the runtime try to
401        // execute a tool it never registered. `hosted_pending` maps a started
402        // id to its tool name until the matching completion has been emitted.
403        let mut hosted_pending: std::collections::HashMap<String, String> =
404            std::collections::HashMap::new();
405        let mut hosted_done: std::collections::HashSet<String> =
406            std::collections::HashSet::new();
407        while let Some(event) = events.recv().await {
408            match event {
409                StreamEvent::ContentChunk(text) => {
410                    if completed {
411                        continue;
412                    }
413                    if !accumulated_text.is_empty()
414                        && echo_match_pos < accumulated_text.len()
415                        && accumulated_text[echo_match_pos..].starts_with(&text)
416                    {
417                        echo_match_pos += text.len();
418                        continue;
419                    }
420                    saw_partial_text = true;
421                    echo_match_pos = 0;
422                    accumulated_text.push_str(&text);
423                    yield InferenceEvent::MessageDelta(MessageDelta { text, phase: None });
424                }
425                StreamEvent::ThinkingChunk { thinking, .. } => {
426                    yield InferenceEvent::ReasoningDelta(ReasoningDelta { text: thinking });
427                }
428                StreamEvent::ToolUseStart { id, name, input } => {
429                    if name.starts_with("mcp__roder__") {
430                        // Executed in-process via the MCP handler; the executor
431                        // owns the canonical tool-call events. Skip emission.
432                        mcp_tool_ids.insert(id);
433                        continue;
434                    }
435                    if name.starts_with("mcp__") {
436                        // A non-Roder MCP server the CLI runs itself (e.g.
437                        // Claude-in-Chrome). Suppress the runtime tool-call path
438                        // and surface it as a hosted tool call instead. Under
439                        // `include_partial_messages` the same tool-use arrives
440                        // twice: once at `content_block_start` (empty input) and
441                        // once in the final assistant message (full input); emit
442                        // `started` on first sight and `completed` once the full
443                        // input is known (or at the tool result / turn end).
444                        mcp_tool_ids.insert(id.clone());
445                        if hosted_done.contains(&id) {
446                            continue;
447                        }
448                        if !hosted_pending.contains_key(&id) {
449                            hosted_pending.insert(id.clone(), name.clone());
450                            yield InferenceEvent::HostedToolCallStarted(HostedToolCallStarted {
451                                id: id.clone(),
452                                name: name.clone(),
453                            });
454                        }
455                        if !input.is_empty() {
456                            hosted_pending.remove(&id);
457                            hosted_done.insert(id.clone());
458                            yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
459                                id,
460                                name,
461                                arguments: serde_json::Value::Object(input).to_string(),
462                            });
463                        }
464                        continue;
465                    }
466                    if !input.is_empty() {
467                        yield InferenceEvent::ToolCallCompleted(ToolCallCompleted {
468                            id,
469                            name,
470                            arguments: serde_json::Value::Object(input).to_string(),
471                        });
472                    }
473                }
474                StreamEvent::ToolUseDelta { id, partial_input } => {
475                    if mcp_tool_ids.contains(&id) {
476                        continue;
477                    }
478                    yield InferenceEvent::ToolCallDelta(ToolCallDelta { id, arguments_delta: partial_input });
479                }
480                StreamEvent::ToolResult { tool_use_id, content, is_error } => {
481                    // Close out a hosted (CLI-executed) tool that never carried
482                    // a full input on its tool-use block (e.g. a no-argument
483                    // browser tool), so its UI row does not stay in progress.
484                    if let Some(name) = hosted_pending.remove(&tool_use_id) {
485                        hosted_done.insert(tool_use_id.clone());
486                        yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
487                            id: tool_use_id,
488                            name,
489                            arguments: "{}".to_string(),
490                        });
491                        continue;
492                    }
493                    yield InferenceEvent::ProviderMetadata(json!({
494                        "provider": PROVIDER_CLAUDE_CODE,
495                        "toolResult": {
496                            "toolUseId": tool_use_id,
497                            "isError": is_error.unwrap_or(false),
498                            "content": content.unwrap_or(serde_json::Value::Null),
499                        }
500                    }));
501                }
502                StreamEvent::RateLimit(info) => {
503                    yield InferenceEvent::ProviderMetadata(json!({
504                        "provider": PROVIDER_CLAUDE_CODE,
505                        "rateLimit": info,
506                    }));
507                }
508                StreamEvent::Complete(response) => {
509                    // One assistant message (or a usage-bearing message delta)
510                    // finished. A turn with tool calls produces several of
511                    // these — the turn only ends at TurnComplete below.
512                    if completed {
513                        continue;
514                    }
515                    if let Some(usage) = usage_from_response(&response) {
516                        yield InferenceEvent::Usage(usage);
517                    }
518                    if response.content.is_empty() {
519                        continue;
520                    }
521                    if !saw_partial_text {
522                        yield InferenceEvent::MessageDelta(MessageDelta { text: response.content.clone(), phase: None });
523                    }
524                    last_session_id = Some(response.session_id);
525                    last_stop_reason = response.stop_reason;
526                    // Assistant-message boundary: the next message streams its
527                    // own deltas and echoes, so reset the echo-dedup state.
528                    accumulated_text.clear();
529                    echo_match_pos = 0;
530                }
531                StreamEvent::TurnComplete(response) => {
532                    if completed {
533                        continue;
534                    }
535                    for (id, name) in std::mem::take(&mut hosted_pending) {
536                        hosted_done.insert(id.clone());
537                        yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
538                            id,
539                            name,
540                            arguments: "{}".to_string(),
541                        });
542                    }
543                    if let Some(usage) = usage_from_response(&response) {
544                        yield InferenceEvent::Usage(usage);
545                    }
546                    completed = true;
547                    let session_id = (!response.session_id.trim().is_empty())
548                        .then_some(response.session_id)
549                        .or(last_session_id.take());
550                    commit_session(&continuity, session_id.as_deref(), &synced);
551                    yield InferenceEvent::Completed(CompletionMetadata {
552                        stop_reason: response.stop_reason.or(last_stop_reason.take()),
553                        provider_response_id: session_id,
554                    });
555                }
556                StreamEvent::Error(message) => {
557                    // The turn failed (possibly a stale/invalid resume). Drop the
558                    // session so the next attempt replays the full transcript.
559                    clear_session(&continuity);
560                    yield InferenceEvent::Failed(InferenceFailure { message: redact_error(&message) });
561                }
562            }
563        }
564        // Complete any hosted tool rows still open so they never hang in the UI
565        // if the stream ended before their results arrived.
566        if !completed {
567            for (id, name) in std::mem::take(&mut hosted_pending) {
568                yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
569                    id,
570                    name,
571                    arguments: "{}".to_string(),
572                });
573            }
574        }
575        // The CLI stream ended without a result message (e.g. the process
576        // died after its last assistant message). Close the turn with what
577        // we saw so the runtime never hangs waiting for completion.
578        if !completed && (last_session_id.is_some() || last_stop_reason.is_some()) {
579            commit_session(&continuity, last_session_id.as_deref(), &synced);
580            yield InferenceEvent::Completed(CompletionMetadata {
581                stop_reason: last_stop_reason,
582                provider_response_id: last_session_id,
583            });
584        }
585    })
586}
587
588/// Records the resumable session id and the transcript prefix it now covers so
589/// the next turn can resume instead of replaying the full transcript.
590fn commit_session(
591    continuity: &Arc<Mutex<SessionContinuity>>,
592    session_id: Option<&str>,
593    synced: &[u64],
594) {
595    let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) else {
596        // No usable session id: force a fresh send next turn rather than
597        // resuming against a session we cannot name.
598        clear_session(continuity);
599        return;
600    };
601    if let Ok(mut state) = continuity.lock() {
602        state.session_id = Some(session_id.to_string());
603        state.synced = synced.to_vec();
604    }
605}
606
607fn clear_session(continuity: &Arc<Mutex<SessionContinuity>>) {
608    if let Ok(mut state) = continuity.lock() {
609        state.session_id = None;
610        state.synced.clear();
611    }
612}
613
614fn usage_from_response(response: &MessageResponse) -> Option<TokenUsage> {
615    let usage = response.usage.as_ref()?;
616    let prompt_tokens = number_from_usage(
617        usage,
618        &[
619            "input_tokens",
620            "inputTokens",
621            "prompt_tokens",
622            "promptTokens",
623            "cache_creation_input_tokens",
624            "cacheCreationInputTokens",
625            "cache_read_input_tokens",
626            "cacheReadInputTokens",
627        ],
628    )
629    .unwrap_or(0);
630    let cached_prompt_tokens =
631        number_from_usage(usage, &["cache_read_input_tokens", "cacheReadInputTokens"]);
632    let cache_creation_prompt_tokens = number_from_usage(
633        usage,
634        &["cache_creation_input_tokens", "cacheCreationInputTokens"],
635    );
636    let completion_tokens = number_from_usage(
637        usage,
638        &[
639            "output_tokens",
640            "outputTokens",
641            "completion_tokens",
642            "completionTokens",
643        ],
644    )
645    .unwrap_or(0);
646    let total_tokens = number_from_usage(usage, &["total_tokens", "totalTokens"])
647        .unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens));
648    if prompt_tokens == 0 && completion_tokens == 0 && total_tokens == 0 {
649        return None;
650    }
651    Some(
652        TokenUsage::new(prompt_tokens, completion_tokens, total_tokens)
653            .with_cached_prompt_tokens(cached_prompt_tokens.unwrap_or(0))
654            .with_cache_creation_prompt_tokens(cache_creation_prompt_tokens.unwrap_or(0)),
655    )
656}
657
658fn number_from_usage(
659    usage: &std::collections::HashMap<String, serde_json::Value>,
660    keys: &[&str],
661) -> Option<u32> {
662    let total = usage.iter().fold(0u32, |total, (key, value)| {
663        total.saturating_add(number_from_usage_value(key, value, keys))
664    });
665    (total > 0).then_some(total)
666}
667
668fn number_from_usage_value(key: &str, value: &serde_json::Value, keys: &[&str]) -> u32 {
669    let direct = if keys.iter().any(|candidate| candidate == &key) {
670        json_u32(value).unwrap_or(0)
671    } else {
672        0
673    };
674    let nested = value
675        .as_object()
676        .map(|object| {
677            object.iter().fold(0u32, |total, (key, value)| {
678                total.saturating_add(number_from_usage_value(key, value, keys))
679            })
680        })
681        .unwrap_or(0);
682    direct.saturating_add(nested)
683}
684
685fn json_u32(value: &serde_json::Value) -> Option<u32> {
686    value
687        .as_u64()
688        .and_then(|value| u32::try_from(value).ok())
689        .or_else(|| value.as_i64().and_then(|value| u32::try_from(value).ok()))
690        .or_else(|| {
691            value
692                .as_f64()
693                .filter(|value| value.is_finite() && *value >= 0.0)
694                .and_then(|value| u32::try_from(value as u64).ok())
695        })
696        .or_else(|| value.as_str().and_then(|value| value.parse::<u32>().ok()))
697}
698
699fn redact_error(message: &str) -> String {
700    let mut redacted = message.to_string();
701    for marker in ["ANTHROPIC_API_KEY", "CLAUDE_CODE", "Bearer "] {
702        if redacted.contains(marker) {
703            redacted = redacted.replace(marker, "[redacted]");
704        }
705    }
706    redacted
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use claude_code_sdk_rust::types::ContentBlock;
713    use futures::StreamExt;
714    use roder_api::inference::{
715        HostedWebSearchConfig, InstructionBundle, ModelSelection, OutputConfig, ReasoningConfig,
716        RuntimeHints,
717    };
718    use roder_api::tools::ToolChoice;
719
720    #[derive(Default)]
721    struct FakeRunner {
722        events: Vec<StreamEvent>,
723    }
724
725    #[async_trait::async_trait]
726    impl ClaudeCodeRunner for FakeRunner {
727        async fn stream(
728            &self,
729            _options: ClaudeAgentOptions,
730            _content: UserMessageInput,
731        ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
732            let (tx, rx) = mpsc::unbounded_channel();
733            for event in self.events.clone() {
734                tx.send(event).unwrap();
735            }
736            Ok(rx)
737        }
738    }
739
740    fn request() -> AgentInferenceRequest {
741        AgentInferenceRequest {
742            model: ModelSelection {
743                provider: PROVIDER_CLAUDE_CODE.to_string(),
744                model: "sonnet".to_string(),
745            },
746            instructions: InstructionBundle::default(),
747            transcript: Vec::new(),
748            tools: Vec::new(),
749            tool_choice: ToolChoice::Auto,
750            reasoning: ReasoningConfig::default(),
751            output: OutputConfig::default(),
752            runtime: RuntimeHints {
753                hosted_web_search: HostedWebSearchConfig::disabled(),
754                ..RuntimeHints::default()
755            },
756            metadata: json!({"prompt": "hello"}),
757        }
758    }
759
760    #[tokio::test]
761    async fn provider_streams_text_and_completion() {
762        let mut usage = std::collections::HashMap::new();
763        usage.insert("input_tokens".to_string(), json!(3));
764        usage.insert("output_tokens".to_string(), json!(5));
765        let engine = ClaudeCodeEngine::new_with_runner(
766            ClaudeCodeConfig::default(),
767            Arc::new(FakeRunner {
768                events: vec![
769                    StreamEvent::ContentChunk("hello".to_string()),
770                    StreamEvent::Complete(MessageResponse {
771                        content: "hello".to_string(),
772                        blocks: vec![ContentBlock::Text {
773                            text: "hello".to_string(),
774                        }],
775                        model: "sonnet".to_string(),
776                        stop_reason: Some("end_turn".to_string()),
777                        session_id: "session-1".to_string(),
778                        usage: Some(usage),
779                    }),
780                    StreamEvent::TurnComplete(MessageResponse {
781                        content: String::new(),
782                        blocks: Vec::new(),
783                        model: String::new(),
784                        stop_reason: Some("end_turn".to_string()),
785                        session_id: "session-1".to_string(),
786                        usage: None,
787                    }),
788                ],
789            }),
790        );
791        let events = engine
792            .stream_turn(
793                InferenceTurnContext {
794                    thread_id: "thread",
795                    turn_id: "turn",
796                    tool_executor: None,
797                },
798                request(),
799            )
800            .await
801            .unwrap()
802            .collect::<Vec<_>>()
803            .await
804            .into_iter()
805            .collect::<Result<Vec<_>, _>>()
806            .unwrap();
807        assert!(matches!(events[0], InferenceEvent::MessageDelta(_)));
808        assert!(
809            events
810                .iter()
811                .any(|event| matches!(event, InferenceEvent::Usage(_)))
812        );
813        assert!(
814            events
815                .iter()
816                .any(|event| matches!(event, InferenceEvent::Completed(_)))
817        );
818    }
819
820    #[tokio::test]
821    async fn provider_keeps_partial_text_and_uses_later_usage_completion() {
822        let mut usage = std::collections::HashMap::new();
823        usage.insert("input_tokens".to_string(), json!(4));
824        usage.insert("cache_read_input_tokens".to_string(), json!(6));
825        usage.insert("output_tokens".to_string(), json!(8));
826        let engine = ClaudeCodeEngine::new_with_runner(
827            ClaudeCodeConfig::default(),
828            Arc::new(FakeRunner {
829                events: vec![
830                    StreamEvent::ContentChunk("one ".to_string()),
831                    StreamEvent::ContentChunk("two".to_string()),
832                    StreamEvent::Complete(MessageResponse {
833                        content: String::new(),
834                        blocks: Vec::new(),
835                        model: String::new(),
836                        stop_reason: Some("end_turn".to_string()),
837                        session_id: "session-usage".to_string(),
838                        usage: Some(usage),
839                    }),
840                    StreamEvent::ContentChunk("one two".to_string()),
841                    StreamEvent::Complete(MessageResponse {
842                        content: "one two".to_string(),
843                        blocks: Vec::new(),
844                        model: "sonnet".to_string(),
845                        stop_reason: Some("end_turn".to_string()),
846                        session_id: "session-final".to_string(),
847                        usage: None,
848                    }),
849                    StreamEvent::TurnComplete(MessageResponse {
850                        content: String::new(),
851                        blocks: Vec::new(),
852                        model: String::new(),
853                        stop_reason: Some("end_turn".to_string()),
854                        session_id: "session-final".to_string(),
855                        usage: None,
856                    }),
857                ],
858            }),
859        );
860        let events = engine
861            .stream_turn(
862                InferenceTurnContext {
863                    thread_id: "thread",
864                    turn_id: "turn",
865                    tool_executor: None,
866                },
867                request(),
868            )
869            .await
870            .unwrap()
871            .collect::<Vec<_>>()
872            .await
873            .into_iter()
874            .collect::<Result<Vec<_>, _>>()
875            .unwrap();
876        let rendered = events
877            .iter()
878            .filter_map(|event| match event {
879                InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
880                _ => None,
881            })
882            .collect::<String>();
883        assert_eq!(rendered, "one two");
884        assert!(events.iter().any(|event| matches!(event, InferenceEvent::Usage(usage) if usage.prompt_tokens == 10 && usage.completion_tokens == 8)));
885        assert!(
886            events
887                .iter()
888                .any(|event| matches!(event, InferenceEvent::Completed(_)))
889        );
890    }
891
892    /// Mirrors the real SDK sequence under `include_partial_messages(true)`:
893    /// the CLI first streams incremental `content_block_delta` chunks, then the
894    /// final `AssistantMsg` re-emits the FULL text of every text block. When an
895    /// assistant message contains more than one text block (text -> tool_use ->
896    /// text), the redundant full-block echoes must not be rendered again.
897    #[tokio::test]
898    async fn provider_does_not_duplicate_multi_block_commentary() {
899        let engine = ClaudeCodeEngine::new_with_runner(
900            ClaudeCodeConfig::default(),
901            Arc::new(FakeRunner {
902                events: vec![
903                    // Incremental deltas as the message streams in.
904                    StreamEvent::ContentChunk("First ".to_string()),
905                    StreamEvent::ContentChunk("block.".to_string()),
906                    StreamEvent::ContentChunk("Second ".to_string()),
907                    StreamEvent::ContentChunk("block.".to_string()),
908                    // Final AssistantMsg re-emits the full text of each block.
909                    StreamEvent::ContentChunk("First block.".to_string()),
910                    StreamEvent::ContentChunk("Second block.".to_string()),
911                    StreamEvent::Complete(MessageResponse {
912                        content: "First block.Second block.".to_string(),
913                        blocks: Vec::new(),
914                        model: "sonnet".to_string(),
915                        stop_reason: Some("end_turn".to_string()),
916                        session_id: "session-final".to_string(),
917                        usage: None,
918                    }),
919                    StreamEvent::TurnComplete(MessageResponse {
920                        content: String::new(),
921                        blocks: Vec::new(),
922                        model: String::new(),
923                        stop_reason: Some("end_turn".to_string()),
924                        session_id: "session-final".to_string(),
925                        usage: None,
926                    }),
927                ],
928            }),
929        );
930        let events = engine
931            .stream_turn(
932                InferenceTurnContext {
933                    thread_id: "thread",
934                    turn_id: "turn",
935                    tool_executor: None,
936                },
937                request(),
938            )
939            .await
940            .unwrap()
941            .collect::<Vec<_>>()
942            .await
943            .into_iter()
944            .collect::<Result<Vec<_>, _>>()
945            .unwrap();
946        let rendered = events
947            .iter()
948            .filter_map(|event| match event {
949                InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
950                _ => None,
951            })
952            .collect::<String>();
953        assert_eq!(rendered, "First block.Second block.");
954    }
955
956    /// Fable 5 (and Opus 4.8) narrate before tool calls. A turn then contains
957    /// MULTIPLE assistant messages: "I'll read the file." -> tool_use ->
958    /// "The answer is X." Each assistant message emits its own Complete; the
959    /// turn must only end at TurnComplete, and the post-tool text must not be
960    /// dropped.
961    #[tokio::test]
962    async fn provider_streams_text_after_tool_use_until_turn_complete() {
963        let engine = ClaudeCodeEngine::new_with_runner(
964            ClaudeCodeConfig::default(),
965            Arc::new(FakeRunner {
966                events: vec![
967                    // First assistant message: pre-tool narration.
968                    StreamEvent::ContentChunk("I'll read the file.".to_string()),
969                    // Echo of the full block from the final AssistantMsg.
970                    StreamEvent::ContentChunk("I'll read the file.".to_string()),
971                    StreamEvent::Complete(MessageResponse {
972                        content: "I'll read the file.".to_string(),
973                        blocks: Vec::new(),
974                        model: "claude-fable-5".to_string(),
975                        stop_reason: None,
976                        session_id: "session-1".to_string(),
977                        usage: None,
978                    }),
979                    // Tool runs (mcp tool -- suppressed lifecycle).
980                    StreamEvent::ToolUseStart {
981                        id: "toolu_1".to_string(),
982                        name: "mcp__roder__read_file".to_string(),
983                        input: serde_json::Map::new(),
984                    },
985                    // Second assistant message: the actual answer.
986                    StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
987                    StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
988                    StreamEvent::Complete(MessageResponse {
989                        content: "The magic word is pomegranate.".to_string(),
990                        blocks: Vec::new(),
991                        model: "claude-fable-5".to_string(),
992                        stop_reason: Some("end_turn".to_string()),
993                        session_id: "session-1".to_string(),
994                        usage: None,
995                    }),
996                    StreamEvent::TurnComplete(MessageResponse {
997                        content: String::new(),
998                        blocks: Vec::new(),
999                        model: String::new(),
1000                        stop_reason: Some("end_turn".to_string()),
1001                        session_id: "session-1".to_string(),
1002                        usage: None,
1003                    }),
1004                ],
1005            }),
1006        );
1007        let events = engine
1008            .stream_turn(
1009                InferenceTurnContext {
1010                    thread_id: "thread",
1011                    turn_id: "turn",
1012                    tool_executor: None,
1013                },
1014                request(),
1015            )
1016            .await
1017            .unwrap()
1018            .collect::<Vec<_>>()
1019            .await
1020            .into_iter()
1021            .collect::<Result<Vec<_>, _>>()
1022            .unwrap();
1023        let rendered = events
1024            .iter()
1025            .filter_map(|event| match event {
1026                InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
1027                _ => None,
1028            })
1029            .collect::<String>();
1030        assert_eq!(
1031            rendered, "I'll read the file.The magic word is pomegranate.",
1032            "post-tool assistant text must stream and echoes must dedupe"
1033        );
1034        let completions = events
1035            .iter()
1036            .filter(|event| matches!(event, InferenceEvent::Completed(_)))
1037            .count();
1038        assert_eq!(completions, 1, "turn must complete exactly once");
1039    }
1040
1041    /// Claude-in-Chrome browser tools are executed by the CLI itself, not by
1042    /// Roder. They must surface as hosted tool calls (so the runtime never tries
1043    /// to run an unregistered tool) and never as runtime `ToolCallCompleted`s.
1044    #[tokio::test]
1045    async fn surfaces_claude_in_chrome_tool_use_as_hosted_calls() {
1046        let engine = ClaudeCodeEngine::new_with_runner(
1047            ClaudeCodeConfig::default(),
1048            Arc::new(FakeRunner {
1049                events: vec![
1050                    // Partial start (empty input) then the full tool-use block.
1051                    StreamEvent::ToolUseStart {
1052                        id: "toolu_chrome".to_string(),
1053                        name: "mcp__claude-in-chrome__navigate".to_string(),
1054                        input: serde_json::Map::new(),
1055                    },
1056                    StreamEvent::ToolUseStart {
1057                        id: "toolu_chrome".to_string(),
1058                        name: "mcp__claude-in-chrome__navigate".to_string(),
1059                        input: {
1060                            let mut input = serde_json::Map::new();
1061                            input.insert("url".to_string(), json!("https://example.com"));
1062                            input
1063                        },
1064                    },
1065                    StreamEvent::ContentChunk("Opened the page.".to_string()),
1066                    StreamEvent::ContentChunk("Opened the page.".to_string()),
1067                    StreamEvent::Complete(MessageResponse {
1068                        content: "Opened the page.".to_string(),
1069                        blocks: Vec::new(),
1070                        model: "opus".to_string(),
1071                        stop_reason: Some("end_turn".to_string()),
1072                        session_id: "session-1".to_string(),
1073                        usage: None,
1074                    }),
1075                    StreamEvent::TurnComplete(MessageResponse {
1076                        content: String::new(),
1077                        blocks: Vec::new(),
1078                        model: String::new(),
1079                        stop_reason: Some("end_turn".to_string()),
1080                        session_id: "session-1".to_string(),
1081                        usage: None,
1082                    }),
1083                ],
1084            }),
1085        );
1086        let events = engine
1087            .stream_turn(
1088                InferenceTurnContext {
1089                    thread_id: "thread",
1090                    turn_id: "turn",
1091                    tool_executor: None,
1092                },
1093                request(),
1094            )
1095            .await
1096            .unwrap()
1097            .collect::<Vec<_>>()
1098            .await
1099            .into_iter()
1100            .collect::<Result<Vec<_>, _>>()
1101            .unwrap();
1102
1103        // The browser tool must never become a runtime tool call.
1104        assert!(
1105            !events
1106                .iter()
1107                .any(|event| matches!(event, InferenceEvent::ToolCallCompleted(_))),
1108            "Claude-in-Chrome tools must not surface as runtime tool calls"
1109        );
1110
1111        let started = events
1112            .iter()
1113            .filter(|event| matches!(event, InferenceEvent::HostedToolCallStarted(_)))
1114            .count();
1115        assert_eq!(started, 1, "browser tool must surface as one hosted start");
1116
1117        let completed = events
1118            .iter()
1119            .filter_map(|event| match event {
1120                InferenceEvent::HostedToolCallCompleted(call) => Some(call),
1121                _ => None,
1122            })
1123            .collect::<Vec<_>>();
1124        assert_eq!(completed.len(), 1, "browser tool must complete exactly once");
1125        assert_eq!(completed[0].name, "mcp__claude-in-chrome__navigate");
1126        assert!(
1127            completed[0].arguments.contains("example.com"),
1128            "completion must carry the full tool input"
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn provider_completes_turn_when_stream_ends_without_result() {
1134        let engine = ClaudeCodeEngine::new_with_runner(
1135            ClaudeCodeConfig::default(),
1136            Arc::new(FakeRunner {
1137                events: vec![
1138                    StreamEvent::ContentChunk("partial".to_string()),
1139                    StreamEvent::Complete(MessageResponse {
1140                        content: "partial".to_string(),
1141                        blocks: Vec::new(),
1142                        model: "claude-fable-5".to_string(),
1143                        stop_reason: Some("end_turn".to_string()),
1144                        session_id: "session-dead".to_string(),
1145                        usage: None,
1146                    }),
1147                    // CLI dies here -- no TurnComplete.
1148                ],
1149            }),
1150        );
1151        let events = engine
1152            .stream_turn(
1153                InferenceTurnContext {
1154                    thread_id: "thread",
1155                    turn_id: "turn",
1156                    tool_executor: None,
1157                },
1158                request(),
1159            )
1160            .await
1161            .unwrap()
1162            .collect::<Vec<_>>()
1163            .await
1164            .into_iter()
1165            .collect::<Result<Vec<_>, _>>()
1166            .unwrap();
1167        assert!(
1168            events
1169                .iter()
1170                .any(|event| matches!(event, InferenceEvent::Completed(_))),
1171            "stream end without a result message must still complete the turn"
1172        );
1173    }
1174
1175    #[tokio::test]
1176    async fn provider_maps_tool_events() {
1177        let engine = ClaudeCodeEngine::new_with_runner(
1178            ClaudeCodeConfig::default(),
1179            Arc::new(FakeRunner {
1180                events: vec![
1181                    StreamEvent::ToolUseStart {
1182                        id: "toolu_1".to_string(),
1183                        name: "Read".to_string(),
1184                        input: serde_json::Map::from_iter([(
1185                            "path".to_string(),
1186                            json!("crates/roder-ext-claude-code"),
1187                        )]),
1188                    },
1189                    StreamEvent::ToolUseDelta {
1190                        id: "toolu_1".to_string(),
1191                        partial_input: "{\"file_path\"".to_string(),
1192                    },
1193                ],
1194            }),
1195        );
1196        let events = engine
1197            .stream_turn(
1198                InferenceTurnContext {
1199                    thread_id: "thread",
1200                    turn_id: "turn",
1201                    tool_executor: None,
1202                },
1203                request(),
1204            )
1205            .await
1206            .unwrap()
1207            .collect::<Vec<_>>()
1208            .await
1209            .into_iter()
1210            .collect::<Result<Vec<_>, _>>()
1211            .unwrap();
1212        assert!(
1213            matches!(&events[0], InferenceEvent::ToolCallCompleted(call) if call.arguments.contains("roder-ext-claude-code"))
1214        );
1215        assert!(matches!(events[1], InferenceEvent::ToolCallDelta(_)));
1216    }
1217
1218    /// `mcp__roder__*` tools are executed in-process by the SDK MCP handler,
1219    /// which routes through Roder's executor and emits the canonical tool-call
1220    /// events. The provider must NOT re-surface those tool calls from the CLI
1221    /// stream, or the runtime would try to execute an unregistered tool named
1222    /// `mcp__roder__read_file` and record a spurious failure.
1223    #[tokio::test]
1224    async fn provider_suppresses_mcp_tool_lifecycle_events() {
1225        let engine = ClaudeCodeEngine::new_with_runner(
1226            ClaudeCodeConfig::default(),
1227            Arc::new(FakeRunner {
1228                events: vec![
1229                    StreamEvent::ToolUseStart {
1230                        id: "toolu_mcp".to_string(),
1231                        name: "mcp__roder__read_file".to_string(),
1232                        input: serde_json::Map::from_iter([(
1233                            "path".to_string(),
1234                            json!("README.md"),
1235                        )]),
1236                    },
1237                    StreamEvent::ToolUseDelta {
1238                        id: "toolu_mcp".to_string(),
1239                        partial_input: "{\"path\"".to_string(),
1240                    },
1241                    StreamEvent::Complete(MessageResponse {
1242                        content: "done".to_string(),
1243                        blocks: Vec::new(),
1244                        model: "sonnet".to_string(),
1245                        stop_reason: Some("end_turn".to_string()),
1246                        session_id: "session".to_string(),
1247                        usage: None,
1248                    }),
1249                ],
1250            }),
1251        );
1252        let events = engine
1253            .stream_turn(
1254                InferenceTurnContext {
1255                    thread_id: "thread",
1256                    turn_id: "turn",
1257                    tool_executor: None,
1258                },
1259                request(),
1260            )
1261            .await
1262            .unwrap()
1263            .collect::<Vec<_>>()
1264            .await
1265            .into_iter()
1266            .collect::<Result<Vec<_>, _>>()
1267            .unwrap();
1268        assert!(
1269            !events.iter().any(|event| matches!(
1270                event,
1271                InferenceEvent::ToolCallCompleted(_) | InferenceEvent::ToolCallDelta(_)
1272            )),
1273            "mcp__ tool lifecycle events must not be surfaced from the CLI stream"
1274        );
1275    }
1276
1277    #[test]
1278    fn usage_parser_reports_cached_prompt_tokens_as_subset() {
1279        let usage = std::collections::HashMap::from([
1280            ("input_tokens".to_string(), json!(123)),
1281            ("cache_creation_input_tokens".to_string(), json!(7)),
1282            ("cache_read_input_tokens".to_string(), json!(11)),
1283            ("output_tokens".to_string(), json!(13)),
1284        ]);
1285        let response = MessageResponse {
1286            content: String::new(),
1287            blocks: Vec::new(),
1288            model: "sonnet".to_string(),
1289            stop_reason: Some("end_turn".to_string()),
1290            session_id: "session".to_string(),
1291            usage: Some(usage),
1292        };
1293
1294        let usage = usage_from_response(&response).unwrap();
1295        assert_eq!(usage.prompt_tokens, 141);
1296        assert_eq!(usage.cached_prompt_tokens, 11);
1297        assert_eq!(usage.cache_creation_prompt_tokens, 7);
1298        assert_eq!(usage.completion_tokens, 13);
1299        assert_eq!(usage.total_tokens, 154);
1300    }
1301
1302    #[test]
1303    fn usage_parser_includes_cache_only_input_tokens_in_prompt_total() {
1304        let usage = std::collections::HashMap::from([
1305            ("cache_creation_input_tokens".to_string(), json!(7)),
1306            ("cache_read_input_tokens".to_string(), json!(11)),
1307            ("output_tokens".to_string(), json!(13)),
1308        ]);
1309        let response = MessageResponse {
1310            content: String::new(),
1311            blocks: Vec::new(),
1312            model: "sonnet".to_string(),
1313            stop_reason: Some("end_turn".to_string()),
1314            session_id: "session".to_string(),
1315            usage: Some(usage),
1316        };
1317
1318        let usage = usage_from_response(&response).unwrap();
1319        assert_eq!(usage.prompt_tokens, 18);
1320        assert_eq!(usage.cached_prompt_tokens, 11);
1321        assert_eq!(usage.cache_creation_prompt_tokens, 7);
1322        assert_eq!(usage.completion_tokens, 13);
1323        assert_eq!(usage.total_tokens, 31);
1324    }
1325
1326    #[test]
1327    fn usage_parser_accepts_output_only_delta_usage() {
1328        let response = MessageResponse {
1329            content: String::new(),
1330            blocks: Vec::new(),
1331            model: "sonnet".to_string(),
1332            stop_reason: Some("end_turn".to_string()),
1333            session_id: "session".to_string(),
1334            usage: Some(std::collections::HashMap::from([(
1335                "output_tokens".to_string(),
1336                json!(42),
1337            )])),
1338        };
1339
1340        let usage = usage_from_response(&response).unwrap();
1341        assert_eq!(usage.prompt_tokens, 0);
1342        assert_eq!(usage.completion_tokens, 42);
1343        assert_eq!(usage.total_tokens, 42);
1344    }
1345
1346    #[test]
1347    fn usage_parser_accepts_nested_camel_case_model_usage() {
1348        let response = MessageResponse {
1349            content: String::new(),
1350            blocks: Vec::new(),
1351            model: "sonnet".to_string(),
1352            stop_reason: Some("end_turn".to_string()),
1353            session_id: "session".to_string(),
1354            usage: Some(std::collections::HashMap::from([(
1355                "modelUsage".to_string(),
1356                json!({
1357                    "inputTokens": "100",
1358                    "cacheReadInputTokens": 25.0,
1359                    "outputTokens": 7,
1360                }),
1361            )])),
1362        };
1363
1364        let usage = usage_from_response(&response).unwrap();
1365        assert_eq!(usage.prompt_tokens, 125);
1366        assert_eq!(usage.cached_prompt_tokens, 25);
1367        assert_eq!(usage.completion_tokens, 7);
1368        assert_eq!(usage.total_tokens, 132);
1369    }
1370
1371    use roder_api::transcript::{AssistantMessage, UserMessage};
1372    use std::sync::Mutex as StdMutex;
1373
1374    /// `(resume_session_id, prompt)` captured for each CLI invocation.
1375    type RecordedCall = (Option<String>, String);
1376
1377    /// Flattens user-message input back into the text the assertions inspect:
1378    /// the string for text input, or the concatenated text blocks otherwise.
1379    fn capture_text(content: &UserMessageInput) -> String {
1380        match content {
1381            UserMessageInput::Text(text) => text.clone(),
1382            UserMessageInput::Blocks(blocks) => blocks
1383                .iter()
1384                .filter_map(|block| match block {
1385                    InputContentBlock::Text { text } => Some(text.clone()),
1386                    InputContentBlock::Image { .. } => None,
1387                })
1388                .collect::<Vec<_>>()
1389                .join("\n\n"),
1390        }
1391    }
1392
1393    /// Records the `resume` id and prompt of every CLI invocation and always
1394    /// completes the turn with the configured session id.
1395    #[derive(Default)]
1396    struct RecordingRunner {
1397        session_id: String,
1398        calls: Arc<StdMutex<Vec<RecordedCall>>>,
1399    }
1400
1401    #[async_trait::async_trait]
1402    impl ClaudeCodeRunner for RecordingRunner {
1403        async fn stream(
1404            &self,
1405            options: ClaudeAgentOptions,
1406            content: UserMessageInput,
1407        ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
1408            self.calls
1409                .lock()
1410                .unwrap()
1411                .push((options.resume.clone(), capture_text(&content)));
1412            let (tx, rx) = mpsc::unbounded_channel();
1413            tx.send(StreamEvent::Complete(MessageResponse {
1414                content: "ok".to_string(),
1415                blocks: Vec::new(),
1416                model: "sonnet".to_string(),
1417                stop_reason: Some("end_turn".to_string()),
1418                session_id: self.session_id.clone(),
1419                usage: None,
1420            }))
1421            .unwrap();
1422            tx.send(StreamEvent::TurnComplete(MessageResponse {
1423                content: String::new(),
1424                blocks: Vec::new(),
1425                model: String::new(),
1426                stop_reason: Some("end_turn".to_string()),
1427                session_id: self.session_id.clone(),
1428                usage: None,
1429            }))
1430            .unwrap();
1431            Ok(rx)
1432        }
1433    }
1434
1435    fn request_with_transcript(transcript: Vec<TranscriptItem>) -> AgentInferenceRequest {
1436        AgentInferenceRequest {
1437            transcript,
1438            metadata: json!({}),
1439            ..request()
1440        }
1441    }
1442
1443    async fn drain(engine: &ClaudeCodeEngine, request: AgentInferenceRequest) {
1444        let _ = engine
1445            .stream_turn(
1446                InferenceTurnContext {
1447                    thread_id: "thread",
1448                    turn_id: "turn",
1449                    tool_executor: None,
1450                },
1451                request,
1452            )
1453            .await
1454            .unwrap()
1455            .collect::<Vec<_>>()
1456            .await;
1457    }
1458
1459    #[tokio::test]
1460    async fn second_turn_resumes_session_and_sends_only_the_new_tail() {
1461        let calls = Arc::new(StdMutex::new(Vec::new()));
1462        let engine = ClaudeCodeEngine::new_with_runner(
1463            ClaudeCodeConfig::default(),
1464            Arc::new(RecordingRunner {
1465                session_id: "session-1".to_string(),
1466                calls: Arc::clone(&calls),
1467            }),
1468        );
1469
1470        // Turn 1: only a user message -> fresh send, no resume.
1471        drain(
1472            &engine,
1473            request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text(
1474                "first question",
1475            ))]),
1476        )
1477        .await;
1478
1479        // Turn 2: prior exchange plus a new user message. The session already
1480        // holds everything up to the assistant reply, so only the new user
1481        // message should be sent, and the call must resume session-1.
1482        drain(
1483            &engine,
1484            request_with_transcript(vec![
1485                TranscriptItem::UserMessage(UserMessage::text("first question")),
1486                TranscriptItem::AssistantMessage(AssistantMessage {
1487                    text: "first answer".to_string(),
1488                    phase: None,
1489                }),
1490                TranscriptItem::UserMessage(UserMessage::text("second question")),
1491            ]),
1492        )
1493        .await;
1494
1495        let calls = calls.lock().unwrap();
1496        assert_eq!(calls.len(), 2);
1497        // Turn 1: fresh, replays the whole transcript.
1498        assert_eq!(calls[0].0, None);
1499        assert!(calls[0].1.contains("first question"));
1500        // Turn 2: resumes and sends ONLY the new tail.
1501        assert_eq!(calls[1].0.as_deref(), Some("session-1"));
1502        assert!(calls[1].1.contains("second question"));
1503        assert!(
1504            !calls[1].1.contains("first answer"),
1505            "resumed turn must not replay prior assistant output: {}",
1506            calls[1].1
1507        );
1508        assert!(
1509            !calls[1].1.contains("first question"),
1510            "resumed turn must not replay the prior user message: {}",
1511            calls[1].1
1512        );
1513    }
1514
1515    #[tokio::test]
1516    async fn compacted_transcript_falls_back_to_a_fresh_send() {
1517        let calls = Arc::new(StdMutex::new(Vec::new()));
1518        let engine = ClaudeCodeEngine::new_with_runner(
1519            ClaudeCodeConfig::default(),
1520            Arc::new(RecordingRunner {
1521                session_id: "session-1".to_string(),
1522                calls: Arc::clone(&calls),
1523            }),
1524        );
1525
1526        drain(
1527            &engine,
1528            request_with_transcript(vec![
1529                TranscriptItem::UserMessage(UserMessage::text("original question")),
1530                TranscriptItem::AssistantMessage(AssistantMessage {
1531                    text: "original answer".to_string(),
1532                    phase: None,
1533                }),
1534            ]),
1535        )
1536        .await;
1537
1538        // Roder compacted the head: the prefix no longer matches what the
1539        // session was synced to, so the next turn must NOT resume.
1540        drain(
1541            &engine,
1542            request_with_transcript(vec![
1543                TranscriptItem::ContextCompaction(roder_api::transcript::ContextCompactionRecord {
1544                    summary: "summary of earlier turns".to_string(),
1545                }),
1546                TranscriptItem::UserMessage(UserMessage::text("next question")),
1547            ]),
1548        )
1549        .await;
1550
1551        let calls = calls.lock().unwrap();
1552        assert_eq!(calls[1].0, None, "compacted transcript must send fresh");
1553        assert!(calls[1].1.contains("summary of earlier turns"));
1554    }
1555
1556    #[tokio::test]
1557    async fn session_reuse_can_be_disabled_via_config() {
1558        let calls = Arc::new(StdMutex::new(Vec::new()));
1559        let engine = ClaudeCodeEngine::new_with_runner(
1560            ClaudeCodeConfig {
1561                reuse_cli_session: Some(false),
1562                ..ClaudeCodeConfig::default()
1563            },
1564            Arc::new(RecordingRunner {
1565                session_id: "session-1".to_string(),
1566                calls: Arc::clone(&calls),
1567            }),
1568        );
1569
1570        drain(
1571            &engine,
1572            request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text("one"))]),
1573        )
1574        .await;
1575        drain(
1576            &engine,
1577            request_with_transcript(vec![
1578                TranscriptItem::UserMessage(UserMessage::text("one")),
1579                TranscriptItem::AssistantMessage(AssistantMessage {
1580                    text: "answer".to_string(),
1581                    phase: None,
1582                }),
1583                TranscriptItem::UserMessage(UserMessage::text("two")),
1584            ]),
1585        )
1586        .await;
1587
1588        let calls = calls.lock().unwrap();
1589        assert!(
1590            calls.iter().all(|(resume, _)| resume.is_none()),
1591            "reuse disabled: no turn should resume a session"
1592        );
1593        // Full transcript replayed every turn.
1594        assert!(calls[1].1.contains("answer"));
1595    }
1596
1597    #[test]
1598    fn claude_code_engine_supports_images() {
1599        let engine = ClaudeCodeEngine::new(ClaudeCodeConfig::default());
1600        assert!(engine.capabilities().image_input);
1601
1602        let models = roder_api::catalog::models_for_provider(PROVIDER_CLAUDE_CODE, false);
1603        assert!(!models.is_empty());
1604        for model in models {
1605            let entry = roder_api::catalog::lookup_model_for_provider(PROVIDER_CLAUDE_CODE, &model.id).unwrap();
1606            assert!(entry.supports_images, "model {} must support images", model.id);
1607        }
1608    }
1609
1610    /// Captures the full [`UserMessageInput`] of each CLI invocation so image
1611    /// delivery can be asserted on the structured content blocks.
1612    #[derive(Default)]
1613    struct ContentCapturingRunner {
1614        contents: Arc<StdMutex<Vec<UserMessageInput>>>,
1615    }
1616
1617    #[async_trait::async_trait]
1618    impl ClaudeCodeRunner for ContentCapturingRunner {
1619        async fn stream(
1620            &self,
1621            _options: ClaudeAgentOptions,
1622            content: UserMessageInput,
1623        ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
1624            self.contents.lock().unwrap().push(content);
1625            let (tx, rx) = mpsc::unbounded_channel();
1626            tx.send(StreamEvent::TurnComplete(MessageResponse {
1627                content: String::new(),
1628                blocks: Vec::new(),
1629                model: String::new(),
1630                stop_reason: Some("end_turn".to_string()),
1631                session_id: "session-1".to_string(),
1632                usage: None,
1633            }))
1634            .unwrap();
1635            Ok(rx)
1636        }
1637    }
1638
1639    #[tokio::test]
1640    async fn image_user_message_is_delivered_as_image_block() {
1641        use roder_api::transcript::{InputImage, UserMessage};
1642
1643        let contents = Arc::new(StdMutex::new(Vec::new()));
1644        let engine = ClaudeCodeEngine::new_with_runner(
1645            ClaudeCodeConfig::default(),
1646            Arc::new(ContentCapturingRunner {
1647                contents: Arc::clone(&contents),
1648            }),
1649        );
1650
1651        drain(
1652            &engine,
1653            request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::with_images(
1654                "describe this",
1655                vec![InputImage {
1656                    image_url: "data:image/png;base64,QUJD".to_string(),
1657                }],
1658            ))]),
1659        )
1660        .await;
1661
1662        let contents = contents.lock().unwrap();
1663        let blocks = match &contents[0] {
1664            UserMessageInput::Blocks(blocks) => blocks,
1665            other => panic!("expected image content blocks, got {other:?}"),
1666        };
1667        // First a text block, then the decoded base64 image block.
1668        assert!(matches!(blocks.first(), Some(InputContentBlock::Text { text }) if text.contains("describe this")));
1669        assert!(blocks.iter().any(|block| matches!(
1670            block,
1671            InputContentBlock::Image {
1672                source: ImageSource::Base64 { media_type, data }
1673            } if media_type == "image/png" && data == "QUJD"
1674        )));
1675        // The raw base64 payload must NOT be dumped into the text prompt.
1676        for block in blocks {
1677            if let InputContentBlock::Text { text } = block {
1678                assert!(!text.contains("QUJD"), "base64 leaked into text: {text}");
1679            }
1680        }
1681    }
1682}