//! Human-readable formatting of raw agent log entries. use crate::chat::util::truncate_at_char_boundary; /// Format a single log entry as a human-readable text line. /// /// `timestamp` is an ISO 8601 string; `event` is the flattened `AgentEvent` /// value (has `type`, `agent_name`, etc. at the top level). /// /// Returns `None` for entries that should be skipped (raw streaming noise, /// trivial status changes, empty output, etc.). #[allow(clippy::string_slice)] // timestamp[11..19]: ISO 8601 is ASCII-only, these byte offsets are always valid pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> Option { let agent_name = event .get("agent_name") .and_then(|v| v.as_str()) .unwrap_or("?"); // Extract HH:MM:SS from ISO 8601 "2026-04-10T12:48:02.123456789+00:00" let ts_short = if timestamp.len() >= 19 { ×tamp[11..19] } else { timestamp }; let pfx = format!("[{ts_short}][{agent_name}]"); match event.get("type").and_then(|v| v.as_str()) { Some("output") => { let text = event .get("text") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); if text.is_empty() { None } else { Some(format!("{pfx} {text}")) } } Some("error") => { let msg = event .get("message") .and_then(|v| v.as_str()) .unwrap_or("(unknown error)"); Some(format!("{pfx} ERROR: {msg}")) } Some("done") => Some(format!("{pfx} DONE")), Some("status") => { // Skip trivial running/started noise let status = event.get("status").and_then(|v| v.as_str()).unwrap_or("?"); match status { "running" | "started" => None, _ => Some(format!("{pfx} STATUS: {status}")), } } Some("agent_json") => { let data = event.get("data")?; match data.get("type").and_then(|v| v.as_str()) { Some("assistant") => { let mut parts: Vec = Vec::new(); if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) { for item in arr { match item.get("type").and_then(|v| v.as_str()) { Some("text") => { let text = item .get("text") .and_then(|v| v.as_str()) .unwrap_or("") .trim(); if !text.is_empty() { parts.push(format!("{pfx} {text}")); } } Some("tool_use") => { let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let input = item .get("input") .map(|v| serde_json::to_string(v).unwrap_or_default()) .unwrap_or_default(); let display = if input.len() > 200 { format!("{}...", truncate_at_char_boundary(&input, 200)) } else { input }; parts.push(format!("{pfx} TOOL: {name}({display})")); } _ => {} } } } if parts.is_empty() { None } else { Some(parts.join("\n")) } } Some("user") => { let mut parts: Vec = Vec::new(); if let Some(arr) = data.pointer("/message/content").and_then(|v| v.as_array()) { for item in arr { if item.get("type").and_then(|v| v.as_str()) != Some("tool_result") { continue; } let content_str = match item.get("content") { Some(serde_json::Value::String(s)) => s.clone(), Some(v) => v.to_string(), None => String::new(), }; let display = if content_str.len() > 500 { // Walk back to the nearest char boundary so we // don't panic when the 500-byte mark lands // inside a multi-byte UTF-8 codepoint (e.g. // box-drawing chars like '─', smart quotes, // emoji). `is_char_boundary(len)` is always // true so the loop terminates. let mut end = 500; while !content_str.is_char_boundary(end) { end -= 1; } format!( "{}... [{} chars total]", &content_str[..end], content_str.len() ) } else { content_str }; if !display.trim().is_empty() { parts.push(format!("{pfx} RESULT: {display}")); } } } if parts.is_empty() { None } else { Some(parts.join("\n")) } } _ => None, // Skip stream_event, system init, etc. } } _ => None, } } #[cfg(test)] mod tests { use super::*; use serde_json::json; /// Regression: a tool_result whose content is >500 bytes AND has a /// multi-byte UTF-8 codepoint straddling byte 500 must not panic. /// Previously `&content_str[..500]` would slice mid-codepoint and crash /// the get_agent_output MCP tool. #[test] fn tool_result_truncation_handles_multibyte_at_boundary() { // 498 ASCII filler + a 3-byte '─' (U+2500) starting at byte 499 + // 100 more ASCII chars. The naive `..500` slice would land inside // the box-drawing char and panic. let mut content = "a".repeat(499); content.push('─'); content.push_str(&"b".repeat(100)); assert!(content.len() > 500); assert!(!content.is_char_boundary(500)); let event = json!({ "type": "agent_json", "agent_name": "coder-1", "data": { "type": "user", "message": { "content": [{ "type": "tool_result", "content": content }] } } }); let out = format_log_entry_as_text("2026-05-12T15:30:00.000000Z", &event); assert!(out.is_some(), "tool_result must format without panicking"); let s = out.unwrap(); assert!(s.contains("RESULT:")); assert!(s.contains("chars total")); } }