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    SubAgentEnd { agent_name: String },
76
77    // ── Approval flow ─────────────────────────────────────────────────
78    /// The engine needs user approval before executing a tool.
79    ///
80    /// The client must respond with `EngineCommand::ApprovalResponse`
81    /// matching the same `id`.
82    ApprovalRequest {
83        /// Unique ID for this approval request.
84        id: String,
85        /// Tool name requiring approval.
86        tool_name: String,
87        /// Human-readable description of the action.
88        detail: String,
89        /// Structured diff preview (rendered by the client).
90        preview: Option<crate::preview::DiffPreview>,
91        /// If set, the client can offer an "Always allow" option for this pattern.
92        whitelist_hint: Option<String>,
93    },
94
95    /// An action was blocked by plan mode (shown but not executed).
96    ActionBlocked {
97        tool_name: String,
98        detail: String,
99        preview: Option<crate::preview::DiffPreview>,
100    },
101
102    // ── Session metadata ──────────────────────────────────────────────
103    /// Progress/status update for the persistent status bar.
104    StatusUpdate {
105        model: String,
106        provider: String,
107        context_pct: f64,
108        approval_mode: String,
109        active_tools: usize,
110    },
111
112    /// Inference completion footer with timing and token stats.
113    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    /// Spinner/progress indicator (presentational hint).
125    ///
126    /// Clients may render this as a terminal spinner, a status bar update,
127    /// or ignore it entirely. The ratatui TUI uses the status bar instead.
128    SpinnerStart { message: String },
129
130    /// Stop the spinner (presentational hint).
131    ///
132    /// See `SpinnerStart` — clients may ignore this.
133    SpinnerStop,
134
135    // ── Turn lifecycle ─────────────────────────────────────────────────
136    /// An inference turn is starting.
137    ///
138    /// Emitted at the beginning of `inference_loop()`. Clients can use this
139    /// to lock input, start timers, or update status indicators.
140    TurnStart { turn_id: String },
141
142    /// An inference turn has ended.
143    ///
144    /// Emitted when `inference_loop()` completes. Clients can use this to
145    /// unlock input, drain type-ahead queues, or update status.
146    TurnEnd {
147        turn_id: String,
148        reason: TurnEndReason,
149    },
150
151    /// The engine's iteration hard cap was reached.
152    ///
153    /// The client must respond with `EngineCommand::LoopDecision`.
154    /// Until the client responds, the inference loop is paused.
155    LoopCapReached { cap: u32, recent_tools: Vec<String> },
156
157    // ── Messages ──────────────────────────────────────────────────────
158    /// Display the todo checklist (raw markdown content, client renders).
159    TodoDisplay { content: String },
160
161    /// Informational message (not from the LLM).
162    Info { message: String },
163
164    /// Warning message.
165    Warn { message: String },
166
167    /// Error message.
168    Error { message: String },
169}
170
171/// Why an inference turn ended.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(tag = "kind", rename_all = "snake_case")]
174pub enum TurnEndReason {
175    /// The LLM produced a final text response (no more tool calls).
176    Complete,
177    /// The user or system cancelled the turn.
178    Cancelled,
179    /// The turn failed with an error.
180    Error { message: String },
181}
182
183// ── Client → Engine ──────────────────────────────────────────────────────
184
185/// Commands sent from the client to the engine.
186///
187/// Currently consumed variants:
188/// - `ApprovalResponse` — during tool confirmation flow
189/// - `Interrupt` — during approval waits and inference streaming
190/// - `LoopDecision` — when iteration hard cap is reached
191///
192/// Future (server mode, v0.2.0):
193/// - `UserPrompt`, `SlashCommand`, `Quit` — defined for wire protocol
194///   completeness but currently handled client-side.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[serde(tag = "type", rename_all = "snake_case")]
197pub enum EngineCommand {
198    /// User submitted a prompt.
199    ///
200    /// Currently handled client-side. Will be consumed by the engine
201    /// in server mode (v0.2.0) for prompt queuing.
202    UserPrompt {
203        text: String,
204        /// Base64-encoded images attached to the prompt.
205        #[serde(default)]
206        images: Vec<ImageAttachment>,
207    },
208
209    /// User requested interruption of the current operation.
210    ///
211    /// Consumed during approval waits. Also triggers `CancellationToken`
212    /// for streaming interruption.
213    Interrupt,
214
215    /// Response to an `EngineEvent::ApprovalRequest`.
216    ApprovalResponse {
217        /// Must match the `id` from the `ApprovalRequest`.
218        id: String,
219        decision: ApprovalDecision,
220    },
221
222    /// Response to an `EngineEvent::LoopCapReached`.
223    ///
224    /// Tells the engine whether to continue or stop after hitting
225    /// the iteration hard cap.
226    LoopDecision {
227        action: crate::loop_guard::LoopContinuation,
228    },
229
230    /// A slash command from the REPL.
231    ///
232    /// Currently handled client-side. Defined for wire protocol completeness.
233    SlashCommand(SlashCommand),
234
235    /// User requested to quit the session.
236    ///
237    /// Currently handled client-side. Defined for wire protocol completeness.
238    Quit,
239}
240
241/// An image attached to a user prompt.
242#[allow(dead_code)]
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ImageAttachment {
245    /// Base64-encoded image data.
246    pub data: String,
247    /// MIME type (e.g., "image/png").
248    pub mime_type: String,
249}
250
251/// The user's decision on an approval request.
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253#[serde(tag = "decision", rename_all = "snake_case")]
254pub enum ApprovalDecision {
255    /// Approve and execute the action.
256    Approve,
257    /// Reject the action.
258    Reject,
259    /// Reject with feedback (tells the LLM what to change).
260    RejectWithFeedback { feedback: String },
261    /// Approve AND whitelist this command pattern for future auto-approval.
262    AlwaysAllow,
263}
264
265/// Slash commands that the client can send to the engine.
266/// Not yet consumed outside the engine module — wired in v0.2.0 server mode.
267#[allow(dead_code)]
268#[derive(Debug, Clone, Serialize, Deserialize)]
269#[serde(tag = "cmd", rename_all = "snake_case")]
270pub enum SlashCommand {
271    /// Compact the conversation by summarizing history.
272    Compact,
273    /// Switch to a specific model by name.
274    SwitchModel { model: String },
275    /// Switch to a specific provider.
276    SwitchProvider { provider: String },
277    /// List recent sessions.
278    ListSessions,
279    /// Delete a session by ID.
280    DeleteSession { id: String },
281    /// Set the approval/trust mode.
282    SetTrust { mode: String },
283    /// MCP server management command.
284    McpCommand { args: String },
285    /// Show token usage for this session.
286    Cost,
287    /// View or save memory.
288    Memory { action: Option<String> },
289    /// Show help / command list.
290    Help,
291    /// Inject a prompt as if the user typed it (used by /diff review, etc.).
292    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}