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 SubAgentEnd { agent_name: String },
76
77 ApprovalRequest {
83 id: String,
85 tool_name: String,
87 detail: String,
89 preview: Option<crate::preview::DiffPreview>,
91 whitelist_hint: Option<String>,
93 },
94
95 ActionBlocked {
97 tool_name: String,
98 detail: String,
99 preview: Option<crate::preview::DiffPreview>,
100 },
101
102 StatusUpdate {
105 model: String,
106 provider: String,
107 context_pct: f64,
108 approval_mode: String,
109 active_tools: usize,
110 },
111
112 Footer {
114 prompt_tokens: i64,
115 completion_tokens: i64,
116 cache_read_tokens: i64,
117 thinking_tokens: i64,
118 total_chars: usize,
119 elapsed_ms: u64,
120 rate: f64,
121 context: String,
122 },
123
124 SpinnerStart { message: String },
129
130 SpinnerStop,
134
135 TurnStart { turn_id: String },
141
142 TurnEnd {
147 turn_id: String,
148 reason: TurnEndReason,
149 },
150
151 LoopCapReached { cap: u32, recent_tools: Vec<String> },
156
157 TodoDisplay { content: String },
160
161 Info { message: String },
163
164 Warn { message: String },
166
167 Error { message: String },
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(tag = "kind", rename_all = "snake_case")]
174pub enum TurnEndReason {
175 Complete,
177 Cancelled,
179 Error { message: String },
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(tag = "type", rename_all = "snake_case")]
197pub enum EngineCommand {
198 UserPrompt {
203 text: String,
204 #[serde(default)]
206 images: Vec<ImageAttachment>,
207 },
208
209 Interrupt,
214
215 ApprovalResponse {
217 id: String,
219 decision: ApprovalDecision,
220 },
221
222 LoopDecision {
227 action: crate::loop_guard::LoopContinuation,
228 },
229
230 SlashCommand(SlashCommand),
234
235 Quit,
239}
240
241#[allow(dead_code)]
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ImageAttachment {
245 pub data: String,
247 pub mime_type: String,
249}
250
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253#[serde(tag = "decision", rename_all = "snake_case")]
254pub enum ApprovalDecision {
255 Approve,
257 Reject,
259 RejectWithFeedback { feedback: String },
261 AlwaysAllow,
263}
264
265#[allow(dead_code)]
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(tag = "cmd", rename_all = "snake_case")]
270pub enum SlashCommand {
271 Compact,
273 SwitchModel { model: String },
275 SwitchProvider { provider: String },
277 ListSessions,
279 DeleteSession { id: String },
281 SetTrust { mode: String },
283 McpCommand { args: String },
285 Cost,
287 Memory { action: Option<String> },
289 Help,
291 InjectPrompt { text: String },
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use serde_json;
299
300 #[test]
301 fn test_engine_event_text_delta_roundtrip() {
302 let event = EngineEvent::TextDelta {
303 text: "Hello world".into(),
304 };
305 let json = serde_json::to_string(&event).unwrap();
306 assert!(json.contains("\"type\":\"text_delta\""));
307 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
308 assert!(matches!(deserialized, EngineEvent::TextDelta { text } if text == "Hello world"));
309 }
310
311 #[test]
312 fn test_engine_event_tool_call_roundtrip() {
313 let event = EngineEvent::ToolCallStart {
314 id: "call_123".into(),
315 name: "Bash".into(),
316 args: serde_json::json!({"command": "cargo test"}),
317 is_sub_agent: false,
318 };
319 let json = serde_json::to_string(&event).unwrap();
320 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
321 assert!(matches!(deserialized, EngineEvent::ToolCallStart { name, .. } if name == "Bash"));
322 }
323
324 #[test]
325 fn test_engine_event_approval_request_roundtrip() {
326 let event = EngineEvent::ApprovalRequest {
327 id: "approval_1".into(),
328 tool_name: "Bash".into(),
329 detail: "rm -rf node_modules".into(),
330 preview: None,
331 whitelist_hint: Some("rm".into()),
332 };
333 let json = serde_json::to_string(&event).unwrap();
334 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
335 assert!(matches!(
336 deserialized,
337 EngineEvent::ApprovalRequest { tool_name, .. } if tool_name == "Bash"
338 ));
339 }
340
341 #[test]
342 fn test_engine_event_footer_roundtrip() {
343 let event = EngineEvent::Footer {
344 prompt_tokens: 4400,
345 completion_tokens: 251,
346 cache_read_tokens: 0,
347 thinking_tokens: 0,
348 total_chars: 1000,
349 elapsed_ms: 43200,
350 rate: 5.8,
351 context: "1.9k/32k (5%)".into(),
352 };
353 let json = serde_json::to_string(&event).unwrap();
354 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
355 assert!(matches!(
356 deserialized,
357 EngineEvent::Footer {
358 prompt_tokens: 4400,
359 ..
360 }
361 ));
362 }
363
364 #[test]
365 fn test_engine_event_simple_variants_roundtrip() {
366 let variants = vec![
367 EngineEvent::TextDone,
368 EngineEvent::ThinkingStart,
369 EngineEvent::ThinkingDone,
370 EngineEvent::ResponseStart,
371 EngineEvent::SpinnerStop,
372 EngineEvent::Info {
373 message: "hello".into(),
374 },
375 EngineEvent::Warn {
376 message: "careful".into(),
377 },
378 EngineEvent::Error {
379 message: "oops".into(),
380 },
381 ];
382 for event in variants {
383 let json = serde_json::to_string(&event).unwrap();
384 let _: EngineEvent = serde_json::from_str(&json).unwrap();
385 }
386 }
387
388 #[test]
389 fn test_engine_command_user_prompt_roundtrip() {
390 let cmd = EngineCommand::UserPrompt {
391 text: "fix the bug".into(),
392 images: vec![],
393 };
394 let json = serde_json::to_string(&cmd).unwrap();
395 assert!(json.contains("\"type\":\"user_prompt\""));
396 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
397 assert!(matches!(
398 deserialized,
399 EngineCommand::UserPrompt { text, .. } if text == "fix the bug"
400 ));
401 }
402
403 #[test]
404 fn test_engine_command_approval_roundtrip() {
405 let cmd = EngineCommand::ApprovalResponse {
406 id: "approval_1".into(),
407 decision: ApprovalDecision::RejectWithFeedback {
408 feedback: "use npm ci instead".into(),
409 },
410 };
411 let json = serde_json::to_string(&cmd).unwrap();
412 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
413 assert!(matches!(
414 deserialized,
415 EngineCommand::ApprovalResponse {
416 decision: ApprovalDecision::RejectWithFeedback { .. },
417 ..
418 }
419 ));
420 }
421
422 #[test]
423 fn test_engine_command_slash_commands_roundtrip() {
424 let commands = vec![
425 EngineCommand::SlashCommand(SlashCommand::Compact),
426 EngineCommand::SlashCommand(SlashCommand::SwitchModel {
427 model: "gpt-4".into(),
428 }),
429 EngineCommand::SlashCommand(SlashCommand::Cost),
430 EngineCommand::SlashCommand(SlashCommand::SetTrust {
431 mode: "yolo".into(),
432 }),
433 EngineCommand::SlashCommand(SlashCommand::Help),
434 EngineCommand::Interrupt,
435 EngineCommand::Quit,
436 ];
437 for cmd in commands {
438 let json = serde_json::to_string(&cmd).unwrap();
439 let _: EngineCommand = serde_json::from_str(&json).unwrap();
440 }
441 }
442
443 #[test]
444 fn test_approval_decision_variants() {
445 let decisions = vec![
446 ApprovalDecision::Approve,
447 ApprovalDecision::Reject,
448 ApprovalDecision::RejectWithFeedback {
449 feedback: "try again".into(),
450 },
451 ApprovalDecision::AlwaysAllow,
452 ];
453 for d in decisions {
454 let json = serde_json::to_string(&d).unwrap();
455 let roundtripped: ApprovalDecision = serde_json::from_str(&json).unwrap();
456 assert_eq!(d, roundtripped);
457 }
458 }
459
460 #[test]
461 fn test_image_attachment_roundtrip() {
462 let img = ImageAttachment {
463 data: "base64data==".into(),
464 mime_type: "image/png".into(),
465 };
466 let json = serde_json::to_string(&img).unwrap();
467 let deserialized: ImageAttachment = serde_json::from_str(&json).unwrap();
468 assert_eq!(deserialized.mime_type, "image/png");
469 }
470
471 #[test]
472 fn test_turn_lifecycle_roundtrip() {
473 let start = EngineEvent::TurnStart {
474 turn_id: "turn-1".into(),
475 };
476 let json = serde_json::to_string(&start).unwrap();
477 assert!(json.contains("turn_start"));
478 let _: EngineEvent = serde_json::from_str(&json).unwrap();
479
480 let end_complete = EngineEvent::TurnEnd {
481 turn_id: "turn-1".into(),
482 reason: TurnEndReason::Complete,
483 };
484 let json = serde_json::to_string(&end_complete).unwrap();
485 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
486 assert!(matches!(
487 deserialized,
488 EngineEvent::TurnEnd {
489 reason: TurnEndReason::Complete,
490 ..
491 }
492 ));
493
494 let end_error = EngineEvent::TurnEnd {
495 turn_id: "turn-2".into(),
496 reason: TurnEndReason::Error {
497 message: "oops".into(),
498 },
499 };
500 let json = serde_json::to_string(&end_error).unwrap();
501 let _: EngineEvent = serde_json::from_str(&json).unwrap();
502
503 let end_cancelled = EngineEvent::TurnEnd {
504 turn_id: "turn-3".into(),
505 reason: TurnEndReason::Cancelled,
506 };
507 let json = serde_json::to_string(&end_cancelled).unwrap();
508 let _: EngineEvent = serde_json::from_str(&json).unwrap();
509 }
510
511 #[test]
512 fn test_loop_cap_reached_roundtrip() {
513 let event = EngineEvent::LoopCapReached {
514 cap: 200,
515 recent_tools: vec!["Bash".into(), "Edit".into()],
516 };
517 let json = serde_json::to_string(&event).unwrap();
518 assert!(json.contains("loop_cap_reached"));
519 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
520 assert!(matches!(
521 deserialized,
522 EngineEvent::LoopCapReached { cap: 200, .. }
523 ));
524 }
525
526 #[test]
527 fn test_loop_decision_roundtrip() {
528 use crate::loop_guard::LoopContinuation;
529
530 let cmd = EngineCommand::LoopDecision {
531 action: LoopContinuation::Continue50,
532 };
533 let json = serde_json::to_string(&cmd).unwrap();
534 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
535 assert!(matches!(
536 deserialized,
537 EngineCommand::LoopDecision {
538 action: LoopContinuation::Continue50
539 }
540 ));
541
542 let cmd_stop = EngineCommand::LoopDecision {
543 action: LoopContinuation::Stop,
544 };
545 let json = serde_json::to_string(&cmd_stop).unwrap();
546 let _: EngineCommand = serde_json::from_str(&json).unwrap();
547 }
548
549 #[test]
550 fn test_turn_end_reason_variants() {
551 let reasons = vec![
552 TurnEndReason::Complete,
553 TurnEndReason::Cancelled,
554 TurnEndReason::Error {
555 message: "failed".into(),
556 },
557 ];
558 for reason in reasons {
559 let json = serde_json::to_string(&reason).unwrap();
560 let roundtripped: TurnEndReason = serde_json::from_str(&json).unwrap();
561 assert_eq!(reason, roundtripped);
562 }
563 }
564}