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