use serde::{Deserialize, Serialize};
use super::msg::Msg;
use super::runtime::{ToolMetadata, ToolStatus};
use crate::models::FinishReason;
pub const RUN_EVENT_PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RunEvent {
SessionStarted {
protocol_version: u32,
cli_version: String,
model: String,
#[serde(default)]
task_id: Option<String>,
#[serde(default)]
session_id: String,
},
Text {
delta: String,
},
Reasoning {
delta: String,
},
ToolStarted {
call_id: String,
},
ToolFinished {
call_id: String,
name: String,
status: String,
summary: String,
#[serde(default)]
error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
plan: Option<PlanApproved>,
},
ApprovalRequired {
call_id: String,
tool: String,
risk: String,
prompt: String,
},
TasksUpdated {
tasks: Vec<TaskLine>,
completed: u32,
total: u32,
},
Error {
message: String,
},
TurnDone {
#[serde(default)]
total_tokens: Option<u64>,
#[serde(default)]
stop_reason: Option<String>,
},
Result {
response: String,
#[serde(default)]
reasoning: Option<String>,
total_tokens: u64,
errors: Vec<String>,
#[serde(default)]
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
structured_output: Option<serde_json::Value>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlanApproved {
pub path: String,
#[serde(default)]
pub start: bool,
#[serde(default)]
pub fresh: bool,
#[serde(default)]
pub fork: bool,
}
impl RunEvent {
pub fn from_msg(msg: &Msg) -> Option<RunEvent> {
Some(match msg {
Msg::StreamText { chunk, .. } => RunEvent::Text {
delta: chunk.clone(),
},
Msg::StreamReasoning { chunk, .. } => RunEvent::Reasoning {
delta: chunk.text.clone(),
},
Msg::ToolStarted { call_id, .. } => RunEvent::ToolStarted {
call_id: call_id.to_string(),
},
Msg::ToolFinished {
call_id, outcome, ..
} => RunEvent::ToolFinished {
call_id: call_id.to_string(),
name: tool_name(&outcome.metadata.detail),
status: status_str(outcome.status).to_string(),
summary: outcome.summary.clone(),
error: outcome.error.clone(),
plan: match &outcome.metadata.detail {
ToolMetadata::Plan {
path,
start,
fresh,
fork,
..
} => Some(PlanApproved {
path: path.clone(),
start: *start,
fresh: *fresh,
fork: *fork,
}),
_ => None,
},
},
Msg::ApprovalRequested {
call_id,
tool,
risk,
prompt,
..
} => RunEvent::ApprovalRequired {
call_id: call_id.to_string(),
tool: tool.clone(),
risk: risk.clone(),
prompt: prompt.clone(),
},
Msg::TasksUpdated { store } => {
let (completed, total) = store.counts();
RunEvent::TasksUpdated {
tasks: store.visible().map(TaskLine::from).collect(),
completed: completed as u32,
total: total as u32,
}
},
Msg::UpstreamError { error, .. } => RunEvent::Error {
message: error.message.clone(),
},
Msg::StreamDone {
usage, stop_reason, ..
} => RunEvent::TurnDone {
total_tokens: usage.as_ref().map(|u| u.total_tokens() as u64),
stop_reason: stop_reason.as_ref().map(finish_reason_str),
},
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskLine {
pub id: u32,
pub subject: String,
pub status: String,
pub active_form: String,
#[serde(default)]
pub elapsed_secs: Option<u64>,
#[serde(default)]
pub tokens_spent: Option<u64>,
#[serde(default)]
pub origin: Option<String>,
}
impl From<&crate::domain::TaskItem> for TaskLine {
fn from(task: &crate::domain::TaskItem) -> Self {
Self {
id: task.id,
subject: task.subject.clone(),
status: task.status.as_str().to_string(),
active_form: task.active_form.clone(),
elapsed_secs: task.elapsed_secs(),
tokens_spent: task.tokens_spent,
origin: Some(
match task.origin {
crate::domain::TaskOrigin::Model => "model",
crate::domain::TaskOrigin::User => "user",
}
.to_string(),
),
}
}
}
fn tool_name(detail: &ToolMetadata) -> String {
match detail {
ToolMetadata::None => "tool".to_string(),
ToolMetadata::ReadFile { .. } => "read_file".to_string(),
ToolMetadata::WriteFile { .. } => "write_file".to_string(),
ToolMetadata::ApplyPatch { .. } => "apply_patch".to_string(),
ToolMetadata::DeleteFile { .. } => "delete_file".to_string(),
ToolMetadata::CreateDirectory { .. } => "create_directory".to_string(),
ToolMetadata::WebSearch { .. } => "web_search".to_string(),
ToolMetadata::WebFetch { .. } => "web_fetch".to_string(),
ToolMetadata::ExecuteCommand { .. } => "execute_command".to_string(),
ToolMetadata::ComputerUse { .. } => "computer_use".to_string(),
ToolMetadata::Mcp { server, tool } => format!("{server}/{tool}"),
ToolMetadata::Subagent { .. } => "agent".to_string(),
ToolMetadata::Tasks { action, .. } => format!("task_{action}"),
ToolMetadata::Questions { .. } => "ask_user_question".to_string(),
ToolMetadata::Plan { .. } => "exit_plan_mode".to_string(),
ToolMetadata::Custom { name, .. } => name.clone(),
}
}
fn status_str(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Success => "success",
ToolStatus::Error => "error",
ToolStatus::Cancelled => "cancelled",
}
}
fn finish_reason_str(reason: &FinishReason) -> String {
match reason {
FinishReason::Stop => "stop".to_string(),
FinishReason::ToolUse => "tool_use".to_string(),
FinishReason::Length => "length".to_string(),
FinishReason::ContentFilter => "content_filter".to_string(),
FinishReason::Other(other) => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::ids::{ToolCallId, TurnId};
use crate::domain::runtime::ToolRunMetadata;
use crate::domain::state::ToolOutcome;
use crate::models::TokenUsage;
fn samples() -> Vec<RunEvent> {
vec![
RunEvent::SessionStarted {
protocol_version: RUN_EVENT_PROTOCOL_VERSION,
cli_version: "9.9.9".to_string(),
model: "anthropic/claude-x".to_string(),
task_id: None,
session_id: "20260709_120000_000".to_string(),
},
RunEvent::Text {
delta: "hello".to_string(),
},
RunEvent::Reasoning {
delta: "thinking".to_string(),
},
RunEvent::ToolStarted {
call_id: "tool#3".to_string(),
},
RunEvent::ToolFinished {
call_id: "tool#3".to_string(),
name: "execute_command".to_string(),
status: "success".to_string(),
summary: "command completed".to_string(),
error: None,
plan: None,
},
RunEvent::ApprovalRequired {
call_id: "tool#4".to_string(),
tool: "execute_command".to_string(),
risk: "network".to_string(),
prompt: "Run curl?".to_string(),
},
RunEvent::TasksUpdated {
tasks: vec![TaskLine {
id: 1,
subject: "wire the broker".to_string(),
status: "completed".to_string(),
active_form: "wiring the broker".to_string(),
elapsed_secs: Some(130),
tokens_spent: Some(8400),
origin: Some("model".to_string()),
}],
completed: 1,
total: 1,
},
RunEvent::Error {
message: "connection failed".to_string(),
},
RunEvent::TurnDone {
total_tokens: Some(1234),
stop_reason: Some("stop".to_string()),
},
RunEvent::Result {
response: "Hi there".to_string(),
reasoning: None,
total_tokens: 1234,
errors: vec![],
session_id: "20260709_120000_000".to_string(),
structured_output: None,
},
]
}
fn golden(ev: &RunEvent) -> &'static str {
match ev {
RunEvent::SessionStarted { .. } => {
r#"{"type":"session_started","protocol_version":1,"cli_version":"9.9.9","model":"anthropic/claude-x","task_id":null,"session_id":"20260709_120000_000"}"#
},
RunEvent::Text { .. } => r#"{"type":"text","delta":"hello"}"#,
RunEvent::Reasoning { .. } => r#"{"type":"reasoning","delta":"thinking"}"#,
RunEvent::ToolStarted { .. } => r#"{"type":"tool_started","call_id":"tool#3"}"#,
RunEvent::ToolFinished { .. } => {
r#"{"type":"tool_finished","call_id":"tool#3","name":"execute_command","status":"success","summary":"command completed","error":null}"#
},
RunEvent::ApprovalRequired { .. } => {
r#"{"type":"approval_required","call_id":"tool#4","tool":"execute_command","risk":"network","prompt":"Run curl?"}"#
},
RunEvent::TasksUpdated { .. } => {
r#"{"type":"tasks_updated","tasks":[{"id":1,"subject":"wire the broker","status":"completed","active_form":"wiring the broker","elapsed_secs":130,"tokens_spent":8400,"origin":"model"}],"completed":1,"total":1}"#
},
RunEvent::Error { .. } => r#"{"type":"error","message":"connection failed"}"#,
RunEvent::TurnDone { .. } => {
r#"{"type":"turn_done","total_tokens":1234,"stop_reason":"stop"}"#
},
RunEvent::Result { .. } => {
r#"{"type":"result","response":"Hi there","reasoning":null,"total_tokens":1234,"errors":[],"session_id":"20260709_120000_000"}"#
},
}
}
#[test]
fn wire_format_is_frozen() {
for ev in samples() {
assert_eq!(
serde_json::to_string(&ev).unwrap(),
golden(&ev),
"RunEvent wire format drifted for {ev:?}"
);
}
}
#[test]
fn every_variant_round_trips() {
for ev in samples() {
let wire = serde_json::to_string(&ev).unwrap();
let back: RunEvent = serde_json::from_str(&wire).unwrap();
assert_eq!(back, ev);
}
}
#[test]
fn every_variant_has_a_sample() {
assert_eq!(samples().len(), 10);
}
#[test]
fn result_structured_output_is_additive() {
let ev = RunEvent::Result {
response: "{\"answer\":42}".to_string(),
reasoning: None,
total_tokens: 10,
errors: vec![],
session_id: "s".to_string(),
structured_output: Some(serde_json::json!({"answer": 42})),
};
let wire = serde_json::to_string(&ev).unwrap();
assert!(
wire.contains("\"structured_output\":{\"answer\":42}"),
"{wire}"
);
let back: RunEvent = serde_json::from_str(&wire).unwrap();
assert_eq!(back, ev);
let old = r#"{"type":"result","response":"x","reasoning":null,"total_tokens":1,"errors":[],"session_id":"s"}"#;
let parsed: RunEvent = serde_json::from_str(old).unwrap();
assert!(matches!(
parsed,
RunEvent::Result {
structured_output: None,
..
}
));
}
#[test]
fn protocol_version_is_pinned() {
assert_eq!(RUN_EVENT_PROTOCOL_VERSION, 1);
}
#[test]
fn tool_name_maps_each_metadata_kind() {
assert_eq!(tool_name(&ToolMetadata::None), "tool");
assert_eq!(
tool_name(&ToolMetadata::Mcp {
server: "srv".to_string(),
tool: "do".to_string(),
}),
"srv/do"
);
assert_eq!(
tool_name(&ToolMetadata::Custom {
name: "weird".to_string(),
data: serde_json::Value::Null,
}),
"weird"
);
}
#[test]
fn from_msg_projects_streamed_and_drops_the_rest() {
let text = Msg::StreamText {
turn: TurnId(1),
chunk: "hi".to_string(),
};
assert_eq!(
RunEvent::from_msg(&text),
Some(RunEvent::Text {
delta: "hi".to_string()
})
);
assert_eq!(RunEvent::from_msg(&Msg::Tick), None);
let done = Msg::StreamDone {
turn: TurnId(1),
usage: Some(TokenUsage::provider(10, 20)),
provider_continuation: None,
stop_reason: Some(FinishReason::Stop),
};
assert_eq!(
RunEvent::from_msg(&done),
Some(RunEvent::TurnDone {
total_tokens: Some(30),
stop_reason: Some("stop".to_string()),
})
);
}
#[test]
fn from_msg_projects_tool_finished_with_name_from_metadata() {
let outcome = ToolOutcome {
status: ToolStatus::Success,
summary: "command completed".to_string(),
model_content: "out".to_string(),
error: None,
metadata: Box::new(ToolRunMetadata {
detail: ToolMetadata::ExecuteCommand {
command: "ls".to_string(),
working_dir: None,
exit_code: Some(0),
timed_out: false,
background: false,
stdout_lines: 1,
stderr_lines: 0,
detected_urls: vec![],
pid: None,
log_path: None,
denied_by_sandbox: false,
},
..ToolRunMetadata::default()
}),
artifacts: vec![],
duration_secs: Some(0.0),
};
let finished = Msg::ToolFinished {
turn: TurnId(1),
call_id: ToolCallId(3),
outcome,
};
assert_eq!(
RunEvent::from_msg(&finished),
Some(RunEvent::ToolFinished {
call_id: "tool#3".to_string(),
name: "execute_command".to_string(),
status: "success".to_string(),
summary: "command completed".to_string(),
error: None,
plan: None,
})
);
}
#[test]
fn approved_plan_rides_tool_finished_as_an_additive_payload() {
let outcome =
ToolOutcome::success("approved", "plan approved", 0.1).with_metadata(ToolRunMetadata {
detail: ToolMetadata::Plan {
path: ".mermaid/plans/x.md".to_string(),
body: "## Summary".to_string(),
start: true,
fresh: true,
fork: false,
model: None,
},
..ToolRunMetadata::default()
});
let event = RunEvent::from_msg(&Msg::ToolFinished {
turn: TurnId(1),
call_id: ToolCallId(7),
outcome,
})
.expect("mapped");
let RunEvent::ToolFinished { name, plan, .. } = &event else {
panic!("expected ToolFinished, got {event:?}");
};
assert_eq!(name, "exit_plan_mode");
let plan = plan.as_ref().expect("plan payload");
assert_eq!(plan.path, ".mermaid/plans/x.md");
assert!(plan.start && plan.fresh && !plan.fork);
let json = serde_json::to_string(&RunEvent::ToolFinished {
call_id: "tool#1".to_string(),
name: "read_file".to_string(),
status: "success".to_string(),
summary: "read".to_string(),
error: None,
plan: None,
})
.unwrap();
assert!(!json.contains("\"plan\""));
}
}