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    // ── Todo list lifecycle (#1077 Phase A) ───────────────────────
107    /// The model called `TodoWrite` and the engine accepted the new
108    /// list. Emitted exactly once per accepted call (skipped when the
109    /// new list is byte-identical to the previous one — the
110    /// dedup-nudge path returns the "unchanged" message to the model
111    /// without surfacing a transition to clients).
112    ///
113    /// Carries the full new list AND a server-computed diff against
114    /// the previously persisted list so every client renders the
115    /// same animation primitives (added / changed / removed) without
116    /// having to maintain its own previous-list snapshot.
117    ///
118    /// Establishes the principle from `DESIGN.md § Progress Tracking:
119    /// Model-Owned, History-Persisted, Engine-Surfaced` — the engine
120    /// surfaces transitions, the conversation history persists the
121    /// list, the system prompt does not re-inject it.
122    TodoUpdate {
123        /// The full todo list as written by the model on this call.
124        items: Vec<crate::tools::todo::TodoItem>,
125        /// Server-computed diff against the previously persisted list
126        /// (matched by `content` string). On the first write of a
127        /// session, every item shows up in `added`.
128        diff: crate::tools::todo::TodoDiff,
129    },
130
131    // ── Background sub-agent lifecycle ────────────────────────────────
132    /// A background sub-agent's status changed.
133    ///
134    /// Emitted on every transition through [`crate::bg_agent::AgentStatus`]
135    /// (`Pending` → `Running { iter }` → terminal). Drained from the
136    /// registry's status queue inside the inference loop alongside
137    /// [`crate::bg_agent::BgAgentRegistry::drain_completed`], so any sink
138    /// (CLI / TUI / headless / ACP) sees the same event stream without
139    /// having to poll the registry directly.
140    ///
141    /// Closes the engine/UI boundary leak documented in #1076 — prior to
142    /// this variant the TUI was the only client that could see live bg
143    /// status because it shared the process and grabbed
144    /// `Arc<BgAgentRegistry>` straight out of `KodaSession`.
145    BgTaskUpdate {
146        /// Monotonic id assigned at `reserve()` time, stable for the
147        /// lifetime of the task.
148        task_id: u32,
149        /// Sub-agent invocation id of the spawner, or `None` if the
150        /// task was launched from the top-level loop. See
151        /// [`crate::bg_agent::BgTaskSnapshot::spawner`].
152        spawner: Option<u32>,
153        /// New status. Includes `Running { iter }` heartbeats so
154        /// clients can render iteration progress without polling.
155        status: crate::bg_agent::AgentStatus,
156    },
157
158    // ── Approval flow ─────────────────────────────────────────────────
159    /// The engine needs user approval before executing a tool.
160    ///
161    /// The client must respond with `EngineCommand::ApprovalResponse`
162    /// matching the same `id`.
163    ApprovalRequest {
164        /// Unique ID for this approval request.
165        id: String,
166        /// Tool name requiring approval.
167        tool_name: String,
168        /// Human-readable description of the action.
169        detail: String,
170        /// Structured diff preview (rendered by the client).
171        preview: Option<crate::preview::DiffPreview>,
172        /// The classified effect that triggered confirmation.
173        effect: crate::tools::ToolEffect,
174    },
175
176    /// The model needs a clarifying answer from the user before proceeding.
177    ///
178    /// The client must respond with `EngineCommand::AskUserResponse`
179    /// matching the same `id`. The answer is returned to the model as the
180    /// tool result, so inference can continue.
181    AskUserRequest {
182        /// Unique ID for this request.
183        id: String,
184        /// The question to ask.
185        question: String,
186        /// Optional answer choices (empty = freeform).
187        options: Vec<String>,
188    },
189
190    /// An action was blocked by safe mode (shown but not executed).
191    ActionBlocked {
192        /// Tool name that was blocked.
193        tool_name: String,
194        /// Description of the blocked action.
195        detail: String,
196        /// Diff preview (if applicable).
197        preview: Option<crate::preview::DiffPreview>,
198    },
199
200    // ── Session metadata ──────────────────────────────────────────────
201    /// Context window usage updated after assembling messages.
202    ///
203    /// Emitted once per inference turn so the client can display
204    /// context percentage and trigger auto-compaction without reading
205    /// engine-internal global state.
206    ContextUsage {
207        /// Tokens used in the current context window.
208        used: usize,
209        /// Maximum context window size.
210        max: usize,
211    },
212
213    /// Progress/status update for the persistent status bar.
214    StatusUpdate {
215        /// Current model identifier.
216        model: String,
217        /// Current provider name.
218        provider: String,
219        /// Context window usage (0.0–1.0).
220        context_pct: f64,
221        /// Current approval mode label.
222        approval_mode: String,
223        /// Number of in-flight tool calls.
224        active_tools: usize,
225    },
226
227    /// Inference completion footer with timing and token stats.
228    Footer {
229        /// Input tokens used.
230        prompt_tokens: i64,
231        /// Output tokens generated.
232        completion_tokens: i64,
233        /// Tokens read from cache.
234        cache_read_tokens: i64,
235        /// Tokens used for reasoning.
236        thinking_tokens: i64,
237        /// Total response characters.
238        total_chars: usize,
239        /// Wall-clock time in milliseconds.
240        elapsed_ms: u64,
241        /// Characters per second.
242        rate: f64,
243        /// Human-readable context usage string.
244        context: String,
245    },
246
247    /// Spinner/progress indicator (presentational hint).
248    ///
249    /// Clients may render this as a terminal spinner, a status bar update,
250    /// or ignore it entirely. The ratatui TUI uses the status bar instead.
251    SpinnerStart {
252        /// Status message to display.
253        message: String,
254    },
255
256    /// Stop the spinner (presentational hint).
257    ///
258    /// See `SpinnerStart` — clients may ignore this.
259    SpinnerStop,
260
261    // ── Turn lifecycle ─────────────────────────────────────────────────
262    /// An inference turn is starting.
263    ///
264    /// Emitted at the beginning of `inference_loop()`. Clients can use this
265    /// to lock input, start timers, or update status indicators.
266    TurnStart {
267        /// Unique identifier for this turn.
268        turn_id: String,
269    },
270
271    /// An inference turn has ended.
272    ///
273    /// Emitted when `inference_loop()` completes. Clients can use this to
274    /// unlock input, drain type-ahead queues, or update status.
275    TurnEnd {
276        /// Matches the `turn_id` from `TurnStart`.
277        turn_id: String,
278        /// Why the turn ended.
279        reason: TurnEndReason,
280    },
281
282    /// The engine's iteration hard cap was reached.
283    ///
284    /// The client must respond with `EngineCommand::LoopDecision`.
285    /// Until the client responds, the inference loop is paused.
286    LoopCapReached {
287        /// The iteration cap that was hit.
288        cap: u32,
289        /// Recent tool names for context.
290        recent_tools: Vec<String>,
291    },
292
293    // ── Messages ──────────────────────────────────────────────────────
294    /// Informational message (not from the LLM).
295    Info {
296        /// The informational message.
297        message: String,
298    },
299
300    /// Warning message.
301    Warn {
302        /// The warning message.
303        message: String,
304    },
305
306    /// Error message.
307    Error {
308        /// The error message.
309        message: String,
310    },
311}
312
313/// Why an inference turn ended.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(tag = "kind", rename_all = "snake_case")]
316pub enum TurnEndReason {
317    /// The LLM produced a final text response (no more tool calls).
318    Complete,
319    /// The user or system cancelled the turn.
320    Cancelled,
321    /// The turn failed with an error.
322    Error {
323        /// The error message.
324        message: String,
325    },
326}
327
328// ── Client → Engine ──────────────────────────────────────────────────────
329
330/// Commands sent from the client to the engine.
331///
332/// Currently consumed variants:
333/// - `ApprovalResponse` — during tool confirmation flow
334/// - `Interrupt` — during approval waits and inference streaming
335/// - `LoopDecision` — when iteration hard cap is reached
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(tag = "type", rename_all = "snake_case")]
338pub enum EngineCommand {
339    /// User requested interruption of the current operation.
340    ///
341    /// Consumed during approval waits. Also triggers `CancellationToken`
342    /// for streaming interruption.
343    Interrupt,
344
345    /// Response to an `EngineEvent::AskUserRequest`.
346    AskUserResponse {
347        /// Must match the `id` from the `AskUserRequest`.
348        id: String,
349        /// The user's answer (empty string = cancelled).
350        answer: String,
351    },
352
353    /// Response to an `EngineEvent::ApprovalRequest`.
354    ApprovalResponse {
355        /// Must match the `id` from the `ApprovalRequest`.
356        id: String,
357        /// The user's decision.
358        decision: ApprovalDecision,
359    },
360
361    /// Response to an `EngineEvent::LoopCapReached`.
362    ///
363    /// Tells the engine whether to continue or stop after hitting
364    /// the iteration hard cap.
365    LoopDecision {
366        /// Whether to continue or stop.
367        action: crate::loop_guard::LoopContinuation,
368    },
369
370    /// User typed a message during inference and wants it injected into the
371    /// **current** turn before the next provider request.
372    ///
373    /// The engine drains all pending `QueueNext` commands at the top of each
374    /// loop iteration, batches them with `\n\n`, and inserts one user message
375    /// into session history before re-querying the provider.  This is the
376    /// "mid-turn steer" lane — the TUI's `later_queue` handles the separate
377    /// "after this turn" lane entirely on the client side.
378    QueueNext {
379        /// The text the user submitted.
380        text: String,
381    },
382}
383
384/// The user's decision on an approval request.
385#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
386#[serde(tag = "decision", rename_all = "snake_case")]
387pub enum ApprovalDecision {
388    /// Approve and execute the action.
389    Approve,
390    /// Reject the action (interactive: a human said no).
391    Reject,
392    /// Reject with feedback (tells the LLM what to change).
393    RejectWithFeedback {
394        /// Feedback explaining why the action was rejected.
395        feedback: String,
396    },
397    /// Reject *automatically*, with no human in the loop. Distinct from
398    /// [`ApprovalDecision::Reject`] because the model needs to know **why** it was
399    /// rejected to act intelligently — a human "no" is a signal to
400    /// re-plan or ask, but an auto-reject (e.g. headless mode
401    /// refusing destructive ops by policy) is a structural constraint
402    /// the model should adapt around for the rest of the session.
403    ///
404    /// **#1022 B15**: pre-fix, headless mode emitted `Reject` for
405    /// auto-blocked destructive tools, which the model saw as `"User
406    /// rejected this action."` — indistinguishable from a real human
407    /// reject. The model would then ask the (nonexistent) user how to
408    /// proceed, then time out.
409    RejectAuto {
410        /// Why the action was auto-rejected (surfaced to the model).
411        reason: String,
412    },
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use serde_json;
419
420    #[test]
421    fn test_ask_user_request_roundtrip() {
422        let event = EngineEvent::AskUserRequest {
423            id: "ask-1".into(),
424            question: "Which database?".into(),
425            options: vec!["SQLite".into(), "PostgreSQL".into()],
426        };
427        let json = serde_json::to_string(&event).unwrap();
428        assert!(json.contains("ask_user_request"));
429        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
430        assert!(
431            matches!(deserialized, EngineEvent::AskUserRequest { ref question, .. } if question == "Which database?")
432        );
433    }
434
435    #[test]
436    fn test_ask_user_response_roundtrip() {
437        let cmd = EngineCommand::AskUserResponse {
438            id: "ask-1".into(),
439            answer: "SQLite".into(),
440        };
441        let json = serde_json::to_string(&cmd).unwrap();
442        assert!(json.contains("ask_user_response"));
443        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
444        assert!(
445            matches!(deserialized, EngineCommand::AskUserResponse { ref answer, .. } if answer == "SQLite")
446        );
447    }
448
449    #[test]
450    fn test_engine_event_text_delta_roundtrip() {
451        let event = EngineEvent::TextDelta {
452            text: "Hello world".into(),
453        };
454        let json = serde_json::to_string(&event).unwrap();
455        assert!(json.contains("\"type\":\"text_delta\""));
456        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
457        assert!(matches!(deserialized, EngineEvent::TextDelta { text } if text == "Hello world"));
458    }
459
460    #[test]
461    fn test_engine_event_tool_call_roundtrip() {
462        let event = EngineEvent::ToolCallStart {
463            id: "call_123".into(),
464            name: "Bash".into(),
465            args: serde_json::json!({"command": "cargo test"}),
466            is_sub_agent: false,
467        };
468        let json = serde_json::to_string(&event).unwrap();
469        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
470        assert!(matches!(deserialized, EngineEvent::ToolCallStart { name, .. } if name == "Bash"));
471    }
472
473    #[test]
474    fn test_engine_event_approval_request_roundtrip() {
475        let event = EngineEvent::ApprovalRequest {
476            id: "approval_1".into(),
477            tool_name: "Bash".into(),
478            detail: "rm -rf node_modules".into(),
479            preview: None,
480            effect: crate::tools::ToolEffect::Destructive,
481        };
482        let json = serde_json::to_string(&event).unwrap();
483        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
484        assert!(matches!(
485            deserialized,
486            EngineEvent::ApprovalRequest { tool_name, .. } if tool_name == "Bash"
487        ));
488    }
489
490    #[test]
491    fn test_engine_event_footer_roundtrip() {
492        let event = EngineEvent::Footer {
493            prompt_tokens: 4400,
494            completion_tokens: 251,
495            cache_read_tokens: 0,
496            thinking_tokens: 0,
497            total_chars: 1000,
498            elapsed_ms: 43200,
499            rate: 5.8,
500            context: "1.9k/32k (5%)".into(),
501        };
502        let json = serde_json::to_string(&event).unwrap();
503        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
504        assert!(matches!(
505            deserialized,
506            EngineEvent::Footer {
507                prompt_tokens: 4400,
508                ..
509            }
510        ));
511    }
512
513    #[test]
514    fn test_engine_event_simple_variants_roundtrip() {
515        let variants = vec![
516            EngineEvent::TextDone,
517            EngineEvent::ThinkingStart,
518            EngineEvent::ThinkingDone,
519            EngineEvent::ResponseStart,
520            EngineEvent::SpinnerStop,
521            EngineEvent::Info {
522                message: "hello".into(),
523            },
524            EngineEvent::Warn {
525                message: "careful".into(),
526            },
527            EngineEvent::Error {
528                message: "oops".into(),
529            },
530        ];
531        for event in variants {
532            let json = serde_json::to_string(&event).unwrap();
533            let _: EngineEvent = serde_json::from_str(&json).unwrap();
534        }
535    }
536
537    #[test]
538    fn test_engine_command_approval_roundtrip() {
539        let cmd = EngineCommand::ApprovalResponse {
540            id: "approval_1".into(),
541            decision: ApprovalDecision::RejectWithFeedback {
542                feedback: "use npm ci instead".into(),
543            },
544        };
545        let json = serde_json::to_string(&cmd).unwrap();
546        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
547        assert!(matches!(
548            deserialized,
549            EngineCommand::ApprovalResponse {
550                decision: ApprovalDecision::RejectWithFeedback { .. },
551                ..
552            }
553        ));
554    }
555
556    #[test]
557    fn test_approval_decision_variants() {
558        let decisions = vec![
559            ApprovalDecision::Approve,
560            ApprovalDecision::Reject,
561            ApprovalDecision::RejectWithFeedback {
562                feedback: "try again".into(),
563            },
564            // #1022 B15: new variant for headless / no-human-in-loop
565            // auto-rejection. Distinct from `Reject` on the wire so
566            // the model can adapt its plan instead of asking a
567            // nonexistent user.
568            ApprovalDecision::RejectAuto {
569                reason: "destructive op blocked by headless policy".into(),
570            },
571        ];
572        for d in decisions {
573            let json = serde_json::to_string(&d).unwrap();
574            let roundtripped: ApprovalDecision = serde_json::from_str(&json).unwrap();
575            assert_eq!(d, roundtripped);
576        }
577    }
578
579    /// #1022 B15: wire-format guard. The `decision` tag for the new
580    /// `RejectAuto` variant must be `"reject_auto"` (snake_case via
581    /// `#[serde(rename_all = "snake_case")]`). Renaming this would
582    /// break ACP clients silently — they'd see an unknown decision
583    /// and fall through to `Reject`, re-introducing the bug.
584    #[test]
585    fn test_reject_auto_wire_tag_is_snake_case() {
586        let d = ApprovalDecision::RejectAuto { reason: "r".into() };
587        let json = serde_json::to_string(&d).unwrap();
588        assert!(
589            json.contains("\"decision\":\"reject_auto\""),
590            "expected snake_case tag, got: {json}"
591        );
592    }
593
594    #[test]
595    fn test_turn_lifecycle_roundtrip() {
596        let start = EngineEvent::TurnStart {
597            turn_id: "turn-1".into(),
598        };
599        let json = serde_json::to_string(&start).unwrap();
600        assert!(json.contains("turn_start"));
601        let _: EngineEvent = serde_json::from_str(&json).unwrap();
602
603        let end_complete = EngineEvent::TurnEnd {
604            turn_id: "turn-1".into(),
605            reason: TurnEndReason::Complete,
606        };
607        let json = serde_json::to_string(&end_complete).unwrap();
608        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
609        assert!(matches!(
610            deserialized,
611            EngineEvent::TurnEnd {
612                reason: TurnEndReason::Complete,
613                ..
614            }
615        ));
616
617        let end_error = EngineEvent::TurnEnd {
618            turn_id: "turn-2".into(),
619            reason: TurnEndReason::Error {
620                message: "oops".into(),
621            },
622        };
623        let json = serde_json::to_string(&end_error).unwrap();
624        let _: EngineEvent = serde_json::from_str(&json).unwrap();
625
626        let end_cancelled = EngineEvent::TurnEnd {
627            turn_id: "turn-3".into(),
628            reason: TurnEndReason::Cancelled,
629        };
630        let json = serde_json::to_string(&end_cancelled).unwrap();
631        let _: EngineEvent = serde_json::from_str(&json).unwrap();
632    }
633
634    #[test]
635    fn test_loop_cap_reached_roundtrip() {
636        let event = EngineEvent::LoopCapReached {
637            cap: 200,
638            recent_tools: vec!["Bash".into(), "Edit".into()],
639        };
640        let json = serde_json::to_string(&event).unwrap();
641        assert!(json.contains("loop_cap_reached"));
642        let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
643        assert!(matches!(
644            deserialized,
645            EngineEvent::LoopCapReached { cap: 200, .. }
646        ));
647    }
648
649    #[test]
650    fn test_loop_decision_roundtrip() {
651        use crate::loop_guard::LoopContinuation;
652
653        let cmd = EngineCommand::LoopDecision {
654            action: LoopContinuation::Continue50,
655        };
656        let json = serde_json::to_string(&cmd).unwrap();
657        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
658        assert!(matches!(
659            deserialized,
660            EngineCommand::LoopDecision {
661                action: LoopContinuation::Continue50
662            }
663        ));
664
665        let cmd_stop = EngineCommand::LoopDecision {
666            action: LoopContinuation::Stop,
667        };
668        let json = serde_json::to_string(&cmd_stop).unwrap();
669        let _: EngineCommand = serde_json::from_str(&json).unwrap();
670    }
671
672    #[test]
673    fn test_queue_next_roundtrip() {
674        let cmd = EngineCommand::QueueNext {
675            text: "also add tests".into(),
676        };
677        let json = serde_json::to_string(&cmd).unwrap();
678        assert!(json.contains("\"type\":\"queue_next\""));
679        let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
680        assert!(
681            matches!(deserialized, EngineCommand::QueueNext { ref text } if text == "also add tests")
682        );
683    }
684
685    #[test]
686    fn test_turn_end_reason_variants() {
687        let reasons = vec![
688            TurnEndReason::Complete,
689            TurnEndReason::Cancelled,
690            TurnEndReason::Error {
691                message: "failed".into(),
692            },
693        ];
694        for reason in reasons {
695            let json = serde_json::to_string(&reason).unwrap();
696            let roundtripped: TurnEndReason = serde_json::from_str(&json).unwrap();
697            assert_eq!(reason, roundtripped);
698        }
699    }
700}