184 lines
7.7 KiB
Rust
184 lines
7.7 KiB
Rust
//! 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<String> {
|
|
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<String> = 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<String> = 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"));
|
|
}
|
|
}
|