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 (DESIGN.md)
8//!
9//! - **Engine as a Library, Not a Process (P2, P3)**: The engine communicates
10//!   exclusively through these enums. Zero IO in the engine crate.
11//! - **Async Approval Flow (P3)**: `ApprovalRequest` / `ApprovalResponse` is
12//!   async request/response, not a blocking call. Works identically over
13//!   in-process channels or network transport.
14//!
15//! ### Principles
16//!
17//! - **Semantic, not presentational**: Events describe *what happened*, not
18//!   *how to render it*. The client decides formatting.
19//! - **Bidirectional**: The engine emits `EngineEvent`s and accepts `EngineCommand`s.
20//!   Some commands (like approval) are request/response pairs.
21//! - **Serde-first**: All types derive `Serialize`/`Deserialize` for future
22//!   wire transport (ACP/WebSocket).
23
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26
27// ── Engine → Client ──────────────────────────────────────────────────────
28
29/// Events emitted by the engine to the client.
30///
31/// The client is responsible for rendering these events appropriately
32/// for its medium (terminal, GUI, JSON stream, etc.).
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum EngineEvent {
36    // ── Streaming LLM output ──────────────────────────────────────────
37    /// A chunk of streaming text from the LLM response.
38    TextDelta {
39        /// The text chunk.
40        text: String,
41    },
42
43    /// The LLM finished streaming text. Flush any buffered output.
44    TextDone,
45
46    /// The LLM started a thinking/reasoning block.
47    ThinkingStart,
48
49    /// A chunk of thinking/reasoning content.
50    ThinkingDelta {
51        /// The thinking text chunk.
52        text: String,
53    },
54
55    /// The thinking/reasoning block finished.
56    ThinkingDone,
57
58    /// The LLM response section is starting (shown after thinking ends).
59    ResponseStart,
60
61    // ── Tool execution ────────────────────────────────────────────────
62    /// A tool call is about to be executed.
63    ToolCallStart {
64        /// Unique ID for this tool call (from the LLM).
65        id: String,
66        /// Tool name (e.g., "Bash", "Read", "Edit").
67        name: String,
68        /// Tool arguments as JSON.
69        args: Value,
70        /// Whether this is a sub-agent's tool call.
71        is_sub_agent: bool,
72    },
73
74    /// A tool call completed with output.
75    ToolCallResult {
76        /// Matches the `id` from `ToolCallStart`.
77        id: String,
78        /// Tool name.
79        name: String,
80        /// The tool's output text.
81        output: String,
82    },
83
84    /// A line of streaming output from a tool (currently Bash only).
85    ///
86    /// Emitted as each line arrives from stdout/stderr, before `ToolCallResult`.
87    /// Clients can render these in real-time for a "live terminal" feel.
88    ToolOutputLine {
89        /// Matches the `id` from `ToolCallStart`.
90        id: String,
91        /// The output line (no trailing newline).
92        line: String,
93        /// Whether this line came from stderr.
94        is_stderr: bool,
95    },
96
97    // ── Sub-agent delegation ──────────────────────────────────────────
98    /// A sub-agent is being invoked.
99    SubAgentStart {
100        /// Name of the sub-agent being invoked.
101        agent_name: String,
102    },
103
104    /// A sub-agent finished.
105
106    // ── Approval flow ─────────────────────────────────────────────────
107    /// The engine needs user approval before executing a tool.
108    ///
109    /// The client must respond with `EngineCommand::ApprovalResponse`
110    /// matching the same `id`.
111    ApprovalRequest {
112        /// Unique ID for this approval request.
113        id: String,
114        /// Tool name requiring approval.
115        tool_name: String,
116        /// Human-readable description of the action.
117        detail: String,
118        /// Structured diff preview (rendered by the client).
119        preview: Option<crate::preview::DiffPreview>,
120        /// The classified effect that triggered confirmation.
121        effect: crate::tools::ToolEffect,
122    },
123
124    /// The model needs a clarifying answer from the user before proceeding.
125    ///
126    /// The client must respond with `EngineCommand::AskUserResponse`
127    /// matching the same `id`. The answer is returned to the model as the
128    /// tool result, so inference can continue.
129    AskUserRequest {
130        /// Unique ID for this request.
131        id: String,
132        /// The question to ask.
133        question: String,
134        /// Optional answer choices (empty = freeform).
135        options: Vec<String>,
136    },
137
138    /// An action was blocked by safe mode (shown but not executed).
139    ActionBlocked {
140        /// Tool name that was blocked.
141        tool_name: String,
142        /// Description of the blocked action.
143        detail: String,
144        /// Diff preview (if applicable).
145        preview: Option<crate::preview::DiffPreview>,
146    },
147
148    // ── Session metadata ──────────────────────────────────────────────
149    /// Context window usage updated after assembling messages.
150    ///
151    /// Emitted once per inference turn so the client can display
152    /// context percentage and trigger auto-compaction without reading
153    /// engine-internal global state.
154    ContextUsage {
155        /// Tokens used in the current context window.
156        used: usize,
157        /// Maximum context window size.
158        max: usize,
159    },
160
161    /// Progress/status update for the persistent status bar.
162    StatusUpdate {
163        /// Current model identifier.
164        model: String,
165        /// Current provider name.
166        provider: String,
167        /// Context window usage (0.0–1.0).
168        context_pct: f64,
169        /// Current approval mode label.
170        approval_mode: String,
171        /// Number of in-flight tool calls.
172        active_tools: usize,
173    },
174
175    /// Inference completion footer with timing and token stats.
176    Footer {
177        /// Input tokens used.
178        prompt_tokens: i64,
179        /// Output tokens generated.
180        completion_tokens: i64,
181        /// Tokens read from cache.
182        cache_read_tokens: i64,
183        /// Tokens used for reasoning.
184        thinking_tokens: i64,
185        /// Total response characters.
186        total_chars: usize,
187        /// Wall-clock time in milliseconds.
188        elapsed_ms: u64,
189        /// Characters per second.
190        rate: f64,
191        /// Human-readable context usage string.
192        context: String,
193    },
194
195    /// Spinner/progress indicator (presentational hint).
196    ///
197    /// Clients may render this as a terminal spinner, a status bar update,
198    /// or ignore it entirely. The ratatui TUI uses the status bar instead.
199    SpinnerStart {
200        /// Status message to display.
201        message: String,
202    },
203
204    /// Stop the spinner (presentational hint).
205    ///
206    /// See `SpinnerStart` — clients may ignore this.
207    SpinnerStop,
208
209    // ── Turn lifecycle ─────────────────────────────────────────────────
210    /// An inference turn is starting.
211    ///
212    /// Emitted at the beginning of `inference_loop()`. Clients can use this
213    /// to lock input, start timers, or update status indicators.
214    TurnStart {
215        /// Unique identifier for this turn.
216        turn_id: String,
217    },
218
219    /// An inference turn has ended.
220    ///
221    /// Emitted when `inference_loop()` completes. Clients can use this to
222    /// unlock input, drain type-ahead queues, or update status.
223    TurnEnd {
224        /// Matches the `turn_id` from `TurnStart`.
225        turn_id: String,
226        /// Why the turn ended.
227        reason: TurnEndReason,
228    },
229
230    /// The engine's iteration hard cap was reached.
231    ///
232    /// The client must respond with `EngineCommand::LoopDecision`.
233    /// Until the client responds, the inference loop is paused.
234    LoopCapReached {
235        /// The iteration cap that was hit.
236        cap: u32,
237        /// Recent tool names for context.
238        recent_tools: Vec<String>,
239    },
240
241    // ── Messages ──────────────────────────────────────────────────────
242    /// Informational message (not from the LLM).
243    Info {
244        /// The informational message.
245        message: String,
246    },
247
248    /// Warning message.
249    Warn {
250        /// The warning message.
251        message: String,
252    },
253
254    /// Error message.
255    Error {
256        /// The error message.
257        message: String,
258    },
259}
260
261/// Why an inference turn ended.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263#[serde(tag = "kind", rename_all = "snake_case")]
264pub enum TurnEndReason {
265    /// The LLM produced a final text response (no more tool calls).
266    Complete,
267    /// The user or system cancelled the turn.
268    Cancelled,
269    /// The turn failed with an error.
270    Error {
271        /// The error message.
272        message: String,
273    },
274}
275
276// ── Client → Engine ──────────────────────────────────────────────────────
277
278/// Commands sent from the client to the engine.
279///
280/// Currently consumed variants:
281/// - `ApprovalResponse` — during tool confirmation flow
282/// - `Interrupt` — during approval waits and inference streaming
283/// - `LoopDecision` — when iteration hard cap is reached
284///
285/// Future (server mode, v0.2.0):
286/// - `UserPrompt`, `SlashCommand`, `Quit` — defined for wire protocol
287///   completeness but currently handled client-side.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(tag = "type", rename_all = "snake_case")]
290pub enum EngineCommand {
291    /// User submitted a prompt.
292    ///
293    /// Currently handled client-side. Will be consumed by the engine
294    /// in server mode (v0.2.0) for prompt queuing.
295    UserPrompt {
296        /// The user's prompt text.
297        text: String,
298        /// Base64-encoded images attached to the prompt.
299        #[serde(default)]
300        images: Vec<ImageAttachment>,
301    },
302
303    /// User requested interruption of the current operation.
304    ///
305    /// Consumed during approval waits. Also triggers `CancellationToken`
306    /// for streaming interruption.
307    Interrupt,
308
309    /// Response to an `EngineEvent::AskUserRequest`.
310    AskUserResponse {
311        /// Must match the `id` from the `AskUserRequest`.
312        id: String,
313        /// The user's answer (empty string = cancelled).
314        answer: String,
315    },
316
317    /// Response to an `EngineEvent::ApprovalRequest`.
318    ApprovalResponse {
319        /// Must match the `id` from the `ApprovalRequest`.
320        id: String,
321        /// The user's decision.
322        decision: ApprovalDecision,
323    },
324
325    /// Response to an `EngineEvent::LoopCapReached`.
326    ///
327    /// Tells the engine whether to continue or stop after hitting
328    /// the iteration hard cap.
329    LoopDecision {
330        /// Whether to continue or stop.
331        action: crate::loop_guard::LoopContinuation,
332    },
333
334    /// A slash command from the REPL.
335    ///
336    /// Currently handled client-side. Defined for wire protocol completeness.
337    SlashCommand(SlashCommand),
338
339    /// User requested to quit the session.
340    ///
341    /// Currently handled client-side. Defined for wire protocol completeness.
342    Quit,
343}
344
345/// An image attached to a user prompt.
346#[allow(dead_code)]
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct ImageAttachment {
349    /// Base64-encoded image data.
350    pub data: String,
351    /// MIME type (e.g., "image/png").
352    pub mime_type: String,
353}
354
355/// The user's decision on an approval request.
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357#[serde(tag = "decision", rename_all = "snake_case")]
358pub enum ApprovalDecision {
359    /// Approve and execute the action.
360    Approve,
361    /// Reject the action.
362    Reject,
363    /// Reject with feedback (tells the LLM what to change).
364    RejectWithFeedback {
365        /// Feedback explaining why the action was rejected.
366        feedback: String,
367    },
368}
369
370/// Slash commands that the client can send to the engine.
371/// Not yet consumed outside the engine module — wired in v0.2.0 server mode.
372#[allow(dead_code)]
373#[derive(Debug, Clone, Serialize, Deserialize)]
374#[serde(tag = "cmd", rename_all = "snake_case")]
375pub enum SlashCommand {
376    /// Compact the conversation by summarizing history.
377    Compact,
378    /// Switch to a specific model by name.
379    SwitchModel {
380        /// Model identifier.
381        model: String,
382    },
383    /// Switch to a specific provider.
384    SwitchProvider {
385        /// Provider name.
386        provider: String,
387    },
388    /// List recent sessions.
389    ListSessions,
390    /// Delete a session by ID.
391    DeleteSession {
392        /// Session ID to delete.
393        id: String,
394    },
395    /// Set the approval/trust mode.
396    SetTrust {
397        /// Approval mode name.
398        mode: String,
399    },
400    /// Show token usage for this session.
401    Cost,
402    /// View or save memory.
403    Memory {
404        /// Optional action (`"save"`, `"show"`, etc.).
405        action: Option<String>,
406    },
407    /// Show help / command list.
408    Help,
409    /// Inject a prompt as if the user typed it (used by /diff review, etc.).
410    InjectPrompt {
411        /// Prompt text to inject.
412        text: String,
413    },
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use serde_json;
420
421    #[test]
422    fn test_ask_user_request_roundtrip() {
423        let event = EngineEvent::AskUserRequest {
424            id: "ask-1".into(),
425            question: "Which database?".into(),
426            options: vec!["SQLite".into(), "PostgreSQL".into()],
427        };
428        let json = serde_json::to_string(&event).unwrap();
429        assert!(json.contains("ask_user_request"));
430        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
431        assert!(
432            matches!(deserialized, EngineEvent::AskUserRequest { ref question, .. } if question == "Which database?")
433        );
434    }
435
436    #[test]
437    fn test_ask_user_response_roundtrip() {
438        let cmd = EngineCommand::AskUserResponse {
439            id: "ask-1".into(),
440            answer: "SQLite".into(),
441        };
442        let json = serde_json::to_string(&cmd).unwrap();
443        assert!(json.contains("ask_user_response"));
444        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
445        assert!(
446            matches!(deserialized, EngineCommand::AskUserResponse { ref answer, .. } if answer == "SQLite")
447        );
448    }
449
450    #[test]
451    fn test_engine_event_text_delta_roundtrip() {
452        let event = EngineEvent::TextDelta {
453            text: "Hello world".into(),
454        };
455        let json = serde_json::to_string(&event).unwrap();
456        assert!(json.contains("\"type\":\"text_delta\""));
457        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
458        assert!(matches!(deserialized, EngineEvent::TextDelta { text } if text == "Hello world"));
459    }
460
461    #[test]
462    fn test_engine_event_tool_call_roundtrip() {
463        let event = EngineEvent::ToolCallStart {
464            id: "call_123".into(),
465            name: "Bash".into(),
466            args: serde_json::json!({"command": "cargo test"}),
467            is_sub_agent: false,
468        };
469        let json = serde_json::to_string(&event).unwrap();
470        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
471        assert!(matches!(deserialized, EngineEvent::ToolCallStart { name, .. } if name == "Bash"));
472    }
473
474    #[test]
475    fn test_engine_event_approval_request_roundtrip() {
476        let event = EngineEvent::ApprovalRequest {
477            id: "approval_1".into(),
478            tool_name: "Bash".into(),
479            detail: "rm -rf node_modules".into(),
480            preview: None,
481            effect: crate::tools::ToolEffect::Destructive,
482        };
483        let json = serde_json::to_string(&event).unwrap();
484        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
485        assert!(matches!(
486            deserialized,
487            EngineEvent::ApprovalRequest { tool_name, .. } if tool_name == "Bash"
488        ));
489    }
490
491    #[test]
492    fn test_engine_event_footer_roundtrip() {
493        let event = EngineEvent::Footer {
494            prompt_tokens: 4400,
495            completion_tokens: 251,
496            cache_read_tokens: 0,
497            thinking_tokens: 0,
498            total_chars: 1000,
499            elapsed_ms: 43200,
500            rate: 5.8,
501            context: "1.9k/32k (5%)".into(),
502        };
503        let json = serde_json::to_string(&event).unwrap();
504        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
505        assert!(matches!(
506            deserialized,
507            EngineEvent::Footer {
508                prompt_tokens: 4400,
509                ..
510            }
511        ));
512    }
513
514    #[test]
515    fn test_engine_event_simple_variants_roundtrip() {
516        let variants = vec![
517            EngineEvent::TextDone,
518            EngineEvent::ThinkingStart,
519            EngineEvent::ThinkingDone,
520            EngineEvent::ResponseStart,
521            EngineEvent::SpinnerStop,
522            EngineEvent::Info {
523                message: "hello".into(),
524            },
525            EngineEvent::Warn {
526                message: "careful".into(),
527            },
528            EngineEvent::Error {
529                message: "oops".into(),
530            },
531        ];
532        for event in variants {
533            let json = serde_json::to_string(&event).unwrap();
534            let _: EngineEvent = serde_json::from_str(&json).unwrap();
535        }
536    }
537
538    #[test]
539    fn test_engine_command_user_prompt_roundtrip() {
540        let cmd = EngineCommand::UserPrompt {
541            text: "fix the bug".into(),
542            images: vec![],
543        };
544        let json = serde_json::to_string(&cmd).unwrap();
545        assert!(json.contains("\"type\":\"user_prompt\""));
546        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
547        assert!(matches!(
548            deserialized,
549            EngineCommand::UserPrompt { text, .. } if text == "fix the bug"
550        ));
551    }
552
553    #[test]
554    fn test_engine_command_approval_roundtrip() {
555        let cmd = EngineCommand::ApprovalResponse {
556            id: "approval_1".into(),
557            decision: ApprovalDecision::RejectWithFeedback {
558                feedback: "use npm ci instead".into(),
559            },
560        };
561        let json = serde_json::to_string(&cmd).unwrap();
562        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
563        assert!(matches!(
564            deserialized,
565            EngineCommand::ApprovalResponse {
566                decision: ApprovalDecision::RejectWithFeedback { .. },
567                ..
568            }
569        ));
570    }
571
572    #[test]
573    fn test_engine_command_slash_commands_roundtrip() {
574        let commands = vec![
575            EngineCommand::SlashCommand(SlashCommand::Compact),
576            EngineCommand::SlashCommand(SlashCommand::SwitchModel {
577                model: "gpt-4".into(),
578            }),
579            EngineCommand::SlashCommand(SlashCommand::Cost),
580            EngineCommand::SlashCommand(SlashCommand::SetTrust {
581                mode: "yolo".into(),
582            }),
583            EngineCommand::SlashCommand(SlashCommand::Help),
584            EngineCommand::Interrupt,
585            EngineCommand::Quit,
586        ];
587        for cmd in commands {
588            let json = serde_json::to_string(&cmd).unwrap();
589            let _: EngineCommand = serde_json::from_str(&json).unwrap();
590        }
591    }
592
593    #[test]
594    fn test_approval_decision_variants() {
595        let decisions = vec![
596            ApprovalDecision::Approve,
597            ApprovalDecision::Reject,
598            ApprovalDecision::RejectWithFeedback {
599                feedback: "try again".into(),
600            },
601        ];
602        for d in decisions {
603            let json = serde_json::to_string(&d).unwrap();
604            let roundtripped: ApprovalDecision = serde_json::from_str(&json).unwrap();
605            assert_eq!(d, roundtripped);
606        }
607    }
608
609    #[test]
610    fn test_image_attachment_roundtrip() {
611        let img = ImageAttachment {
612            data: "base64data==".into(),
613            mime_type: "image/png".into(),
614        };
615        let json = serde_json::to_string(&img).unwrap();
616        let deserialized: ImageAttachment = serde_json::from_str(&json).unwrap();
617        assert_eq!(deserialized.mime_type, "image/png");
618    }
619
620    #[test]
621    fn test_turn_lifecycle_roundtrip() {
622        let start = EngineEvent::TurnStart {
623            turn_id: "turn-1".into(),
624        };
625        let json = serde_json::to_string(&start).unwrap();
626        assert!(json.contains("turn_start"));
627        let _: EngineEvent = serde_json::from_str(&json).unwrap();
628
629        let end_complete = EngineEvent::TurnEnd {
630            turn_id: "turn-1".into(),
631            reason: TurnEndReason::Complete,
632        };
633        let json = serde_json::to_string(&end_complete).unwrap();
634        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
635        assert!(matches!(
636            deserialized,
637            EngineEvent::TurnEnd {
638                reason: TurnEndReason::Complete,
639                ..
640            }
641        ));
642
643        let end_error = EngineEvent::TurnEnd {
644            turn_id: "turn-2".into(),
645            reason: TurnEndReason::Error {
646                message: "oops".into(),
647            },
648        };
649        let json = serde_json::to_string(&end_error).unwrap();
650        let _: EngineEvent = serde_json::from_str(&json).unwrap();
651
652        let end_cancelled = EngineEvent::TurnEnd {
653            turn_id: "turn-3".into(),
654            reason: TurnEndReason::Cancelled,
655        };
656        let json = serde_json::to_string(&end_cancelled).unwrap();
657        let _: EngineEvent = serde_json::from_str(&json).unwrap();
658    }
659
660    #[test]
661    fn test_loop_cap_reached_roundtrip() {
662        let event = EngineEvent::LoopCapReached {
663            cap: 200,
664            recent_tools: vec!["Bash".into(), "Edit".into()],
665        };
666        let json = serde_json::to_string(&event).unwrap();
667        assert!(json.contains("loop_cap_reached"));
668        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
669        assert!(matches!(
670            deserialized,
671            EngineEvent::LoopCapReached { cap: 200, .. }
672        ));
673    }
674
675    #[test]
676    fn test_loop_decision_roundtrip() {
677        use crate::loop_guard::LoopContinuation;
678
679        let cmd = EngineCommand::LoopDecision {
680            action: LoopContinuation::Continue50,
681        };
682        let json = serde_json::to_string(&cmd).unwrap();
683        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
684        assert!(matches!(
685            deserialized,
686            EngineCommand::LoopDecision {
687                action: LoopContinuation::Continue50
688            }
689        ));
690
691        let cmd_stop = EngineCommand::LoopDecision {
692            action: LoopContinuation::Stop,
693        };
694        let json = serde_json::to_string(&cmd_stop).unwrap();
695        let _: EngineCommand = serde_json::from_str(&json).unwrap();
696    }
697
698    #[test]
699    fn test_turn_end_reason_variants() {
700        let reasons = vec![
701            TurnEndReason::Complete,
702            TurnEndReason::Cancelled,
703            TurnEndReason::Error {
704                message: "failed".into(),
705            },
706        ];
707        for reason in reasons {
708            let json = serde_json::to_string(&reason).unwrap();
709            let roundtripped: TurnEndReason = serde_json::from_str(&json).unwrap();
710            assert_eq!(reason, roundtripped);
711        }
712    }
713}