use serde::Serialize;
use serde_json::Value;
use crate::domain::report::OutputFormat;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ActionEvent {
pub kind: String,
pub name: Option<String>,
pub input: Option<Value>,
pub output: Option<String>,
pub index: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EventsReading {
pub events: Vec<ActionEvent>,
pub source: String,
}
struct PartialEvent {
kind: &'static str,
name: Option<String>,
input: Option<Value>,
output: Option<String>,
}
impl PartialEvent {
fn into_event(self, index: usize) -> ActionEvent {
ActionEvent {
kind: self.kind.to_string(),
name: self.name,
input: self.input,
output: self.output,
index,
}
}
}
pub fn extract_events(stdout: &str, fmt: OutputFormat) -> Option<EventsReading> {
let mut events: Vec<PartialEvent> = Vec::new();
let mut recognizer: Option<&'static str> = None;
for value in json_candidates(stdout) {
if let Some((label, mut partials)) = recognize(&value) {
recognizer.get_or_insert(label);
events.append(&mut partials);
}
}
let recognizer = recognizer?;
Some(EventsReading {
source: format!("{}:{recognizer}", format_prefix(fmt)),
events: events
.into_iter()
.enumerate()
.map(|(i, pe)| pe.into_event(i))
.collect(),
})
}
pub fn events_from_value(value: &Value, start_index: usize) -> Vec<ActionEvent> {
match recognize(value) {
Some((_, partials)) => partials
.into_iter()
.enumerate()
.map(|(i, pe)| pe.into_event(start_index + i))
.collect(),
None => Vec::new(),
}
}
fn recognize(value: &Value) -> Option<(&'static str, Vec<PartialEvent>)> {
if let Some(pe) = opencode_tool_event(value) {
return Some(("opencode-parts", vec![pe]));
}
if let Some(pe) = cursor_tool_call(value) {
return Some(("cursor-tool-calls", vec![pe]));
}
if let Some(pe) = codex_command_item(value) {
return Some(("codex-items", vec![pe]));
}
let blocks = content_block_events(value);
if !blocks.is_empty() {
return Some(("content-blocks", blocks));
}
None
}
fn opencode_tool_event(value: &Value) -> Option<PartialEvent> {
let part = value.get("part").and_then(Value::as_object)?;
if part.get("type").and_then(Value::as_str) != Some("tool") {
return None;
}
let state = part.get("state").and_then(Value::as_object);
Some(PartialEvent {
kind: "tool_call",
name: part.get("tool").and_then(Value::as_str).map(str::to_string),
input: state.and_then(|s| s.get("input").cloned()),
output: state
.and_then(|s| s.get("output"))
.and_then(Value::as_str)
.map(str::to_string),
})
}
fn content_block_events(value: &Value) -> Vec<PartialEvent> {
let blocks = value
.get("message")
.and_then(|m| m.get("content"))
.or_else(|| value.get("content"))
.and_then(Value::as_array);
let Some(blocks) = blocks else {
return Vec::new();
};
let mut out = Vec::new();
for block in blocks {
let Some(obj) = block.as_object() else {
continue;
};
match obj.get("type").and_then(Value::as_str) {
Some("tool_use") => out.push(PartialEvent {
kind: "tool_call",
name: obj.get("name").and_then(Value::as_str).map(str::to_string),
input: obj.get("input").cloned(),
output: None,
}),
Some("tool_result") => out.push(PartialEvent {
kind: "tool_result",
name: None,
input: None,
output: tool_result_text(obj.get("content")),
}),
_ => {}
}
}
out
}
fn cursor_tool_call(value: &Value) -> Option<PartialEvent> {
let obj = value.as_object()?;
if obj.get("type").and_then(Value::as_str) != Some("tool_call") {
return None;
}
if obj.get("subtype").and_then(Value::as_str) != Some("completed") {
return None;
}
let tool_call = obj.get("tool_call").and_then(Value::as_object)?;
let (key, payload) = tool_call
.iter()
.find(|(k, v)| k.ends_with("ToolCall") && v.is_object())?;
let name = key.strip_suffix("ToolCall").unwrap_or(key).to_string();
let payload = payload.as_object()?;
Some(PartialEvent {
kind: "tool_call",
name: Some(name),
input: payload.get("args").cloned(),
output: payload
.get("result")
.and_then(|r| r.get("success"))
.and_then(|s| s.get("stdout"))
.and_then(Value::as_str)
.map(str::to_string),
})
}
fn codex_command_item(value: &Value) -> Option<PartialEvent> {
let obj = value.as_object()?;
if obj.get("type").and_then(Value::as_str) != Some("item.completed") {
return None;
}
let item = obj.get("item").and_then(Value::as_object)?;
if item.get("type").and_then(Value::as_str) != Some("command_execution") {
return None;
}
Some(PartialEvent {
kind: "tool_call",
name: Some("command_execution".to_string()),
input: item
.get("command")
.map(|c| serde_json::json!({ "command": c.clone() })),
output: item
.get("aggregated_output")
.and_then(Value::as_str)
.map(str::to_string),
})
}
fn tool_result_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::String(s) => Some(s.clone()),
Value::Array(items) => {
let joined = items
.iter()
.filter_map(|it| it.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
(!joined.is_empty()).then_some(joined)
}
_ => None,
}
}
fn format_prefix(fmt: OutputFormat) -> &'static str {
match fmt {
OutputFormat::Text => "text",
OutputFormat::Json => "json",
OutputFormat::StreamJson => "stream-json",
}
}
fn json_candidates(stdout: &str) -> Vec<Value> {
let trimmed = stdout.trim();
if trimmed.is_empty() {
return Vec::new();
}
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
return match value {
Value::Array(items) => items,
other => vec![other],
};
}
stdout
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line.trim()).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn opencode_tool_parts_become_ordered_tool_calls() {
let raw = concat!(
r#"{"type":"text","part":{"type":"text","text":"I'll run that."}}"#,
"\n",
r#"{"type":"tool_use","part":{"id":"p2","type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"git commit -m x"},"output":"HELLO-FROM-TOOL"}}}"#,
"\n",
r#"{"type":"step_finish","part":{"type":"step-finish","cost":0.01}}"#,
"\n",
);
let got = extract_events(raw, OutputFormat::Json).unwrap();
assert_eq!(got.source, "json:opencode-parts");
assert_eq!(got.events.len(), 1);
let ev = &got.events[0];
assert_eq!(ev.kind, "tool_call");
assert_eq!(ev.name.as_deref(), Some("bash"));
assert_eq!(ev.input, Some(json!({"command": "git commit -m x"})));
assert_eq!(ev.output.as_deref(), Some("HELLO-FROM-TOOL"));
assert_eq!(ev.index, 0);
}
#[test]
fn opencode_multiple_tool_parts_are_indexed_in_order() {
let raw = concat!(
r#"{"part":{"type":"tool","tool":"read","state":{"input":{"path":"a.rs"}}}}"#,
"\n",
r#"{"part":{"type":"tool","tool":"bash","state":{"input":{"command":"ls"},"output":"a.rs"}}}"#,
"\n",
);
let got = extract_events(raw, OutputFormat::Json).unwrap();
assert_eq!(got.events.len(), 2);
assert_eq!(got.events[0].name.as_deref(), Some("read"));
assert_eq!(got.events[0].index, 0);
assert_eq!(got.events[0].output, None); assert_eq!(got.events[1].name.as_deref(), Some("bash"));
assert_eq!(got.events[1].index, 1);
assert_eq!(got.events[1].output.as_deref(), Some("a.rs"));
}
#[test]
fn anthropic_content_blocks_yield_call_and_result_events() {
let raw = concat!(
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"ok"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"echo hi"}}]}}"#,
"\n",
r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"hi\n"}]}}"#,
"\n",
r#"{"type":"result","result":"done"}"#,
"\n",
);
let got = extract_events(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.source, "stream-json:content-blocks");
assert_eq!(got.events.len(), 2);
assert_eq!(got.events[0].kind, "tool_call");
assert_eq!(got.events[0].name.as_deref(), Some("Bash"));
assert_eq!(got.events[0].input, Some(json!({"command": "echo hi"})));
assert_eq!(got.events[0].output, None);
assert_eq!(got.events[1].kind, "tool_result");
assert_eq!(got.events[1].name, None);
assert_eq!(got.events[1].input, None);
assert_eq!(got.events[1].output.as_deref(), Some("hi\n"));
assert_eq!(got.events[1].index, 1);
}
#[test]
fn tool_result_content_array_is_joined() {
let raw = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t","content":[{"type":"text","text":"line one"},{"type":"text","text":"line two"}]}]}}"#;
let got = extract_events(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.events.len(), 1);
assert_eq!(got.events[0].output.as_deref(), Some("line one\nline two"));
}
#[test]
fn top_level_content_array_without_message_wrapper() {
let raw = r#"{"content":[{"type":"tool_use","name":"read","input":{"path":"x"}}]}"#;
let got = extract_events(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.events.len(), 1);
assert_eq!(got.events[0].name.as_deref(), Some("read"));
}
#[test]
fn cursor_tool_call_completed_event() {
let raw = concat!(
r#"{"type":"tool_call","subtype":"started","call_id":"c1","tool_call":{"shellToolCall":{"args":{"command":"echo hi"}},"toolCallId":"c1","startedAtMs":"1"}}"#,
"\n",
r#"{"type":"tool_call","subtype":"completed","call_id":"c1","tool_call":{"shellToolCall":{"args":{"command":"echo hi"},"result":{"success":{"command":"echo hi","exitCode":0,"stdout":"hi\n","stderr":""}}},"toolCallId":"c1","completedAtMs":"2"}}"#,
"\n",
r#"{"type":"result","subtype":"success","result":"done"}"#,
"\n",
);
let got = extract_events(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.source, "stream-json:cursor-tool-calls");
assert_eq!(got.events.len(), 1);
assert_eq!(got.events[0].kind, "tool_call");
assert_eq!(got.events[0].name.as_deref(), Some("shell"));
assert_eq!(got.events[0].input, Some(json!({"command": "echo hi"})));
assert_eq!(got.events[0].output.as_deref(), Some("hi\n"));
}
#[test]
fn codex_command_execution_item_event() {
let raw = concat!(
r#"{"type":"thread.started","thread_id":"th_1"}"#,
"\n",
r#"{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"/bin/bash -lc 'echo hi'","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
"\n",
r#"{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"/bin/bash -lc 'echo hi'","aggregated_output":"hi\n","exit_code":0,"status":"completed"}}"#,
"\n",
r#"{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"I ran it."}}"#,
"\n",
);
let got = extract_events(raw, OutputFormat::Json).unwrap();
assert_eq!(got.source, "json:codex-items");
assert_eq!(got.events.len(), 1);
assert_eq!(got.events[0].kind, "tool_call");
assert_eq!(got.events[0].name.as_deref(), Some("command_execution"));
assert_eq!(
got.events[0].input,
Some(json!({"command": "/bin/bash -lc 'echo hi'"}))
);
assert_eq!(got.events[0].output.as_deref(), Some("hi\n"));
}
#[test]
fn qwen_content_blocks_stream_and_json_array() {
let call = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"call_1","name":"run_shell_command","input":{"command":"echo hi"}}]}}"#;
let result = r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"call_1","is_error":false,"content":"hi"}]}}"#;
let ndjson = format!("{call}\n{result}\n");
let got = extract_events(&ndjson, OutputFormat::StreamJson).unwrap();
assert_eq!(got.source, "stream-json:content-blocks");
assert_eq!(got.events.len(), 2);
assert_eq!(got.events[0].name.as_deref(), Some("run_shell_command"));
assert_eq!(got.events[1].output.as_deref(), Some("hi"));
let array = format!("[{call},{result}]");
let got = extract_events(&array, OutputFormat::Json).unwrap();
assert_eq!(got.source, "json:content-blocks");
assert_eq!(got.events.len(), 2);
assert_eq!(got.events[0].name.as_deref(), Some("run_shell_command"));
}
#[test]
fn events_from_value_numbers_from_start_index() {
let line: Value = serde_json::from_str(
r#"{"part":{"type":"tool","tool":"bash","state":{"input":{"command":"ls"}}}}"#,
)
.unwrap();
let evs = events_from_value(&line, 5);
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].index, 5);
assert_eq!(evs[0].name.as_deref(), Some("bash"));
let noise: Value = serde_json::from_str(r#"{"type":"step_start"}"#).unwrap();
assert!(events_from_value(&noise, 0).is_empty());
}
#[test]
fn no_tool_events_yields_none() {
assert!(extract_events(r#"{"type":"result","result":"hi"}"#, OutputFormat::Json).is_none());
let text_only = concat!(
r#"{"type":"text","part":{"type":"text","text":"just prose"}}"#,
"\n",
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}"#,
"\n",
);
assert!(extract_events(text_only, OutputFormat::Json).is_none());
assert!(extract_events("not json", OutputFormat::Text).is_none());
assert!(extract_events("", OutputFormat::Json).is_none());
}
#[test]
fn skips_noise_lines_and_still_recovers_events() {
let raw = concat!(
"\n",
" \n",
"not json at all\n",
r#"{"type":"step_start","part":{"type":"step-start"}}"#,
"\n",
r#"{"part":{"type":"tool","tool":"bash","state":{"input":{"command":"ls"}}}}"#,
"\n",
);
let got = extract_events(raw, OutputFormat::Json).unwrap();
assert_eq!(got.events.len(), 1);
assert_eq!(got.events[0].index, 0);
assert_eq!(got.events[0].name.as_deref(), Some("bash"));
}
#[test]
fn tool_part_without_state_still_emits_call_with_null_fields() {
let raw = r#"{"part":{"type":"tool","tool":"webfetch"}}"#;
let got = extract_events(raw, OutputFormat::Json).unwrap();
assert_eq!(got.events[0].name.as_deref(), Some("webfetch"));
assert_eq!(got.events[0].input, None);
assert_eq!(got.events[0].output, None);
}
#[test]
fn tool_result_with_non_text_content_yields_null_output() {
let raw = r#"{"message":{"content":[{"type":"tool_result","content":{"weird":true}}]}}"#;
let got = extract_events(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.events[0].kind, "tool_result");
assert_eq!(got.events[0].output, None);
}
#[test]
fn source_prefix_tracks_output_format() {
let oc = r#"{"part":{"type":"tool","tool":"bash","state":{"input":{}}}}"#;
assert_eq!(
extract_events(oc, OutputFormat::Json).unwrap().source,
"json:opencode-parts"
);
let cb = r#"{"message":{"content":[{"type":"tool_use","name":"x","input":{}}]}}"#;
assert_eq!(
extract_events(cb, OutputFormat::Text).unwrap().source,
"text:content-blocks"
);
}
}