1use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum EngineEvent {
28 TextDelta { text: String },
31
32 TextDone,
34
35 ThinkingStart,
37
38 ThinkingDelta { text: String },
40
41 ThinkingDone,
43
44 ResponseStart,
46
47 ToolCallStart {
50 id: String,
52 name: String,
54 args: Value,
56 is_sub_agent: bool,
58 },
59
60 ToolCallResult {
62 id: String,
64 name: String,
66 output: String,
68 },
69
70 SubAgentStart { agent_name: String },
73
74 ApprovalRequest {
82 id: String,
84 tool_name: String,
86 detail: String,
88 preview: Option<crate::preview::DiffPreview>,
90 },
91
92 ActionBlocked {
94 tool_name: String,
95 detail: String,
96 preview: Option<crate::preview::DiffPreview>,
97 },
98
99 StatusUpdate {
102 model: String,
103 provider: String,
104 context_pct: f64,
105 approval_mode: String,
106 active_tools: usize,
107 },
108
109 Footer {
111 prompt_tokens: i64,
112 completion_tokens: i64,
113 cache_read_tokens: i64,
114 thinking_tokens: i64,
115 total_chars: usize,
116 elapsed_ms: u64,
117 rate: f64,
118 context: String,
119 },
120
121 SpinnerStart { message: String },
126
127 SpinnerStop,
131
132 TurnStart { turn_id: String },
138
139 TurnEnd {
144 turn_id: String,
145 reason: TurnEndReason,
146 },
147
148 LoopCapReached { cap: u32, recent_tools: Vec<String> },
153
154 Info { message: String },
157
158 Warn { message: String },
160
161 Error { message: String },
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(tag = "kind", rename_all = "snake_case")]
168pub enum TurnEndReason {
169 Complete,
171 Cancelled,
173 Error { message: String },
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(tag = "type", rename_all = "snake_case")]
191pub enum EngineCommand {
192 UserPrompt {
197 text: String,
198 #[serde(default)]
200 images: Vec<ImageAttachment>,
201 },
202
203 Interrupt,
208
209 ApprovalResponse {
211 id: String,
213 decision: ApprovalDecision,
214 },
215
216 LoopDecision {
221 action: crate::loop_guard::LoopContinuation,
222 },
223
224 SlashCommand(SlashCommand),
228
229 Quit,
233}
234
235#[allow(dead_code)]
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ImageAttachment {
239 pub data: String,
241 pub mime_type: String,
243}
244
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(tag = "decision", rename_all = "snake_case")]
248pub enum ApprovalDecision {
249 Approve,
251 Reject,
253 RejectWithFeedback { feedback: String },
255}
256
257#[allow(dead_code)]
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(tag = "cmd", rename_all = "snake_case")]
262pub enum SlashCommand {
263 Compact,
265 SwitchModel { model: String },
267 SwitchProvider { provider: String },
269 ListSessions,
271 DeleteSession { id: String },
273 SetTrust { mode: String },
275 McpCommand { args: String },
277 Cost,
279 Memory { action: Option<String> },
281 Help,
283 InjectPrompt { text: String },
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use serde_json;
291
292 #[test]
293 fn test_engine_event_text_delta_roundtrip() {
294 let event = EngineEvent::TextDelta {
295 text: "Hello world".into(),
296 };
297 let json = serde_json::to_string(&event).unwrap();
298 assert!(json.contains("\"type\":\"text_delta\""));
299 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
300 assert!(matches!(deserialized, EngineEvent::TextDelta { text } if text == "Hello world"));
301 }
302
303 #[test]
304 fn test_engine_event_tool_call_roundtrip() {
305 let event = EngineEvent::ToolCallStart {
306 id: "call_123".into(),
307 name: "Bash".into(),
308 args: serde_json::json!({"command": "cargo test"}),
309 is_sub_agent: false,
310 };
311 let json = serde_json::to_string(&event).unwrap();
312 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
313 assert!(matches!(deserialized, EngineEvent::ToolCallStart { name, .. } if name == "Bash"));
314 }
315
316 #[test]
317 fn test_engine_event_approval_request_roundtrip() {
318 let event = EngineEvent::ApprovalRequest {
319 id: "approval_1".into(),
320 tool_name: "Bash".into(),
321 detail: "rm -rf node_modules".into(),
322 preview: None,
323 };
324 let json = serde_json::to_string(&event).unwrap();
325 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
326 assert!(matches!(
327 deserialized,
328 EngineEvent::ApprovalRequest { tool_name, .. } if tool_name == "Bash"
329 ));
330 }
331
332 #[test]
333 fn test_engine_event_footer_roundtrip() {
334 let event = EngineEvent::Footer {
335 prompt_tokens: 4400,
336 completion_tokens: 251,
337 cache_read_tokens: 0,
338 thinking_tokens: 0,
339 total_chars: 1000,
340 elapsed_ms: 43200,
341 rate: 5.8,
342 context: "1.9k/32k (5%)".into(),
343 };
344 let json = serde_json::to_string(&event).unwrap();
345 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
346 assert!(matches!(
347 deserialized,
348 EngineEvent::Footer {
349 prompt_tokens: 4400,
350 ..
351 }
352 ));
353 }
354
355 #[test]
356 fn test_engine_event_simple_variants_roundtrip() {
357 let variants = vec![
358 EngineEvent::TextDone,
359 EngineEvent::ThinkingStart,
360 EngineEvent::ThinkingDone,
361 EngineEvent::ResponseStart,
362 EngineEvent::SpinnerStop,
363 EngineEvent::Info {
364 message: "hello".into(),
365 },
366 EngineEvent::Warn {
367 message: "careful".into(),
368 },
369 EngineEvent::Error {
370 message: "oops".into(),
371 },
372 ];
373 for event in variants {
374 let json = serde_json::to_string(&event).unwrap();
375 let _: EngineEvent = serde_json::from_str(&json).unwrap();
376 }
377 }
378
379 #[test]
380 fn test_engine_command_user_prompt_roundtrip() {
381 let cmd = EngineCommand::UserPrompt {
382 text: "fix the bug".into(),
383 images: vec![],
384 };
385 let json = serde_json::to_string(&cmd).unwrap();
386 assert!(json.contains("\"type\":\"user_prompt\""));
387 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
388 assert!(matches!(
389 deserialized,
390 EngineCommand::UserPrompt { text, .. } if text == "fix the bug"
391 ));
392 }
393
394 #[test]
395 fn test_engine_command_approval_roundtrip() {
396 let cmd = EngineCommand::ApprovalResponse {
397 id: "approval_1".into(),
398 decision: ApprovalDecision::RejectWithFeedback {
399 feedback: "use npm ci instead".into(),
400 },
401 };
402 let json = serde_json::to_string(&cmd).unwrap();
403 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
404 assert!(matches!(
405 deserialized,
406 EngineCommand::ApprovalResponse {
407 decision: ApprovalDecision::RejectWithFeedback { .. },
408 ..
409 }
410 ));
411 }
412
413 #[test]
414 fn test_engine_command_slash_commands_roundtrip() {
415 let commands = vec![
416 EngineCommand::SlashCommand(SlashCommand::Compact),
417 EngineCommand::SlashCommand(SlashCommand::SwitchModel {
418 model: "gpt-4".into(),
419 }),
420 EngineCommand::SlashCommand(SlashCommand::Cost),
421 EngineCommand::SlashCommand(SlashCommand::SetTrust {
422 mode: "yolo".into(),
423 }),
424 EngineCommand::SlashCommand(SlashCommand::Help),
425 EngineCommand::Interrupt,
426 EngineCommand::Quit,
427 ];
428 for cmd in commands {
429 let json = serde_json::to_string(&cmd).unwrap();
430 let _: EngineCommand = serde_json::from_str(&json).unwrap();
431 }
432 }
433
434 #[test]
435 fn test_approval_decision_variants() {
436 let decisions = vec![
437 ApprovalDecision::Approve,
438 ApprovalDecision::Reject,
439 ApprovalDecision::RejectWithFeedback {
440 feedback: "try again".into(),
441 },
442 ];
443 for d in decisions {
444 let json = serde_json::to_string(&d).unwrap();
445 let roundtripped: ApprovalDecision = serde_json::from_str(&json).unwrap();
446 assert_eq!(d, roundtripped);
447 }
448 }
449
450 #[test]
451 fn test_image_attachment_roundtrip() {
452 let img = ImageAttachment {
453 data: "base64data==".into(),
454 mime_type: "image/png".into(),
455 };
456 let json = serde_json::to_string(&img).unwrap();
457 let deserialized: ImageAttachment = serde_json::from_str(&json).unwrap();
458 assert_eq!(deserialized.mime_type, "image/png");
459 }
460
461 #[test]
462 fn test_turn_lifecycle_roundtrip() {
463 let start = EngineEvent::TurnStart {
464 turn_id: "turn-1".into(),
465 };
466 let json = serde_json::to_string(&start).unwrap();
467 assert!(json.contains("turn_start"));
468 let _: EngineEvent = serde_json::from_str(&json).unwrap();
469
470 let end_complete = EngineEvent::TurnEnd {
471 turn_id: "turn-1".into(),
472 reason: TurnEndReason::Complete,
473 };
474 let json = serde_json::to_string(&end_complete).unwrap();
475 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
476 assert!(matches!(
477 deserialized,
478 EngineEvent::TurnEnd {
479 reason: TurnEndReason::Complete,
480 ..
481 }
482 ));
483
484 let end_error = EngineEvent::TurnEnd {
485 turn_id: "turn-2".into(),
486 reason: TurnEndReason::Error {
487 message: "oops".into(),
488 },
489 };
490 let json = serde_json::to_string(&end_error).unwrap();
491 let _: EngineEvent = serde_json::from_str(&json).unwrap();
492
493 let end_cancelled = EngineEvent::TurnEnd {
494 turn_id: "turn-3".into(),
495 reason: TurnEndReason::Cancelled,
496 };
497 let json = serde_json::to_string(&end_cancelled).unwrap();
498 let _: EngineEvent = serde_json::from_str(&json).unwrap();
499 }
500
501 #[test]
502 fn test_loop_cap_reached_roundtrip() {
503 let event = EngineEvent::LoopCapReached {
504 cap: 200,
505 recent_tools: vec!["Bash".into(), "Edit".into()],
506 };
507 let json = serde_json::to_string(&event).unwrap();
508 assert!(json.contains("loop_cap_reached"));
509 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
510 assert!(matches!(
511 deserialized,
512 EngineEvent::LoopCapReached { cap: 200, .. }
513 ));
514 }
515
516 #[test]
517 fn test_loop_decision_roundtrip() {
518 use crate::loop_guard::LoopContinuation;
519
520 let cmd = EngineCommand::LoopDecision {
521 action: LoopContinuation::Continue50,
522 };
523 let json = serde_json::to_string(&cmd).unwrap();
524 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
525 assert!(matches!(
526 deserialized,
527 EngineCommand::LoopDecision {
528 action: LoopContinuation::Continue50
529 }
530 ));
531
532 let cmd_stop = EngineCommand::LoopDecision {
533 action: LoopContinuation::Stop,
534 };
535 let json = serde_json::to_string(&cmd_stop).unwrap();
536 let _: EngineCommand = serde_json::from_str(&json).unwrap();
537 }
538
539 #[test]
540 fn test_turn_end_reason_variants() {
541 let reasons = vec![
542 TurnEndReason::Complete,
543 TurnEndReason::Cancelled,
544 TurnEndReason::Error {
545 message: "failed".into(),
546 },
547 ];
548 for reason in reasons {
549 let json = serde_json::to_string(&reason).unwrap();
550 let roundtripped: TurnEndReason = serde_json::from_str(&json).unwrap();
551 assert_eq!(reason, roundtripped);
552 }
553 }
554}