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