use serde::Serialize;
use cli_stream::ProcessEvent;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ByteRange {
pub start: u64,
pub end: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SuggestedEdit {
pub file_path: String,
pub range: ByteRange,
pub replacement: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCallStart {
pub tool_call_id: String,
pub name: String,
pub input: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolCallEnd {
pub tool_call_id: String,
pub ok: bool,
pub output: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
#[non_exhaustive]
pub enum RunEvent {
Started { run_id: String },
Session {
run_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
},
Text { run_id: String, delta: String },
Thinking { run_id: String, delta: String },
ToolStart {
run_id: String,
tool_call_id: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<String>,
},
ToolEnd {
run_id: String,
tool_call_id: String,
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
SuggestedEdits {
run_id: String,
edits: Vec<SuggestedEdit>,
},
Activity { run_id: String, message: String },
Usage {
run_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
input_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
output_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
total_tokens: Option<u64>,
},
Error { run_id: String, message: String },
Exited {
run_id: String,
exit_code: Option<i32>,
cancelled: bool,
},
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SessionInfo {
pub session_id: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UsageInfo {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub total_tokens: Option<u64>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ParsedLine {
pub text: Option<String>,
pub thinking: Option<String>,
pub session: Option<SessionInfo>,
pub tool_start: Option<ToolCallStart>,
pub tool_end: Option<ToolCallEnd>,
pub edits: Vec<SuggestedEdit>,
pub usage: Option<UsageInfo>,
pub activity: Option<String>,
}
impl ParsedLine {
pub fn is_empty(&self) -> bool {
self.text.is_none()
&& self.thinking.is_none()
&& self.session.is_none()
&& self.tool_start.is_none()
&& self.tool_end.is_none()
&& self.edits.is_empty()
&& self.usage.is_none()
&& self.activity.is_none()
}
}
pub fn normalize_process_event(
event: ProcessEvent,
mut parse_line: impl FnMut(&str) -> ParsedLine,
) -> Vec<RunEvent> {
match event {
ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
ProcessEvent::Exited {
run_id,
exit_code,
cancelled,
} => vec![RunEvent::Exited {
run_id,
exit_code,
cancelled,
}],
ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
ProcessEvent::Stderr { run_id, line } => {
let message = truncate(&line, 240);
if message.is_empty() {
vec![]
} else {
vec![RunEvent::Activity { run_id, message }]
}
}
ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
_ => Vec::new(),
}
}
pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
let mut out = Vec::new();
if let Some(session) = parsed.session {
out.push(RunEvent::Session {
run_id: run_id.to_owned(),
session_id: session.session_id,
model: session.model,
});
}
if let Some(text) = parsed.text {
out.push(RunEvent::Text {
run_id: run_id.to_owned(),
delta: text,
});
}
if let Some(thinking) = parsed.thinking {
out.push(RunEvent::Thinking {
run_id: run_id.to_owned(),
delta: thinking,
});
}
if let Some(start) = parsed.tool_start {
out.push(RunEvent::ToolStart {
run_id: run_id.to_owned(),
tool_call_id: start.tool_call_id,
name: start.name,
input: start.input,
});
}
if let Some(end) = parsed.tool_end {
out.push(RunEvent::ToolEnd {
run_id: run_id.to_owned(),
tool_call_id: end.tool_call_id,
ok: end.ok,
output: end.output,
});
}
if !parsed.edits.is_empty() {
out.push(RunEvent::SuggestedEdits {
run_id: run_id.to_owned(),
edits: parsed.edits,
});
}
if let Some(usage) = parsed.usage {
out.push(RunEvent::Usage {
run_id: run_id.to_owned(),
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
});
}
if let Some(activity) = parsed.activity {
out.push(RunEvent::Activity {
run_id: run_id.to_owned(),
message: activity,
});
}
out
}
fn truncate(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_parser(_: &str) -> ParsedLine {
ParsedLine::default()
}
#[test]
fn normalize_passes_through_lifecycle_events() {
assert!(matches!(
normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
.as_slice(),
[RunEvent::Started { .. }]
));
assert!(matches!(
normalize_process_event(
ProcessEvent::Exited {
run_id: "r".into(),
exit_code: Some(0),
cancelled: false
},
empty_parser
)
.as_slice(),
[RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
));
}
#[test]
fn stderr_becomes_truncated_activity() {
let long = "x".repeat(500);
let events = normalize_process_event(
ProcessEvent::Stderr {
run_id: "r1".into(),
line: long,
},
empty_parser,
);
match events.as_slice() {
[RunEvent::Activity { run_id, message }] => {
assert_eq!(run_id, "r1");
assert_eq!(message.chars().count(), 240);
}
other => panic!("expected one Activity, got {other:?}"),
}
assert!(normalize_process_event(
ProcessEvent::Stderr {
run_id: "r1".into(),
line: String::new(),
},
empty_parser,
)
.is_empty());
}
#[test]
fn thinking_normalizes_and_serializes() {
let events = normalize_process_event(
ProcessEvent::Stdout {
run_id: "r1".to_owned(),
line: "ignored".to_owned(),
},
|_| ParsedLine {
thinking: Some("pondering".to_owned()),
..ParsedLine::default()
},
);
assert!(matches!(
events.as_slice(),
[RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
));
let json = serde_json::to_value(RunEvent::Thinking {
run_id: "r1".to_owned(),
delta: "d".to_owned(),
})
.unwrap();
assert_eq!(json["kind"], "thinking");
assert_eq!(json["runId"], "r1");
assert_eq!(json["delta"], "d");
}
#[test]
fn run_event_serializes_with_kind_and_camelcase() {
let json = serde_json::to_value(RunEvent::Exited {
run_id: "r1".to_owned(),
exit_code: Some(2),
cancelled: true,
})
.unwrap();
assert_eq!(json["kind"], "exited");
assert_eq!(json["runId"], "r1");
assert_eq!(json["exitCode"], 2);
assert_eq!(json["cancelled"], true);
}
#[test]
fn session_normalizes_and_serializes() {
let events = normalize_process_event(
ProcessEvent::Stdout {
run_id: "r1".to_owned(),
line: "ignored".to_owned(),
},
|_| ParsedLine {
session: Some(SessionInfo {
session_id: Some("sess-1".to_owned()),
model: Some("opus".to_owned()),
}),
..ParsedLine::default()
},
);
assert!(matches!(
events.as_slice(),
[RunEvent::Session { run_id, session_id, model }]
if run_id == "r1"
&& session_id.as_deref() == Some("sess-1")
&& model.as_deref() == Some("opus")
));
let json = serde_json::to_value(RunEvent::Session {
run_id: "r1".to_owned(),
session_id: Some("sess-1".to_owned()),
model: None,
})
.unwrap();
assert_eq!(json["kind"], "session");
assert_eq!(json["sessionId"], "sess-1");
assert!(json.get("model").is_none());
}
#[test]
fn usage_normalizes_and_serializes() {
let events = normalize_process_event(
ProcessEvent::Stdout {
run_id: "r1".to_owned(),
line: "ignored".to_owned(),
},
|_| ParsedLine {
usage: Some(UsageInfo {
input_tokens: Some(10),
output_tokens: Some(20),
total_tokens: Some(30),
}),
..ParsedLine::default()
},
);
assert!(matches!(
events.as_slice(),
[RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
if run_id == "r1"
));
let json = serde_json::to_value(RunEvent::Usage {
run_id: "r1".to_owned(),
input_tokens: Some(10),
output_tokens: None,
total_tokens: Some(30),
})
.unwrap();
assert_eq!(json["kind"], "usage");
assert_eq!(json["inputTokens"], 10);
assert_eq!(json["totalTokens"], 30);
assert!(json.get("outputTokens").is_none()); }
#[test]
fn tool_io_is_carried_and_omitted_when_absent() {
let start = normalize_process_event(
ProcessEvent::Stdout {
run_id: "r1".to_owned(),
line: "ignored".to_owned(),
},
|_| ParsedLine {
tool_start: Some(ToolCallStart {
tool_call_id: "t1".to_owned(),
name: "ls".to_owned(),
input: Some("{\"dir\":\"/x\"}".to_owned()),
}),
..ParsedLine::default()
},
);
assert!(matches!(
start.as_slice(),
[RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
));
let json = serde_json::to_value(RunEvent::ToolStart {
run_id: "r1".to_owned(),
tool_call_id: "t1".to_owned(),
name: "ls".to_owned(),
input: None,
})
.unwrap();
assert_eq!(json["kind"], "toolStart");
assert_eq!(json["toolCallId"], "t1");
assert!(json.get("input").is_none());
let json = serde_json::to_value(RunEvent::ToolEnd {
run_id: "r1".to_owned(),
tool_call_id: "t1".to_owned(),
ok: true,
output: Some("done".to_owned()),
})
.unwrap();
assert_eq!(json["kind"], "toolEnd");
assert_eq!(json["output"], "done");
}
#[test]
fn suggested_edits_event_serializes_camelcase() {
let json = serde_json::to_value(RunEvent::SuggestedEdits {
run_id: "r1".to_owned(),
edits: vec![SuggestedEdit {
file_path: "a.md".to_owned(),
range: ByteRange { start: 1, end: 2 },
replacement: "x".to_owned(),
title: None,
}],
})
.unwrap();
assert_eq!(json["kind"], "suggestedEdits");
assert_eq!(json["edits"][0]["filePath"], "a.md");
assert_eq!(json["edits"][0]["range"]["start"], 1);
assert!(json["edits"][0].get("title").is_none());
}
}