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    /// User typed a message during inference and wants it injected into the
345    /// **current** turn before the next provider request.
346    ///
347    /// The engine drains all pending `QueueNext` commands at the top of each
348    /// loop iteration, batches them with `\n\n`, and inserts one user message
349    /// into session history before re-querying the provider.  This is the
350    /// "mid-turn steer" lane — the TUI's `later_queue` handles the separate
351    /// "after this turn" lane entirely on the client side.
352    QueueNext {
353        /// The text the user submitted.
354        text: String,
355    },
356}
357
358/// An image attached to a user prompt.
359#[allow(dead_code)]
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct ImageAttachment {
362    /// Base64-encoded image data.
363    pub data: String,
364    /// MIME type (e.g., "image/png").
365    pub mime_type: String,
366}
367
368/// The user's decision on an approval request.
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370#[serde(tag = "decision", rename_all = "snake_case")]
371pub enum ApprovalDecision {
372    /// Approve and execute the action.
373    Approve,
374    /// Reject the action.
375    Reject,
376    /// Reject with feedback (tells the LLM what to change).
377    RejectWithFeedback {
378        /// Feedback explaining why the action was rejected.
379        feedback: String,
380    },
381}
382
383/// Slash commands that the client can send to the engine.
384/// Not yet consumed outside the engine module — wired in v0.2.0 server mode.
385#[allow(dead_code)]
386#[derive(Debug, Clone, Serialize, Deserialize)]
387#[serde(tag = "cmd", rename_all = "snake_case")]
388pub enum SlashCommand {
389    /// Compact the conversation by summarizing history.
390    Compact,
391    /// Switch to a specific model by name.
392    SwitchModel {
393        /// Model identifier.
394        model: String,
395    },
396    /// Switch to a specific provider.
397    SwitchProvider {
398        /// Provider name.
399        provider: String,
400    },
401    /// List recent sessions.
402    ListSessions,
403    /// Delete a session by ID.
404    DeleteSession {
405        /// Session ID to delete.
406        id: String,
407    },
408    /// Set the approval/trust mode.
409    SetTrust {
410        /// Approval mode name.
411        mode: String,
412    },
413    /// Show token usage for this session.
414    Cost,
415    /// View or save memory.
416    Memory {
417        /// Optional action (`"save"`, `"show"`, etc.).
418        action: Option<String>,
419    },
420    /// Show help / command list.
421    Help,
422    /// Inject a prompt as if the user typed it (used by /diff review, etc.).
423    InjectPrompt {
424        /// Prompt text to inject.
425        text: String,
426    },
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use serde_json;
433
434    #[test]
435    fn test_ask_user_request_roundtrip() {
436        let event = EngineEvent::AskUserRequest {
437            id: "ask-1".into(),
438            question: "Which database?".into(),
439            options: vec!["SQLite".into(), "PostgreSQL".into()],
440        };
441        let json = serde_json::to_string(&event).unwrap();
442        assert!(json.contains("ask_user_request"));
443        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
444        assert!(
445            matches!(deserialized, EngineEvent::AskUserRequest { ref question, .. } if question == "Which database?")
446        );
447    }
448
449    #[test]
450    fn test_ask_user_response_roundtrip() {
451        let cmd = EngineCommand::AskUserResponse {
452            id: "ask-1".into(),
453            answer: "SQLite".into(),
454        };
455        let json = serde_json::to_string(&cmd).unwrap();
456        assert!(json.contains("ask_user_response"));
457        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
458        assert!(
459            matches!(deserialized, EngineCommand::AskUserResponse { ref answer, .. } if answer == "SQLite")
460        );
461    }
462
463    #[test]
464    fn test_engine_event_text_delta_roundtrip() {
465        let event = EngineEvent::TextDelta {
466            text: "Hello world".into(),
467        };
468        let json = serde_json::to_string(&event).unwrap();
469        assert!(json.contains("\"type\":\"text_delta\""));
470        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
471        assert!(matches!(deserialized, EngineEvent::TextDelta { text } if text == "Hello world"));
472    }
473
474    #[test]
475    fn test_engine_event_tool_call_roundtrip() {
476        let event = EngineEvent::ToolCallStart {
477            id: "call_123".into(),
478            name: "Bash".into(),
479            args: serde_json::json!({"command": "cargo test"}),
480            is_sub_agent: false,
481        };
482        let json = serde_json::to_string(&event).unwrap();
483        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
484        assert!(matches!(deserialized, EngineEvent::ToolCallStart { name, .. } if name == "Bash"));
485    }
486
487    #[test]
488    fn test_engine_event_approval_request_roundtrip() {
489        let event = EngineEvent::ApprovalRequest {
490            id: "approval_1".into(),
491            tool_name: "Bash".into(),
492            detail: "rm -rf node_modules".into(),
493            preview: None,
494            effect: crate::tools::ToolEffect::Destructive,
495        };
496        let json = serde_json::to_string(&event).unwrap();
497        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
498        assert!(matches!(
499            deserialized,
500            EngineEvent::ApprovalRequest { tool_name, .. } if tool_name == "Bash"
501        ));
502    }
503
504    #[test]
505    fn test_engine_event_footer_roundtrip() {
506        let event = EngineEvent::Footer {
507            prompt_tokens: 4400,
508            completion_tokens: 251,
509            cache_read_tokens: 0,
510            thinking_tokens: 0,
511            total_chars: 1000,
512            elapsed_ms: 43200,
513            rate: 5.8,
514            context: "1.9k/32k (5%)".into(),
515        };
516        let json = serde_json::to_string(&event).unwrap();
517        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
518        assert!(matches!(
519            deserialized,
520            EngineEvent::Footer {
521                prompt_tokens: 4400,
522                ..
523            }
524        ));
525    }
526
527    #[test]
528    fn test_engine_event_simple_variants_roundtrip() {
529        let variants = vec![
530            EngineEvent::TextDone,
531            EngineEvent::ThinkingStart,
532            EngineEvent::ThinkingDone,
533            EngineEvent::ResponseStart,
534            EngineEvent::SpinnerStop,
535            EngineEvent::Info {
536                message: "hello".into(),
537            },
538            EngineEvent::Warn {
539                message: "careful".into(),
540            },
541            EngineEvent::Error {
542                message: "oops".into(),
543            },
544        ];
545        for event in variants {
546            let json = serde_json::to_string(&event).unwrap();
547            let _: EngineEvent = serde_json::from_str(&json).unwrap();
548        }
549    }
550
551    #[test]
552    fn test_engine_command_user_prompt_roundtrip() {
553        let cmd = EngineCommand::UserPrompt {
554            text: "fix the bug".into(),
555            images: vec![],
556        };
557        let json = serde_json::to_string(&cmd).unwrap();
558        assert!(json.contains("\"type\":\"user_prompt\""));
559        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
560        assert!(matches!(
561            deserialized,
562            EngineCommand::UserPrompt { text, .. } if text == "fix the bug"
563        ));
564    }
565
566    #[test]
567    fn test_engine_command_approval_roundtrip() {
568        let cmd = EngineCommand::ApprovalResponse {
569            id: "approval_1".into(),
570            decision: ApprovalDecision::RejectWithFeedback {
571                feedback: "use npm ci instead".into(),
572            },
573        };
574        let json = serde_json::to_string(&cmd).unwrap();
575        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
576        assert!(matches!(
577            deserialized,
578            EngineCommand::ApprovalResponse {
579                decision: ApprovalDecision::RejectWithFeedback { .. },
580                ..
581            }
582        ));
583    }
584
585    #[test]
586    fn test_engine_command_slash_commands_roundtrip() {
587        let commands = vec![
588            EngineCommand::SlashCommand(SlashCommand::Compact),
589            EngineCommand::SlashCommand(SlashCommand::SwitchModel {
590                model: "gpt-4".into(),
591            }),
592            EngineCommand::SlashCommand(SlashCommand::Cost),
593            EngineCommand::SlashCommand(SlashCommand::SetTrust {
594                mode: "yolo".into(),
595            }),
596            EngineCommand::SlashCommand(SlashCommand::Help),
597            EngineCommand::Interrupt,
598            EngineCommand::Quit,
599        ];
600        for cmd in commands {
601            let json = serde_json::to_string(&cmd).unwrap();
602            let _: EngineCommand = serde_json::from_str(&json).unwrap();
603        }
604    }
605
606    #[test]
607    fn test_approval_decision_variants() {
608        let decisions = vec![
609            ApprovalDecision::Approve,
610            ApprovalDecision::Reject,
611            ApprovalDecision::RejectWithFeedback {
612                feedback: "try again".into(),
613            },
614        ];
615        for d in decisions {
616            let json = serde_json::to_string(&d).unwrap();
617            let roundtripped: ApprovalDecision = serde_json::from_str(&json).unwrap();
618            assert_eq!(d, roundtripped);
619        }
620    }
621
622    #[test]
623    fn test_image_attachment_roundtrip() {
624        let img = ImageAttachment {
625            data: "base64data==".into(),
626            mime_type: "image/png".into(),
627        };
628        let json = serde_json::to_string(&img).unwrap();
629        let deserialized: ImageAttachment = serde_json::from_str(&json).unwrap();
630        assert_eq!(deserialized.mime_type, "image/png");
631    }
632
633    #[test]
634    fn test_turn_lifecycle_roundtrip() {
635        let start = EngineEvent::TurnStart {
636            turn_id: "turn-1".into(),
637        };
638        let json = serde_json::to_string(&start).unwrap();
639        assert!(json.contains("turn_start"));
640        let _: EngineEvent = serde_json::from_str(&json).unwrap();
641
642        let end_complete = EngineEvent::TurnEnd {
643            turn_id: "turn-1".into(),
644            reason: TurnEndReason::Complete,
645        };
646        let json = serde_json::to_string(&end_complete).unwrap();
647        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
648        assert!(matches!(
649            deserialized,
650            EngineEvent::TurnEnd {
651                reason: TurnEndReason::Complete,
652                ..
653            }
654        ));
655
656        let end_error = EngineEvent::TurnEnd {
657            turn_id: "turn-2".into(),
658            reason: TurnEndReason::Error {
659                message: "oops".into(),
660            },
661        };
662        let json = serde_json::to_string(&end_error).unwrap();
663        let _: EngineEvent = serde_json::from_str(&json).unwrap();
664
665        let end_cancelled = EngineEvent::TurnEnd {
666            turn_id: "turn-3".into(),
667            reason: TurnEndReason::Cancelled,
668        };
669        let json = serde_json::to_string(&end_cancelled).unwrap();
670        let _: EngineEvent = serde_json::from_str(&json).unwrap();
671    }
672
673    #[test]
674    fn test_loop_cap_reached_roundtrip() {
675        let event = EngineEvent::LoopCapReached {
676            cap: 200,
677            recent_tools: vec!["Bash".into(), "Edit".into()],
678        };
679        let json = serde_json::to_string(&event).unwrap();
680        assert!(json.contains("loop_cap_reached"));
681        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
682        assert!(matches!(
683            deserialized,
684            EngineEvent::LoopCapReached { cap: 200, .. }
685        ));
686    }
687
688    #[test]
689    fn test_loop_decision_roundtrip() {
690        use crate::loop_guard::LoopContinuation;
691
692        let cmd = EngineCommand::LoopDecision {
693            action: LoopContinuation::Continue50,
694        };
695        let json = serde_json::to_string(&cmd).unwrap();
696        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
697        assert!(matches!(
698            deserialized,
699            EngineCommand::LoopDecision {
700                action: LoopContinuation::Continue50
701            }
702        ));
703
704        let cmd_stop = EngineCommand::LoopDecision {
705            action: LoopContinuation::Stop,
706        };
707        let json = serde_json::to_string(&cmd_stop).unwrap();
708        let _: EngineCommand = serde_json::from_str(&json).unwrap();
709    }
710
711    #[test]
712    fn test_queue_next_roundtrip() {
713        let cmd = EngineCommand::QueueNext {
714            text: "also add tests".into(),
715        };
716        let json = serde_json::to_string(&cmd).unwrap();
717        assert!(json.contains("\"type\":\"queue_next\""));
718        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
719        assert!(
720            matches!(deserialized, EngineCommand::QueueNext { ref text } if text == "also add tests")
721        );
722    }
723
724    #[test]
725    fn test_turn_end_reason_variants() {
726        let reasons = vec![
727            TurnEndReason::Complete,
728            TurnEndReason::Cancelled,
729            TurnEndReason::Error {
730                message: "failed".into(),
731            },
732        ];
733        for reason in reasons {
734            let json = serde_json::to_string(&reason).unwrap();
735            let roundtripped: TurnEndReason = serde_json::from_str(&json).unwrap();
736            assert_eq!(reason, roundtripped);
737        }
738    }
739}