pub fn is_stream_json_event(line: &str) -> bool {
let trimmed = line.trim();
if !trimmed.starts_with('{') {
return false;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
return false;
};
value.get("type").and_then(|t| t.as_str()).is_some()
}
pub fn extract_text_from_stream_json(line: &str) -> Option<String> {
let trimmed = line.trim();
if !trimmed.starts_with('{') {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
let event_type = value.get("type")?.as_str()?;
match event_type {
"stream_event" => extract_from_stream_event(&value),
"assistant" => extract_from_assistant(&value),
"result" => extract_from_result(&value),
"item.completed" => extract_from_codex_item_completed(&value),
_ => None,
}
}
pub fn extract_final_text_from_stream_json_output(output: &str) -> String {
for line in output.lines().rev() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(text) = extract_text_from_stream_json(line) {
return text;
}
}
output.to_string()
}
fn extract_from_stream_event(value: &serde_json::Value) -> Option<String> {
let delta = value.get("event")?.get("delta")?;
if delta.get("type")?.as_str()? != "text_delta" {
return None;
}
let text = delta.get("text")?.as_str()?;
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}
fn extract_from_assistant(value: &serde_json::Value) -> Option<String> {
let content = value.get("message")?.get("content")?.as_array()?;
let mut parts = Vec::new();
for block in content {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
if !text.is_empty() {
parts.push(text.to_string());
}
}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(""))
}
}
fn extract_from_result(value: &serde_json::Value) -> Option<String> {
let is_error = value
.get("is_error")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_error {
return None;
}
let result = value.get("result")?.as_str()?;
if result.is_empty() {
None
} else {
Some(result.to_string())
}
}
fn extract_from_codex_item_completed(value: &serde_json::Value) -> Option<String> {
let item = value.get("item")?;
if item.get("type")?.as_str()? != "agent_message" {
return None;
}
let text = item.get("text")?.as_str()?;
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}
pub fn extract_tool_summary_from_stream_json(line: &str) -> Option<String> {
let trimmed = line.trim();
if !trimmed.starts_with('{') {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
let event_type = value.get("type")?.as_str()?;
match event_type {
"item.completed" => extract_codex_item_summary(&value),
"tool_use" => extract_tool_use_summary(&value),
"tool_result" => extract_tool_result_summary(&value),
"assistant" => extract_assistant_tool_summary(&value),
_ => None,
}
}
fn extract_codex_item_summary(value: &serde_json::Value) -> Option<String> {
let item = value.get("item")?;
let item_type = item.get("type")?.as_str()?;
match item_type {
"file_change" => extract_codex_file_change_summary(item),
_ => None,
}
}
fn extract_codex_file_change_summary(item: &serde_json::Value) -> Option<String> {
let changes = item.get("changes")?.as_array()?;
if changes.is_empty() {
return None;
}
let mut parts = Vec::new();
for change in changes.iter().take(5) {
let path = change.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let kind = change
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("change");
parts.push(format!("{}:{}", kind, path));
}
if changes.len() > 5 {
parts.push(format!("+{} more", changes.len() - 5));
}
Some(format!("[file_change] {}", parts.join(", ")))
}
fn extract_tool_use_summary(value: &serde_json::Value) -> Option<String> {
let name = value.get("name")?.as_str()?;
let input = value.get("input")?;
let name_lower = name.to_ascii_lowercase();
let mut parts = Vec::new();
parts.push(format!("[tool_use:{}]", name));
if let Some(obj) = input.as_object() {
match name_lower.as_str() {
"read" => {
let file_path = obj
.get("filePath")
.or_else(|| obj.get("file_path"))
.or_else(|| obj.get("path"))
.and_then(|v| v.as_str());
if let Some(fp) = file_path {
parts.push(format!("filePath={}", truncate_string(fp, 100)));
}
if let Some(offset) = obj.get("offset").and_then(|v| v.as_i64()) {
parts.push(format!("offset={}", offset));
}
if let Some(limit) = obj.get("limit").and_then(|v| v.as_i64()) {
parts.push(format!("limit={}", limit));
}
}
"write" | "edit" | "multiedit" => {
let file_path = obj
.get("filePath")
.or_else(|| obj.get("file_path"))
.or_else(|| obj.get("path"))
.and_then(|v| v.as_str());
if let Some(fp) = file_path {
parts.push(format!("filePath={}", truncate_string(fp, 100)));
}
let body_text = obj
.get("text")
.or_else(|| obj.get("content"))
.or_else(|| obj.get("new_string"))
.and_then(|v| v.as_str());
if let Some(body) = body_text {
parts.push(format!("chars={}", body.len()));
parts.push(format!("lines={}", body.lines().count()));
}
}
"grep" => {
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
parts.push(format!("pattern={}", truncate_string(pattern, 80)));
}
if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
parts.push(format!("path={}", truncate_string(path, 80)));
}
if let Some(glob) = obj.get("glob").and_then(|v| v.as_str()) {
parts.push(format!("glob={}", truncate_string(glob, 80)));
}
if let Some(mode) = obj.get("output_mode").and_then(|v| v.as_str()) {
parts.push(format!("output_mode={}", mode));
}
}
"glob" => {
if let Some(pattern) = obj.get("pattern").and_then(|v| v.as_str()) {
parts.push(format!("pattern={}", truncate_string(pattern, 80)));
}
if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
parts.push(format!("path={}", truncate_string(path, 80)));
}
}
"todowrite" => {
if let Some(todos) = obj.get("todos").and_then(|v| v.as_array()) {
let total = todos.len();
let done = todos
.iter()
.filter(|t| t.get("status").and_then(|s| s.as_str()) == Some("completed"))
.count();
let in_progress = todos
.iter()
.filter(|t| t.get("status").and_then(|s| s.as_str()) == Some("in_progress"))
.count();
parts.push(format!("todos={}", total));
if done > 0 {
parts.push(format!("completed={}", done));
}
if in_progress > 0 {
parts.push(format!("in_progress={}", in_progress));
}
}
}
"webfetch" => {
if let Some(url) = obj.get("url").and_then(|v| v.as_str()) {
parts.push(format!("url={}", truncate_string(url, 100)));
}
if let Some(prompt) = obj.get("prompt").and_then(|v| v.as_str()) {
parts.push(format!("prompt={}", truncate_string(prompt, 60)));
}
}
"skill" => {
let skill_name = obj
.get("name")
.and_then(|v| v.as_str())
.or_else(|| obj.get("skill").and_then(|v| v.as_str()));
if let Some(skill_name) = skill_name {
parts.push(format!("name={}", truncate_string(skill_name, 80)));
}
if let Some(args) = obj.get("args").and_then(|v| v.as_str()) {
parts.push(format!("args={}", truncate_string(args, 80)));
}
}
_ => {
let key_fields = [
"command",
"url",
"path",
"query",
"selector",
"text",
"filePath",
"pattern",
"description",
];
for key in &key_fields {
if let Some(val) = obj.get(*key) {
let val_str = match val {
serde_json::Value::String(s) => s.clone(),
_ => val.to_string(),
};
let truncated = truncate_string(&val_str, 100);
parts.push(format!("{}={}", key, truncated));
}
}
}
}
}
Some(parts.join(" "))
}
fn extract_tool_result_summary(value: &serde_json::Value) -> Option<String> {
let tool_use_id = value.get("tool_use_id").and_then(|v| v.as_str());
let content = value.get("content");
let mut parts = Vec::new();
if let Some(id) = tool_use_id {
parts.push(format!("[tool_result:{}]", id));
} else {
parts.push("[tool_result]".to_string());
}
if let Some(content_val) = content {
let content_str = match content_val {
serde_json::Value::String(s) => s.clone(),
_ => content_val.to_string(),
};
let truncated = truncate_string(&content_str, 200);
parts.push(truncated);
}
Some(parts.join(" "))
}
fn extract_assistant_tool_summary(value: &serde_json::Value) -> Option<String> {
let content = value.get("message")?.get("content")?.as_array()?;
for block in content {
if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
return extract_tool_use_summary(block);
}
}
None
}
fn truncate_string(s: &str, max_len: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_len {
s.to_string()
} else {
let truncated: String = s.chars().take(max_len).collect();
format!("{}...", truncated)
}
}
#[derive(Default)]
pub struct StreamJsonTextBuffer {
partial: String,
}
impl StreamJsonTextBuffer {
pub fn new() -> Self {
Self::default()
}
pub fn feed(&mut self, text: &str) -> Vec<String> {
self.partial.push_str(text);
self.flush_lines()
}
pub fn finalize(&mut self) -> Option<String> {
if self.partial.is_empty() {
None
} else {
Some(std::mem::take(&mut self.partial))
}
}
fn flush_lines(&mut self) -> Vec<String> {
let mut lines = Vec::new();
while let Some(pos) = self.partial.find('\n') {
let line = self.partial[..pos].to_string();
self.partial = self.partial[pos + 1..].to_string();
lines.push(line);
}
lines
}
}
pub fn process_stdout_line(line: &str, buffer: &mut StreamJsonTextBuffer) -> Vec<String> {
if let Some(text) = extract_text_from_stream_json(line) {
return buffer.feed(&text);
}
if let Some(summary) = extract_tool_summary_from_stream_json(line) {
return vec![summary];
}
if is_stream_json_event(line) {
vec![]
} else {
vec![line.to_string()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_stream_event_text_delta() {
let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello "}}}"#;
assert_eq!(
extract_text_from_stream_json(line),
Some("Hello ".to_string())
);
}
#[test]
fn test_extract_stream_event_empty_text() {
let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":""}}}"#;
assert_eq!(extract_text_from_stream_json(line), None);
}
#[test]
fn test_extract_stream_event_non_text_delta() {
let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{"}}}"#;
assert_eq!(extract_text_from_stream_json(line), None);
}
#[test]
fn test_extract_assistant_text_block() {
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello world"}]}}"#;
assert_eq!(
extract_text_from_stream_json(line),
Some("Hello world".to_string())
);
}
#[test]
fn test_extract_assistant_multiple_text_blocks() {
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello "},{"type":"text","text":"world"}]}}"#;
assert_eq!(
extract_text_from_stream_json(line),
Some("Hello world".to_string())
);
}
#[test]
fn test_extract_assistant_no_text_blocks() {
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"bash","input":{}}]}}"#;
assert_eq!(extract_text_from_stream_json(line), None);
}
#[test]
fn test_extract_result_success() {
let line = r#"{"type":"result","subtype":"success","result":"Done successfully","is_error":false}"#;
assert_eq!(
extract_text_from_stream_json(line),
Some("Done successfully".to_string())
);
}
#[test]
fn test_extract_result_error_suppressed() {
let line =
r#"{"type":"result","subtype":"error","result":"Something failed","is_error":true}"#;
assert_eq!(extract_text_from_stream_json(line), None);
}
#[test]
fn test_extract_result_empty_suppressed() {
let line = r#"{"type":"result","subtype":"success","result":"","is_error":false}"#;
assert_eq!(extract_text_from_stream_json(line), None);
}
#[test]
fn test_extract_codex_item_completed_agent_message() {
let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Hello from Codex"}}"#;
assert_eq!(
extract_text_from_stream_json(line),
Some("Hello from Codex".to_string())
);
}
#[test]
fn test_extract_codex_item_completed_non_message_suppressed() {
let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"tool_call","text":"ignore"}}"#;
assert_eq!(extract_text_from_stream_json(line), None);
}
#[test]
fn test_extract_final_text_from_stream_json_output_prefers_last_text_event() {
let output = [
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"earlier"}]}}"#,
r#"{"type":"item.completed","item":{"id":"item_0","type":"file_change","changes":[]}}"#,
r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"final from Codex"}}"#,
]
.join("\n");
assert_eq!(
extract_final_text_from_stream_json_output(&output),
"final from Codex"
);
}
#[test]
fn test_extract_final_text_from_stream_json_output_falls_back_to_raw_output() {
let output = "plain output";
assert_eq!(extract_final_text_from_stream_json_output(output), output);
}
#[test]
fn test_non_json_returns_none() {
assert_eq!(extract_text_from_stream_json("plain text"), None);
assert_eq!(extract_text_from_stream_json(""), None);
assert_eq!(extract_text_from_stream_json("not json"), None);
}
#[test]
fn test_unknown_type_returns_none() {
let line = r#"{"type":"system","subtype":"init","session_id":"abc"}"#;
assert_eq!(extract_text_from_stream_json(line), None);
}
#[test]
fn test_buffer_single_complete_line() {
let mut buf = StreamJsonTextBuffer::new();
let lines = buf.feed("Hello world\n");
assert_eq!(lines, vec!["Hello world".to_string()]);
assert_eq!(buf.finalize(), None);
}
#[test]
fn test_buffer_partial_then_complete() {
let mut buf = StreamJsonTextBuffer::new();
assert!(buf.feed("Hello ").is_empty());
assert_eq!(buf.feed("world\n"), vec!["Hello world".to_string()]);
}
#[test]
fn test_buffer_multiple_lines_in_one_chunk() {
let mut buf = StreamJsonTextBuffer::new();
let lines = buf.feed("line1\nline2\nline3\n");
assert_eq!(
lines,
vec![
"line1".to_string(),
"line2".to_string(),
"line3".to_string()
]
);
}
#[test]
fn test_buffer_partial_retained_until_finalize() {
let mut buf = StreamJsonTextBuffer::new();
let lines = buf.feed("line1\npartial");
assert_eq!(lines, vec!["line1".to_string()]);
assert_eq!(buf.finalize(), Some("partial".to_string()));
}
#[test]
fn test_buffer_finalize_empty() {
let mut buf = StreamJsonTextBuffer::new();
assert_eq!(buf.finalize(), None);
}
#[test]
fn test_process_plain_text_passthrough() {
let mut buf = StreamJsonTextBuffer::new();
let result = process_stdout_line("plain text output", &mut buf);
assert_eq!(result, vec!["plain text output".to_string()]);
}
#[test]
fn test_process_stream_event_buffers_partial() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello "}}}"#;
assert!(process_stdout_line(line, &mut buf).is_empty());
let line2 = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"world\n"}}}"#;
let result = process_stdout_line(line2, &mut buf);
assert_eq!(result, vec!["Hello world".to_string()]);
}
#[test]
fn test_process_assistant_multiline_split() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"line1\nline2\nline3"}]}}"#;
let result = process_stdout_line(line, &mut buf);
assert_eq!(result, vec!["line1".to_string(), "line2".to_string()]);
assert_eq!(buf.finalize(), Some("line3".to_string()));
}
#[test]
fn test_process_unrecognized_json_suppressed() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"system","subtype":"init"}"#;
let result = process_stdout_line(line, &mut buf);
assert!(result.is_empty());
}
#[test]
fn test_process_codex_item_completed_buffers_text() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"line1\nline2"}}"#;
let result = process_stdout_line(line, &mut buf);
assert_eq!(result, vec!["line1".to_string()]);
assert_eq!(buf.finalize(), Some("line2".to_string()));
}
#[test]
fn test_process_codex_file_change_summary() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"item.completed","item":{"id":"item_1","type":"file_change","changes":[{"path":"src/main.rs","kind":"modify"},{"path":"README.md","kind":"add"}],"status":"completed"}}"#;
let result = process_stdout_line(line, &mut buf);
assert_eq!(
result,
vec!["[file_change] modify:src/main.rs, add:README.md".to_string()]
);
}
#[test]
fn test_process_thinking_event_suppressed() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"thinking","thinking":"some internal reasoning"}"#;
assert!(process_stdout_line(line, &mut buf).is_empty());
}
#[test]
fn test_process_tool_use_event_emits_summary() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"tool_use","name":"bash","input":{"command":"ls"}}"#;
let result = process_stdout_line(line, &mut buf);
assert_eq!(result.len(), 1);
assert!(result[0].starts_with("[tool_use:bash]"));
assert!(result[0].contains("command=ls"));
}
#[test]
fn test_process_empty_line_passthrough() {
let mut buf = StreamJsonTextBuffer::new();
let result = process_stdout_line("", &mut buf);
assert_eq!(result, vec!["".to_string()]);
}
#[test]
fn test_is_stream_json_event_true() {
assert!(is_stream_json_event(
r#"{"type":"system","subtype":"init"}"#
));
assert!(is_stream_json_event(r#"{"type":"tool_use","name":"bash"}"#));
assert!(is_stream_json_event(
r#"{"type":"thinking","thinking":"..."}"#
));
assert!(is_stream_json_event(
r#"{"type":"stream_event","event":{}}"#
));
}
#[test]
fn test_is_stream_json_event_false() {
assert!(!is_stream_json_event("plain text"));
assert!(!is_stream_json_event(""));
assert!(!is_stream_json_event(r#"{"no_type": true}"#));
assert!(!is_stream_json_event("not json {"));
}
#[test]
fn test_multiline_assistant_emits_separate_log_lines() {
let mut buf = StreamJsonTextBuffer::new();
let event = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"First line\nSecond line\nThird line"}]}}"#;
let mut all_lines = process_stdout_line(event, &mut buf);
assert_eq!(
all_lines,
vec!["First line".to_string(), "Second line".to_string()]
);
if let Some(tail) = buf.finalize() {
all_lines.push(tail);
}
assert_eq!(
all_lines,
vec![
"First line".to_string(),
"Second line".to_string(),
"Third line".to_string(),
]
);
}
#[test]
fn test_streaming_deltas_multiline_emit_separate_log_lines() {
let mut buf = StreamJsonTextBuffer::new();
let delta1 = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello\n"}}}"#;
let delta2 = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"World\n"}}}"#;
let lines1 = process_stdout_line(delta1, &mut buf);
let lines2 = process_stdout_line(delta2, &mut buf);
assert_eq!(lines1, vec!["Hello".to_string()]);
assert_eq!(lines2, vec!["World".to_string()]);
assert_eq!(buf.finalize(), None);
}
#[test]
fn test_extract_tool_use_summary_bash() {
let line =
r#"{"type":"tool_use","name":"bash","id":"tool_123","input":{"command":"ls -la"}}"#;
let summary = extract_tool_summary_from_stream_json(line);
assert!(summary.is_some());
let s = summary.unwrap();
assert!(s.starts_with("[tool_use:bash]"));
assert!(s.contains("command=ls -la"));
}
#[test]
fn test_extract_tool_use_summary_read() {
let line = r#"{"type":"tool_use","name":"read","input":{"filePath":"/path/to/file.txt"}}"#;
let summary = extract_tool_summary_from_stream_json(line);
assert!(summary.is_some());
let s = summary.unwrap();
assert!(s.starts_with("[tool_use:read]"));
assert!(s.contains("filePath=/path/to/file.txt"));
}
#[test]
fn test_extract_tool_use_summary_truncates_long_values() {
let long_cmd = "a".repeat(150);
let line = format!(
r#"{{"type":"tool_use","name":"bash","input":{{"command":"{}"}}}}"#,
long_cmd
);
let summary = extract_tool_summary_from_stream_json(&line);
assert!(summary.is_some());
let s = summary.unwrap();
assert!(s.len() < line.len());
assert!(s.contains("..."));
}
#[test]
fn test_extract_tool_use_summary_truncates_utf8_values_safely() {
let long_pattern = "あ".repeat(120);
let line = format!(
r#"{{"type":"tool_use","name":"grep","input":{{"pattern":"{}"}}}}"#,
long_pattern
);
let summary = extract_tool_summary_from_stream_json(&line).unwrap();
assert!(summary.starts_with("[tool_use:grep]"));
assert!(summary.contains("pattern="));
assert!(summary.ends_with("..."));
assert!(summary.is_char_boundary(summary.len()));
}
#[test]
fn test_extract_tool_result_summary() {
let line =
r#"{"type":"tool_result","tool_use_id":"tool_123","content":"Success: file created"}"#;
let summary = extract_tool_summary_from_stream_json(line);
assert!(summary.is_some());
let s = summary.unwrap();
assert!(s.starts_with("[tool_result:tool_123]"));
assert!(s.contains("Success: file created"));
}
#[test]
fn test_extract_tool_result_truncates_long_content() {
let long_content = "x".repeat(300);
let line = format!(
r#"{{"type":"tool_result","tool_use_id":"tool_456","content":"{}"}}"#,
long_content
);
let summary = extract_tool_summary_from_stream_json(&line);
assert!(summary.is_some());
let s = summary.unwrap();
assert!(s.len() < line.len());
assert!(s.contains("..."));
}
#[test]
fn test_extract_tool_result_truncates_utf8_content_safely() {
let long_content = "界".repeat(260);
let line = format!(
r#"{{"type":"tool_result","tool_use_id":"tool_utf8","content":"{}"}}"#,
long_content
);
let summary = extract_tool_summary_from_stream_json(&line).unwrap();
assert!(summary.starts_with("[tool_result:tool_utf8]"));
assert!(summary.ends_with("..."));
assert!(summary.is_char_boundary(summary.len()));
}
#[test]
fn test_extract_assistant_tool_use_summary() {
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"grep","input":{"pattern":"error"}}]}}"#;
let summary = extract_tool_summary_from_stream_json(line);
assert!(summary.is_some());
let s = summary.unwrap();
assert!(s.starts_with("[tool_use:grep]"));
assert!(s.contains("pattern=error"));
}
#[test]
fn test_extract_tool_summary_non_tool_event_returns_none() {
let line = r#"{"type":"thinking","thinking":"some reasoning"}"#;
assert_eq!(extract_tool_summary_from_stream_json(line), None);
let line2 = r#"{"type":"system","subtype":"init"}"#;
assert_eq!(extract_tool_summary_from_stream_json(line2), None);
}
#[test]
fn test_read_tool_includes_filepath() {
let line = r#"{"type":"tool_use","name":"read","input":{"filePath":"/src/main.rs"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:read]"));
assert!(summary.contains("filePath=/src/main.rs"));
}
#[test]
fn test_read_tool_uses_path_alias_when_no_filepath() {
let line = r#"{"type":"tool_use","name":"read","input":{"path":"/etc/config.toml"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.contains("filePath=/etc/config.toml"));
}
#[test]
fn test_write_tool_includes_filepath_no_body() {
let body = "fn main() { println!(\"hello\"); }";
let json_val = serde_json::json!({
"type": "tool_use",
"name": "write",
"input": {
"filePath": "/out/foo.rs",
"text": body
}
});
let line = json_val.to_string();
let summary = extract_tool_summary_from_stream_json(&line).unwrap();
assert!(summary.starts_with("[tool_use:write]"));
assert!(summary.contains("filePath=/out/foo.rs"));
assert!(!summary.contains("fn main"));
assert!(!summary.contains("text="));
}
#[test]
fn test_write_tool_includes_chars_and_lines_metadata() {
let body = "line one\nline two\nline three";
let json_val = serde_json::json!({
"type": "tool_use",
"name": "write",
"input": {
"filePath": "/out/foo.rs",
"text": body
}
});
let line = json_val.to_string();
let summary = extract_tool_summary_from_stream_json(&line).unwrap();
assert!(summary.contains(&format!("chars={}", body.len())));
assert!(summary.contains("lines=3"));
}
#[test]
fn test_edit_tool_includes_filepath_no_old_new_string() {
let line = r#"{"type":"tool_use","name":"edit","input":{"filePath":"/src/lib.rs","old_string":"foo","new_string":"bar baz qux"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:edit]"));
assert!(summary.contains("filePath=/src/lib.rs"));
assert!(!summary.contains("old_string="));
assert!(!summary.contains("new_string="));
assert!(!summary.contains("bar baz qux"));
}
#[test]
fn test_edit_tool_new_string_body_shows_metadata() {
let new_string = "replacement content here";
let line = format!(
r#"{{"type":"tool_use","name":"edit","input":{{"filePath":"/f.rs","old_string":"x","new_string":"{}"}}}}"#,
new_string
);
let summary = extract_tool_summary_from_stream_json(&line).unwrap();
assert!(summary.contains(&format!("chars={}", new_string.len())));
}
#[test]
fn test_non_file_tool_still_includes_text_field() {
let line =
r#"{"type":"tool_use","name":"bash","input":{"command":"echo hi","text":"some info"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.contains("text=some info"));
}
#[test]
fn test_process_tool_use_summary_does_not_use_buffer() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"tool_use","name":"bash","input":{"command":"echo hello"}}"#;
let result = process_stdout_line(line, &mut buf);
assert_eq!(result.len(), 1);
assert!(result[0].starts_with("[tool_use:bash]"));
assert_eq!(buf.finalize(), None);
}
#[test]
fn test_process_tool_result_summary() {
let mut buf = StreamJsonTextBuffer::new();
let line = r#"{"type":"tool_result","tool_use_id":"tool_789","content":"File not found"}"#;
let result = process_stdout_line(line, &mut buf);
assert_eq!(result.len(), 1);
assert!(result[0].starts_with("[tool_result:tool_789]"));
assert!(result[0].contains("File not found"));
}
#[test]
fn test_glob_tool_shows_pattern_and_path() {
let line =
r#"{"type":"tool_use","name":"glob","input":{"pattern":"**/*.rs","path":"/src"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:glob]"));
assert!(summary.contains("pattern=**/*.rs"));
assert!(summary.contains("path=/src"));
}
#[test]
fn test_glob_tool_without_path() {
let line = r#"{"type":"tool_use","name":"glob","input":{"pattern":"*.toml"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:glob]"));
assert!(summary.contains("pattern=*.toml"));
assert!(!summary.contains("path="));
}
#[test]
fn test_grep_tool_shows_pattern_path_and_glob() {
let line = r#"{"type":"tool_use","name":"grep","input":{"pattern":"fn main","path":"/src","glob":"*.rs","output_mode":"files_with_matches"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:grep]"));
assert!(summary.contains("pattern=fn main"));
assert!(summary.contains("path=/src"));
assert!(summary.contains("glob=*.rs"));
assert!(summary.contains("output_mode=files_with_matches"));
}
#[test]
fn test_todowrite_tool_shows_todo_counts() {
let line = r#"{"type":"tool_use","name":"todowrite","input":{"todos":[{"content":"Task A","status":"completed"},{"content":"Task B","status":"in_progress"},{"content":"Task C","status":"pending"}]}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:todowrite]"));
assert!(summary.contains("todos=3"));
assert!(summary.contains("completed=1"));
assert!(summary.contains("in_progress=1"));
}
#[test]
fn test_todowrite_no_completed_or_inprogress_omits_those_fields() {
let line = r#"{"type":"tool_use","name":"todowrite","input":{"todos":[{"content":"Task A","status":"pending"},{"content":"Task B","status":"pending"}]}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.contains("todos=2"));
assert!(!summary.contains("completed="));
assert!(!summary.contains("in_progress="));
}
#[test]
fn test_webfetch_tool_shows_url_and_prompt() {
let line = r#"{"type":"tool_use","name":"webfetch","input":{"url":"https://example.com/docs","prompt":"Extract API endpoints"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:webfetch]"));
assert!(summary.contains("url=https://example.com/docs"));
assert!(summary.contains("prompt=Extract API endpoints"));
}
#[test]
fn test_read_tool_includes_offset_and_limit() {
let line = r#"{"type":"tool_use","name":"read","input":{"filePath":"/src/main.rs","offset":100,"limit":50}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:read]"));
assert!(summary.contains("filePath=/src/main.rs"));
assert!(summary.contains("offset=100"));
assert!(summary.contains("limit=50"));
}
#[test]
fn test_read_tool_mixed_case_matches_dedicated_formatter() {
let line = r#"{"type":"tool_use","name":"Read","input":{"filePath":"/src/lib.rs"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:Read]"));
assert!(summary.contains("filePath=/src/lib.rs"));
assert!(summary.contains("filePath="));
}
#[test]
fn test_grep_tool_uppercase_matches_dedicated_formatter() {
let line = r#"{"type":"tool_use","name":"Grep","input":{"pattern":"TODO","path":"/src"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:Grep]"));
assert!(summary.contains("pattern=TODO"));
assert!(summary.contains("path=/src"));
}
#[test]
fn test_todowrite_mixed_case_matches_dedicated_formatter() {
let line = r#"{"type":"tool_use","name":"TodoWrite","input":{"todos":[{"content":"X","status":"completed"}]}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:TodoWrite]"));
assert!(summary.contains("todos=1"));
assert!(summary.contains("completed=1"));
}
#[test]
fn test_bash_uppercase_uses_generic_fallback() {
let line = r#"{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:Bash]"));
assert!(summary.contains("command=ls -la"));
}
#[test]
fn test_unknown_tool_uses_generic_fallback_with_safe_fields() {
let line = r#"{"type":"tool_use","name":"UnknownTool","input":{"query":"search term","description":"some tool","secret_body":"sensitive data"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:UnknownTool]"));
assert!(summary.contains("query=search term"));
assert!(summary.contains("description=some tool"));
assert!(!summary.contains("secret_body"));
assert!(!summary.contains("sensitive data"));
}
#[test]
fn test_skill_tool_shows_skill_name() {
let line = r#"{"type":"tool_use","name":"skill","input":{"skill":"commit","args":"-m 'Fix bug'"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:skill]"));
assert!(summary.contains("name=commit"));
assert!(summary.contains("args=-m 'Fix bug'"));
}
#[test]
fn test_skill_tool_without_args() {
let line = r#"{"type":"tool_use","name":"skill","input":{"skill":"pdf"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:skill]"));
assert!(summary.contains("name=pdf"));
assert!(!summary.contains("args="));
}
#[test]
fn test_skill_tool_mixed_case_matches_dedicated_formatter() {
let line =
r#"{"type":"tool_use","name":"Skill","input":{"skill":"commit","args":"--dry-run"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:Skill]"));
assert!(summary.contains("name=commit"));
assert!(summary.contains("args=--dry-run"));
}
#[test]
fn test_assistant_message_skill_tool_block_shows_dedicated_summary() {
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Skill","input":{"skill":"review-pr","args":"123"}}]}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:Skill]"));
assert!(summary.contains("name=review-pr"));
assert!(summary.contains("args=123"));
}
#[test]
fn test_skill_tool_truncates_long_args() {
let long_args = "x".repeat(150);
let json_val = serde_json::json!({
"type": "tool_use",
"name": "skill",
"input": {
"skill": "my-skill",
"args": long_args
}
});
let line = json_val.to_string();
let summary = extract_tool_summary_from_stream_json(&line).unwrap();
assert!(summary.starts_with("[tool_use:skill]"));
assert!(summary.contains("name=my-skill"));
assert!(summary.contains("..."));
}
#[test]
fn test_skill_tool_input_name_field_top_level() {
let line =
r#"{"type":"tool_use","name":"skill","input":{"name":"commit","args":"-m 'Fix bug'"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:skill]"));
assert!(summary.contains("name=commit"));
assert!(summary.contains("args=-m 'Fix bug'"));
}
#[test]
fn test_skill_tool_input_name_field_assistant_block() {
let line = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Skill","input":{"name":"review-pr","args":"123"}}]}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:Skill]"));
assert!(summary.contains("name=review-pr"));
assert!(summary.contains("args=123"));
}
#[test]
fn test_write_tool_body_safety_omits_raw_content() {
let line = r#"{"type":"tool_use","name":"write","input":{"filePath":"/out/file.txt","content":"secret content here"}}"#;
let summary = extract_tool_summary_from_stream_json(line).unwrap();
assert!(summary.starts_with("[tool_use:write]"));
assert!(summary.contains("filePath=/out/file.txt"));
assert!(!summary.contains("secret content here"));
assert!(!summary.contains("content="));
assert!(summary.contains("chars="));
assert!(summary.contains("lines="));
}
}