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}