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::{ClaudeAgentClient, ClaudeAgentOptions, MessageResponse, StreamEvent};
8use roder_api::catalog::{PROVIDER_CLAUDE_CODE, models_for_provider};
9use roder_api::extension::InferenceEngineId;
10use roder_api::inference::{
11    AgentInferenceRequest, CompletionMetadata, InferenceCapabilities, InferenceEngine,
12    InferenceEvent, InferenceEventStream, InferenceFailure, InferenceProviderContext,
13    InferenceProviderMetadata, InferenceTurnContext, MessageDelta, ModelDescriptor,
14    ProviderAuthType, ReasoningDelta, TokenUsage, ToolCallCompleted, ToolCallDelta,
15};
16use roder_api::transcript::TranscriptItem;
17use serde_json::json;
18use tokio::sync::mpsc;
19
20use crate::options::build_options;
21
22#[derive(Debug, Clone, Default)]
23pub struct ClaudeCodeConfig {
24    pub cli_path: Option<String>,
25    pub permission_mode: Option<String>,
26    pub setting_sources: Option<Vec<String>>,
27    pub workspace: Option<PathBuf>,
28    /// Reuse the persisted `claude` CLI session across turns (default: on) so
29    /// the CLI keeps history server-side and auto-compacts it, and Roder only
30    /// sends the new transcript tail each turn. Set to `Some(false)` to force
31    /// the legacy behavior of replaying the full transcript every turn.
32    pub reuse_cli_session: Option<bool>,
33}
34
35impl ClaudeCodeConfig {
36    fn session_reuse_enabled(&self) -> bool {
37        self.reuse_cli_session.unwrap_or(true)
38    }
39}
40
41#[async_trait::async_trait]
42pub trait ClaudeCodeRunner: Send + Sync {
43    async fn stream(
44        &self,
45        options: ClaudeAgentOptions,
46        prompt: String,
47    ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>>;
48}
49
50#[derive(Debug, Default)]
51pub struct SdkClaudeCodeRunner;
52
53#[async_trait::async_trait]
54impl ClaudeCodeRunner for SdkClaudeCodeRunner {
55    async fn stream(
56        &self,
57        options: ClaudeAgentOptions,
58        prompt: String,
59    ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
60        Ok(ClaudeAgentClient::spawn_stream_message(options, prompt))
61    }
62}
63
64/// Tracks the persisted `claude` CLI session so successive turns resume it
65/// instead of replaying the whole transcript. `synced` holds a per-item
66/// fingerprint of the transcript prefix the CLI session is already known to
67/// contain (everything up to and including the last assistant message of the
68/// previous turn). If a later request no longer extends that prefix — e.g.
69/// Roder compacted the transcript or a brand-new conversation started — the
70/// fingerprint check fails and the provider falls back to a fresh full send.
71#[derive(Debug, Default)]
72struct SessionContinuity {
73    session_id: Option<String>,
74    synced: Vec<u64>,
75}
76
77pub struct ClaudeCodeEngine {
78    config: ClaudeCodeConfig,
79    runner: Arc<dyn ClaudeCodeRunner>,
80    continuity: Arc<Mutex<SessionContinuity>>,
81}
82
83impl ClaudeCodeEngine {
84    pub fn new(config: ClaudeCodeConfig) -> Self {
85        Self::new_with_runner(config, Arc::new(SdkClaudeCodeRunner))
86    }
87
88    pub fn new_with_runner(config: ClaudeCodeConfig, runner: Arc<dyn ClaudeCodeRunner>) -> Self {
89        Self {
90            config,
91            runner,
92            continuity: Arc::new(Mutex::new(SessionContinuity::default())),
93        }
94    }
95
96    fn auth_configured(&self) -> bool {
97        self.config
98            .cli_path
99            .as_deref()
100            .map(|value| !value.trim().is_empty())
101            .unwrap_or_else(|| which::which("claude").is_ok())
102    }
103}
104
105#[async_trait::async_trait]
106impl InferenceEngine for ClaudeCodeEngine {
107    fn id(&self) -> InferenceEngineId {
108        PROVIDER_CLAUDE_CODE.to_string()
109    }
110
111    fn capabilities(&self) -> InferenceCapabilities {
112        InferenceCapabilities {
113            streaming: true,
114            tool_calls: true,
115            parallel_tool_calls: false,
116            reasoning_summaries: true,
117            structured_output: false,
118            image_input: false,
119            prompt_cache: false,
120            provider_metadata: true,
121            tool_search: false,
122        }
123    }
124
125    fn metadata(&self) -> InferenceProviderMetadata {
126        InferenceProviderMetadata {
127            name: "Claude Code".to_string(),
128            description: Some("Local Claude Code CLI harness via claude-agent-sdk".to_string()),
129            auth_type: ProviderAuthType::None,
130            auth_label: Some("Authenticated local Claude Code CLI".to_string()),
131            auth_configured: Some(self.auth_configured()),
132            recommended: true,
133            sort_order: 19,
134        }
135    }
136
137    async fn list_models(
138        &self,
139        _ctx: InferenceProviderContext<'_>,
140    ) -> anyhow::Result<Vec<ModelDescriptor>> {
141        Ok(models_for_provider(PROVIDER_CLAUDE_CODE, false))
142    }
143
144    async fn stream_turn(
145        &self,
146        _ctx: InferenceTurnContext<'_>,
147        request: AgentInferenceRequest,
148    ) -> anyhow::Result<InferenceEventStream> {
149        validate_request(&request)?;
150        let current_dir = std::env::current_dir().ok();
151        let cwd = self
152            .config
153            .workspace
154            .as_deref()
155            .or_else(|| current_dir.as_deref());
156        let plan = self.plan_turn(&request);
157        let options = build_options(
158            &self.config,
159            &request,
160            _ctx.tool_executor.clone(),
161            cwd,
162            plan.resume_session_id.as_deref(),
163        )?;
164        let events = self.runner.stream(options, plan.prompt).await?;
165        Ok(Box::pin(map_stream_events(
166            events,
167            Arc::clone(&self.continuity),
168            plan.synced,
169        )))
170    }
171}
172
173/// Outcome of deciding whether the upcoming turn resumes the persisted CLI
174/// session (sending only the new transcript tail) or starts fresh (sending the
175/// whole transcript).
176struct TurnPlan {
177    resume_session_id: Option<String>,
178    prompt: String,
179    /// Fingerprint of the transcript prefix the session will be known to
180    /// contain once this turn is sent. Committed to `SessionContinuity` only
181    /// after the turn completes successfully.
182    synced: Vec<u64>,
183}
184
185impl ClaudeCodeEngine {
186    fn plan_turn(&self, request: &AgentInferenceRequest) -> TurnPlan {
187        let fingerprints = transcript_fingerprints(&request.transcript);
188        // The CLI session always contains everything up to and including the
189        // previous turn's final assistant message; only items after it are new
190        // input the resumed session has not seen yet.
191        let boundary = delta_boundary(&request.transcript);
192
193        if self.config.session_reuse_enabled()
194            && let Ok(state) = self.continuity.lock()
195        {
196            let can_resume = state.session_id.is_some()
197                && boundary > 0
198                && is_prefix(&state.synced, &fingerprints)
199                && boundary <= request.transcript.len();
200            if can_resume {
201                let delta = prompt_from_delta(request, boundary);
202                if !delta.trim().is_empty() {
203                    return TurnPlan {
204                        resume_session_id: state.session_id.clone(),
205                        prompt: delta,
206                        // After this resume the session covers the full current
207                        // transcript (prior items + this new tail).
208                        synced: fingerprints,
209                    };
210                }
211            }
212        }
213
214        // Fresh send: replay the whole transcript and start a new session. The
215        // synced baseline becomes the current transcript so the next turn can
216        // resume from here.
217        TurnPlan {
218            resume_session_id: None,
219            prompt: prompt_from_request(request),
220            synced: fingerprints,
221        }
222    }
223}
224
225fn validate_request(request: &AgentInferenceRequest) -> anyhow::Result<()> {
226    if request.output.response_format.is_some() {
227        anyhow::bail!("Claude Code provider does not support structured response_format yet");
228    }
229    Ok(())
230}
231
232fn prompt_from_request(request: &AgentInferenceRequest) -> String {
233    prompt_from_items(request, &request.transcript)
234}
235
236/// Builds the prompt from only the transcript items at/after `boundary`, used
237/// when resuming a persisted CLI session that already holds the earlier items.
238fn prompt_from_delta(request: &AgentInferenceRequest, boundary: usize) -> String {
239    let tail = request.transcript.get(boundary..).unwrap_or(&[]);
240    prompt_from_items(request, tail)
241}
242
243fn prompt_from_items(request: &AgentInferenceRequest, items: &[TranscriptItem]) -> String {
244    let mut parts = Vec::new();
245    for item in items {
246        // Provider metadata (rate-limit blobs, tool-result echoes) is internal
247        // bookkeeping, not conversational input. Never replay it as a prompt.
248        if matches!(item, TranscriptItem::ProviderMetadata(_)) {
249            continue;
250        }
251        parts.push(format!("{item:?}"));
252    }
253    if let Some(value) = request
254        .metadata
255        .get("prompt")
256        .and_then(|value| value.as_str())
257    {
258        parts.push(value.to_string());
259    }
260    if parts.is_empty() {
261        "Continue the current Roder turn.".to_string()
262    } else {
263        parts.join("\n\n")
264    }
265}
266
267/// Index of the first transcript item the CLI session has NOT seen yet: the
268/// position just after the previous turn's final assistant message. Returns 0
269/// when there is no prior assistant message (first turn -> send everything).
270fn delta_boundary(transcript: &[TranscriptItem]) -> usize {
271    transcript
272        .iter()
273        .rposition(|item| matches!(item, TranscriptItem::AssistantMessage(_)))
274        .map(|index| index + 1)
275        .unwrap_or(0)
276}
277
278fn transcript_fingerprints(transcript: &[TranscriptItem]) -> Vec<u64> {
279    transcript.iter().map(fingerprint_item).collect()
280}
281
282fn fingerprint_item(item: &TranscriptItem) -> u64 {
283    let mut hasher = DefaultHasher::new();
284    format!("{item:?}").hash(&mut hasher);
285    hasher.finish()
286}
287
288/// True when `prefix` is a (non-strict) leading slice of `full`.
289fn is_prefix(prefix: &[u64], full: &[u64]) -> bool {
290    prefix.len() <= full.len() && full[..prefix.len()] == *prefix
291}
292
293fn map_stream_events(
294    mut events: mpsc::UnboundedReceiver<StreamEvent>,
295    continuity: Arc<Mutex<SessionContinuity>>,
296    synced: Vec<u64>,
297) -> InferenceEventStream {
298    Box::pin(try_stream! {
299        let mut saw_partial_text = false;
300        let mut accumulated_text = String::new();
301        // Tracks how far into `accumulated_text` the trailing full-message echo
302        // has been re-matched. Under `include_partial_messages`, the SDK streams
303        // incremental `content_block_delta` chunks and then re-emits the full
304        // text of every block from the final `AssistantMsg`. Those echoes replay
305        // the already-streamed text from the start, one block at a time, so we
306        // walk `accumulated_text` and drop each chunk that matches. Reset to 0 on
307        // any genuinely new text, since echoes only ever arrive contiguously at
308        // the end of the message.
309        let mut echo_match_pos = 0usize;
310        let mut completed = false;
311        let mut last_session_id: Option<String> = None;
312        let mut last_stop_reason: Option<String> = None;
313        // Tool-use ids for `mcp__roder__*` tools. Those tools are executed
314        // in-process by the SDK MCP handler, which routes through Roder's
315        // TurnToolExecutor and emits the canonical tool-call lifecycle events.
316        // The provider must NOT also surface them from the CLI stream: doing so
317        // makes the runtime try to execute a tool literally named
318        // `mcp__roder__read_file` (which is not registered, so it fails) and
319        // produces duplicate, failing rows that trip the reliability limit.
320        let mut mcp_tool_ids: std::collections::HashSet<String> =
321            std::collections::HashSet::new();
322        while let Some(event) = events.recv().await {
323            match event {
324                StreamEvent::ContentChunk(text) => {
325                    if completed {
326                        continue;
327                    }
328                    if !accumulated_text.is_empty()
329                        && echo_match_pos < accumulated_text.len()
330                        && accumulated_text[echo_match_pos..].starts_with(&text)
331                    {
332                        echo_match_pos += text.len();
333                        continue;
334                    }
335                    saw_partial_text = true;
336                    echo_match_pos = 0;
337                    accumulated_text.push_str(&text);
338                    yield InferenceEvent::MessageDelta(MessageDelta { text, phase: None });
339                }
340                StreamEvent::ThinkingChunk { thinking, .. } => {
341                    yield InferenceEvent::ReasoningDelta(ReasoningDelta { text: thinking });
342                }
343                StreamEvent::ToolUseStart { id, name, input } => {
344                    if name.starts_with("mcp__") {
345                        // Executed in-process via the MCP handler; the executor
346                        // owns the canonical tool-call events. Skip emission.
347                        mcp_tool_ids.insert(id);
348                        continue;
349                    }
350                    if !input.is_empty() {
351                        yield InferenceEvent::ToolCallCompleted(ToolCallCompleted {
352                            id,
353                            name,
354                            arguments: serde_json::Value::Object(input).to_string(),
355                        });
356                    }
357                }
358                StreamEvent::ToolUseDelta { id, partial_input } => {
359                    if mcp_tool_ids.contains(&id) {
360                        continue;
361                    }
362                    yield InferenceEvent::ToolCallDelta(ToolCallDelta { id, arguments_delta: partial_input });
363                }
364                StreamEvent::ToolResult { tool_use_id, content, is_error } => {
365                    yield InferenceEvent::ProviderMetadata(json!({
366                        "provider": PROVIDER_CLAUDE_CODE,
367                        "toolResult": {
368                            "toolUseId": tool_use_id,
369                            "isError": is_error.unwrap_or(false),
370                            "content": content.unwrap_or(serde_json::Value::Null),
371                        }
372                    }));
373                }
374                StreamEvent::RateLimit(info) => {
375                    yield InferenceEvent::ProviderMetadata(json!({
376                        "provider": PROVIDER_CLAUDE_CODE,
377                        "rateLimit": info,
378                    }));
379                }
380                StreamEvent::Complete(response) => {
381                    // One assistant message (or a usage-bearing message delta)
382                    // finished. A turn with tool calls produces several of
383                    // these — the turn only ends at TurnComplete below.
384                    if completed {
385                        continue;
386                    }
387                    if let Some(usage) = usage_from_response(&response) {
388                        yield InferenceEvent::Usage(usage);
389                    }
390                    if response.content.is_empty() {
391                        continue;
392                    }
393                    if !saw_partial_text {
394                        yield InferenceEvent::MessageDelta(MessageDelta { text: response.content.clone(), phase: None });
395                    }
396                    last_session_id = Some(response.session_id);
397                    last_stop_reason = response.stop_reason;
398                    // Assistant-message boundary: the next message streams its
399                    // own deltas and echoes, so reset the echo-dedup state.
400                    accumulated_text.clear();
401                    echo_match_pos = 0;
402                }
403                StreamEvent::TurnComplete(response) => {
404                    if completed {
405                        continue;
406                    }
407                    if let Some(usage) = usage_from_response(&response) {
408                        yield InferenceEvent::Usage(usage);
409                    }
410                    completed = true;
411                    let session_id = (!response.session_id.trim().is_empty())
412                        .then_some(response.session_id)
413                        .or(last_session_id.take());
414                    commit_session(&continuity, session_id.as_deref(), &synced);
415                    yield InferenceEvent::Completed(CompletionMetadata {
416                        stop_reason: response.stop_reason.or(last_stop_reason.take()),
417                        provider_response_id: session_id,
418                    });
419                }
420                StreamEvent::Error(message) => {
421                    // The turn failed (possibly a stale/invalid resume). Drop the
422                    // session so the next attempt replays the full transcript.
423                    clear_session(&continuity);
424                    yield InferenceEvent::Failed(InferenceFailure { message: redact_error(&message) });
425                }
426            }
427        }
428        // The CLI stream ended without a result message (e.g. the process
429        // died after its last assistant message). Close the turn with what
430        // we saw so the runtime never hangs waiting for completion.
431        if !completed && (last_session_id.is_some() || last_stop_reason.is_some()) {
432            commit_session(&continuity, last_session_id.as_deref(), &synced);
433            yield InferenceEvent::Completed(CompletionMetadata {
434                stop_reason: last_stop_reason,
435                provider_response_id: last_session_id,
436            });
437        }
438    })
439}
440
441/// Records the resumable session id and the transcript prefix it now covers so
442/// the next turn can resume instead of replaying the full transcript.
443fn commit_session(
444    continuity: &Arc<Mutex<SessionContinuity>>,
445    session_id: Option<&str>,
446    synced: &[u64],
447) {
448    let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) else {
449        // No usable session id: force a fresh send next turn rather than
450        // resuming against a session we cannot name.
451        clear_session(continuity);
452        return;
453    };
454    if let Ok(mut state) = continuity.lock() {
455        state.session_id = Some(session_id.to_string());
456        state.synced = synced.to_vec();
457    }
458}
459
460fn clear_session(continuity: &Arc<Mutex<SessionContinuity>>) {
461    if let Ok(mut state) = continuity.lock() {
462        state.session_id = None;
463        state.synced.clear();
464    }
465}
466
467fn usage_from_response(response: &MessageResponse) -> Option<TokenUsage> {
468    let usage = response.usage.as_ref()?;
469    let prompt_tokens = number_from_usage(
470        usage,
471        &[
472            "input_tokens",
473            "inputTokens",
474            "prompt_tokens",
475            "promptTokens",
476            "cache_creation_input_tokens",
477            "cacheCreationInputTokens",
478            "cache_read_input_tokens",
479            "cacheReadInputTokens",
480        ],
481    )
482    .unwrap_or(0);
483    let cached_prompt_tokens =
484        number_from_usage(usage, &["cache_read_input_tokens", "cacheReadInputTokens"]);
485    let cache_creation_prompt_tokens = number_from_usage(
486        usage,
487        &["cache_creation_input_tokens", "cacheCreationInputTokens"],
488    );
489    let completion_tokens = number_from_usage(
490        usage,
491        &[
492            "output_tokens",
493            "outputTokens",
494            "completion_tokens",
495            "completionTokens",
496        ],
497    )
498    .unwrap_or(0);
499    let total_tokens = number_from_usage(usage, &["total_tokens", "totalTokens"])
500        .unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens));
501    if prompt_tokens == 0 && completion_tokens == 0 && total_tokens == 0 {
502        return None;
503    }
504    Some(
505        TokenUsage::new(prompt_tokens, completion_tokens, total_tokens)
506            .with_cached_prompt_tokens(cached_prompt_tokens.unwrap_or(0))
507            .with_cache_creation_prompt_tokens(cache_creation_prompt_tokens.unwrap_or(0)),
508    )
509}
510
511fn number_from_usage(
512    usage: &std::collections::HashMap<String, serde_json::Value>,
513    keys: &[&str],
514) -> Option<u32> {
515    let total = usage.iter().fold(0u32, |total, (key, value)| {
516        total.saturating_add(number_from_usage_value(key, value, keys))
517    });
518    (total > 0).then_some(total)
519}
520
521fn number_from_usage_value(key: &str, value: &serde_json::Value, keys: &[&str]) -> u32 {
522    let direct = if keys.iter().any(|candidate| candidate == &key) {
523        json_u32(value).unwrap_or(0)
524    } else {
525        0
526    };
527    let nested = value
528        .as_object()
529        .map(|object| {
530            object.iter().fold(0u32, |total, (key, value)| {
531                total.saturating_add(number_from_usage_value(key, value, keys))
532            })
533        })
534        .unwrap_or(0);
535    direct.saturating_add(nested)
536}
537
538fn json_u32(value: &serde_json::Value) -> Option<u32> {
539    value
540        .as_u64()
541        .and_then(|value| u32::try_from(value).ok())
542        .or_else(|| value.as_i64().and_then(|value| u32::try_from(value).ok()))
543        .or_else(|| {
544            value
545                .as_f64()
546                .filter(|value| value.is_finite() && *value >= 0.0)
547                .and_then(|value| u32::try_from(value as u64).ok())
548        })
549        .or_else(|| value.as_str().and_then(|value| value.parse::<u32>().ok()))
550}
551
552fn redact_error(message: &str) -> String {
553    let mut redacted = message.to_string();
554    for marker in ["ANTHROPIC_API_KEY", "CLAUDE_CODE", "Bearer "] {
555        if redacted.contains(marker) {
556            redacted = redacted.replace(marker, "[redacted]");
557        }
558    }
559    redacted
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use claude_code_sdk_rust::types::ContentBlock;
566    use futures::StreamExt;
567    use roder_api::inference::{
568        HostedWebSearchConfig, InstructionBundle, ModelSelection, OutputConfig, ReasoningConfig,
569        RuntimeHints,
570    };
571    use roder_api::tools::ToolChoice;
572
573    #[derive(Default)]
574    struct FakeRunner {
575        events: Vec<StreamEvent>,
576    }
577
578    #[async_trait::async_trait]
579    impl ClaudeCodeRunner for FakeRunner {
580        async fn stream(
581            &self,
582            _options: ClaudeAgentOptions,
583            _prompt: String,
584        ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
585            let (tx, rx) = mpsc::unbounded_channel();
586            for event in self.events.clone() {
587                tx.send(event).unwrap();
588            }
589            Ok(rx)
590        }
591    }
592
593    fn request() -> AgentInferenceRequest {
594        AgentInferenceRequest {
595            model: ModelSelection {
596                provider: PROVIDER_CLAUDE_CODE.to_string(),
597                model: "sonnet".to_string(),
598            },
599            instructions: InstructionBundle::default(),
600            transcript: Vec::new(),
601            tools: Vec::new(),
602            tool_choice: ToolChoice::Auto,
603            reasoning: ReasoningConfig::default(),
604            output: OutputConfig::default(),
605            runtime: RuntimeHints {
606                hosted_web_search: HostedWebSearchConfig::disabled(),
607                ..RuntimeHints::default()
608            },
609            metadata: json!({"prompt": "hello"}),
610        }
611    }
612
613    #[tokio::test]
614    async fn provider_streams_text_and_completion() {
615        let mut usage = std::collections::HashMap::new();
616        usage.insert("input_tokens".to_string(), json!(3));
617        usage.insert("output_tokens".to_string(), json!(5));
618        let engine = ClaudeCodeEngine::new_with_runner(
619            ClaudeCodeConfig::default(),
620            Arc::new(FakeRunner {
621                events: vec![
622                    StreamEvent::ContentChunk("hello".to_string()),
623                    StreamEvent::Complete(MessageResponse {
624                        content: "hello".to_string(),
625                        blocks: vec![ContentBlock::Text {
626                            text: "hello".to_string(),
627                        }],
628                        model: "sonnet".to_string(),
629                        stop_reason: Some("end_turn".to_string()),
630                        session_id: "session-1".to_string(),
631                        usage: Some(usage),
632                    }),
633                    StreamEvent::TurnComplete(MessageResponse {
634                        content: String::new(),
635                        blocks: Vec::new(),
636                        model: String::new(),
637                        stop_reason: Some("end_turn".to_string()),
638                        session_id: "session-1".to_string(),
639                        usage: None,
640                    }),
641                ],
642            }),
643        );
644        let events = engine
645            .stream_turn(
646                InferenceTurnContext {
647                    thread_id: "thread",
648                    turn_id: "turn",
649                    tool_executor: None,
650                },
651                request(),
652            )
653            .await
654            .unwrap()
655            .collect::<Vec<_>>()
656            .await
657            .into_iter()
658            .collect::<Result<Vec<_>, _>>()
659            .unwrap();
660        assert!(matches!(events[0], InferenceEvent::MessageDelta(_)));
661        assert!(
662            events
663                .iter()
664                .any(|event| matches!(event, InferenceEvent::Usage(_)))
665        );
666        assert!(
667            events
668                .iter()
669                .any(|event| matches!(event, InferenceEvent::Completed(_)))
670        );
671    }
672
673    #[tokio::test]
674    async fn provider_keeps_partial_text_and_uses_later_usage_completion() {
675        let mut usage = std::collections::HashMap::new();
676        usage.insert("input_tokens".to_string(), json!(4));
677        usage.insert("cache_read_input_tokens".to_string(), json!(6));
678        usage.insert("output_tokens".to_string(), json!(8));
679        let engine = ClaudeCodeEngine::new_with_runner(
680            ClaudeCodeConfig::default(),
681            Arc::new(FakeRunner {
682                events: vec![
683                    StreamEvent::ContentChunk("one ".to_string()),
684                    StreamEvent::ContentChunk("two".to_string()),
685                    StreamEvent::Complete(MessageResponse {
686                        content: String::new(),
687                        blocks: Vec::new(),
688                        model: String::new(),
689                        stop_reason: Some("end_turn".to_string()),
690                        session_id: "session-usage".to_string(),
691                        usage: Some(usage),
692                    }),
693                    StreamEvent::ContentChunk("one two".to_string()),
694                    StreamEvent::Complete(MessageResponse {
695                        content: "one two".to_string(),
696                        blocks: Vec::new(),
697                        model: "sonnet".to_string(),
698                        stop_reason: Some("end_turn".to_string()),
699                        session_id: "session-final".to_string(),
700                        usage: None,
701                    }),
702                    StreamEvent::TurnComplete(MessageResponse {
703                        content: String::new(),
704                        blocks: Vec::new(),
705                        model: String::new(),
706                        stop_reason: Some("end_turn".to_string()),
707                        session_id: "session-final".to_string(),
708                        usage: None,
709                    }),
710                ],
711            }),
712        );
713        let events = engine
714            .stream_turn(
715                InferenceTurnContext {
716                    thread_id: "thread",
717                    turn_id: "turn",
718                    tool_executor: None,
719                },
720                request(),
721            )
722            .await
723            .unwrap()
724            .collect::<Vec<_>>()
725            .await
726            .into_iter()
727            .collect::<Result<Vec<_>, _>>()
728            .unwrap();
729        let rendered = events
730            .iter()
731            .filter_map(|event| match event {
732                InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
733                _ => None,
734            })
735            .collect::<String>();
736        assert_eq!(rendered, "one two");
737        assert!(events.iter().any(|event| matches!(event, InferenceEvent::Usage(usage) if usage.prompt_tokens == 10 && usage.completion_tokens == 8)));
738        assert!(
739            events
740                .iter()
741                .any(|event| matches!(event, InferenceEvent::Completed(_)))
742        );
743    }
744
745    /// Mirrors the real SDK sequence under `include_partial_messages(true)`:
746    /// the CLI first streams incremental `content_block_delta` chunks, then the
747    /// final `AssistantMsg` re-emits the FULL text of every text block. When an
748    /// assistant message contains more than one text block (text -> tool_use ->
749    /// text), the redundant full-block echoes must not be rendered again.
750    #[tokio::test]
751    async fn provider_does_not_duplicate_multi_block_commentary() {
752        let engine = ClaudeCodeEngine::new_with_runner(
753            ClaudeCodeConfig::default(),
754            Arc::new(FakeRunner {
755                events: vec![
756                    // Incremental deltas as the message streams in.
757                    StreamEvent::ContentChunk("First ".to_string()),
758                    StreamEvent::ContentChunk("block.".to_string()),
759                    StreamEvent::ContentChunk("Second ".to_string()),
760                    StreamEvent::ContentChunk("block.".to_string()),
761                    // Final AssistantMsg re-emits the full text of each block.
762                    StreamEvent::ContentChunk("First block.".to_string()),
763                    StreamEvent::ContentChunk("Second block.".to_string()),
764                    StreamEvent::Complete(MessageResponse {
765                        content: "First block.Second block.".to_string(),
766                        blocks: Vec::new(),
767                        model: "sonnet".to_string(),
768                        stop_reason: Some("end_turn".to_string()),
769                        session_id: "session-final".to_string(),
770                        usage: None,
771                    }),
772                    StreamEvent::TurnComplete(MessageResponse {
773                        content: String::new(),
774                        blocks: Vec::new(),
775                        model: String::new(),
776                        stop_reason: Some("end_turn".to_string()),
777                        session_id: "session-final".to_string(),
778                        usage: None,
779                    }),
780                ],
781            }),
782        );
783        let events = engine
784            .stream_turn(
785                InferenceTurnContext {
786                    thread_id: "thread",
787                    turn_id: "turn",
788                    tool_executor: None,
789                },
790                request(),
791            )
792            .await
793            .unwrap()
794            .collect::<Vec<_>>()
795            .await
796            .into_iter()
797            .collect::<Result<Vec<_>, _>>()
798            .unwrap();
799        let rendered = events
800            .iter()
801            .filter_map(|event| match event {
802                InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
803                _ => None,
804            })
805            .collect::<String>();
806        assert_eq!(rendered, "First block.Second block.");
807    }
808
809    /// Fable 5 (and Opus 4.8) narrate before tool calls. A turn then contains
810    /// MULTIPLE assistant messages: "I'll read the file." -> tool_use ->
811    /// "The answer is X." Each assistant message emits its own Complete; the
812    /// turn must only end at TurnComplete, and the post-tool text must not be
813    /// dropped.
814    #[tokio::test]
815    async fn provider_streams_text_after_tool_use_until_turn_complete() {
816        let engine = ClaudeCodeEngine::new_with_runner(
817            ClaudeCodeConfig::default(),
818            Arc::new(FakeRunner {
819                events: vec![
820                    // First assistant message: pre-tool narration.
821                    StreamEvent::ContentChunk("I'll read the file.".to_string()),
822                    // Echo of the full block from the final AssistantMsg.
823                    StreamEvent::ContentChunk("I'll read the file.".to_string()),
824                    StreamEvent::Complete(MessageResponse {
825                        content: "I'll read the file.".to_string(),
826                        blocks: Vec::new(),
827                        model: "claude-fable-5".to_string(),
828                        stop_reason: None,
829                        session_id: "session-1".to_string(),
830                        usage: None,
831                    }),
832                    // Tool runs (mcp tool -- suppressed lifecycle).
833                    StreamEvent::ToolUseStart {
834                        id: "toolu_1".to_string(),
835                        name: "mcp__roder__read_file".to_string(),
836                        input: serde_json::Map::new(),
837                    },
838                    // Second assistant message: the actual answer.
839                    StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
840                    StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
841                    StreamEvent::Complete(MessageResponse {
842                        content: "The magic word is pomegranate.".to_string(),
843                        blocks: Vec::new(),
844                        model: "claude-fable-5".to_string(),
845                        stop_reason: Some("end_turn".to_string()),
846                        session_id: "session-1".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-1".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!(
884            rendered, "I'll read the file.The magic word is pomegranate.",
885            "post-tool assistant text must stream and echoes must dedupe"
886        );
887        let completions = events
888            .iter()
889            .filter(|event| matches!(event, InferenceEvent::Completed(_)))
890            .count();
891        assert_eq!(completions, 1, "turn must complete exactly once");
892    }
893
894    #[tokio::test]
895    async fn provider_completes_turn_when_stream_ends_without_result() {
896        let engine = ClaudeCodeEngine::new_with_runner(
897            ClaudeCodeConfig::default(),
898            Arc::new(FakeRunner {
899                events: vec![
900                    StreamEvent::ContentChunk("partial".to_string()),
901                    StreamEvent::Complete(MessageResponse {
902                        content: "partial".to_string(),
903                        blocks: Vec::new(),
904                        model: "claude-fable-5".to_string(),
905                        stop_reason: Some("end_turn".to_string()),
906                        session_id: "session-dead".to_string(),
907                        usage: None,
908                    }),
909                    // CLI dies here -- no TurnComplete.
910                ],
911            }),
912        );
913        let events = engine
914            .stream_turn(
915                InferenceTurnContext {
916                    thread_id: "thread",
917                    turn_id: "turn",
918                    tool_executor: None,
919                },
920                request(),
921            )
922            .await
923            .unwrap()
924            .collect::<Vec<_>>()
925            .await
926            .into_iter()
927            .collect::<Result<Vec<_>, _>>()
928            .unwrap();
929        assert!(
930            events
931                .iter()
932                .any(|event| matches!(event, InferenceEvent::Completed(_))),
933            "stream end without a result message must still complete the turn"
934        );
935    }
936
937    #[tokio::test]
938    async fn provider_maps_tool_events() {
939        let engine = ClaudeCodeEngine::new_with_runner(
940            ClaudeCodeConfig::default(),
941            Arc::new(FakeRunner {
942                events: vec![
943                    StreamEvent::ToolUseStart {
944                        id: "toolu_1".to_string(),
945                        name: "Read".to_string(),
946                        input: serde_json::Map::from_iter([(
947                            "path".to_string(),
948                            json!("crates/roder-ext-claude-code"),
949                        )]),
950                    },
951                    StreamEvent::ToolUseDelta {
952                        id: "toolu_1".to_string(),
953                        partial_input: "{\"file_path\"".to_string(),
954                    },
955                ],
956            }),
957        );
958        let events = engine
959            .stream_turn(
960                InferenceTurnContext {
961                    thread_id: "thread",
962                    turn_id: "turn",
963                    tool_executor: None,
964                },
965                request(),
966            )
967            .await
968            .unwrap()
969            .collect::<Vec<_>>()
970            .await
971            .into_iter()
972            .collect::<Result<Vec<_>, _>>()
973            .unwrap();
974        assert!(
975            matches!(&events[0], InferenceEvent::ToolCallCompleted(call) if call.arguments.contains("roder-ext-claude-code"))
976        );
977        assert!(matches!(events[1], InferenceEvent::ToolCallDelta(_)));
978    }
979
980    /// `mcp__roder__*` tools are executed in-process by the SDK MCP handler,
981    /// which routes through Roder's executor and emits the canonical tool-call
982    /// events. The provider must NOT re-surface those tool calls from the CLI
983    /// stream, or the runtime would try to execute an unregistered tool named
984    /// `mcp__roder__read_file` and record a spurious failure.
985    #[tokio::test]
986    async fn provider_suppresses_mcp_tool_lifecycle_events() {
987        let engine = ClaudeCodeEngine::new_with_runner(
988            ClaudeCodeConfig::default(),
989            Arc::new(FakeRunner {
990                events: vec![
991                    StreamEvent::ToolUseStart {
992                        id: "toolu_mcp".to_string(),
993                        name: "mcp__roder__read_file".to_string(),
994                        input: serde_json::Map::from_iter([(
995                            "path".to_string(),
996                            json!("README.md"),
997                        )]),
998                    },
999                    StreamEvent::ToolUseDelta {
1000                        id: "toolu_mcp".to_string(),
1001                        partial_input: "{\"path\"".to_string(),
1002                    },
1003                    StreamEvent::Complete(MessageResponse {
1004                        content: "done".to_string(),
1005                        blocks: Vec::new(),
1006                        model: "sonnet".to_string(),
1007                        stop_reason: Some("end_turn".to_string()),
1008                        session_id: "session".to_string(),
1009                        usage: None,
1010                    }),
1011                ],
1012            }),
1013        );
1014        let events = engine
1015            .stream_turn(
1016                InferenceTurnContext {
1017                    thread_id: "thread",
1018                    turn_id: "turn",
1019                    tool_executor: None,
1020                },
1021                request(),
1022            )
1023            .await
1024            .unwrap()
1025            .collect::<Vec<_>>()
1026            .await
1027            .into_iter()
1028            .collect::<Result<Vec<_>, _>>()
1029            .unwrap();
1030        assert!(
1031            !events.iter().any(|event| matches!(
1032                event,
1033                InferenceEvent::ToolCallCompleted(_) | InferenceEvent::ToolCallDelta(_)
1034            )),
1035            "mcp__ tool lifecycle events must not be surfaced from the CLI stream"
1036        );
1037    }
1038
1039    #[test]
1040    fn usage_parser_reports_cached_prompt_tokens_as_subset() {
1041        let usage = std::collections::HashMap::from([
1042            ("input_tokens".to_string(), json!(123)),
1043            ("cache_creation_input_tokens".to_string(), json!(7)),
1044            ("cache_read_input_tokens".to_string(), json!(11)),
1045            ("output_tokens".to_string(), json!(13)),
1046        ]);
1047        let response = MessageResponse {
1048            content: String::new(),
1049            blocks: Vec::new(),
1050            model: "sonnet".to_string(),
1051            stop_reason: Some("end_turn".to_string()),
1052            session_id: "session".to_string(),
1053            usage: Some(usage),
1054        };
1055
1056        let usage = usage_from_response(&response).unwrap();
1057        assert_eq!(usage.prompt_tokens, 141);
1058        assert_eq!(usage.cached_prompt_tokens, 11);
1059        assert_eq!(usage.cache_creation_prompt_tokens, 7);
1060        assert_eq!(usage.completion_tokens, 13);
1061        assert_eq!(usage.total_tokens, 154);
1062    }
1063
1064    #[test]
1065    fn usage_parser_includes_cache_only_input_tokens_in_prompt_total() {
1066        let usage = std::collections::HashMap::from([
1067            ("cache_creation_input_tokens".to_string(), json!(7)),
1068            ("cache_read_input_tokens".to_string(), json!(11)),
1069            ("output_tokens".to_string(), json!(13)),
1070        ]);
1071        let response = MessageResponse {
1072            content: String::new(),
1073            blocks: Vec::new(),
1074            model: "sonnet".to_string(),
1075            stop_reason: Some("end_turn".to_string()),
1076            session_id: "session".to_string(),
1077            usage: Some(usage),
1078        };
1079
1080        let usage = usage_from_response(&response).unwrap();
1081        assert_eq!(usage.prompt_tokens, 18);
1082        assert_eq!(usage.cached_prompt_tokens, 11);
1083        assert_eq!(usage.cache_creation_prompt_tokens, 7);
1084        assert_eq!(usage.completion_tokens, 13);
1085        assert_eq!(usage.total_tokens, 31);
1086    }
1087
1088    #[test]
1089    fn usage_parser_accepts_output_only_delta_usage() {
1090        let response = MessageResponse {
1091            content: String::new(),
1092            blocks: Vec::new(),
1093            model: "sonnet".to_string(),
1094            stop_reason: Some("end_turn".to_string()),
1095            session_id: "session".to_string(),
1096            usage: Some(std::collections::HashMap::from([(
1097                "output_tokens".to_string(),
1098                json!(42),
1099            )])),
1100        };
1101
1102        let usage = usage_from_response(&response).unwrap();
1103        assert_eq!(usage.prompt_tokens, 0);
1104        assert_eq!(usage.completion_tokens, 42);
1105        assert_eq!(usage.total_tokens, 42);
1106    }
1107
1108    #[test]
1109    fn usage_parser_accepts_nested_camel_case_model_usage() {
1110        let response = MessageResponse {
1111            content: String::new(),
1112            blocks: Vec::new(),
1113            model: "sonnet".to_string(),
1114            stop_reason: Some("end_turn".to_string()),
1115            session_id: "session".to_string(),
1116            usage: Some(std::collections::HashMap::from([(
1117                "modelUsage".to_string(),
1118                json!({
1119                    "inputTokens": "100",
1120                    "cacheReadInputTokens": 25.0,
1121                    "outputTokens": 7,
1122                }),
1123            )])),
1124        };
1125
1126        let usage = usage_from_response(&response).unwrap();
1127        assert_eq!(usage.prompt_tokens, 125);
1128        assert_eq!(usage.cached_prompt_tokens, 25);
1129        assert_eq!(usage.completion_tokens, 7);
1130        assert_eq!(usage.total_tokens, 132);
1131    }
1132
1133    use roder_api::transcript::{AssistantMessage, UserMessage};
1134    use std::sync::Mutex as StdMutex;
1135
1136    /// `(resume_session_id, prompt)` captured for each CLI invocation.
1137    type RecordedCall = (Option<String>, String);
1138
1139    /// Records the `resume` id and prompt of every CLI invocation and always
1140    /// completes the turn with the configured session id.
1141    #[derive(Default)]
1142    struct RecordingRunner {
1143        session_id: String,
1144        calls: Arc<StdMutex<Vec<RecordedCall>>>,
1145    }
1146
1147    #[async_trait::async_trait]
1148    impl ClaudeCodeRunner for RecordingRunner {
1149        async fn stream(
1150            &self,
1151            options: ClaudeAgentOptions,
1152            prompt: String,
1153        ) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
1154            self.calls
1155                .lock()
1156                .unwrap()
1157                .push((options.resume.clone(), prompt));
1158            let (tx, rx) = mpsc::unbounded_channel();
1159            tx.send(StreamEvent::Complete(MessageResponse {
1160                content: "ok".to_string(),
1161                blocks: Vec::new(),
1162                model: "sonnet".to_string(),
1163                stop_reason: Some("end_turn".to_string()),
1164                session_id: self.session_id.clone(),
1165                usage: None,
1166            }))
1167            .unwrap();
1168            tx.send(StreamEvent::TurnComplete(MessageResponse {
1169                content: String::new(),
1170                blocks: Vec::new(),
1171                model: String::new(),
1172                stop_reason: Some("end_turn".to_string()),
1173                session_id: self.session_id.clone(),
1174                usage: None,
1175            }))
1176            .unwrap();
1177            Ok(rx)
1178        }
1179    }
1180
1181    fn request_with_transcript(transcript: Vec<TranscriptItem>) -> AgentInferenceRequest {
1182        AgentInferenceRequest {
1183            transcript,
1184            metadata: json!({}),
1185            ..request()
1186        }
1187    }
1188
1189    async fn drain(engine: &ClaudeCodeEngine, request: AgentInferenceRequest) {
1190        let _ = engine
1191            .stream_turn(
1192                InferenceTurnContext {
1193                    thread_id: "thread",
1194                    turn_id: "turn",
1195                    tool_executor: None,
1196                },
1197                request,
1198            )
1199            .await
1200            .unwrap()
1201            .collect::<Vec<_>>()
1202            .await;
1203    }
1204
1205    #[tokio::test]
1206    async fn second_turn_resumes_session_and_sends_only_the_new_tail() {
1207        let calls = Arc::new(StdMutex::new(Vec::new()));
1208        let engine = ClaudeCodeEngine::new_with_runner(
1209            ClaudeCodeConfig::default(),
1210            Arc::new(RecordingRunner {
1211                session_id: "session-1".to_string(),
1212                calls: Arc::clone(&calls),
1213            }),
1214        );
1215
1216        // Turn 1: only a user message -> fresh send, no resume.
1217        drain(
1218            &engine,
1219            request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text(
1220                "first question",
1221            ))]),
1222        )
1223        .await;
1224
1225        // Turn 2: prior exchange plus a new user message. The session already
1226        // holds everything up to the assistant reply, so only the new user
1227        // message should be sent, and the call must resume session-1.
1228        drain(
1229            &engine,
1230            request_with_transcript(vec![
1231                TranscriptItem::UserMessage(UserMessage::text("first question")),
1232                TranscriptItem::AssistantMessage(AssistantMessage {
1233                    text: "first answer".to_string(),
1234                    phase: None,
1235                }),
1236                TranscriptItem::UserMessage(UserMessage::text("second question")),
1237            ]),
1238        )
1239        .await;
1240
1241        let calls = calls.lock().unwrap();
1242        assert_eq!(calls.len(), 2);
1243        // Turn 1: fresh, replays the whole transcript.
1244        assert_eq!(calls[0].0, None);
1245        assert!(calls[0].1.contains("first question"));
1246        // Turn 2: resumes and sends ONLY the new tail.
1247        assert_eq!(calls[1].0.as_deref(), Some("session-1"));
1248        assert!(calls[1].1.contains("second question"));
1249        assert!(
1250            !calls[1].1.contains("first answer"),
1251            "resumed turn must not replay prior assistant output: {}",
1252            calls[1].1
1253        );
1254        assert!(
1255            !calls[1].1.contains("first question"),
1256            "resumed turn must not replay the prior user message: {}",
1257            calls[1].1
1258        );
1259    }
1260
1261    #[tokio::test]
1262    async fn compacted_transcript_falls_back_to_a_fresh_send() {
1263        let calls = Arc::new(StdMutex::new(Vec::new()));
1264        let engine = ClaudeCodeEngine::new_with_runner(
1265            ClaudeCodeConfig::default(),
1266            Arc::new(RecordingRunner {
1267                session_id: "session-1".to_string(),
1268                calls: Arc::clone(&calls),
1269            }),
1270        );
1271
1272        drain(
1273            &engine,
1274            request_with_transcript(vec![
1275                TranscriptItem::UserMessage(UserMessage::text("original question")),
1276                TranscriptItem::AssistantMessage(AssistantMessage {
1277                    text: "original answer".to_string(),
1278                    phase: None,
1279                }),
1280            ]),
1281        )
1282        .await;
1283
1284        // Roder compacted the head: the prefix no longer matches what the
1285        // session was synced to, so the next turn must NOT resume.
1286        drain(
1287            &engine,
1288            request_with_transcript(vec![
1289                TranscriptItem::ContextCompaction(roder_api::transcript::ContextCompactionRecord {
1290                    summary: "summary of earlier turns".to_string(),
1291                }),
1292                TranscriptItem::UserMessage(UserMessage::text("next question")),
1293            ]),
1294        )
1295        .await;
1296
1297        let calls = calls.lock().unwrap();
1298        assert_eq!(calls[1].0, None, "compacted transcript must send fresh");
1299        assert!(calls[1].1.contains("summary of earlier turns"));
1300    }
1301
1302    #[tokio::test]
1303    async fn session_reuse_can_be_disabled_via_config() {
1304        let calls = Arc::new(StdMutex::new(Vec::new()));
1305        let engine = ClaudeCodeEngine::new_with_runner(
1306            ClaudeCodeConfig {
1307                reuse_cli_session: Some(false),
1308                ..ClaudeCodeConfig::default()
1309            },
1310            Arc::new(RecordingRunner {
1311                session_id: "session-1".to_string(),
1312                calls: Arc::clone(&calls),
1313            }),
1314        );
1315
1316        drain(
1317            &engine,
1318            request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text("one"))]),
1319        )
1320        .await;
1321        drain(
1322            &engine,
1323            request_with_transcript(vec![
1324                TranscriptItem::UserMessage(UserMessage::text("one")),
1325                TranscriptItem::AssistantMessage(AssistantMessage {
1326                    text: "answer".to_string(),
1327                    phase: None,
1328                }),
1329                TranscriptItem::UserMessage(UserMessage::text("two")),
1330            ]),
1331        )
1332        .await;
1333
1334        let calls = calls.lock().unwrap();
1335        assert!(
1336            calls.iter().all(|(resume, _)| resume.is_none()),
1337            "reuse disabled: no turn should resume a session"
1338        );
1339        // Full transcript replayed every turn.
1340        assert!(calls[1].1.contains("answer"));
1341    }
1342}