Skip to main content

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}