Skip to main content

koda_core/engine/
event.rs

1//! Protocol types for engine ↔ client communication.
2//!
3//! These types form the contract between the Koda engine and any client surface.
4//! They are serde-serializable so they can be sent over in-process channels
5//! (CLI mode) or over the wire (ACP server mode).
6//!
7//! # Design Principles
8//!
9//! - **Semantic, not presentational**: Events describe *what happened*, not
10//!   *how to render it*. The client decides formatting.
11//! - **Bidirectional**: The engine emits `EngineEvent`s and accepts `EngineCommand`s.
12//!   Some commands (like approval) are request/response pairs.
13//! - **Serde-first**: All types derive `Serialize`/`Deserialize` for future
14//!   wire transport (ACP/WebSocket).
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19// ── Engine → Client ──────────────────────────────────────────────────────
20
21/// Events emitted by the engine to the client.
22///
23/// The client is responsible for rendering these events appropriately
24/// for its medium (terminal, GUI, JSON stream, etc.).
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum EngineEvent {
28    // ── Streaming LLM output ──────────────────────────────────────────
29    /// A chunk of streaming text from the LLM response.
30    TextDelta { text: String },
31
32    /// The LLM finished streaming text. Flush any buffered output.
33    TextDone,
34
35    /// The LLM started a thinking/reasoning block.
36    ThinkingStart,
37
38    /// A chunk of thinking/reasoning content.
39    ThinkingDelta { text: String },
40
41    /// The thinking/reasoning block finished.
42    ThinkingDone,
43
44    /// The LLM response section is starting (shown after thinking ends).
45    ResponseStart,
46
47    // ── Tool execution ────────────────────────────────────────────────
48    /// A tool call is about to be executed.
49    ToolCallStart {
50        /// Unique ID for this tool call (from the LLM).
51        id: String,
52        /// Tool name (e.g., "Bash", "Read", "Edit").
53        name: String,
54        /// Tool arguments as JSON.
55        args: Value,
56        /// Whether this is a sub-agent's tool call.
57        is_sub_agent: bool,
58    },
59
60    /// A tool call completed with output.
61    ToolCallResult {
62        /// Matches the `id` from `ToolCallStart`.
63        id: String,
64        /// Tool name.
65        name: String,
66        /// The tool's output text.
67        output: String,
68    },
69
70    // ── Sub-agent delegation ──────────────────────────────────────────
71    /// A sub-agent is being invoked.
72    SubAgentStart { agent_name: String },
73
74    /// A sub-agent finished.
75
76    // ── Approval flow ─────────────────────────────────────────────────
77    /// The engine needs user approval before executing a tool.
78    ///
79    /// The client must respond with `EngineCommand::ApprovalResponse`
80    /// matching the same `id`.
81    ApprovalRequest {
82        /// Unique ID for this approval request.
83        id: String,
84        /// Tool name requiring approval.
85        tool_name: String,
86        /// Human-readable description of the action.
87        detail: String,
88        /// Structured diff preview (rendered by the client).
89        preview: Option<crate::preview::DiffPreview>,
90    },
91
92    /// An action was blocked by safe mode (shown but not executed).
93    ActionBlocked {
94        tool_name: String,
95        detail: String,
96        preview: Option<crate::preview::DiffPreview>,
97    },
98
99    // ── Session metadata ──────────────────────────────────────────────
100    /// Progress/status update for the persistent status bar.
101    StatusUpdate {
102        model: String,
103        provider: String,
104        context_pct: f64,
105        approval_mode: String,
106        active_tools: usize,
107    },
108
109    /// Inference completion footer with timing and token stats.
110    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    /// Spinner/progress indicator (presentational hint).
122    ///
123    /// Clients may render this as a terminal spinner, a status bar update,
124    /// or ignore it entirely. The ratatui TUI uses the status bar instead.
125    SpinnerStart { message: String },
126
127    /// Stop the spinner (presentational hint).
128    ///
129    /// See `SpinnerStart` — clients may ignore this.
130    SpinnerStop,
131
132    // ── Turn lifecycle ─────────────────────────────────────────────────
133    /// An inference turn is starting.
134    ///
135    /// Emitted at the beginning of `inference_loop()`. Clients can use this
136    /// to lock input, start timers, or update status indicators.
137    TurnStart { turn_id: String },
138
139    /// An inference turn has ended.
140    ///
141    /// Emitted when `inference_loop()` completes. Clients can use this to
142    /// unlock input, drain type-ahead queues, or update status.
143    TurnEnd {
144        turn_id: String,
145        reason: TurnEndReason,
146    },
147
148    /// The engine's iteration hard cap was reached.
149    ///
150    /// The client must respond with `EngineCommand::LoopDecision`.
151    /// Until the client responds, the inference loop is paused.
152    LoopCapReached { cap: u32, recent_tools: Vec<String> },
153
154    // ── Messages ──────────────────────────────────────────────────────
155    /// Informational message (not from the LLM).
156    Info { message: String },
157
158    /// Warning message.
159    Warn { message: String },
160
161    /// Error message.
162    Error { message: String },
163}
164
165/// Why an inference turn ended.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(tag = "kind", rename_all = "snake_case")]
168pub enum TurnEndReason {
169    /// The LLM produced a final text response (no more tool calls).
170    Complete,
171    /// The user or system cancelled the turn.
172    Cancelled,
173    /// The turn failed with an error.
174    Error { message: String },
175}
176
177// ── Client → Engine ──────────────────────────────────────────────────────
178
179/// Commands sent from the client to the engine.
180///
181/// Currently consumed variants:
182/// - `ApprovalResponse` — during tool confirmation flow
183/// - `Interrupt` — during approval waits and inference streaming
184/// - `LoopDecision` — when iteration hard cap is reached
185///
186/// Future (server mode, v0.2.0):
187/// - `UserPrompt`, `SlashCommand`, `Quit` — defined for wire protocol
188///   completeness but currently handled client-side.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(tag = "type", rename_all = "snake_case")]
191pub enum EngineCommand {
192    /// User submitted a prompt.
193    ///
194    /// Currently handled client-side. Will be consumed by the engine
195    /// in server mode (v0.2.0) for prompt queuing.
196    UserPrompt {
197        text: String,
198        /// Base64-encoded images attached to the prompt.
199        #[serde(default)]
200        images: Vec<ImageAttachment>,
201    },
202
203    /// User requested interruption of the current operation.
204    ///
205    /// Consumed during approval waits. Also triggers `CancellationToken`
206    /// for streaming interruption.
207    Interrupt,
208
209    /// Response to an `EngineEvent::ApprovalRequest`.
210    ApprovalResponse {
211        /// Must match the `id` from the `ApprovalRequest`.
212        id: String,
213        decision: ApprovalDecision,
214    },
215
216    /// Response to an `EngineEvent::LoopCapReached`.
217    ///
218    /// Tells the engine whether to continue or stop after hitting
219    /// the iteration hard cap.
220    LoopDecision {
221        action: crate::loop_guard::LoopContinuation,
222    },
223
224    /// A slash command from the REPL.
225    ///
226    /// Currently handled client-side. Defined for wire protocol completeness.
227    SlashCommand(SlashCommand),
228
229    /// User requested to quit the session.
230    ///
231    /// Currently handled client-side. Defined for wire protocol completeness.
232    Quit,
233}
234
235/// An image attached to a user prompt.
236#[allow(dead_code)]
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ImageAttachment {
239    /// Base64-encoded image data.
240    pub data: String,
241    /// MIME type (e.g., "image/png").
242    pub mime_type: String,
243}
244
245/// The user's decision on an approval request.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(tag = "decision", rename_all = "snake_case")]
248pub enum ApprovalDecision {
249    /// Approve and execute the action.
250    Approve,
251    /// Reject the action.
252    Reject,
253    /// Reject with feedback (tells the LLM what to change).
254    RejectWithFeedback { feedback: String },
255}
256
257/// Slash commands that the client can send to the engine.
258/// Not yet consumed outside the engine module — wired in v0.2.0 server mode.
259#[allow(dead_code)]
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(tag = "cmd", rename_all = "snake_case")]
262pub enum SlashCommand {
263    /// Compact the conversation by summarizing history.
264    Compact,
265    /// Switch to a specific model by name.
266    SwitchModel { model: String },
267    /// Switch to a specific provider.
268    SwitchProvider { provider: String },
269    /// List recent sessions.
270    ListSessions,
271    /// Delete a session by ID.
272    DeleteSession { id: String },
273    /// Set the approval/trust mode.
274    SetTrust { mode: String },
275    /// MCP server management command.
276    McpCommand { args: String },
277    /// Show token usage for this session.
278    Cost,
279    /// View or save memory.
280    Memory { action: Option<String> },
281    /// Show help / command list.
282    Help,
283    /// Inject a prompt as if the user typed it (used by /diff review, etc.).
284    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}