use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use async_stream::try_stream;
use claude_code_sdk_rust::{
ClaudeAgentClient, ClaudeAgentOptions, ImageSource, InputContentBlock, MessageResponse,
SpawnedStreamCleanup, StreamEvent, UserMessageInput,
};
use roder_api::catalog::{PROVIDER_CLAUDE_CODE, models_for_provider};
use roder_api::extension::InferenceEngineId;
use roder_api::inference::{
AgentInferenceRequest, CompletionMetadata, HostedToolCallCompleted, HostedToolCallStarted,
InferenceCapabilities, InferenceEngine, InferenceEvent, InferenceEventStream, InferenceFailure,
InferenceProviderContext, InferenceProviderMetadata, InferenceTurnContext, MessageDelta,
ModelDescriptor, ProviderAuthType, ProviderTurnCleanup, ReasoningDelta, TokenUsage,
ToolCallCompleted, ToolCallDelta,
};
use roder_api::lifecycle::TurnCleanupOwnership;
use roder_api::transcript::TranscriptItem;
use serde_json::json;
use tokio::sync::mpsc;
use crate::options::build_options;
#[derive(Debug, Clone, Default)]
pub struct ClaudeCodeConfig {
pub cli_path: Option<String>,
pub permission_mode: Option<String>,
pub setting_sources: Option<Vec<String>>,
pub workspace: Option<PathBuf>,
pub reuse_cli_session: Option<bool>,
pub enable_claude_in_chrome: Option<bool>,
}
impl ClaudeCodeConfig {
fn session_reuse_enabled(&self) -> bool {
self.reuse_cli_session.unwrap_or(true)
}
}
#[async_trait::async_trait]
pub trait ClaudeCodeRunner: Send + Sync {
async fn stream(
&self,
options: ClaudeAgentOptions,
content: UserMessageInput,
) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>>;
async fn stream_with_cleanup(
&self,
options: ClaudeAgentOptions,
content: UserMessageInput,
) -> anyhow::Result<ClaudeCodeStream> {
Ok(ClaudeCodeStream {
events: self.stream(options, content).await?,
cleanup: None,
})
}
}
pub struct ClaudeCodeStream {
events: mpsc::UnboundedReceiver<StreamEvent>,
cleanup: Option<Arc<dyn ProviderTurnCleanup>>,
}
#[derive(Debug, Default)]
pub struct SdkClaudeCodeRunner;
#[async_trait::async_trait]
impl ClaudeCodeRunner for SdkClaudeCodeRunner {
async fn stream(
&self,
options: ClaudeAgentOptions,
content: UserMessageInput,
) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
Ok(ClaudeAgentClient::spawn_stream_message(options, content))
}
async fn stream_with_cleanup(
&self,
options: ClaudeAgentOptions,
content: UserMessageInput,
) -> anyhow::Result<ClaudeCodeStream> {
let stream = ClaudeAgentClient::spawn_stream_message_supervised(options, content);
Ok(ClaudeCodeStream {
events: stream.events,
cleanup: Some(Arc::new(SdkClaudeCodeCleanup {
cleanup: stream.cleanup,
})),
})
}
}
#[derive(Clone)]
struct SdkClaudeCodeCleanup {
cleanup: SpawnedStreamCleanup,
}
#[async_trait::async_trait]
impl ProviderTurnCleanup for SdkClaudeCodeCleanup {
fn ownership(&self) -> TurnCleanupOwnership {
TurnCleanupOwnership::ProviderCleanupPending
}
async fn wait_for_cleanup(&self) -> anyhow::Result<()> {
self.cleanup.wait_for_cleanup().await?;
Ok(())
}
}
#[derive(Debug, Default)]
struct SessionContinuity {
session_id: Option<String>,
synced: Vec<u64>,
}
pub struct ClaudeCodeEngine {
config: ClaudeCodeConfig,
runner: Arc<dyn ClaudeCodeRunner>,
continuity: Arc<Mutex<SessionContinuity>>,
}
impl ClaudeCodeEngine {
pub fn new(config: ClaudeCodeConfig) -> Self {
Self::new_with_runner(config, Arc::new(SdkClaudeCodeRunner))
}
pub fn new_with_runner(config: ClaudeCodeConfig, runner: Arc<dyn ClaudeCodeRunner>) -> Self {
Self {
config,
runner,
continuity: Arc::new(Mutex::new(SessionContinuity::default())),
}
}
fn auth_configured(&self) -> bool {
self.config
.cli_path
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or_else(|| which::which("claude").is_ok())
}
}
#[async_trait::async_trait]
impl InferenceEngine for ClaudeCodeEngine {
fn id(&self) -> InferenceEngineId {
PROVIDER_CLAUDE_CODE.to_string()
}
fn capabilities(&self) -> InferenceCapabilities {
InferenceCapabilities {
streaming: true,
tool_calls: true,
parallel_tool_calls: false,
reasoning_summaries: true,
structured_output: false,
image_input: true,
prompt_cache: false,
provider_metadata: true,
tool_search: false,
}
}
fn metadata(&self) -> InferenceProviderMetadata {
InferenceProviderMetadata {
name: "Claude Code".to_string(),
description: Some("Local Claude Code CLI harness via claude-agent-sdk".to_string()),
auth_type: ProviderAuthType::None,
auth_label: Some("Authenticated local Claude Code CLI".to_string()),
auth_configured: Some(self.auth_configured()),
recommended: true,
sort_order: 19,
}
}
async fn list_models(
&self,
_ctx: InferenceProviderContext<'_>,
) -> anyhow::Result<Vec<ModelDescriptor>> {
Ok(models_for_provider(PROVIDER_CLAUDE_CODE, false))
}
async fn stream_turn(
&self,
ctx: InferenceTurnContext<'_>,
request: AgentInferenceRequest,
) -> anyhow::Result<InferenceEventStream> {
validate_request(&request)?;
let current_dir = std::env::current_dir().ok();
let cwd = self
.config
.workspace
.as_deref()
.or_else(|| current_dir.as_deref());
let plan = self.plan_turn(&request);
let options = build_options(
&self.config,
&request,
ctx.tool_executor.clone(),
cwd,
plan.resume_session_id.as_deref(),
)?;
let ClaudeCodeStream { events, cleanup } = self
.runner
.stream_with_cleanup(options, plan.content)
.await?;
if let (Some(cleanup), Some(executor)) = (cleanup, ctx.tool_executor.as_ref()) {
executor.register_provider_cleanup(cleanup);
}
Ok(Box::pin(map_stream_events(
events,
Arc::clone(&self.continuity),
plan.synced,
)))
}
}
struct TurnPlan {
resume_session_id: Option<String>,
content: UserMessageInput,
synced: Vec<u64>,
}
impl ClaudeCodeEngine {
fn plan_turn(&self, request: &AgentInferenceRequest) -> TurnPlan {
let fingerprints = transcript_fingerprints(&request.transcript);
let boundary = delta_boundary(&request.transcript);
if self.config.session_reuse_enabled()
&& let Ok(state) = self.continuity.lock()
{
let can_resume = state.session_id.is_some()
&& boundary > 0
&& is_prefix(&state.synced, &fingerprints)
&& boundary <= request.transcript.len();
if can_resume {
let delta = prompt_from_delta(request, boundary);
if input_has_content(&delta) {
return TurnPlan {
resume_session_id: state.session_id.clone(),
content: delta,
synced: fingerprints,
};
}
}
}
TurnPlan {
resume_session_id: None,
content: prompt_from_request(request),
synced: fingerprints,
}
}
}
fn validate_request(request: &AgentInferenceRequest) -> anyhow::Result<()> {
if request.output.response_format.is_some() {
anyhow::bail!("Claude Code provider does not support structured response_format yet");
}
Ok(())
}
fn prompt_from_request(request: &AgentInferenceRequest) -> UserMessageInput {
prompt_from_items(request, &request.transcript)
}
fn prompt_from_delta(request: &AgentInferenceRequest, boundary: usize) -> UserMessageInput {
let tail = request.transcript.get(boundary..).unwrap_or(&[]);
prompt_from_items(request, tail)
}
fn prompt_from_items(
request: &AgentInferenceRequest,
items: &[TranscriptItem],
) -> UserMessageInput {
let mut parts = Vec::new();
let mut images = Vec::new();
for item in items {
if matches!(item, TranscriptItem::ProviderMetadata(_)) {
continue;
}
if let TranscriptItem::UserMessage(message) = item
&& !message.images.is_empty()
{
for image in &message.images {
if let Some(block) = image_block_from_url(&image.image_url) {
images.push(block);
}
}
let text_only = TranscriptItem::UserMessage(roder_api::transcript::UserMessage::text(
message.text.clone(),
));
parts.push(format!("{text_only:?}"));
continue;
}
parts.push(format!("{item:?}"));
}
if let Some(value) = request
.metadata
.get("prompt")
.and_then(|value| value.as_str())
{
parts.push(value.to_string());
}
let text = if parts.is_empty() {
"Continue the current Roder turn.".to_string()
} else {
parts.join("\n\n")
};
if images.is_empty() {
UserMessageInput::Text(text)
} else {
let mut blocks = Vec::with_capacity(images.len() + 1);
blocks.push(InputContentBlock::text(text));
blocks.extend(images);
UserMessageInput::Blocks(blocks)
}
}
fn image_block_from_url(url: &str) -> Option<InputContentBlock> {
if let Some(source) = ImageSource::from_data_url(url) {
return Some(InputContentBlock::image(source));
}
if url.starts_with("http://") || url.starts_with("https://") {
return Some(InputContentBlock::image(ImageSource::Url {
url: url.to_string(),
}));
}
None
}
fn input_has_content(input: &UserMessageInput) -> bool {
match input {
UserMessageInput::Text(text) => !text.trim().is_empty(),
UserMessageInput::Blocks(blocks) => !blocks.is_empty(),
}
}
fn delta_boundary(transcript: &[TranscriptItem]) -> usize {
transcript
.iter()
.rposition(|item| matches!(item, TranscriptItem::AssistantMessage(_)))
.map(|index| index + 1)
.unwrap_or(0)
}
fn transcript_fingerprints(transcript: &[TranscriptItem]) -> Vec<u64> {
transcript.iter().map(fingerprint_item).collect()
}
fn fingerprint_item(item: &TranscriptItem) -> u64 {
let mut hasher = DefaultHasher::new();
format!("{item:?}").hash(&mut hasher);
hasher.finish()
}
fn is_prefix(prefix: &[u64], full: &[u64]) -> bool {
prefix.len() <= full.len() && full[..prefix.len()] == *prefix
}
fn map_stream_events(
mut events: mpsc::UnboundedReceiver<StreamEvent>,
continuity: Arc<Mutex<SessionContinuity>>,
synced: Vec<u64>,
) -> InferenceEventStream {
Box::pin(try_stream! {
let mut saw_partial_text = false;
let mut accumulated_text = String::new();
let mut echo_match_pos = 0usize;
let mut completed = false;
let mut last_session_id: Option<String> = None;
let mut last_stop_reason: Option<String> = None;
let mut mcp_tool_ids: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut hosted_pending: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut hosted_done: std::collections::HashSet<String> =
std::collections::HashSet::new();
while let Some(event) = events.recv().await {
match event {
StreamEvent::ContentChunk(text) => {
if completed {
continue;
}
if !accumulated_text.is_empty()
&& echo_match_pos < accumulated_text.len()
&& accumulated_text[echo_match_pos..].starts_with(&text)
{
echo_match_pos += text.len();
continue;
}
saw_partial_text = true;
echo_match_pos = 0;
accumulated_text.push_str(&text);
yield InferenceEvent::MessageDelta(MessageDelta { text, phase: None });
}
StreamEvent::ThinkingChunk { thinking, .. } => {
yield InferenceEvent::ReasoningDelta(ReasoningDelta { text: thinking });
}
StreamEvent::ToolUseStart { id, name, input } => {
if name.starts_with("mcp__roder__") {
mcp_tool_ids.insert(id);
continue;
}
if name.starts_with("mcp__") {
mcp_tool_ids.insert(id.clone());
if hosted_done.contains(&id) {
continue;
}
if !hosted_pending.contains_key(&id) {
hosted_pending.insert(id.clone(), name.clone());
yield InferenceEvent::HostedToolCallStarted(HostedToolCallStarted {
id: id.clone(),
name: name.clone(),
});
}
if !input.is_empty() {
hosted_pending.remove(&id);
hosted_done.insert(id.clone());
yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
id,
name,
arguments: serde_json::Value::Object(input).to_string(),
});
}
continue;
}
if !input.is_empty() {
yield InferenceEvent::ToolCallCompleted(ToolCallCompleted {
id,
name,
arguments: serde_json::Value::Object(input).to_string(),
});
}
}
StreamEvent::ToolUseDelta { id, partial_input } => {
if mcp_tool_ids.contains(&id) {
continue;
}
yield InferenceEvent::ToolCallDelta(ToolCallDelta { id, arguments_delta: partial_input });
}
StreamEvent::ToolResult { tool_use_id, content, is_error } => {
if let Some(name) = hosted_pending.remove(&tool_use_id) {
hosted_done.insert(tool_use_id.clone());
yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
id: tool_use_id,
name,
arguments: "{}".to_string(),
});
continue;
}
yield InferenceEvent::ProviderMetadata(json!({
"provider": PROVIDER_CLAUDE_CODE,
"toolResult": {
"toolUseId": tool_use_id,
"isError": is_error.unwrap_or(false),
"content": content.unwrap_or(serde_json::Value::Null),
}
}));
}
StreamEvent::RateLimit(info) => {
yield InferenceEvent::ProviderMetadata(json!({
"provider": PROVIDER_CLAUDE_CODE,
"rateLimit": info,
}));
}
StreamEvent::Complete(response) => {
if completed {
continue;
}
if let Some(usage) = usage_from_response(&response) {
yield InferenceEvent::Usage(usage);
}
if response.content.is_empty() {
continue;
}
if !saw_partial_text {
yield InferenceEvent::MessageDelta(MessageDelta { text: response.content.clone(), phase: None });
}
last_session_id = Some(response.session_id);
last_stop_reason = response.stop_reason;
accumulated_text.clear();
echo_match_pos = 0;
}
StreamEvent::TurnComplete(response) => {
if completed {
continue;
}
for (id, name) in std::mem::take(&mut hosted_pending) {
hosted_done.insert(id.clone());
yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
id,
name,
arguments: "{}".to_string(),
});
}
if let Some(usage) = usage_from_response(&response) {
yield InferenceEvent::Usage(usage);
}
completed = true;
let session_id = (!response.session_id.trim().is_empty())
.then_some(response.session_id)
.or(last_session_id.take());
commit_session(&continuity, session_id.as_deref(), &synced);
yield InferenceEvent::Completed(CompletionMetadata {
stop_reason: response.stop_reason.or(last_stop_reason.take()),
provider_response_id: session_id,
});
}
StreamEvent::Error(message) => {
clear_session(&continuity);
yield InferenceEvent::Failed(InferenceFailure { message: redact_error(&message) });
}
}
}
if !completed {
for (id, name) in std::mem::take(&mut hosted_pending) {
yield InferenceEvent::HostedToolCallCompleted(HostedToolCallCompleted {
id,
name,
arguments: "{}".to_string(),
});
}
}
if !completed && (last_session_id.is_some() || last_stop_reason.is_some()) {
commit_session(&continuity, last_session_id.as_deref(), &synced);
yield InferenceEvent::Completed(CompletionMetadata {
stop_reason: last_stop_reason,
provider_response_id: last_session_id,
});
}
})
}
fn commit_session(
continuity: &Arc<Mutex<SessionContinuity>>,
session_id: Option<&str>,
synced: &[u64],
) {
let Some(session_id) = session_id.filter(|value| !value.trim().is_empty()) else {
clear_session(continuity);
return;
};
if let Ok(mut state) = continuity.lock() {
state.session_id = Some(session_id.to_string());
state.synced = synced.to_vec();
}
}
fn clear_session(continuity: &Arc<Mutex<SessionContinuity>>) {
if let Ok(mut state) = continuity.lock() {
state.session_id = None;
state.synced.clear();
}
}
fn usage_from_response(response: &MessageResponse) -> Option<TokenUsage> {
let usage = response.usage.as_ref()?;
let prompt_tokens = number_from_usage(
usage,
&[
"input_tokens",
"inputTokens",
"prompt_tokens",
"promptTokens",
"cache_creation_input_tokens",
"cacheCreationInputTokens",
"cache_read_input_tokens",
"cacheReadInputTokens",
],
)
.unwrap_or(0);
let cached_prompt_tokens =
number_from_usage(usage, &["cache_read_input_tokens", "cacheReadInputTokens"]);
let cache_creation_prompt_tokens = number_from_usage(
usage,
&["cache_creation_input_tokens", "cacheCreationInputTokens"],
);
let completion_tokens = number_from_usage(
usage,
&[
"output_tokens",
"outputTokens",
"completion_tokens",
"completionTokens",
],
)
.unwrap_or(0);
let total_tokens = number_from_usage(usage, &["total_tokens", "totalTokens"])
.unwrap_or_else(|| prompt_tokens.saturating_add(completion_tokens));
if prompt_tokens == 0 && completion_tokens == 0 && total_tokens == 0 {
return None;
}
Some(
TokenUsage::new(prompt_tokens, completion_tokens, total_tokens)
.with_cached_prompt_tokens(cached_prompt_tokens.unwrap_or(0))
.with_cache_creation_prompt_tokens(cache_creation_prompt_tokens.unwrap_or(0)),
)
}
fn number_from_usage(
usage: &std::collections::HashMap<String, serde_json::Value>,
keys: &[&str],
) -> Option<u32> {
let total = usage.iter().fold(0u32, |total, (key, value)| {
total.saturating_add(number_from_usage_value(key, value, keys))
});
(total > 0).then_some(total)
}
fn number_from_usage_value(key: &str, value: &serde_json::Value, keys: &[&str]) -> u32 {
let direct = if keys.iter().any(|candidate| candidate == &key) {
json_u32(value).unwrap_or(0)
} else {
0
};
let nested = value
.as_object()
.map(|object| {
object.iter().fold(0u32, |total, (key, value)| {
total.saturating_add(number_from_usage_value(key, value, keys))
})
})
.unwrap_or(0);
direct.saturating_add(nested)
}
fn json_u32(value: &serde_json::Value) -> Option<u32> {
value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.or_else(|| value.as_i64().and_then(|value| u32::try_from(value).ok()))
.or_else(|| {
value
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)
.and_then(|value| u32::try_from(value as u64).ok())
})
.or_else(|| value.as_str().and_then(|value| value.parse::<u32>().ok()))
}
fn redact_error(message: &str) -> String {
let mut redacted = message.to_string();
for marker in ["ANTHROPIC_API_KEY", "CLAUDE_CODE", "Bearer "] {
if redacted.contains(marker) {
redacted = redacted.replace(marker, "[redacted]");
}
}
redacted
}
#[cfg(test)]
mod tests {
use super::*;
use claude_code_sdk_rust::types::ContentBlock;
use futures::StreamExt;
use roder_api::inference::{
HostedWebSearchConfig, InstructionBundle, ModelSelection, OutputConfig, ReasoningConfig,
RuntimeHints,
};
use roder_api::tools::ToolChoice;
#[derive(Default)]
struct FakeRunner {
events: Vec<StreamEvent>,
}
#[async_trait::async_trait]
impl ClaudeCodeRunner for FakeRunner {
async fn stream(
&self,
_options: ClaudeAgentOptions,
_content: UserMessageInput,
) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
let (tx, rx) = mpsc::unbounded_channel();
for event in self.events.clone() {
tx.send(event).unwrap();
}
Ok(rx)
}
}
fn request() -> AgentInferenceRequest {
AgentInferenceRequest {
model: ModelSelection {
provider: PROVIDER_CLAUDE_CODE.to_string(),
model: "sonnet".to_string(),
},
instructions: InstructionBundle::default(),
transcript: Vec::new(),
tools: Vec::new(),
tool_choice: ToolChoice::Auto,
reasoning: ReasoningConfig::default(),
output: OutputConfig::default(),
runtime: RuntimeHints {
hosted_web_search: HostedWebSearchConfig::disabled(),
..RuntimeHints::default()
},
metadata: json!({"prompt": "hello"}),
}
}
#[tokio::test]
async fn provider_streams_text_and_completion() {
let mut usage = std::collections::HashMap::new();
usage.insert("input_tokens".to_string(), json!(3));
usage.insert("output_tokens".to_string(), json!(5));
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ContentChunk("hello".to_string()),
StreamEvent::Complete(MessageResponse {
content: "hello".to_string(),
blocks: vec![ContentBlock::Text {
text: "hello".to_string(),
}],
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-1".to_string(),
usage: Some(usage),
}),
StreamEvent::TurnComplete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-1".to_string(),
usage: None,
}),
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(matches!(events[0], InferenceEvent::MessageDelta(_)));
assert!(
events
.iter()
.any(|event| matches!(event, InferenceEvent::Usage(_)))
);
assert!(
events
.iter()
.any(|event| matches!(event, InferenceEvent::Completed(_)))
);
}
#[tokio::test]
async fn provider_keeps_partial_text_and_uses_later_usage_completion() {
let mut usage = std::collections::HashMap::new();
usage.insert("input_tokens".to_string(), json!(4));
usage.insert("cache_read_input_tokens".to_string(), json!(6));
usage.insert("output_tokens".to_string(), json!(8));
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ContentChunk("one ".to_string()),
StreamEvent::ContentChunk("two".to_string()),
StreamEvent::Complete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-usage".to_string(),
usage: Some(usage),
}),
StreamEvent::ContentChunk("one two".to_string()),
StreamEvent::Complete(MessageResponse {
content: "one two".to_string(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-final".to_string(),
usage: None,
}),
StreamEvent::TurnComplete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-final".to_string(),
usage: None,
}),
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let rendered = events
.iter()
.filter_map(|event| match event {
InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
_ => None,
})
.collect::<String>();
assert_eq!(rendered, "one two");
assert!(events.iter().any(|event| matches!(event, InferenceEvent::Usage(usage) if usage.prompt_tokens == 10 && usage.completion_tokens == 8)));
assert!(
events
.iter()
.any(|event| matches!(event, InferenceEvent::Completed(_)))
);
}
#[tokio::test]
async fn provider_does_not_duplicate_multi_block_commentary() {
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ContentChunk("First ".to_string()),
StreamEvent::ContentChunk("block.".to_string()),
StreamEvent::ContentChunk("Second ".to_string()),
StreamEvent::ContentChunk("block.".to_string()),
StreamEvent::ContentChunk("First block.".to_string()),
StreamEvent::ContentChunk("Second block.".to_string()),
StreamEvent::Complete(MessageResponse {
content: "First block.Second block.".to_string(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-final".to_string(),
usage: None,
}),
StreamEvent::TurnComplete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-final".to_string(),
usage: None,
}),
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let rendered = events
.iter()
.filter_map(|event| match event {
InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
_ => None,
})
.collect::<String>();
assert_eq!(rendered, "First block.Second block.");
}
#[tokio::test]
async fn provider_streams_text_after_tool_use_until_turn_complete() {
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ContentChunk("I'll read the file.".to_string()),
StreamEvent::ContentChunk("I'll read the file.".to_string()),
StreamEvent::Complete(MessageResponse {
content: "I'll read the file.".to_string(),
blocks: Vec::new(),
model: "claude-fable-5".to_string(),
stop_reason: None,
session_id: "session-1".to_string(),
usage: None,
}),
StreamEvent::ToolUseStart {
id: "toolu_1".to_string(),
name: "mcp__roder__read_file".to_string(),
input: serde_json::Map::new(),
},
StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
StreamEvent::ContentChunk("The magic word is pomegranate.".to_string()),
StreamEvent::Complete(MessageResponse {
content: "The magic word is pomegranate.".to_string(),
blocks: Vec::new(),
model: "claude-fable-5".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-1".to_string(),
usage: None,
}),
StreamEvent::TurnComplete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-1".to_string(),
usage: None,
}),
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
let rendered = events
.iter()
.filter_map(|event| match event {
InferenceEvent::MessageDelta(delta) => Some(delta.text.as_str()),
_ => None,
})
.collect::<String>();
assert_eq!(
rendered, "I'll read the file.The magic word is pomegranate.",
"post-tool assistant text must stream and echoes must dedupe"
);
let completions = events
.iter()
.filter(|event| matches!(event, InferenceEvent::Completed(_)))
.count();
assert_eq!(completions, 1, "turn must complete exactly once");
}
#[tokio::test]
async fn surfaces_claude_in_chrome_tool_use_as_hosted_calls() {
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ToolUseStart {
id: "toolu_chrome".to_string(),
name: "mcp__claude-in-chrome__navigate".to_string(),
input: serde_json::Map::new(),
},
StreamEvent::ToolUseStart {
id: "toolu_chrome".to_string(),
name: "mcp__claude-in-chrome__navigate".to_string(),
input: {
let mut input = serde_json::Map::new();
input.insert("url".to_string(), json!("https://example.com"));
input
},
},
StreamEvent::ContentChunk("Opened the page.".to_string()),
StreamEvent::ContentChunk("Opened the page.".to_string()),
StreamEvent::Complete(MessageResponse {
content: "Opened the page.".to_string(),
blocks: Vec::new(),
model: "opus".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-1".to_string(),
usage: None,
}),
StreamEvent::TurnComplete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-1".to_string(),
usage: None,
}),
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
!events
.iter()
.any(|event| matches!(event, InferenceEvent::ToolCallCompleted(_))),
"Claude-in-Chrome tools must not surface as runtime tool calls"
);
let started = events
.iter()
.filter(|event| matches!(event, InferenceEvent::HostedToolCallStarted(_)))
.count();
assert_eq!(started, 1, "browser tool must surface as one hosted start");
let completed = events
.iter()
.filter_map(|event| match event {
InferenceEvent::HostedToolCallCompleted(call) => Some(call),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
completed.len(),
1,
"browser tool must complete exactly once"
);
assert_eq!(completed[0].name, "mcp__claude-in-chrome__navigate");
assert!(
completed[0].arguments.contains("example.com"),
"completion must carry the full tool input"
);
}
#[tokio::test]
async fn provider_completes_turn_when_stream_ends_without_result() {
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ContentChunk("partial".to_string()),
StreamEvent::Complete(MessageResponse {
content: "partial".to_string(),
blocks: Vec::new(),
model: "claude-fable-5".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-dead".to_string(),
usage: None,
}),
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
events
.iter()
.any(|event| matches!(event, InferenceEvent::Completed(_))),
"stream end without a result message must still complete the turn"
);
}
#[tokio::test]
async fn provider_maps_tool_events() {
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ToolUseStart {
id: "toolu_1".to_string(),
name: "Read".to_string(),
input: serde_json::Map::from_iter([(
"path".to_string(),
json!("crates/roder-ext-claude-code"),
)]),
},
StreamEvent::ToolUseDelta {
id: "toolu_1".to_string(),
partial_input: "{\"file_path\"".to_string(),
},
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
matches!(&events[0], InferenceEvent::ToolCallCompleted(call) if call.arguments.contains("roder-ext-claude-code"))
);
assert!(matches!(events[1], InferenceEvent::ToolCallDelta(_)));
}
#[tokio::test]
async fn provider_suppresses_mcp_tool_lifecycle_events() {
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(FakeRunner {
events: vec![
StreamEvent::ToolUseStart {
id: "toolu_mcp".to_string(),
name: "mcp__roder__read_file".to_string(),
input: serde_json::Map::from_iter([(
"path".to_string(),
json!("README.md"),
)]),
},
StreamEvent::ToolUseDelta {
id: "toolu_mcp".to_string(),
partial_input: "{\"path\"".to_string(),
},
StreamEvent::Complete(MessageResponse {
content: "done".to_string(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session".to_string(),
usage: None,
}),
],
}),
);
let events = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request(),
)
.await
.unwrap()
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
!events.iter().any(|event| matches!(
event,
InferenceEvent::ToolCallCompleted(_) | InferenceEvent::ToolCallDelta(_)
)),
"mcp__ tool lifecycle events must not be surfaced from the CLI stream"
);
}
#[test]
fn usage_parser_reports_cached_prompt_tokens_as_subset() {
let usage = std::collections::HashMap::from([
("input_tokens".to_string(), json!(123)),
("cache_creation_input_tokens".to_string(), json!(7)),
("cache_read_input_tokens".to_string(), json!(11)),
("output_tokens".to_string(), json!(13)),
]);
let response = MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session".to_string(),
usage: Some(usage),
};
let usage = usage_from_response(&response).unwrap();
assert_eq!(usage.prompt_tokens, 141);
assert_eq!(usage.cached_prompt_tokens, 11);
assert_eq!(usage.cache_creation_prompt_tokens, 7);
assert_eq!(usage.completion_tokens, 13);
assert_eq!(usage.total_tokens, 154);
}
#[test]
fn usage_parser_includes_cache_only_input_tokens_in_prompt_total() {
let usage = std::collections::HashMap::from([
("cache_creation_input_tokens".to_string(), json!(7)),
("cache_read_input_tokens".to_string(), json!(11)),
("output_tokens".to_string(), json!(13)),
]);
let response = MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session".to_string(),
usage: Some(usage),
};
let usage = usage_from_response(&response).unwrap();
assert_eq!(usage.prompt_tokens, 18);
assert_eq!(usage.cached_prompt_tokens, 11);
assert_eq!(usage.cache_creation_prompt_tokens, 7);
assert_eq!(usage.completion_tokens, 13);
assert_eq!(usage.total_tokens, 31);
}
#[test]
fn usage_parser_accepts_output_only_delta_usage() {
let response = MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session".to_string(),
usage: Some(std::collections::HashMap::from([(
"output_tokens".to_string(),
json!(42),
)])),
};
let usage = usage_from_response(&response).unwrap();
assert_eq!(usage.prompt_tokens, 0);
assert_eq!(usage.completion_tokens, 42);
assert_eq!(usage.total_tokens, 42);
}
#[test]
fn usage_parser_accepts_nested_camel_case_model_usage() {
let response = MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: "session".to_string(),
usage: Some(std::collections::HashMap::from([(
"modelUsage".to_string(),
json!({
"inputTokens": "100",
"cacheReadInputTokens": 25.0,
"outputTokens": 7,
}),
)])),
};
let usage = usage_from_response(&response).unwrap();
assert_eq!(usage.prompt_tokens, 125);
assert_eq!(usage.cached_prompt_tokens, 25);
assert_eq!(usage.completion_tokens, 7);
assert_eq!(usage.total_tokens, 132);
}
use roder_api::transcript::{AssistantMessage, UserMessage};
use std::sync::Mutex as StdMutex;
type RecordedCall = (Option<String>, String);
fn capture_text(content: &UserMessageInput) -> String {
match content {
UserMessageInput::Text(text) => text.clone(),
UserMessageInput::Blocks(blocks) => blocks
.iter()
.filter_map(|block| match block {
InputContentBlock::Text { text } => Some(text.clone()),
InputContentBlock::Image { .. } => None,
})
.collect::<Vec<_>>()
.join("\n\n"),
}
}
#[derive(Default)]
struct RecordingRunner {
session_id: String,
calls: Arc<StdMutex<Vec<RecordedCall>>>,
}
#[async_trait::async_trait]
impl ClaudeCodeRunner for RecordingRunner {
async fn stream(
&self,
options: ClaudeAgentOptions,
content: UserMessageInput,
) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
self.calls
.lock()
.unwrap()
.push((options.resume.clone(), capture_text(&content)));
let (tx, rx) = mpsc::unbounded_channel();
tx.send(StreamEvent::Complete(MessageResponse {
content: "ok".to_string(),
blocks: Vec::new(),
model: "sonnet".to_string(),
stop_reason: Some("end_turn".to_string()),
session_id: self.session_id.clone(),
usage: None,
}))
.unwrap();
tx.send(StreamEvent::TurnComplete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: self.session_id.clone(),
usage: None,
}))
.unwrap();
Ok(rx)
}
}
fn request_with_transcript(transcript: Vec<TranscriptItem>) -> AgentInferenceRequest {
AgentInferenceRequest {
transcript,
metadata: json!({}),
..request()
}
}
async fn drain(engine: &ClaudeCodeEngine, request: AgentInferenceRequest) {
let _ = engine
.stream_turn(
InferenceTurnContext {
thread_id: "thread",
turn_id: "turn",
tool_executor: None,
},
request,
)
.await
.unwrap()
.collect::<Vec<_>>()
.await;
}
#[tokio::test]
async fn second_turn_resumes_session_and_sends_only_the_new_tail() {
let calls = Arc::new(StdMutex::new(Vec::new()));
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(RecordingRunner {
session_id: "session-1".to_string(),
calls: Arc::clone(&calls),
}),
);
drain(
&engine,
request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text(
"first question",
))]),
)
.await;
drain(
&engine,
request_with_transcript(vec![
TranscriptItem::UserMessage(UserMessage::text("first question")),
TranscriptItem::AssistantMessage(AssistantMessage {
text: "first answer".to_string(),
phase: None,
}),
TranscriptItem::UserMessage(UserMessage::text("second question")),
]),
)
.await;
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].0, None);
assert!(calls[0].1.contains("first question"));
assert_eq!(calls[1].0.as_deref(), Some("session-1"));
assert!(calls[1].1.contains("second question"));
assert!(
!calls[1].1.contains("first answer"),
"resumed turn must not replay prior assistant output: {}",
calls[1].1
);
assert!(
!calls[1].1.contains("first question"),
"resumed turn must not replay the prior user message: {}",
calls[1].1
);
}
#[tokio::test]
async fn compacted_transcript_falls_back_to_a_fresh_send() {
let calls = Arc::new(StdMutex::new(Vec::new()));
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(RecordingRunner {
session_id: "session-1".to_string(),
calls: Arc::clone(&calls),
}),
);
drain(
&engine,
request_with_transcript(vec![
TranscriptItem::UserMessage(UserMessage::text("original question")),
TranscriptItem::AssistantMessage(AssistantMessage {
text: "original answer".to_string(),
phase: None,
}),
]),
)
.await;
drain(
&engine,
request_with_transcript(vec![
TranscriptItem::ContextCompaction(roder_api::transcript::ContextCompactionRecord {
summary: "summary of earlier turns".to_string(),
}),
TranscriptItem::UserMessage(UserMessage::text("next question")),
]),
)
.await;
let calls = calls.lock().unwrap();
assert_eq!(calls[1].0, None, "compacted transcript must send fresh");
assert!(calls[1].1.contains("summary of earlier turns"));
}
#[tokio::test]
async fn session_reuse_can_be_disabled_via_config() {
let calls = Arc::new(StdMutex::new(Vec::new()));
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig {
reuse_cli_session: Some(false),
..ClaudeCodeConfig::default()
},
Arc::new(RecordingRunner {
session_id: "session-1".to_string(),
calls: Arc::clone(&calls),
}),
);
drain(
&engine,
request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::text("one"))]),
)
.await;
drain(
&engine,
request_with_transcript(vec![
TranscriptItem::UserMessage(UserMessage::text("one")),
TranscriptItem::AssistantMessage(AssistantMessage {
text: "answer".to_string(),
phase: None,
}),
TranscriptItem::UserMessage(UserMessage::text("two")),
]),
)
.await;
let calls = calls.lock().unwrap();
assert!(
calls.iter().all(|(resume, _)| resume.is_none()),
"reuse disabled: no turn should resume a session"
);
assert!(calls[1].1.contains("answer"));
}
#[test]
fn claude_code_engine_supports_images() {
let engine = ClaudeCodeEngine::new(ClaudeCodeConfig::default());
assert!(engine.capabilities().image_input);
let models = roder_api::catalog::models_for_provider(PROVIDER_CLAUDE_CODE, false);
assert!(!models.is_empty());
for model in models {
let entry =
roder_api::catalog::lookup_model_for_provider(PROVIDER_CLAUDE_CODE, &model.id)
.unwrap();
assert!(
entry.supports_images,
"model {} must support images",
model.id
);
}
}
#[derive(Default)]
struct ContentCapturingRunner {
contents: Arc<StdMutex<Vec<UserMessageInput>>>,
}
#[async_trait::async_trait]
impl ClaudeCodeRunner for ContentCapturingRunner {
async fn stream(
&self,
_options: ClaudeAgentOptions,
content: UserMessageInput,
) -> anyhow::Result<mpsc::UnboundedReceiver<StreamEvent>> {
self.contents.lock().unwrap().push(content);
let (tx, rx) = mpsc::unbounded_channel();
tx.send(StreamEvent::TurnComplete(MessageResponse {
content: String::new(),
blocks: Vec::new(),
model: String::new(),
stop_reason: Some("end_turn".to_string()),
session_id: "session-1".to_string(),
usage: None,
}))
.unwrap();
Ok(rx)
}
}
#[tokio::test]
async fn image_user_message_is_delivered_as_image_block() {
use roder_api::transcript::{InputImage, UserMessage};
let contents = Arc::new(StdMutex::new(Vec::new()));
let engine = ClaudeCodeEngine::new_with_runner(
ClaudeCodeConfig::default(),
Arc::new(ContentCapturingRunner {
contents: Arc::clone(&contents),
}),
);
drain(
&engine,
request_with_transcript(vec![TranscriptItem::UserMessage(UserMessage::with_images(
"describe this",
vec![InputImage {
image_url: "data:image/png;base64,QUJD".to_string(),
}],
))]),
)
.await;
let contents = contents.lock().unwrap();
let blocks = match &contents[0] {
UserMessageInput::Blocks(blocks) => blocks,
other => panic!("expected image content blocks, got {other:?}"),
};
assert!(
matches!(blocks.first(), Some(InputContentBlock::Text { text }) if text.contains("describe this"))
);
assert!(blocks.iter().any(|block| matches!(
block,
InputContentBlock::Image {
source: ImageSource::Base64 { media_type, data }
} if media_type == "image/png" && data == "QUJD"
)));
for block in blocks {
if let InputContentBlock::Text { text } = block {
assert!(!text.contains("QUJD"), "base64 leaked into text: {text}");
}
}
}
}