use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EngineEvent {
TextDelta {
text: String,
},
TextDone,
ThinkingStart,
ThinkingDelta {
text: String,
},
ThinkingDone,
ResponseStart,
ToolCallStart {
id: String,
name: String,
args: Value,
is_sub_agent: bool,
},
ToolCallResult {
id: String,
name: String,
output: String,
},
SubAgentStart {
agent_name: String,
},
ApprovalRequest {
id: String,
tool_name: String,
detail: String,
preview: Option<crate::preview::DiffPreview>,
},
ActionBlocked {
tool_name: String,
detail: String,
preview: Option<crate::preview::DiffPreview>,
},
ContextUsage {
used: usize,
max: usize,
},
StatusUpdate {
model: String,
provider: String,
context_pct: f64,
approval_mode: String,
active_tools: usize,
},
Footer {
prompt_tokens: i64,
completion_tokens: i64,
cache_read_tokens: i64,
thinking_tokens: i64,
total_chars: usize,
elapsed_ms: u64,
rate: f64,
context: String,
},
SpinnerStart {
message: String,
},
SpinnerStop,
TurnStart {
turn_id: String,
},
TurnEnd {
turn_id: String,
reason: TurnEndReason,
},
LoopCapReached {
cap: u32,
recent_tools: Vec<String>,
},
Info {
message: String,
},
Warn {
message: String,
},
Error {
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TurnEndReason {
Complete,
Cancelled,
Error {
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EngineCommand {
UserPrompt {
text: String,
#[serde(default)]
images: Vec<ImageAttachment>,
},
Interrupt,
ApprovalResponse {
id: String,
decision: ApprovalDecision,
},
LoopDecision {
action: crate::loop_guard::LoopContinuation,
},
SlashCommand(SlashCommand),
Quit,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageAttachment {
pub data: String,
pub mime_type: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum ApprovalDecision {
Approve,
Reject,
RejectWithFeedback {
feedback: String,
},
}
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum SlashCommand {
Compact,
SwitchModel {
model: String,
},
SwitchProvider {
provider: String,
},
ListSessions,
DeleteSession {
id: String,
},
SetTrust {
mode: String,
},
Cost,
Memory {
action: Option<String>,
},
Help,
InjectPrompt {
text: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
#[test]
fn test_engine_event_text_delta_roundtrip() {
let event = EngineEvent::TextDelta {
text: "Hello world".into(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"text_delta\""));
let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(deserialized, EngineEvent::TextDelta { text } if text == "Hello world"));
}
#[test]
fn test_engine_event_tool_call_roundtrip() {
let event = EngineEvent::ToolCallStart {
id: "call_123".into(),
name: "Bash".into(),
args: serde_json::json!({"command": "cargo test"}),
is_sub_agent: false,
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(deserialized, EngineEvent::ToolCallStart { name, .. } if name == "Bash"));
}
#[test]
fn test_engine_event_approval_request_roundtrip() {
let event = EngineEvent::ApprovalRequest {
id: "approval_1".into(),
tool_name: "Bash".into(),
detail: "rm -rf node_modules".into(),
preview: None,
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
EngineEvent::ApprovalRequest { tool_name, .. } if tool_name == "Bash"
));
}
#[test]
fn test_engine_event_footer_roundtrip() {
let event = EngineEvent::Footer {
prompt_tokens: 4400,
completion_tokens: 251,
cache_read_tokens: 0,
thinking_tokens: 0,
total_chars: 1000,
elapsed_ms: 43200,
rate: 5.8,
context: "1.9k/32k (5%)".into(),
};
let json = serde_json::to_string(&event).unwrap();
let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
EngineEvent::Footer {
prompt_tokens: 4400,
..
}
));
}
#[test]
fn test_engine_event_simple_variants_roundtrip() {
let variants = vec![
EngineEvent::TextDone,
EngineEvent::ThinkingStart,
EngineEvent::ThinkingDone,
EngineEvent::ResponseStart,
EngineEvent::SpinnerStop,
EngineEvent::Info {
message: "hello".into(),
},
EngineEvent::Warn {
message: "careful".into(),
},
EngineEvent::Error {
message: "oops".into(),
},
];
for event in variants {
let json = serde_json::to_string(&event).unwrap();
let _: EngineEvent = serde_json::from_str(&json).unwrap();
}
}
#[test]
fn test_engine_command_user_prompt_roundtrip() {
let cmd = EngineCommand::UserPrompt {
text: "fix the bug".into(),
images: vec![],
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"type\":\"user_prompt\""));
let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
EngineCommand::UserPrompt { text, .. } if text == "fix the bug"
));
}
#[test]
fn test_engine_command_approval_roundtrip() {
let cmd = EngineCommand::ApprovalResponse {
id: "approval_1".into(),
decision: ApprovalDecision::RejectWithFeedback {
feedback: "use npm ci instead".into(),
},
};
let json = serde_json::to_string(&cmd).unwrap();
let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
EngineCommand::ApprovalResponse {
decision: ApprovalDecision::RejectWithFeedback { .. },
..
}
));
}
#[test]
fn test_engine_command_slash_commands_roundtrip() {
let commands = vec![
EngineCommand::SlashCommand(SlashCommand::Compact),
EngineCommand::SlashCommand(SlashCommand::SwitchModel {
model: "gpt-4".into(),
}),
EngineCommand::SlashCommand(SlashCommand::Cost),
EngineCommand::SlashCommand(SlashCommand::SetTrust {
mode: "yolo".into(),
}),
EngineCommand::SlashCommand(SlashCommand::Help),
EngineCommand::Interrupt,
EngineCommand::Quit,
];
for cmd in commands {
let json = serde_json::to_string(&cmd).unwrap();
let _: EngineCommand = serde_json::from_str(&json).unwrap();
}
}
#[test]
fn test_approval_decision_variants() {
let decisions = vec![
ApprovalDecision::Approve,
ApprovalDecision::Reject,
ApprovalDecision::RejectWithFeedback {
feedback: "try again".into(),
},
];
for d in decisions {
let json = serde_json::to_string(&d).unwrap();
let roundtripped: ApprovalDecision = serde_json::from_str(&json).unwrap();
assert_eq!(d, roundtripped);
}
}
#[test]
fn test_image_attachment_roundtrip() {
let img = ImageAttachment {
data: "base64data==".into(),
mime_type: "image/png".into(),
};
let json = serde_json::to_string(&img).unwrap();
let deserialized: ImageAttachment = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.mime_type, "image/png");
}
#[test]
fn test_turn_lifecycle_roundtrip() {
let start = EngineEvent::TurnStart {
turn_id: "turn-1".into(),
};
let json = serde_json::to_string(&start).unwrap();
assert!(json.contains("turn_start"));
let _: EngineEvent = serde_json::from_str(&json).unwrap();
let end_complete = EngineEvent::TurnEnd {
turn_id: "turn-1".into(),
reason: TurnEndReason::Complete,
};
let json = serde_json::to_string(&end_complete).unwrap();
let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
EngineEvent::TurnEnd {
reason: TurnEndReason::Complete,
..
}
));
let end_error = EngineEvent::TurnEnd {
turn_id: "turn-2".into(),
reason: TurnEndReason::Error {
message: "oops".into(),
},
};
let json = serde_json::to_string(&end_error).unwrap();
let _: EngineEvent = serde_json::from_str(&json).unwrap();
let end_cancelled = EngineEvent::TurnEnd {
turn_id: "turn-3".into(),
reason: TurnEndReason::Cancelled,
};
let json = serde_json::to_string(&end_cancelled).unwrap();
let _: EngineEvent = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_loop_cap_reached_roundtrip() {
let event = EngineEvent::LoopCapReached {
cap: 200,
recent_tools: vec!["Bash".into(), "Edit".into()],
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("loop_cap_reached"));
let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
EngineEvent::LoopCapReached { cap: 200, .. }
));
}
#[test]
fn test_loop_decision_roundtrip() {
use crate::loop_guard::LoopContinuation;
let cmd = EngineCommand::LoopDecision {
action: LoopContinuation::Continue50,
};
let json = serde_json::to_string(&cmd).unwrap();
let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
EngineCommand::LoopDecision {
action: LoopContinuation::Continue50
}
));
let cmd_stop = EngineCommand::LoopDecision {
action: LoopContinuation::Stop,
};
let json = serde_json::to_string(&cmd_stop).unwrap();
let _: EngineCommand = serde_json::from_str(&json).unwrap();
}
#[test]
fn test_turn_end_reason_variants() {
let reasons = vec![
TurnEndReason::Complete,
TurnEndReason::Cancelled,
TurnEndReason::Error {
message: "failed".into(),
},
];
for reason in reasons {
let json = serde_json::to_string(&reason).unwrap();
let roundtripped: TurnEndReason = serde_json::from_str(&json).unwrap();
assert_eq!(reason, roundtripped);
}
}
}