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")]
35#[non_exhaustive]
36pub enum EngineEvent {
37 // ── Streaming LLM output ──────────────────────────────────────────
38 /// A chunk of streaming text from the LLM response.
39 TextDelta {
40 /// The text chunk.
41 text: String,
42 },
43
44 /// The LLM finished streaming text. Flush any buffered output.
45 TextDone,
46
47 /// The LLM started a thinking/reasoning block.
48 ThinkingStart,
49
50 /// A chunk of thinking/reasoning content.
51 ThinkingDelta {
52 /// The thinking text chunk.
53 text: String,
54 },
55
56 /// The thinking/reasoning block finished.
57 ThinkingDone,
58
59 /// The LLM response section is starting (shown after thinking ends).
60 ResponseStart,
61
62 // ── Tool execution ────────────────────────────────────────────────
63 /// A tool call is about to be executed.
64 ToolCallStart {
65 /// Unique ID for this tool call (from the LLM).
66 id: String,
67 /// Tool name (e.g., "Bash", "Read", "Edit").
68 name: String,
69 /// Tool arguments as JSON.
70 args: Value,
71 /// Whether this is a sub-agent's tool call.
72 is_sub_agent: bool,
73 },
74
75 /// A tool call completed with output.
76 ToolCallResult {
77 /// Matches the `id` from `ToolCallStart`.
78 id: String,
79 /// Tool name.
80 name: String,
81 /// The tool's output text.
82 output: String,
83 },
84
85 /// A line of streaming output from a tool (currently Bash only).
86 ///
87 /// Emitted as each line arrives from stdout/stderr, before `ToolCallResult`.
88 /// Clients can render these in real-time for a "live terminal" feel.
89 ToolOutputLine {
90 /// Matches the `id` from `ToolCallStart`.
91 id: String,
92 /// The output line (no trailing newline).
93 line: String,
94 /// Whether this line came from stderr.
95 is_stderr: bool,
96 },
97
98 // ── Sub-agent delegation ──────────────────────────────────────────
99 /// A sub-agent is being invoked.
100 SubAgentStart {
101 /// Name of the sub-agent being invoked.
102 agent_name: String,
103 },
104
105 /// A sub-agent finished.
106
107 // ── Todo list lifecycle (#1077 Phase A) ───────────────────────
108 /// The model called `TodoWrite` and the engine accepted the new
109 /// list. Emitted exactly once per accepted call (skipped when the
110 /// new list is byte-identical to the previous one — the
111 /// dedup-nudge path returns the "unchanged" message to the model
112 /// without surfacing a transition to clients).
113 ///
114 /// Carries the full new list AND a server-computed diff against
115 /// the previously persisted list so every client renders the
116 /// same animation primitives (added / changed / removed) without
117 /// having to maintain its own previous-list snapshot.
118 ///
119 /// Establishes the principle from `DESIGN.md § Progress Tracking:
120 /// Model-Owned, History-Persisted, Engine-Surfaced` — the engine
121 /// surfaces transitions, the conversation history persists the
122 /// list, the system prompt does not re-inject it.
123 TodoUpdate {
124 /// The full todo list as written by the model on this call.
125 items: Vec<crate::tools::todo::TodoItem>,
126 /// Server-computed diff against the previously persisted list
127 /// (matched by `content` string). On the first write of a
128 /// session, every item shows up in `added`.
129 diff: crate::tools::todo::TodoDiff,
130 },
131
132 // ── Child sub-agent lifecycle ──────────────────────────────────
133 /// A child sub-agent's status changed.
134 ///
135 /// Emitted on every transition through [`crate::child_agent::AgentStatus`]
136 /// (`Pending` → `Running { iter }` → terminal). Drained from the
137 /// registry's status queue inside the inference loop alongside
138 /// [`crate::child_agent::ChildAgentRegistry::drain_completed`], so any sink
139 /// (CLI / TUI / headless / ACP) sees the same event stream without
140 /// having to poll the registry directly.
141 ///
142 /// Closes the engine/UI boundary leak documented in #1076 — prior to
143 /// this variant the TUI was the only client that could see live
144 /// status because it shared the process and grabbed
145 /// `Arc<ChildAgentRegistry>` straight out of `KodaSession`.
146 ///
147 /// **PR-A0.5 of #1232**: renamed from `BgTaskUpdate`. The wire tag
148 /// (`"type":"bg_task_update"`) is preserved via `#[serde(rename)]`
149 /// so ACP / headless clients keep parsing the same envelope. The
150 /// new `is_background` field defaults to `true` on legacy payloads
151 /// for the same reason.
152 #[serde(rename = "bg_task_update")]
153 ChildTaskUpdate {
154 /// Monotonic id assigned at `reserve()` time, stable for the
155 /// lifetime of the task.
156 task_id: u32,
157 /// Sub-agent invocation id of the spawner, or `None` if the
158 /// task was launched from the top-level loop. See
159 /// [`crate::child_agent::ChildTaskSnapshot::spawner`].
160 spawner: Option<u32>,
161 /// `true` if this is a background sub-agent (auto-drains its
162 /// result on a future iteration), `false` for foreground
163 /// sub-agents (parent awaits inline). Wire-default is `true`
164 /// so older clients that never received this field
165 /// deserialize as the historical bg-only behavior.
166 #[serde(default = "default_is_background_true")]
167 is_background: bool,
168 /// New status. Includes `Running { iter }` heartbeats so
169 /// clients can render iteration progress without polling.
170 status: crate::child_agent::AgentStatus,
171 },
172
173 /// Live activity from inside a running child agent (foreground or
174 /// background sub-agent).
175 ///
176 /// **#1201 B**: pre-this-event the parent's TUI had no live signal
177 /// from inside a bg agent — only `ChildTaskUpdate` heartbeats
178 /// (`Running { iter: N }`), which tell you "still going" but not
179 /// "doing what". The narrative trace shipped via `BufferingSink`
180 /// only surfaced at result-injection time.
181 ///
182 /// **PR-A0 of #1232 § 1**: renamed from `BgChildActivity`. The
183 /// underlying mechanism is identical — pushed onto the registry's
184 /// status-event queue and forwarded to the active sink — but the
185 /// type name no longer pretends bg is the only valid source.
186 /// Foreground sub-agent routing through this event is the actual
187 /// behavior change in PR-A; PR-A0 is just the rename so the type
188 /// stops lying. Today every emit site still passes
189 /// `is_background: true`.
190 ///
191 /// Wire format (`"type":"bg_child_activity"`) is preserved via
192 /// `#[serde(rename)]` so ACP / headless clients keep parsing the
193 /// same envelope. PR-A will revisit the wire tag once fg actually
194 /// flows through here.
195 ///
196 /// `ChildAgentActivity` is the live tap: each interesting event
197 /// inside the child agent (tool start/end, info line) fans out
198 /// to the parent's sink as soon as it happens, so the parent's
199 /// TUI can render a Gemini-style activity feed under the child's
200 /// spawn cell. The post-completion narrative trace via
201 /// `BufferingSink` is still emitted (and is still authoritative
202 /// for the persisted transcript) — this event is purely for
203 /// real-time UX.
204 #[serde(rename = "bg_child_activity")]
205 ChildAgentActivity {
206 /// Matches the `task_id` from `ChildTaskUpdate` for the same
207 /// running task. For foreground sub-agents (PR-A) this will
208 /// be a synthetic id assigned at dispatch time.
209 task_id: u32,
210 /// Sub-agent invocation id of the spawner, or `None` for
211 /// top-level-spawned tasks. Mirrors `ChildTaskUpdate.spawner`.
212 spawner: Option<u32>,
213 /// `true` if the child runs as a background task (today: all
214 /// emit sites). `false` reserved for foreground sub-agents
215 /// in PR-A. Wire-default is `true` so older clients that
216 /// never received this field deserialize as the historical
217 /// behavior.
218 #[serde(default = "default_is_background_true")]
219 is_background: bool,
220 /// What just happened inside the child agent.
221 kind: ChildAgentActivityKind,
222 },
223
224 // ── Approval flow ─────────────────────────────────────────────────
225 /// The engine needs user approval before executing a tool.
226 ///
227 /// The client must respond with `EngineCommand::ApprovalResponse`
228 /// matching the same `id`.
229 ApprovalRequest {
230 /// Unique ID for this approval request.
231 id: String,
232 /// Tool name requiring approval.
233 tool_name: String,
234 /// Human-readable description of the action.
235 detail: String,
236 /// Structured diff preview (rendered by the client).
237 preview: Option<crate::preview::DiffPreview>,
238 /// The classified effect that triggered confirmation.
239 effect: crate::tools::ToolEffect,
240 },
241
242 /// The model needs a clarifying answer from the user before proceeding.
243 ///
244 /// The client must respond with `EngineCommand::AskUserResponse`
245 /// matching the same `id`. The answer is returned to the model as the
246 /// tool result, so inference can continue.
247 AskUserRequest {
248 /// Unique ID for this request.
249 id: String,
250 /// The question to ask.
251 question: String,
252 /// Optional answer choices (empty = freeform).
253 options: Vec<String>,
254 },
255
256 /// An action was blocked by safe mode (shown but not executed).
257 ActionBlocked {
258 /// Tool name that was blocked.
259 tool_name: String,
260 /// Description of the blocked action.
261 detail: String,
262 /// Diff preview (if applicable).
263 preview: Option<crate::preview::DiffPreview>,
264 },
265
266 // ── Session metadata ──────────────────────────────────────────────
267 /// Context window usage updated after assembling messages.
268 ///
269 /// Emitted once per inference turn so the client can display
270 /// context percentage and trigger auto-compaction without reading
271 /// engine-internal global state.
272 ContextUsage {
273 /// Tokens used in the current context window.
274 used: usize,
275 /// Maximum context window size.
276 max: usize,
277 },
278
279 /// Progress/status update for the persistent status bar.
280 StatusUpdate {
281 /// Current model identifier.
282 model: String,
283 /// Current provider name.
284 provider: String,
285 /// Context window usage (0.0–1.0).
286 context_pct: f64,
287 /// Current approval mode label.
288 approval_mode: String,
289 /// Number of in-flight tool calls.
290 active_tools: usize,
291 },
292
293 /// Inference completion footer with timing and token stats.
294 Footer {
295 /// Input tokens used.
296 prompt_tokens: i64,
297 /// Output tokens generated.
298 completion_tokens: i64,
299 /// Tokens read from cache.
300 cache_read_tokens: i64,
301 /// Tokens used for reasoning.
302 thinking_tokens: i64,
303 /// Total response characters.
304 total_chars: usize,
305 /// Wall-clock time in milliseconds.
306 elapsed_ms: u64,
307 /// Characters per second.
308 rate: f64,
309 /// Human-readable context usage string.
310 context: String,
311 },
312
313 /// Spinner/progress indicator (presentational hint).
314 ///
315 /// Clients may render this as a terminal spinner, a status bar update,
316 /// or ignore it entirely. The ratatui TUI uses the status bar instead.
317 SpinnerStart {
318 /// Status message to display.
319 message: String,
320 },
321
322 /// Stop the spinner (presentational hint).
323 ///
324 /// See `SpinnerStart` — clients may ignore this.
325 SpinnerStop,
326
327 // ── Turn lifecycle ─────────────────────────────────────────────────
328 /// An inference turn is starting.
329 ///
330 /// Emitted at the beginning of `inference_loop()`. Clients can use this
331 /// to lock input, start timers, or update status indicators.
332 TurnStart {
333 /// Unique identifier for this turn.
334 turn_id: String,
335 },
336
337 /// An inference turn has ended.
338 ///
339 /// Emitted when `inference_loop()` completes. Clients can use this to
340 /// unlock input, drain type-ahead queues, or update status.
341 TurnEnd {
342 /// Matches the `turn_id` from `TurnStart`.
343 turn_id: String,
344 /// Why the turn ended.
345 reason: TurnEndReason,
346 },
347
348 /// The engine's iteration hard cap was reached.
349 ///
350 /// The client must respond with `EngineCommand::LoopDecision`.
351 /// Until the client responds, the inference loop is paused.
352 LoopCapReached {
353 /// The iteration cap that was hit.
354 cap: u32,
355 /// Recent tool names for context.
356 recent_tools: Vec<String>,
357 },
358
359 // ── Messages ──────────────────────────────────────────────────────
360 /// Informational message (not from the LLM).
361 Info {
362 /// The informational message.
363 message: String,
364 },
365
366 /// Warning message.
367 Warn {
368 /// The warning message.
369 message: String,
370 },
371
372 /// Error message.
373 Error {
374 /// The error message.
375 message: String,
376 },
377}
378
379/// What kind of activity happened inside a running background sub-agent.
380///
381/// **#1201 B**: deliberately a small, fixed set rather than "forward
382/// every `EngineEvent`". The parent's TUI is rendering a *summary*
383/// of child activity, not replaying the child's full event stream;
384/// most events (streaming text deltas, thinking deltas, status
385/// updates) would be noise at this granularity.
386///
387/// Wire format is `snake_case` with an internal `kind` tag, matching
388/// the convention for [`TurnEndReason`] and
389/// [`crate::child_agent::AgentStatus`].
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(tag = "kind", rename_all = "snake_case")]
392#[non_exhaustive]
393pub enum ChildAgentActivityKind {
394 /// The child started a tool call.
395 ///
396 /// `summary` is a pre-truncated one-line description suitable
397 /// for direct render (e.g. `"Read src/auth.rs"`, `"Bash cargo
398 /// test"`). Computed at emit time so every client renders the
399 /// same string without having to know the per-tool argument
400 /// schema.
401 ToolStart {
402 /// Tool name (matches `EngineEvent::ToolCallStart.name`).
403 tool_name: String,
404 /// Pre-truncated one-line summary suitable for direct render.
405 summary: String,
406 },
407 /// The child's tool call completed.
408 ///
409 /// Output is intentionally NOT included — it can be arbitrarily
410 /// large and the parent's TUI is rendering a feed, not a
411 /// transcript. The model's narrative trace via `BufferingSink`
412 /// remains the authoritative record.
413 ToolEnd {
414 /// Tool name (matches `EngineEvent::ToolCallStart.name`).
415 tool_name: String,
416 /// Whether the tool succeeded. Best-effort classification
417 /// at the emit site by inspecting the result string for an
418 /// error-marker prefix; not load-bearing for correctness.
419 success: bool,
420 },
421 /// An informational line from inside the child.
422 ///
423 /// These pass through verbatim from `EngineEvent::Info` so the
424 /// child agent's own status messages (cache hit, microcompact
425 /// fired, etc.) surface in the parent's feed.
426 Info {
427 /// The info line, rendered as-is.
428 message: String,
429 },
430}
431
432/// Serde default for the `is_background` field on
433/// [`EngineEvent::ChildAgentActivity`]. Returns `true` so older wire
434/// payloads that pre-date the field deserialize as the historical
435/// behavior (every emit was from a bg agent before PR-A).
436fn default_is_background_true() -> bool {
437 true
438}
439
440/// Why an inference turn ended.
441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(tag = "kind", rename_all = "snake_case")]
443#[non_exhaustive]
444pub enum TurnEndReason {
445 /// The LLM produced a final text response (no more tool calls).
446 Complete,
447 /// The user or system cancelled the turn.
448 Cancelled,
449 /// The turn failed with an error.
450 Error {
451 /// The error message.
452 message: String,
453 },
454}
455
456// ── Client → Engine ──────────────────────────────────────────────────────
457
458/// Commands sent from the client to the engine.
459///
460/// Currently consumed variants:
461/// - `ApprovalResponse` — during tool confirmation flow
462/// - `Interrupt` — during approval waits and inference streaming
463/// - `LoopDecision` — when iteration hard cap is reached
464#[derive(Debug, Clone, Serialize, Deserialize)]
465#[serde(tag = "type", rename_all = "snake_case")]
466pub enum EngineCommand {
467 /// User requested interruption of the current operation.
468 ///
469 /// Consumed during approval waits. Also triggers `CancellationToken`
470 /// for streaming interruption.
471 Interrupt,
472
473 /// Response to an `EngineEvent::AskUserRequest`.
474 AskUserResponse {
475 /// Must match the `id` from the `AskUserRequest`.
476 id: String,
477 /// The user's answer (empty string = cancelled).
478 answer: String,
479 },
480
481 /// Response to an `EngineEvent::ApprovalRequest`.
482 ApprovalResponse {
483 /// Must match the `id` from the `ApprovalRequest`.
484 id: String,
485 /// The user's decision.
486 decision: ApprovalDecision,
487 },
488
489 /// Response to an `EngineEvent::LoopCapReached`.
490 ///
491 /// Tells the engine whether to continue or stop after hitting
492 /// the iteration hard cap.
493 LoopDecision {
494 /// Whether to continue or stop.
495 action: crate::loop_guard::LoopContinuation,
496 },
497
498 /// User typed a message during inference and wants it injected into the
499 /// **current** turn before the next provider request.
500 ///
501 /// The engine drains all pending `QueueNext` commands at the top of each
502 /// loop iteration, batches them with `\n\n`, and inserts one user message
503 /// into session history before re-querying the provider. This is the
504 /// "mid-turn steer" lane — the TUI's `later_queue` handles the separate
505 /// "after this turn" lane entirely on the client side.
506 QueueNext {
507 /// The text the user submitted.
508 text: String,
509 },
510}
511
512impl EngineCommand {
513 /// Stable, payload-free name of this variant.
514 ///
515 /// **#1232 §6**: pre-fix, `inference.rs` logged unexpected commands
516 /// as `Discriminant(2)`, forcing devs to grep the source to map the
517 /// integer back to a variant. Naive `{:?}` on `Self` would surface
518 /// the variant name but also dump payload fields like
519 /// `AskUserResponse.answer` and `QueueNext.text` — user-typed
520 /// content that has no business in a structured log line. This
521 /// accessor returns just the variant name so logs stay readable
522 /// AND payload-safe.
523 ///
524 /// Returned strings are stable identifiers — treat them as a
525 /// public API for downstream metric/log filters.
526 pub fn kind(&self) -> &'static str {
527 match self {
528 Self::Interrupt => "Interrupt",
529 Self::AskUserResponse { .. } => "AskUserResponse",
530 Self::ApprovalResponse { .. } => "ApprovalResponse",
531 Self::LoopDecision { .. } => "LoopDecision",
532 Self::QueueNext { .. } => "QueueNext",
533 }
534 }
535}
536
537/// The user's decision on an approval request.
538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
539#[serde(tag = "decision", rename_all = "snake_case")]
540pub enum ApprovalDecision {
541 /// Approve and execute the action.
542 Approve,
543 /// Reject the action (interactive: a human said no).
544 Reject,
545 /// Reject with feedback (tells the LLM what to change).
546 RejectWithFeedback {
547 /// Feedback explaining why the action was rejected.
548 feedback: String,
549 },
550 /// Reject *automatically*, with no human in the loop. Distinct from
551 /// [`ApprovalDecision::Reject`] because the model needs to know **why** it was
552 /// rejected to act intelligently — a human "no" is a signal to
553 /// re-plan or ask, but an auto-reject (e.g. headless mode
554 /// refusing destructive ops by policy) is a structural constraint
555 /// the model should adapt around for the rest of the session.
556 ///
557 /// **#1022 B15**: pre-fix, headless mode emitted `Reject` for
558 /// auto-blocked destructive tools, which the model saw as `"User
559 /// rejected this action."` — indistinguishable from a real human
560 /// reject. The model would then ask the (nonexistent) user how to
561 /// proceed, then time out.
562 RejectAuto {
563 /// Why the action was auto-rejected (surfaced to the model).
564 reason: String,
565 },
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use serde_json;
572
573 #[test]
574 fn test_ask_user_request_roundtrip() {
575 let event = EngineEvent::AskUserRequest {
576 id: "ask-1".into(),
577 question: "Which database?".into(),
578 options: vec!["SQLite".into(), "PostgreSQL".into()],
579 };
580 let json = serde_json::to_string(&event).unwrap();
581 assert!(json.contains("ask_user_request"));
582 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
583 assert!(
584 matches!(deserialized, EngineEvent::AskUserRequest { ref question, .. } if question == "Which database?")
585 );
586 }
587
588 #[test]
589 fn test_ask_user_response_roundtrip() {
590 let cmd = EngineCommand::AskUserResponse {
591 id: "ask-1".into(),
592 answer: "SQLite".into(),
593 };
594 let json = serde_json::to_string(&cmd).unwrap();
595 assert!(json.contains("ask_user_response"));
596 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
597 assert!(
598 matches!(deserialized, EngineCommand::AskUserResponse { ref answer, .. } if answer == "SQLite")
599 );
600 }
601
602 #[test]
603 fn test_engine_event_text_delta_roundtrip() {
604 let event = EngineEvent::TextDelta {
605 text: "Hello world".into(),
606 };
607 let json = serde_json::to_string(&event).unwrap();
608 assert!(json.contains("\"type\":\"text_delta\""));
609 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
610 assert!(matches!(deserialized, EngineEvent::TextDelta { text } if text == "Hello world"));
611 }
612
613 #[test]
614 fn test_engine_event_tool_call_roundtrip() {
615 let event = EngineEvent::ToolCallStart {
616 id: "call_123".into(),
617 name: "Bash".into(),
618 args: serde_json::json!({"command": "cargo test"}),
619 is_sub_agent: false,
620 };
621 let json = serde_json::to_string(&event).unwrap();
622 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
623 assert!(matches!(deserialized, EngineEvent::ToolCallStart { name, .. } if name == "Bash"));
624 }
625
626 #[test]
627 fn test_engine_event_approval_request_roundtrip() {
628 let event = EngineEvent::ApprovalRequest {
629 id: "approval_1".into(),
630 tool_name: "Bash".into(),
631 detail: "rm -rf node_modules".into(),
632 preview: None,
633 effect: crate::tools::ToolEffect::Destructive,
634 };
635 let json = serde_json::to_string(&event).unwrap();
636 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
637 assert!(matches!(
638 deserialized,
639 EngineEvent::ApprovalRequest { tool_name, .. } if tool_name == "Bash"
640 ));
641 }
642
643 #[test]
644 fn test_engine_event_footer_roundtrip() {
645 let event = EngineEvent::Footer {
646 prompt_tokens: 4400,
647 completion_tokens: 251,
648 cache_read_tokens: 0,
649 thinking_tokens: 0,
650 total_chars: 1000,
651 elapsed_ms: 43200,
652 rate: 5.8,
653 context: "1.9k/32k (5%)".into(),
654 };
655 let json = serde_json::to_string(&event).unwrap();
656 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
657 assert!(matches!(
658 deserialized,
659 EngineEvent::Footer {
660 prompt_tokens: 4400,
661 ..
662 }
663 ));
664 }
665
666 #[test]
667 fn test_engine_event_simple_variants_roundtrip() {
668 let variants = vec![
669 EngineEvent::TextDone,
670 EngineEvent::ThinkingStart,
671 EngineEvent::ThinkingDone,
672 EngineEvent::ResponseStart,
673 EngineEvent::SpinnerStop,
674 EngineEvent::Info {
675 message: "hello".into(),
676 },
677 EngineEvent::Warn {
678 message: "careful".into(),
679 },
680 EngineEvent::Error {
681 message: "oops".into(),
682 },
683 ];
684 for event in variants {
685 let json = serde_json::to_string(&event).unwrap();
686 let _: EngineEvent = serde_json::from_str(&json).unwrap();
687 }
688 }
689
690 #[test]
691 fn test_engine_command_approval_roundtrip() {
692 let cmd = EngineCommand::ApprovalResponse {
693 id: "approval_1".into(),
694 decision: ApprovalDecision::RejectWithFeedback {
695 feedback: "use npm ci instead".into(),
696 },
697 };
698 let json = serde_json::to_string(&cmd).unwrap();
699 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
700 assert!(matches!(
701 deserialized,
702 EngineCommand::ApprovalResponse {
703 decision: ApprovalDecision::RejectWithFeedback { .. },
704 ..
705 }
706 ));
707 }
708
709 #[test]
710 fn test_approval_decision_variants() {
711 let decisions = vec![
712 ApprovalDecision::Approve,
713 ApprovalDecision::Reject,
714 ApprovalDecision::RejectWithFeedback {
715 feedback: "try again".into(),
716 },
717 // #1022 B15: new variant for headless / no-human-in-loop
718 // auto-rejection. Distinct from `Reject` on the wire so
719 // the model can adapt its plan instead of asking a
720 // nonexistent user.
721 ApprovalDecision::RejectAuto {
722 reason: "destructive op blocked by headless policy".into(),
723 },
724 ];
725 for d in decisions {
726 let json = serde_json::to_string(&d).unwrap();
727 let roundtripped: ApprovalDecision = serde_json::from_str(&json).unwrap();
728 assert_eq!(d, roundtripped);
729 }
730 }
731
732 /// #1022 B15: wire-format guard. The `decision` tag for the new
733 /// `RejectAuto` variant must be `"reject_auto"` (snake_case via
734 /// `#[serde(rename_all = "snake_case")]`). Renaming this would
735 /// break ACP clients silently — they'd see an unknown decision
736 /// and fall through to `Reject`, re-introducing the bug.
737 #[test]
738 fn test_reject_auto_wire_tag_is_snake_case() {
739 let d = ApprovalDecision::RejectAuto { reason: "r".into() };
740 let json = serde_json::to_string(&d).unwrap();
741 assert!(
742 json.contains("\"decision\":\"reject_auto\""),
743 "expected snake_case tag, got: {json}"
744 );
745 }
746
747 #[test]
748 fn test_turn_lifecycle_roundtrip() {
749 let start = EngineEvent::TurnStart {
750 turn_id: "turn-1".into(),
751 };
752 let json = serde_json::to_string(&start).unwrap();
753 assert!(json.contains("turn_start"));
754 let _: EngineEvent = serde_json::from_str(&json).unwrap();
755
756 let end_complete = EngineEvent::TurnEnd {
757 turn_id: "turn-1".into(),
758 reason: TurnEndReason::Complete,
759 };
760 let json = serde_json::to_string(&end_complete).unwrap();
761 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
762 assert!(matches!(
763 deserialized,
764 EngineEvent::TurnEnd {
765 reason: TurnEndReason::Complete,
766 ..
767 }
768 ));
769
770 let end_error = EngineEvent::TurnEnd {
771 turn_id: "turn-2".into(),
772 reason: TurnEndReason::Error {
773 message: "oops".into(),
774 },
775 };
776 let json = serde_json::to_string(&end_error).unwrap();
777 let _: EngineEvent = serde_json::from_str(&json).unwrap();
778
779 let end_cancelled = EngineEvent::TurnEnd {
780 turn_id: "turn-3".into(),
781 reason: TurnEndReason::Cancelled,
782 };
783 let json = serde_json::to_string(&end_cancelled).unwrap();
784 let _: EngineEvent = serde_json::from_str(&json).unwrap();
785 }
786
787 #[test]
788 fn test_loop_cap_reached_roundtrip() {
789 let event = EngineEvent::LoopCapReached {
790 cap: 200,
791 recent_tools: vec!["Bash".into(), "Edit".into()],
792 };
793 let json = serde_json::to_string(&event).unwrap();
794 assert!(json.contains("loop_cap_reached"));
795 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
796 assert!(matches!(
797 deserialized,
798 EngineEvent::LoopCapReached { cap: 200, .. }
799 ));
800 }
801
802 #[test]
803 fn test_loop_decision_roundtrip() {
804 use crate::loop_guard::LoopContinuation;
805
806 let cmd = EngineCommand::LoopDecision {
807 action: LoopContinuation::Continue50,
808 };
809 let json = serde_json::to_string(&cmd).unwrap();
810 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
811 assert!(matches!(
812 deserialized,
813 EngineCommand::LoopDecision {
814 action: LoopContinuation::Continue50
815 }
816 ));
817
818 let cmd_stop = EngineCommand::LoopDecision {
819 action: LoopContinuation::Stop,
820 };
821 let json = serde_json::to_string(&cmd_stop).unwrap();
822 let _: EngineCommand = serde_json::from_str(&json).unwrap();
823 }
824
825 #[test]
826 fn test_queue_next_roundtrip() {
827 let cmd = EngineCommand::QueueNext {
828 text: "also add tests".into(),
829 };
830 let json = serde_json::to_string(&cmd).unwrap();
831 assert!(json.contains("\"type\":\"queue_next\""));
832 let deserialized: EngineCommand = serde_json::from_str(&json).unwrap();
833 assert!(
834 matches!(deserialized, EngineCommand::QueueNext { ref text } if text == "also add tests")
835 );
836 }
837
838 #[test]
839 fn test_turn_end_reason_variants() {
840 let reasons = vec![
841 TurnEndReason::Complete,
842 TurnEndReason::Cancelled,
843 TurnEndReason::Error {
844 message: "failed".into(),
845 },
846 ];
847 for reason in reasons {
848 let json = serde_json::to_string(&reason).unwrap();
849 let roundtripped: TurnEndReason = serde_json::from_str(&json).unwrap();
850 assert_eq!(reason, roundtripped);
851 }
852 }
853
854 /// #1201 B + PR-A0 of #1232: ChildAgentActivity must roundtrip
855 /// cleanly so ACP / headless clients see the same wire shape as
856 /// the in-process TUI. Tests all three kinds, the envelope, and
857 /// the wire-tag preservation (still `bg_child_activity` for back
858 /// compat).
859 #[test]
860 fn test_child_agent_activity_roundtrip() {
861 let kinds = vec![
862 ChildAgentActivityKind::ToolStart {
863 tool_name: "Read".into(),
864 summary: "Read src/auth.rs".into(),
865 },
866 ChildAgentActivityKind::ToolEnd {
867 tool_name: "Bash".into(),
868 success: true,
869 },
870 ChildAgentActivityKind::ToolEnd {
871 tool_name: "Edit".into(),
872 success: false,
873 },
874 ChildAgentActivityKind::Info {
875 message: " \u{26a1} cache hit".into(),
876 },
877 ];
878 for kind in kinds {
879 let json = serde_json::to_string(&kind).unwrap();
880 let roundtripped: ChildAgentActivityKind = serde_json::from_str(&json).unwrap();
881 assert_eq!(kind, roundtripped);
882 }
883
884 // Envelope event — tests the outer EngineEvent serialization
885 // including the preserved snake_case type tag
886 // ("bg_child_activity") and the new is_background field.
887 let event = EngineEvent::ChildAgentActivity {
888 task_id: 7,
889 spawner: Some(3),
890 is_background: true,
891 kind: ChildAgentActivityKind::ToolStart {
892 tool_name: "Grep".into(),
893 summary: "Grep TODO src/".into(),
894 },
895 };
896 let json = serde_json::to_string(&event).unwrap();
897 assert!(
898 json.contains("\"type\":\"bg_child_activity\""),
899 "envelope must preserve historical wire tag for ACP / headless clients"
900 );
901 assert!(
902 json.contains("\"kind\":\"tool_start\""),
903 "inner kind must use snake_case tag"
904 );
905 assert!(
906 json.contains("\"is_background\":true"),
907 "is_background must serialize on the wire so future fg emits are distinguishable"
908 );
909 let deserialized: EngineEvent = serde_json::from_str(&json).unwrap();
910 assert!(matches!(
911 deserialized,
912 EngineEvent::ChildAgentActivity {
913 task_id: 7,
914 spawner: Some(3),
915 is_background: true,
916 ..
917 }
918 ));
919
920 // Top-level-spawned task — spawner is None.
921 let top_level = EngineEvent::ChildAgentActivity {
922 task_id: 1,
923 spawner: None,
924 is_background: true,
925 kind: ChildAgentActivityKind::Info {
926 message: "hello".into(),
927 },
928 };
929 let json = serde_json::to_string(&top_level).unwrap();
930 let _: EngineEvent = serde_json::from_str(&json).unwrap();
931
932 // Back-compat: a payload from before is_background existed
933 // must still deserialize, defaulting is_background to true.
934 let legacy_json = r#"{"type":"bg_child_activity","task_id":2,"spawner":null,"kind":{"kind":"info","message":"legacy"}}"#;
935 let legacy: EngineEvent = serde_json::from_str(legacy_json).unwrap();
936 assert!(matches!(
937 legacy,
938 EngineEvent::ChildAgentActivity {
939 is_background: true,
940 ..
941 }
942 ));
943 }
944
945 // ── EngineCommand::kind (#1232 §6) ──────────────────────────
946
947 /// Pin every variant → stable name. If a future PR adds a new
948 /// `EngineCommand` variant the `match` in `kind()` becomes
949 /// non-exhaustive and the build breaks — but if someone *renames*
950 /// an existing variant without updating `kind()`, only this test
951 /// catches it. Treat the names as a stable API.
952 #[test]
953 fn engine_command_kind_names_every_variant() {
954 let cases: &[(EngineCommand, &str)] = &[
955 (EngineCommand::Interrupt, "Interrupt"),
956 (
957 EngineCommand::AskUserResponse {
958 id: "x".into(),
959 answer: "y".into(),
960 },
961 "AskUserResponse",
962 ),
963 (
964 EngineCommand::ApprovalResponse {
965 id: "x".into(),
966 decision: ApprovalDecision::Approve,
967 },
968 "ApprovalResponse",
969 ),
970 (
971 EngineCommand::LoopDecision {
972 action: crate::loop_guard::LoopContinuation::Stop,
973 },
974 "LoopDecision",
975 ),
976 (EngineCommand::QueueNext { text: "hi".into() }, "QueueNext"),
977 ];
978 for (cmd, expected) in cases {
979 assert_eq!(
980 cmd.kind(),
981 *expected,
982 "variant name mismatch — update kind() AND log/metric consumers if renaming"
983 );
984 }
985 }
986
987 /// Payload-safety guard: `kind()` must NOT leak user-typed text
988 /// into the returned static string. The whole point of using
989 /// `kind()` instead of `{:?}` in the WARN log is to keep
990 /// `AskUserResponse.answer` and `QueueNext.text` out of logs.
991 #[test]
992 fn engine_command_kind_does_not_leak_payload() {
993 let secret = "P@SSW0RD-leaked-via-logs";
994 let answer_cmd = EngineCommand::AskUserResponse {
995 id: "x".into(),
996 answer: secret.into(),
997 };
998 let queue_cmd = EngineCommand::QueueNext {
999 text: secret.into(),
1000 };
1001 assert!(
1002 !answer_cmd.kind().contains(secret),
1003 "AskUserResponse.kind() leaked the answer payload"
1004 );
1005 assert!(
1006 !queue_cmd.kind().contains(secret),
1007 "QueueNext.kind() leaked the text payload"
1008 );
1009 }
1010}