use serde_json::Value;
use crate::config::AgentProfileSchema;
use super::{AgentAdapter, AgentCommand, AgentEvent, AgentStdin, build_extra_args};
const CLAUDE_PROGRAM: &str = "claude";
#[derive(Debug, Clone)]
pub struct ClaudeCodeAdapter;
impl AgentAdapter for ClaudeCodeAdapter {
fn build_command(&self, profile: &AgentProfileSchema, prompt: String) -> AgentCommand {
let mut args: Vec<String> = vec![
"--verbose".into(),
"--output-format".into(),
"stream-json".into(),
"--model".into(),
profile.model.clone(),
"-p".into(),
];
args.extend(build_extra_args(&profile.args));
AgentCommand {
program: CLAUDE_PROGRAM.into(),
args,
stdin: AgentStdin::Pipe(prompt),
}
}
fn map_event(&self, value: Value) -> Vec<AgentEvent> {
map_value(&value)
}
}
pub(super) fn map_value(value: &Value) -> Vec<AgentEvent> {
let Some(ty) = value.get("type").and_then(Value::as_str) else {
return Vec::new();
};
match ty {
"system" => {
let subtype = value.get("subtype").and_then(Value::as_str);
if subtype != Some("init") {
return Vec::new();
}
let session_id = value.get("session_id").and_then(Value::as_str).unwrap_or("").to_string();
if session_id.is_empty() {
return Vec::new();
}
vec![AgentEvent::SessionStarted { session_id }]
},
"assistant" => {
let text = extract_assistant_text(value);
if text.is_empty() {
return Vec::new();
}
vec![AgentEvent::Message { text }]
},
"result" => {
let mut out = Vec::new();
if let Some(usage) = value.get("usage") {
let input = usage.get("input_tokens").and_then(Value::as_u64).unwrap_or(0);
let output = usage.get("output_tokens").and_then(Value::as_u64).unwrap_or(0);
let cache_read = usage.get("cache_read_input_tokens").and_then(Value::as_u64).unwrap_or(0);
out.push(AgentEvent::TokenUsage {
input,
output,
cache_read,
});
}
out.push(AgentEvent::Completed);
out
},
other => {
tracing::debug!(
runtime = "claude_code",
claude_event_type = other,
"claude_code event ignored: unknown type",
);
Vec::new()
},
}
}
fn extract_assistant_text(value: &Value) -> String {
let Some(content) = value.get("message").and_then(|m| m.get("content")).and_then(Value::as_array) else {
return String::new();
};
let mut buf = String::new();
for block in content {
if block.get("type").and_then(Value::as_str) == Some("text")
&& let Some(text) = block.get("text").and_then(Value::as_str)
{
if !buf.is_empty() {
buf.push('\n');
}
buf.push_str(text);
}
}
buf
}
#[cfg(test)]
mod tests {
use crate::config::AgentRuntime;
use super::*;
fn parse(line: &str) -> Vec<AgentEvent> {
let value: Value = serde_json::from_str(line).expect("fixture is valid JSON");
map_value(&value)
}
#[test]
fn system_init_maps_to_session_started() {
let line = r#"{"type":"system","subtype":"init","session_id":"S-42","model":"claude-sonnet-4-6"}"#;
assert_eq!(
parse(line),
vec![AgentEvent::SessionStarted {
session_id: "S-42".into(),
}]
);
}
#[test]
fn assistant_text_blocks_concatenate() {
let line = r#"{
"type":"assistant",
"message":{"content":[
{"type":"text","text":"hello"},
{"type":"text","text":"world"}
]}
}"#;
assert_eq!(
parse(line),
vec![AgentEvent::Message {
text: "hello\nworld".into(),
}]
);
}
#[test]
fn assistant_tool_only_drops() {
let line = r#"{
"type":"assistant",
"message":{"content":[{"type":"tool_use","id":"t-1","name":"Bash","input":{}}]}
}"#;
assert!(parse(line).is_empty());
}
#[test]
fn result_emits_usage_then_completed() {
let line = r#"{
"type":"result",
"usage":{"input_tokens":11,"output_tokens":22,"cache_read_input_tokens":3}
}"#;
assert_eq!(
parse(line),
vec![
AgentEvent::TokenUsage {
input: 11,
output: 22,
cache_read: 3,
},
AgentEvent::Completed,
]
);
}
#[test]
fn result_without_usage_still_completes() {
let line = r#"{"type":"result"}"#;
assert_eq!(parse(line), vec![AgentEvent::Completed]);
}
#[test]
fn user_event_is_dropped() {
let line = r#"{"type":"user","message":{"content":[]}}"#;
assert!(parse(line).is_empty());
}
#[test]
fn happy_path_fixture_yields_full_session() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/agent_events/claude_code/happy_path.jsonl"
);
let body = std::fs::read_to_string(path).expect("fixture present");
let mut events: Vec<AgentEvent> = Vec::new();
for line in body.lines() {
events.extend(parse(line));
}
assert!(
matches!(events[0], AgentEvent::SessionStarted { .. }),
"first event must be SessionStarted"
);
let messages = events.iter().filter(|e| matches!(e, AgentEvent::Message { .. })).count();
assert_eq!(messages, 2, "two text-only assistant turns");
assert!(
events.iter().any(|e| matches!(e, AgentEvent::TokenUsage { .. })),
"result event yields TokenUsage"
);
assert!(
matches!(events.last(), Some(AgentEvent::Completed)),
"stream terminates with Completed"
);
}
#[test]
fn multi_text_blocks_fixture_joins_text() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/agent_events/claude_code/multi_text_blocks.jsonl"
);
let body = std::fs::read_to_string(path).expect("fixture present");
let mut events: Vec<AgentEvent> = Vec::new();
for line in body.lines() {
events.extend(parse(line));
}
let joined_message = events.iter().find_map(|e| match e {
AgentEvent::Message { text } => Some(text.clone()),
_ => None,
});
assert_eq!(
joined_message.as_deref(),
Some("line one\nline two"),
"multi text blocks concatenate with a newline separator"
);
}
#[test]
fn command_contains_expected_flags_and_closed_stdin() {
let adapter = ClaudeCodeAdapter;
let profile =
AgentProfileSchema::new(AgentRuntime::ClaudeCode, "opus".into()).with_args(serde_yaml::Mapping::from_iter([
(
serde_yaml::Value::String("--permission-mode".into()),
serde_yaml::Value::String("plan".into()),
),
(
serde_yaml::Value::String("--allowed-tools".into()),
serde_yaml::Value::Sequence(vec![
serde_yaml::Value::String("Edit".into()),
serde_yaml::Value::String("Read".into()),
]),
),
(
serde_yaml::Value::String("--effort".into()),
serde_yaml::Value::String("high".into()),
),
]));
let cmd = adapter.build_command(&profile, "hello".into());
assert_eq!(cmd.program, "claude");
assert_eq!(
cmd.args,
vec![
"--verbose",
"--output-format",
"stream-json",
"--model",
"opus",
"-p",
"--permission-mode",
"plan",
"--allowed-tools",
"Edit,Read",
"--effort",
"high"
]
);
assert!(matches!(cmd.stdin, AgentStdin::Pipe(ref s) if s == "hello"));
}
}