use std::io::Write;
#[derive(Debug, Clone, PartialEq)]
pub enum StreamEvent {
Text(String),
ToolUse { name: String, summary: String },
Usage {
input_tokens: Option<u64>,
output_tokens: Option<u64>,
cache_read_tokens: Option<u64>,
},
Result {
subtype: ResultSubtype,
cost_usd: Option<f64>,
duration_secs: Option<f64>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResultSubtype {
Success,
Error,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ParseResult {
Events(Vec<StreamEvent>),
Skipped,
Passthrough,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFormat {
Raw,
Human(bool),
}
impl Default for StreamFormat {
fn default() -> Self {
Self::Human(false)
}
}
#[derive(Debug, Default)]
pub struct StreamParser;
impl StreamParser {
pub fn new() -> Self {
Self
}
pub fn feed_line(&mut self, line: &str) -> ParseResult {
let v = match serde_json::from_str::<serde_json::Value>(line) {
Ok(v) => v,
Err(_) => return ParseResult::Passthrough,
};
let event_type = match v.get("type").and_then(|t| t.as_str()) {
Some(t) => t,
None => return ParseResult::Passthrough,
};
match event_type {
"assistant" => {
let events = parse_claude_assistant(&v);
if events.is_empty() {
ParseResult::Skipped
} else {
ParseResult::Events(events)
}
}
"system"
| "user"
| "content_block_start"
| "content_block_delta"
| "content_block_stop"
| "rate_limit_event" => ParseResult::Skipped,
"item.started" | "item.completed" => {
let events = parse_codex_item(&v);
if events.is_empty() {
ParseResult::Skipped
} else {
ParseResult::Events(events)
}
}
"turn.completed" => {
let events = parse_codex_turn_completed(&v);
if events.is_empty() {
ParseResult::Skipped
} else {
ParseResult::Events(events)
}
}
"turn.failed" => ParseResult::Events(vec![StreamEvent::Result {
subtype: ResultSubtype::Error,
cost_usd: None,
duration_secs: None,
}]),
"thread.started" | "turn.started" | "item.updated" => ParseResult::Skipped,
"text" if v.get("sessionID").is_some() => match parse_opencode_text(&v) {
Some(event) => ParseResult::Events(vec![event]),
None => ParseResult::Skipped,
},
"skill_start" => ParseResult::Skipped,
"skill_finish" => ParseResult::Events(parse_opencode_finish(&v)),
"result" => ParseResult::Events(parse_result(&v)),
"error" => ParseResult::Skipped,
_ => ParseResult::Skipped,
}
}
}
fn parse_claude_assistant(v: &serde_json::Value) -> Vec<StreamEvent> {
let mut events = Vec::new();
let content = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array());
let Some(blocks) = content else {
return events;
};
for block in blocks {
let block_type = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
events.push(StreamEvent::Text(text.to_string()));
}
}
}
"tool_use" => {
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("unknown")
.to_string();
let input = block.get("input").cloned().unwrap_or_default();
let summary = summarize_tool(&name, &input);
events.push(StreamEvent::ToolUse { name, summary });
}
_ => {}
}
}
events
}
fn parse_codex_item(v: &serde_json::Value) -> Vec<StreamEvent> {
let item = match v.get("item") {
Some(item) => item,
None => return vec![],
};
let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
let event_type = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
match item_type {
"agent_message" | "assistant_message" | "message" => {
if event_type == "item.completed" {
if let Some(text) = extract_codex_text(item) {
return vec![StreamEvent::Text(text)];
}
}
vec![]
}
"command_execution" => {
if event_type == "item.started" {
let cmd = item.get("command").and_then(|c| c.as_str()).unwrap_or("");
return vec![StreamEvent::ToolUse {
name: "Bash".to_string(),
summary: truncate(cmd, 60),
}];
}
vec![]
}
"file_change" => {
if event_type == "item.completed" {
if let Some(changes) = item.get("changes").and_then(|c| c.as_array()) {
let paths: Vec<&str> = changes
.iter()
.filter_map(|c| c.get("path").and_then(|p| p.as_str()))
.take(3)
.collect();
let summary = paths.join(", ");
return vec![StreamEvent::ToolUse {
name: "Edit".to_string(),
summary,
}];
}
}
vec![]
}
"mcp_tool_call" => {
if event_type == "item.started" {
let tool = item
.get("tool")
.and_then(|t| t.as_str())
.unwrap_or("unknown");
let server = item.get("server").and_then(|s| s.as_str()).unwrap_or("");
let summary = if server.is_empty() {
String::new()
} else {
server.to_string()
};
return vec![StreamEvent::ToolUse {
name: tool.to_string(),
summary,
}];
}
vec![]
}
_ => vec![],
}
}
fn parse_codex_turn_completed(v: &serde_json::Value) -> Vec<StreamEvent> {
let mut events = Vec::new();
if let Some(text) = extract_codex_turn_text(v) {
events.push(StreamEvent::Text(text));
}
if let Some(usage) = v.get("usage") {
let gross_input = json_u64(usage.get("input_tokens"));
let cached = json_u64(usage.get("cached_input_tokens"));
let output_tokens = json_u64(usage.get("output_tokens"));
if gross_input.is_some() || cached.is_some() || output_tokens.is_some() {
events.push(StreamEvent::Usage {
input_tokens: gross_input.map(|input| input.saturating_sub(cached.unwrap_or(0))),
output_tokens,
cache_read_tokens: cached,
});
}
}
events.push(StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: None,
duration_secs: None,
});
events
}
fn extract_codex_text(item: &serde_json::Value) -> Option<String> {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
return Some(text.to_string());
}
}
if let Some(message) = item.get("message").and_then(|m| m.as_str()) {
if !message.is_empty() {
return Some(message.to_string());
}
}
if let Some(content) = item.get("content").and_then(|c| c.as_array()) {
let mut parts = Vec::new();
for block in content {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
parts.push(text);
}
}
}
if !parts.is_empty() {
return Some(parts.join(""));
}
}
None
}
fn extract_codex_turn_text(v: &serde_json::Value) -> Option<String> {
let candidates = [
v.get("output_text"),
v.get("text"),
v.get("final_text"),
v.get("response").and_then(|r| r.get("text")),
v.get("response").and_then(|r| r.get("output_text")),
];
for candidate in candidates.into_iter().flatten() {
if let Some(text) = candidate.as_str() {
if !text.is_empty() {
return Some(text.to_string());
}
}
}
None
}
fn parse_opencode_text(v: &serde_json::Value) -> Option<StreamEvent> {
let text = v
.get("part")
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())?;
if text.is_empty() {
return None;
}
Some(StreamEvent::Text(text.to_string()))
}
fn parse_opencode_finish(v: &serde_json::Value) -> Vec<StreamEvent> {
let part = v.get("part");
let cost = part.and_then(|p| p.get("cost")).and_then(|c| c.as_f64());
let mut events = Vec::new();
if let Some(tokens) = part.and_then(|p| p.get("tokens")) {
let input_tokens = json_u64(tokens.get("input"));
let output_tokens = json_u64(tokens.get("output"));
let cache_read_tokens = tokens
.get("cache")
.and_then(|c| json_u64(c.get("read")))
.or_else(|| json_u64(tokens.get("cache_read")));
if input_tokens.is_some() || output_tokens.is_some() || cache_read_tokens.is_some() {
events.push(StreamEvent::Usage {
input_tokens,
output_tokens,
cache_read_tokens,
});
}
}
events.push(StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: cost,
duration_secs: None,
});
events
}
fn json_u64(value: Option<&serde_json::Value>) -> Option<u64> {
value.and_then(|v| v.as_u64())
}
fn parse_token_usage(usage: &serde_json::Value) -> Option<StreamEvent> {
let input_tokens = json_u64(usage.get("input_tokens"));
let output_tokens = json_u64(usage.get("output_tokens"));
let cache_read_tokens = json_u64(usage.get("cache_read_input_tokens"))
.or_else(|| json_u64(usage.get("cache_read_tokens")));
if input_tokens.is_none() && output_tokens.is_none() && cache_read_tokens.is_none() {
return None;
}
Some(StreamEvent::Usage {
input_tokens,
output_tokens,
cache_read_tokens,
})
}
fn parse_result(v: &serde_json::Value) -> Vec<StreamEvent> {
let subtype_str = v.get("subtype").and_then(|s| s.as_str());
let subtype = match subtype_str {
Some("error") => ResultSubtype::Error,
_ => ResultSubtype::Success,
};
let cost_usd = v
.get("total_cost_usd")
.or_else(|| v.get("cost_usd"))
.and_then(|c| c.as_f64());
let duration_secs = v.get("duration_secs").and_then(|d| d.as_f64()).or_else(|| {
v.get("duration_ms")
.and_then(|d| d.as_f64())
.map(|ms| ms / 1000.0)
});
let mut events = Vec::new();
if let Some(usage) = v.get("usage").and_then(parse_token_usage) {
events.push(usage);
}
events.push(StreamEvent::Result {
subtype,
cost_usd,
duration_secs,
});
events
}
fn summarize_tool(name: &str, input: &serde_json::Value) -> String {
match name {
"Read" | "Edit" | "Write" => input
.get("file_path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"Bash" => input
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
truncate(cmd, 60)
}),
"Grep" => {
let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
match input.get("path").and_then(|v| v.as_str()) {
Some(path) => format!("{pattern} {path}"),
None => pattern.to_string(),
}
}
"Glob" => input
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"Task" => input
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"WebFetch" => input
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
"WebSearch" => input
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => String::new(),
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let end = s
.char_indices()
.nth(max)
.map(|(idx, _)| idx)
.unwrap_or(s.len());
format!("{}...", &s[..end])
}
}
pub fn render_event(event: &StreamEvent, use_color: bool) -> (String, String) {
match event {
StreamEvent::Text(text) => {
if text.ends_with('\n') {
(text.clone(), String::new())
} else {
(format!("{text}\n"), String::new())
}
}
StreamEvent::ToolUse { name, summary } => {
let line = if summary.is_empty() {
format!("-> {name}")
} else {
format!("-> {name} {summary}")
};
if use_color {
(String::new(), format!("\x1b[90m{line}\x1b[0m"))
} else {
(String::new(), line)
}
}
StreamEvent::Usage { .. } => (String::new(), String::new()),
StreamEvent::Result {
subtype,
cost_usd,
duration_secs,
} => {
let line = match subtype {
ResultSubtype::Success => {
let mut parts = Vec::new();
if let Some(cost) = cost_usd {
parts.push(format!("{cost:.2} USD"));
}
if let Some(dur) = duration_secs {
let secs = dur.round() as u64;
if secs >= 60 {
parts.push(format!("{}m{}s", secs / 60, secs % 60));
} else {
parts.push(format!("{secs}s"));
}
}
if parts.is_empty() {
"ok".to_string()
} else {
format!("ok ({})", parts.join(", "))
}
}
ResultSubtype::Error => "failed".to_string(),
};
if use_color {
let color = match subtype {
ResultSubtype::Success => "\x1b[32m",
ResultSubtype::Error => "\x1b[31m",
};
(String::new(), format!("{color}{line}\x1b[0m"))
} else {
(String::new(), line)
}
}
}
}
pub fn format_event(event: &StreamEvent, use_color: bool) {
let (stdout_str, stderr_str) = render_event(event, use_color);
if !stdout_str.is_empty() {
print!("{stdout_str}");
let _ = std::io::stdout().flush();
}
if !stderr_str.is_empty() {
eprintln!("{stderr_str}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn claude_assistant_text() {
let mut parser = StreamParser::new();
let line =
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello world"}]}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::Text("hello world".to_string())])
);
}
#[test]
fn claude_assistant_tool_use() {
let mut parser = StreamParser::new();
let line = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/main.rs"}}]}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::ToolUse {
name: "Read".to_string(),
summary: "/src/main.rs".to_string(),
}])
);
}
#[test]
fn claude_assistant_mixed_content() {
let mut parser = StreamParser::new();
let line = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"reading file"},{"type":"tool_use","name":"Bash","input":{"command":"cargo test","description":"Run tests"}}]}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![
StreamEvent::Text("reading file".to_string()),
StreamEvent::ToolUse {
name: "Bash".to_string(),
summary: "Run tests".to_string(),
},
])
);
}
#[test]
fn claude_system_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"system","subtype":"init","session_id":"abc"}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn claude_user_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"user","message":{"role":"user","content":[]}}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn claude_rate_limit_event_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1772157600},"uuid":"abc"}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn claude_content_block_start_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tu_1","name":"Bash"}}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn claude_content_block_delta_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"cmd\":\"ls\"}"}}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn claude_content_block_stop_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"content_block_stop","index":0}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn claude_result_success() {
let mut parser = StreamParser::new();
let line =
r#"{"type":"result","subtype":"success","total_cost_usd":0.05,"duration_ms":45200}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: Some(0.05),
duration_secs: Some(45.2),
}])
);
}
#[test]
fn claude_result_legacy_field_names() {
let mut parser = StreamParser::new();
let line = r#"{"type":"result","subtype":"success","cost_usd":0.03,"duration_secs":30.0}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: Some(0.03),
duration_secs: Some(30.0),
}])
);
}
#[test]
fn claude_result_error() {
let mut parser = StreamParser::new();
let line = r#"{"type":"result","subtype":"error"}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::Result {
subtype: ResultSubtype::Error,
cost_usd: None,
duration_secs: None,
}])
);
}
#[test]
fn codex_command_started() {
let mut parser = StreamParser::new();
let line = r#"{"type":"item.started","item":{"id":"i1","type":"command_execution","command":"bash -lc 'cargo test'","status":"in_progress"}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::ToolUse {
name: "Bash".to_string(),
summary: "bash -lc 'cargo test'".to_string(),
}])
);
}
#[test]
fn codex_agent_message() {
let mut parser = StreamParser::new();
let line = r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"Done fixing the bug."}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::Text("Done fixing the bug.".to_string())])
);
}
#[test]
fn codex_file_change() {
let mut parser = StreamParser::new();
let line = r#"{"type":"item.completed","item":{"id":"i1","type":"file_change","changes":[{"path":"src/main.rs","kind":"update"}],"status":"completed"}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::ToolUse {
name: "Edit".to_string(),
summary: "src/main.rs".to_string(),
}])
);
}
#[test]
fn codex_turn_completed() {
let mut parser = StreamParser::new();
let line = r#"{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":50}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![
StreamEvent::Usage {
input_tokens: Some(100),
output_tokens: Some(50),
cache_read_tokens: None,
},
StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: None,
duration_secs: None,
}
])
);
}
#[test]
fn claude_result_emits_usage() {
let mut parser = StreamParser::new();
let line = r#"{"type":"result","subtype":"success","total_cost_usd":0.05,"usage":{"input_tokens":200,"output_tokens":80,"cache_read_input_tokens":1500}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![
StreamEvent::Usage {
input_tokens: Some(200),
output_tokens: Some(80),
cache_read_tokens: Some(1500),
},
StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: Some(0.05),
duration_secs: None,
}
])
);
}
#[test]
fn codex_turn_failed() {
let mut parser = StreamParser::new();
let line = r#"{"type":"turn.failed","error":{"message":"boom"}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::Result {
subtype: ResultSubtype::Error,
cost_usd: None,
duration_secs: None,
}])
);
}
#[test]
fn codex_thread_started_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"thread.started","thread_id":"abc"}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn parse_opencode_text_event() {
let mut parser = StreamParser::new();
let line = r#"{"type":"text","timestamp":1759406015783,"sessionID":"ses_abc","part":{"type":"text","text":"Hello world","time":{"start":1759406015783,"end":1759406015783}}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![StreamEvent::Text("Hello world".to_string())])
);
}
#[test]
fn parse_opencode_empty_text_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"text","timestamp":1759406015783,"sessionID":"ses_abc","part":{"type":"text","text":""}}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn parse_opencode_skill_start_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"skill_start","timestamp":1759406015000,"sessionID":"ses_abc","part":{"type":"skill-start"}}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn parse_opencode_skill_finish() {
let mut parser = StreamParser::new();
let line = r#"{"type":"skill_finish","timestamp":1759406020000,"sessionID":"ses_abc","part":{"type":"skill-finish","tokens":{"input":1234,"output":567},"cost":0.05}}"#;
assert_eq!(
parser.feed_line(line),
ParseResult::Events(vec![
StreamEvent::Usage {
input_tokens: Some(1234),
output_tokens: Some(567),
cache_read_tokens: None,
},
StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: Some(0.05),
duration_secs: None,
}
])
);
}
#[test]
fn parse_opencode_unknown_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"tool_call","sessionID":"ses_abc","part":{"type":"tool-call","name":"edit"}}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn parse_opencode_malformed_text_skipped() {
let mut parser = StreamParser::new();
let line = r#"{"type":"text","sessionID":"ses_abc","part":{"type":"text"}}"#;
assert_eq!(parser.feed_line(line), ParseResult::Skipped);
}
#[test]
fn non_json_passes_through() {
let mut parser = StreamParser::new();
assert_eq!(
parser.feed_line("not json at all"),
ParseResult::Passthrough
);
assert_eq!(parser.feed_line("{"), ParseResult::Passthrough);
assert_eq!(parser.feed_line(""), ParseResult::Passthrough);
}
#[test]
fn json_without_type_passes_through() {
let mut parser = StreamParser::new();
assert_eq!(
parser.feed_line(r#"{"data":"something"}"#),
ParseResult::Passthrough
);
}
#[test]
fn unknown_json_type_skipped() {
let mut parser = StreamParser::new();
assert_eq!(
parser.feed_line(r#"{"type":"some_new_thing","data":123}"#),
ParseResult::Skipped
);
}
#[test]
fn summarize_tool_bash_uses_description() {
let input =
serde_json::json!({"command": "cargo test --all", "description": "Run all tests"});
assert_eq!(summarize_tool("Bash", &input), "Run all tests");
}
#[test]
fn summarize_tool_bash_falls_back_to_command() {
let input = serde_json::json!({"command": "cargo test --all"});
assert_eq!(summarize_tool("Bash", &input), "cargo test --all");
}
#[test]
fn summarize_tool_bash_truncates_long_command() {
let long_cmd = "x".repeat(100);
let input = serde_json::json!({"command": long_cmd});
let summary = summarize_tool("Bash", &input);
assert!(summary.chars().count() <= 63); assert!(summary.ends_with("..."));
}
#[test]
fn summarize_tool_bash_truncates_on_char_boundary() {
let long_cmd = format!("{}{}{}", "x".repeat(58), "—", "y".repeat(10));
let input = serde_json::json!({"command": long_cmd});
let summary = summarize_tool("Bash", &input);
assert_eq!(summary, format!("{}...", "x".repeat(58) + "—" + "y"));
}
#[test]
fn summarize_tool_grep_with_path() {
let input = serde_json::json!({"pattern": "fn main", "path": "src/"});
assert_eq!(summarize_tool("Grep", &input), "fn main src/");
}
#[test]
fn render_text_event() {
let (stdout, stderr) = render_event(&StreamEvent::Text("hello".to_string()), false);
assert_eq!(stdout, "hello\n");
assert!(stderr.is_empty());
}
#[test]
fn render_tool_use_no_color() {
let (stdout, stderr) = render_event(
&StreamEvent::ToolUse {
name: "Read".to_string(),
summary: "/src/main.rs".to_string(),
},
false,
);
assert!(stdout.is_empty());
assert_eq!(stderr, "-> Read /src/main.rs");
}
#[test]
fn render_tool_use_with_color() {
let (_, stderr) = render_event(
&StreamEvent::ToolUse {
name: "Read".to_string(),
summary: "/src/main.rs".to_string(),
},
true,
);
assert!(stderr.contains("\x1b[90m"));
assert!(stderr.contains("-> Read /src/main.rs"));
}
#[test]
fn render_result_success_with_cost() {
let (_, stderr) = render_event(
&StreamEvent::Result {
subtype: ResultSubtype::Success,
cost_usd: Some(0.05),
duration_secs: Some(45.0),
},
false,
);
assert_eq!(stderr, "ok (0.05 USD, 45s)");
}
#[test]
fn render_result_error() {
let (_, stderr) = render_event(
&StreamEvent::Result {
subtype: ResultSubtype::Error,
cost_usd: None,
duration_secs: None,
},
false,
);
assert_eq!(stderr, "failed");
}
#[test]
fn render_result_error_with_color() {
let (_, stderr) = render_event(
&StreamEvent::Result {
subtype: ResultSubtype::Error,
cost_usd: None,
duration_secs: None,
},
true,
);
assert!(stderr.contains("\x1b[31m"));
assert!(stderr.contains("failed"));
}
}