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}