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}