use serde_json::Value;
#[derive(Default)]
struct MessageCollector {
messages: Vec<String>,
streaming_message: String,
replaceable_message: Option<String>,
pending_result_text: Option<String>,
}
pub(super) fn collect_messages(content: &str) -> Vec<String> {
let mut collector = MessageCollector::default();
for line in content.lines() {
let cleaned = crate::watcher::strip_terminal_escapes(line);
let Ok(value) = serde_json::from_str::<Value>(&cleaned) else {
continue;
};
collector.collect(&value);
}
collector.finish()
}
impl MessageCollector {
fn collect(&mut self, value: &Value) {
let value = if value.get("type").and_then(|k| k.as_str()) == Some("event") {
value.get("event").unwrap_or(value)
} else {
value
};
match value.get("type").and_then(|kind| kind.as_str()) {
Some("item.completed") => self.push_message(completed_agent_message(value)),
Some("message" | "message_end") => self.collect_message_event(value),
Some("message_update") => self.collect_message_update_event(value),
Some("assistant.message") => self.collect_copilot_message_event(value),
Some("assistant.message_delta") => {
self.append_streaming(copilot_delta_text(value));
}
Some("text_delta") => {
self.append_streaming(text_delta_text(value));
}
Some("assistant") => self.append_streaming(assistant_event_text(value)),
Some("text") => self.collect_text_event(value),
Some("thinking" | "thinking_start" | "thinking_delta" | "thinking_end") => {}
Some("tool.execution_start") => {
self.flush_pending();
if let Some(detail) = copilot_tool_start_message(value) {
self.messages.push(detail);
}
}
Some("tool.execution_complete") => {
self.flush_pending();
if let Some(detail) = copilot_tool_error_message(value) {
self.messages.push(detail);
}
}
Some("tool_use" | "tool_call" | "function_call" | "tool_result" | "tool_queued" | "tool_running" | "tool_completed") => {
self.flush_pending();
if let Some(detail) = tool_event_message(value) {
self.messages.push(detail);
}
}
Some("error") => {
self.flush_pending();
if let Some(detail) = error_event_message(value) {
self.messages.push(detail);
}
}
Some("result" | "turn_complete" | "completion" | "done" | "step_finish" | "turn_end" | "run_end") => {
self.flush_pending();
if let Some(text) = final_text_field(value) {
self.push_message(Some(text));
} else if let Some(text) = duplicate_prone_result_text(value) {
self.pending_result_text = Some(text);
}
}
_ => {
if let Some(text) = result_event_text(value) {
self.push_message(Some(text));
}
}
}
}
fn collect_message_update_event(&mut self, value: &Value) {
let text = extract_text_payload(value);
let Some(text) = text.filter(|text| !text.is_empty()) else {
return;
};
if !self.streaming_message.is_empty() {
if text == self.streaming_message || text.starts_with(&self.streaming_message) {
self.streaming_message = text;
return;
}
if self.streaming_message.starts_with(&text) {
return;
}
}
self.streaming_message = text;
}
fn collect_message_event(&mut self, value: &Value) {
if value.get("role").and_then(|role| role.as_str()) != Some("assistant") {
return;
}
let text = assistant_message_text(value);
if value.get("delta").and_then(|delta| delta.as_bool()) == Some(true) {
self.append_streaming(text);
} else {
self.push_message(text);
}
}
fn collect_copilot_message_event(&mut self, value: &Value) {
let text = value.pointer("/data/content").and_then(Value::as_str);
let Some(text) = text.filter(|text| !text.is_empty()) else {
self.flush_pending();
return;
};
if !self.streaming_message.is_empty() && self.streaming_message == text {
self.messages.push(std::mem::take(&mut self.streaming_message));
return;
}
self.push_message(Some(text.to_string()));
}
fn collect_text_event(&mut self, value: &Value) {
if value.pointer("/part/text").is_some() {
self.push_message(text_event_message(value));
return;
}
if let Some(text) = replaceable_text_event(value) {
self.replaceable_message = Some(text);
}
}
fn append_streaming(&mut self, text: Option<String>) {
let Some(text) = text else {
return;
};
if self.replaceable_message.is_some() {
self.flush_replaceable();
}
self.streaming_message.push_str(&text);
}
fn push_message(&mut self, text: Option<String>) {
let Some(text) = text else {
return;
};
self.flush_pending();
self.messages.push(text);
}
fn flush_pending(&mut self) {
self.flush_replaceable();
self.flush_streaming();
}
fn flush_replaceable(&mut self) {
if let Some(text) = self.replaceable_message.take() {
self.messages.push(text);
}
}
fn flush_streaming(&mut self) {
if !self.streaming_message.is_empty() {
self.messages.push(std::mem::take(&mut self.streaming_message));
}
}
fn finish(mut self) -> Vec<String> {
self.flush_pending();
if self.messages.is_empty() {
if let Some(text) = self.pending_result_text.take() {
self.messages.push(text);
}
}
self.messages
}
}
fn completed_agent_message(value: &Value) -> Option<String> {
let item = value.get("item")?;
let is_agent_message = item.get("type").and_then(|kind| kind.as_str()) == Some("agent_message");
let text = item.get("text").and_then(|text| text.as_str())?;
is_agent_message.then(|| text.to_string())
}
fn assistant_message_text(value: &Value) -> Option<String> {
value.get("content").and_then(extract_text_payload)
}
fn assistant_event_text(value: &Value) -> Option<String> {
value.pointer("/message/content").and_then(extract_text_payload)
}
fn text_event_message(value: &Value) -> Option<String> {
value.pointer("/part/text")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn replaceable_text_event(value: &Value) -> Option<String> {
value.get("content")
.and_then(extract_text_payload)
.or_else(|| value.get("text").and_then(extract_text_payload))
}
fn error_event_message(value: &Value) -> Option<String> {
[
value.get("message"),
value.pointer("/error/message"),
value.pointer("/error/details/0/message"),
]
.into_iter()
.flatten()
.find_map(extract_text_payload)
.map(|message| format!("[error] {message}"))
}
fn tool_event_message(value: &Value) -> Option<String> {
let tool = tool_name(value).unwrap_or("tool");
let error = [
value.pointer("/part/state/error"),
value.pointer("/error/message"),
value.get("error"),
]
.into_iter()
.flatten()
.find_map(extract_text_payload);
if let Some(error) = error {
return Some(format!("[{tool}] Error: {error}"));
}
let payload = [
value.pointer("/part/state/output"),
value.get("output"),
value.get("arguments"),
value.pointer("/functionCall/args"),
value.get("parameters"),
value.get("input"),
value.pointer("/tool_call"),
]
.into_iter()
.flatten()
.find_map(format_tool_payload);
payload.map(|payload| format!("[{tool}] {payload}"))
}
fn format_tool_payload(value: &Value) -> Option<String> {
if let Some(text) = extract_text_payload(value) {
return Some(truncate_payload(&text));
}
if let Value::Object(map) = value {
const PRIMARY_KEYS: &[&str] = &[
"file_path",
"path",
"directory_path",
"url",
"command",
"pattern",
"query",
"prompt",
];
for key in PRIMARY_KEYS {
if let Some(primary) = map.get(*key).and_then(Value::as_str) {
let mut out = primary.to_string();
let extras: Vec<String> = map
.iter()
.filter(|(k, _)| k.as_str() != *key)
.filter_map(|(k, v)| {
let v_str = match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => return None,
};
Some(format!("{k}={v_str}"))
})
.collect();
if !extras.is_empty() {
out.push_str(" (");
out.push_str(&extras.join(", "));
out.push(')');
}
return Some(truncate_payload(&out));
}
}
}
let raw = match value {
Value::Null => return None,
other => other.to_string(),
};
Some(truncate_payload(&raw))
}
fn truncate_payload(text: &str) -> String {
const MAX_LEN: usize = 160;
if text.chars().count() <= MAX_LEN {
return text.to_string();
}
let truncated: String = text.chars().take(MAX_LEN).collect();
format!("{truncated}…")
}
fn tool_name(value: &Value) -> Option<&str> {
value.get("tool_name")
.and_then(Value::as_str)
.or_else(|| value.get("name").and_then(Value::as_str))
.or_else(|| value.get("toolName").and_then(Value::as_str))
.or_else(|| value.pointer("/part/tool").and_then(Value::as_str))
.or_else(|| value.pointer("/functionCall/name").and_then(Value::as_str))
.or_else(|| value.get("tool").and_then(Value::as_str))
.or_else(|| value.pointer("/tool/name").and_then(Value::as_str))
.or_else(|| value.pointer("/data/toolName").and_then(Value::as_str))
}
fn copilot_delta_text(value: &Value) -> Option<String> {
value.pointer("/data/deltaContent")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn text_delta_text(value: &Value) -> Option<String> {
value
.get("delta")
.and_then(Value::as_str)
.or_else(|| value.pointer("/data/deltaContent").and_then(Value::as_str))
.map(ToOwned::to_owned)
}
fn result_event_text(value: &Value) -> Option<String> {
final_text_field(value).or_else(|| duplicate_prone_result_text(value))
}
fn final_text_field(value: &Value) -> Option<String> {
value
.get("finalText")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn duplicate_prone_result_text(value: &Value) -> Option<String> {
value
.pointer("/result/text")
.and_then(Value::as_str)
.or_else(|| value.get("result").and_then(Value::as_str))
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
}
fn copilot_tool_start_message(value: &Value) -> Option<String> {
let tool = value.pointer("/data/toolName").and_then(Value::as_str)?;
let payload = value.pointer("/data/arguments").and_then(stringify_payload)?;
Some(format!("[{tool}] {payload}"))
}
fn copilot_tool_error_message(value: &Value) -> Option<String> {
if value.pointer("/data/success").and_then(Value::as_bool) != Some(false) {
return None;
}
let tool = value.pointer("/data/toolName").and_then(Value::as_str).unwrap_or("tool");
let message = value.pointer("/data/error").and_then(Value::as_str)
.or_else(|| value.pointer("/data/result/error").and_then(Value::as_str))
.unwrap_or("unknown error");
Some(format!("[{tool}] Error: {message}"))
}
fn extract_text_payload(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(text) => Some(text.clone()),
Value::Array(items) => {
let parts = items
.iter()
.filter_map(extract_text_payload)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>();
(!parts.is_empty()).then(|| parts.concat())
}
Value::Object(map) => {
if map.get("type").and_then(Value::as_str) == Some("thinking") {
return None;
}
for key in ["text", "content", "parts", "finalText"] {
if let Some(text) = map.get(key).and_then(extract_text_payload)
&& !text.is_empty()
{
return Some(text);
}
}
None
}
_ => None,
}
}
fn stringify_payload(value: &Value) -> Option<String> {
extract_text_payload(value).or_else(|| match value {
Value::Null => None,
other => Some(other.to_string()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn collect_messages_renders_droid_tool_call_concisely() {
let line = r#"{"type":"tool_call","toolName":"Read","parameters":{"file_path":"src/main.rs","limit":250,"offset":450}}"#;
let messages = collect_messages(line);
assert_eq!(messages.len(), 1);
let msg = &messages[0];
assert!(msg.starts_with("[Read] src/main.rs"), "msg={msg}");
assert!(msg.contains("limit=250"), "msg={msg}");
assert!(msg.contains("offset=450"), "msg={msg}");
assert!(!msg.contains("\"file_path\""), "raw JSON leaked: {msg}");
}
#[test]
fn collect_messages_truncates_very_long_payloads() {
let big = "x".repeat(500);
let line = json!({
"type": "tool_call",
"toolName": "Bash",
"parameters": {"command": big.clone()}
})
.to_string();
let messages = collect_messages(&line);
assert_eq!(messages.len(), 1);
let msg = &messages[0];
assert!(msg.starts_with("[Bash] "), "msg={msg}");
assert!(msg.ends_with('…'), "expected ellipsis: {msg}");
assert!(msg.chars().count() < big.len(), "msg should be truncated");
}
#[test]
fn collect_messages_skips_droid_tool_result_to_avoid_dupes() {
let lines = "\
{\"type\":\"tool_call\",\"toolName\":\"Read\",\"parameters\":{\"file_path\":\"a.rs\"}}\n\
{\"type\":\"tool_result\",\"toolName\":\"Read\",\"output\":\"file body...\"}\n";
let messages = collect_messages(lines);
for msg in &messages {
assert!(!msg.contains("\"toolName\""), "JSON blob leaked: {msg}");
}
}
#[test]
fn collect_messages_preserves_utf8_after_stripping_ansi() {
let line = "\x1b[1m{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"I’m using\"}\x1b[0m";
assert_eq!(collect_messages(line), vec!["I’m using"]);
}
}