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