Skip to main content

capo_agent/
events.rs

1#![cfg_attr(test, allow(clippy::expect_used))]
2
3use motosan_agent_tool::ToolResult;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Clone, Serialize)]
8pub enum ProgressChunk {
9    Stdout(Vec<u8>),
10    Stderr(Vec<u8>),
11    Status(String),
12}
13
14impl From<crate::tools::ToolProgressChunk> for ProgressChunk {
15    fn from(c: crate::tools::ToolProgressChunk) -> Self {
16        use crate::tools::ToolProgressChunk as TPC;
17        match c {
18            TPC::Stdout(b) => Self::Stdout(b),
19            TPC::Stderr(b) => Self::Stderr(b),
20            TPC::Status(s) => Self::Status(s),
21        }
22    }
23}
24
25/// User's answer to a `PermissionRequested` prompt, carried back from the
26/// front-end to the binary so the binary can update the session cache.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct PermissionResolution {
29    pub tool: String,
30    pub args: serde_json::Value,
31    pub choice: PermissionChoice,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum PermissionChoice {
37    AllowOnce,
38    AllowSession,
39    Deny,
40}
41
42/// Whether a compaction event came from explicit user action (`/compact`)
43/// or from `AutocompactExtension` triggering on a heavy turn.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum CompactSource {
47    /// User typed `/compact` (or pressed the palette entry).
48    Manual,
49    /// `AutocompactExtension` triggered automatically.
50    Auto,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
55pub enum Command {
56    SendUserMessage {
57        text: String,
58        #[serde(default, skip_serializing_if = "Vec::is_empty")]
59        attachments: Vec<crate::user_message::Attachment>,
60    },
61    CancelAgent,
62    Quit,
63    ResolvePermission(PermissionResolution),
64    /// v0.10 Phase A: change the permission mode for the current session.
65    /// Session-only; does NOT persist to settings.toml. RPC equivalent
66    /// of TUI Shift+Tab.
67    SetPermissionMode {
68        mode: crate::permissions::PermissionMode,
69    },
70    /// `!cmd` / `!!cmd` — run a shell command typed directly by the user.
71    /// `send_to_llm` is true for `!cmd` (output becomes a user message),
72    /// false for `!!cmd` (output shown in the transcript only).
73    RunInlineBash {
74        command: String,
75        send_to_llm: bool,
76    },
77    /// Manual context compaction, triggered by the `/compact` slash command.
78    Compact,
79    /// Apply a `Settings` struct in-memory and rebuild the live session
80    /// with new settings, then atomically persist to settings.toml on
81    /// success. Used by the TUI `/settings` editor. **Rebuild-first**:
82    /// if the rebuild fails (e.g. provider lacks auth), disk is NOT
83    /// touched — the user's live session stays consistent with disk.
84    /// See spec §4.3 and §4.4.
85    ApplySettings {
86        settings: crate::settings::Settings,
87    },
88    /// Re-read ~/.capo/agent/settings.toml and rebuild the live session
89    /// with whatever settings are on disk. Used by external RPC clients
90    /// that have edited the file themselves. Failure leaves disk and
91    /// live session divergent — caller's responsibility. See spec §4.4.
92    ReloadSettings,
93    /// `/new` — replace the live session with a fresh empty one.
94    NewSession,
95    /// `/clone` — copy the current session to a new independent file and
96    /// switch to it. Handled by `forward_commands`.
97    CloneSession,
98    /// `--rpc` read query: reply with the session's `Vec<Message>`
99    /// history via `UiEvent::MessagesSnapshot`. No effect on the agent.
100    GetMessages,
101    /// `--rpc` read query: reply with a small status snapshot via
102    /// `UiEvent::StateSnapshot`. No effect on the agent.
103    GetState,
104    /// `--rpc` read query: reply with the registered extensions + load
105    /// diagnostics via `UiEvent::ExtensionList`. No agent effect.
106    ListExtensions,
107    /// Invoke an extension-registered slash command.
108    /// Emitted by the TUI's slash palette when the user selects a command
109    /// owned by an extension (not a built-in).
110    InvokeExtensionCommand {
111        name: String,
112        args: String,
113    },
114    /// `/model` — rebuild the live session under a different model.
115    SwitchModel(crate::model::ModelId),
116    /// `/resume` — replace the live session with a stored one, by id.
117    LoadSession(String),
118    /// `/fork` — continue from an earlier entry, creating a branch.
119    ForkFrom {
120        from: String,
121        message: String,
122    },
123}
124
125impl PartialEq for Command {
126    fn eq(&self, other: &Self) -> bool {
127        use Command as C;
128        match (self, other) {
129            (
130                C::SendUserMessage {
131                    text: left_text,
132                    attachments: left_attachments,
133                },
134                C::SendUserMessage {
135                    text: right_text,
136                    attachments: right_attachments,
137                },
138            ) => left_text == right_text && left_attachments == right_attachments,
139            (C::CancelAgent, C::CancelAgent)
140            | (C::Quit, C::Quit)
141            | (C::Compact, C::Compact)
142            | (C::ReloadSettings, C::ReloadSettings)
143            | (C::NewSession, C::NewSession)
144            | (C::CloneSession, C::CloneSession)
145            | (C::GetMessages, C::GetMessages)
146            | (C::GetState, C::GetState)
147            | (C::ListExtensions, C::ListExtensions) => true,
148            (C::ResolvePermission(left), C::ResolvePermission(right)) => left == right,
149            (C::SetPermissionMode { mode: left }, C::SetPermissionMode { mode: right }) => {
150                left == right
151            }
152            (
153                C::RunInlineBash {
154                    command: left_command,
155                    send_to_llm: left_send_to_llm,
156                },
157                C::RunInlineBash {
158                    command: right_command,
159                    send_to_llm: right_send_to_llm,
160                },
161            ) => left_command == right_command && left_send_to_llm == right_send_to_llm,
162            (C::ApplySettings { settings: left }, C::ApplySettings { settings: right }) => {
163                settings_command_eq(left, right)
164            }
165            (
166                C::InvokeExtensionCommand {
167                    name: left_name,
168                    args: left_args,
169                },
170                C::InvokeExtensionCommand {
171                    name: right_name,
172                    args: right_args,
173                },
174            ) => left_name == right_name && left_args == right_args,
175            (C::SwitchModel(left), C::SwitchModel(right)) => left == right,
176            (C::LoadSession(left), C::LoadSession(right)) => left == right,
177            (
178                C::ForkFrom {
179                    from: left_from,
180                    message: left_message,
181                },
182                C::ForkFrom {
183                    from: right_from,
184                    message: right_message,
185                },
186            ) => left_from == right_from && left_message == right_message,
187            _ => false,
188        }
189    }
190}
191
192impl Eq for Command {}
193
194fn settings_command_eq(
195    left: &crate::settings::Settings,
196    right: &crate::settings::Settings,
197) -> bool {
198    left.model == right.model
199        && left.anthropic == right.anthropic
200        && left.ui == right.ui
201        && left.session.autosave == right.session.autosave
202        && left.session.compact_at_context_pct.to_bits()
203            == right.session.compact_at_context_pct.to_bits()
204        && left.session.max_context_tokens == right.session.max_context_tokens
205        && left.session.keep_turns == right.session.keep_turns
206        && left.logging == right.logging
207        && left.permissions == right.permissions
208}
209
210#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
211pub struct ExtensionInfo {
212    pub name: String,
213    pub hooks: Vec<String>,
214    pub commands: Vec<String>,
215    /// `true` if the extension's binary exists and is executable at the
216    /// configured path. `false` if a load-time diagnostic blocked it.
217    pub healthy: bool,
218    /// Load-time diagnostic if `!healthy`. `None` otherwise.
219    pub diagnostic: Option<String>,
220}
221
222#[derive(Debug, Serialize)]
223#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
224pub enum UiEvent {
225    AgentTurnStarted,
226    AgentThinking,
227    AgentTextDelta(String),
228    /// Mid-stream extended-thinking delta from the LLM. Anthropic-only
229    /// at the wire level (motosan-ai 0.15.4 emits thinking events for
230    /// the Anthropic provider only). Each delta is a partial chunk; the
231    /// TUI accumulates them into an `InFlight::StreamingThinking` block
232    /// and finalizes on the matching `UiEvent::ThinkingComplete`. A
233    /// turn may emit zero or more `AgentThinkingDelta`s before a single
234    /// `ThinkingComplete`; deltas always precede any `AgentTextDelta`
235    /// for the same turn.
236    AgentThinkingDelta(String),
237    /// v0.9 Phase A: emitted by capo-agent after each `AssistantContent::Reasoning`
238    /// (thinking/chain-of-thought) ContentPart is observed in a completed
239    /// assistant Message. Fires BEFORE `AgentMessageComplete` for the same
240    /// turn so the TUI's transcript order is: thinking → text. Multiple
241    /// thinking phases per turn produce multiple events in stream order.
242    /// See spec §3.5.
243    ///
244    /// **Dual source (since v0.11):** this variant has two emitters in
245    /// `capo-agent::app::run_turn`:
246    ///   1. **Streaming path** — `CoreEvent::ThinkingDone` maps directly to
247    ///      `ThinkingComplete` via `map_event` (Anthropic-only). When live
248    ///      this is preceded by zero or more `AgentThinkingDelta`s.
249    ///   2. **Post-turn walker path** — `extract_new_thinking_events` walks
250    ///      `AssistantContent::Reasoning` in the just-finished turn's
251    ///      messages and emits one `ThinkingComplete` per part. Used for
252    ///      backends that don't stream thinking (everything non-Anthropic
253    ///      under motosan-ai 0.15.4 today).
254    ///
255    /// A per-turn `streamed_thinking_seen` flag ensures only one of the two
256    /// paths fires for a given turn. The TUI distinguishes the two sources
257    /// implicitly: if `in_flight` is `StreamingThinking` when this event
258    /// arrives, it's streamed (collapse default = true); otherwise it's
259    /// walker-sourced (collapse default = false, preserving pre-v0.11 UX
260    /// for non-Anthropic users).
261    ThinkingComplete {
262        text: String,
263    },
264    AgentMessageComplete(String),
265    ToolCallStarted {
266        id: String,
267        name: String,
268        args: Value,
269    },
270    ToolCallProgress {
271        id: String,
272        chunk: ProgressChunk,
273    },
274    ToolCallCompleted {
275        id: String,
276        result: UiToolResult,
277    },
278    AgentTurnComplete,
279    /// Token-count summary emitted after each LLM chat call. Cumulative
280    /// fields carry the running total for this session. Per-turn deltas
281    /// (`input_tokens` / `output_tokens`) are the increment from THIS
282    /// turn — 0 if the provider didn't surface counts (CLI shells may
283    /// not). See spec §3.1.
284    TurnStats {
285        input_tokens: u64,
286        output_tokens: u64,
287        cumulative_input: u64,
288        cumulative_output: u64,
289        model: String,
290    },
291    /// Compaction occurred. From explicit `/compact` (Manual) or from
292    /// `AutocompactExtension` triggering automatically (Auto).
293    ///
294    /// `turns_removed` is the number of user-turn boundaries summarized.
295    /// `summary_tokens` is the approximate token count of the summary that
296    /// replaced them. For Manual compactions sourced via session marker,
297    /// `summary_tokens` is the character-length-divided-by-4 estimate
298    /// (motosan's `CompactionResult` does not expose a token count).
299    Compacted {
300        turns_removed: usize,
301        summary_tokens: usize,
302        source: CompactSource,
303    },
304    PermissionRequested {
305        tool: String,
306        args: serde_json::Value,
307        #[serde(skip)]
308        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
309    },
310    /// v0.10 Phase A: permission mode changed (Shift+Tab in TUI,
311    /// --mode CLI flag at startup, or Command::SetPermissionMode RPC).
312    /// RPC clients with strict UiEvent dispatchers need a new arm.
313    PermissionModeChanged {
314        mode: crate::permissions::PermissionMode,
315    },
316    /// Output of a `!!cmd` inline-bash run, for transcript display.
317    InlineBashOutput {
318        command: String,
319        output: String,
320    },
321    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
322    /// stored one). Carries the new session ID + transcript so the TUI can
323    /// rebuild `state.messages` and detect same-session rebuilds.
324    ///
325    /// v0.10 Phase B-2: BREAKING wire change from payload `[...]` to
326    /// payload `{ "session_id": "...", "history": [...] }`.
327    SessionReplaced {
328        session_id: String,
329        history: Vec<motosan_agent_loop::Message>,
330    },
331    /// The active model changed successfully.
332    ModelSwitched(crate::model::ModelId),
333    /// Refreshed `/fork` candidate list (active-branch user messages,
334    /// newest first) — `(EntryId, preview)` pairs. The binary emits this
335    /// at startup and after every turn so the picker is never stale.
336    ForkCandidates(Vec<(String, String)>),
337    /// Refreshed session branch tree — the binary emits this at startup
338    /// and after every turn so the `/tree` view is never stale.
339    BranchTree(
340        #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
341        motosan_agent_loop::BranchTree,
342    ),
343    /// Reply to `Command::GetMessages` — the session's persisted message
344    /// history. RPC clients use this to snapshot transcript state.
345    MessagesSnapshot(Vec<motosan_agent_loop::Message>),
346    /// Reply to `Command::GetState` — a small status snapshot for RPC
347    /// clients (the TUI holds these in `AppState` so does not need it).
348    StateSnapshot {
349        session_id: String,
350        model: String,
351        active_turn: bool,
352    },
353    /// Reply to `Command::ListExtensions` — the registered extensions
354    /// + per-extension health status.
355    ExtensionList {
356        extensions: Vec<ExtensionInfo>,
357    },
358    /// Snapshot of the current settings. Emitted by the binary at
359    /// startup and after every successful `ApplySettings` / `ReloadSettings`
360    /// so the TUI can update `state.settings_snapshot`. RPC clients
361    /// can also consume this to observe live settings changes.
362    SettingsSnapshot {
363        settings: crate::settings::Settings,
364    },
365    Error(String),
366    /// A registered extension failed during a turn. Surfaced from motosan's
367    /// `CoreEvent::ExtensionFailed`. The turn continues running (extension
368    /// failures are not fatal at the loop level), but the user has visibility
369    /// to know e.g. autocompact is broken.
370    ExtensionFailed {
371        name: String,
372        error: String,
373    },
374    /// Emitted when an attachment in a `UserMessage` failed validation
375    /// (path missing, unsupported extension, oversize, unreadable). No turn
376    /// is started; the agent stays in `Idle`. See spec §3 / §4.4.
377    AttachmentError {
378        kind: crate::user_message::AttachmentErrorKind,
379        message: String,
380    },
381    /// An extension cancelled a `before_user_message` or
382    /// `session_before_switch` hook. The TUI rolls back any optimistic
383    /// transcript push (via `state.pending_turn_submit`) and restores
384    /// the composer buffer. See spec §4.4.
385    ExtensionCancelled {
386        extension_name: String,
387        reason: Option<String>,
388    },
389    /// Surface a transcript notice from an extension `reply` action,
390    /// `/image` errors, or other non-fatal informational events.
391    Notice {
392        title: String,
393        body: String,
394    },
395}
396
397#[derive(Debug, Clone, Serialize)]
398pub struct UiToolResult {
399    pub is_error: bool,
400    pub text: String,
401}
402
403impl From<&ToolResult> for UiToolResult {
404    fn from(r: &ToolResult) -> Self {
405        Self {
406            is_error: r.is_error,
407            text: format!("{r:?}"),
408        }
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn attachment_error_event_serializes_with_kind_and_message() {
418        use crate::user_message::AttachmentErrorKind;
419
420        let ev = UiEvent::AttachmentError {
421            kind: AttachmentErrorKind::NotFound,
422            message: "image not found: /tmp/foo.png".into(),
423        };
424        let json = serde_json::to_string(&ev).expect("serialize");
425        // UiEvent uses tag = "type", content = "payload", rename_all = "snake_case"
426        assert_eq!(
427            json,
428            r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
429        );
430    }
431
432    #[test]
433    fn compact_command_is_constructible() {
434        let c = Command::Compact;
435        assert_eq!(c, Command::Compact);
436        assert!(format!("{c:?}").contains("Compact"));
437    }
438
439    #[test]
440    fn d2_command_and_event_variants_construct() {
441        let _ = Command::NewSession;
442        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
443        let _ = Command::LoadSession("sess-id".into());
444        let e = UiEvent::SessionReplaced {
445            session_id: "test".into(),
446            history: Vec::new(),
447        };
448        assert!(format!("{e:?}").contains("SessionReplaced"));
449        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
450        assert!(format!("{e:?}").contains("ModelSwitched"));
451    }
452
453    #[test]
454    fn clone_session_command_constructs() {
455        let c = Command::CloneSession;
456        assert!(format!("{c:?}").contains("CloneSession"));
457    }
458
459    #[test]
460    fn read_query_commands_round_trip() {
461        for c in [Command::GetMessages, Command::GetState] {
462            let line = serde_json::to_string(&c).expect("serialize");
463            assert!(line.contains("\"type\":"), "{line}");
464            let back: Command = serde_json::from_str(&line).expect("deserialize");
465            assert_eq!(c, back);
466        }
467        // Unit variants serialize as bare `{"type":"…"}`.
468        assert_eq!(
469            serde_json::to_string(&Command::GetMessages).expect("ser"),
470            r#"{"type":"get_messages"}"#
471        );
472    }
473
474    #[test]
475    fn fork_protocol_variants_construct() {
476        let c = Command::ForkFrom {
477            from: "e1".into(),
478            message: "hi".into(),
479        };
480        assert!(format!("{c:?}").contains("ForkFrom"));
481        let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
482        assert!(format!("{e:?}").contains("ForkCandidates"));
483    }
484
485    #[test]
486    fn branch_tree_event_constructs() {
487        let tree = motosan_agent_loop::BranchTree {
488            nodes: Vec::new(),
489            root: None,
490            active_leaf: None,
491        };
492        let e = UiEvent::BranchTree(tree);
493        assert!(format!("{e:?}").contains("BranchTree"));
494    }
495
496    #[test]
497    fn run_inline_bash_command_is_constructible() {
498        let c = Command::RunInlineBash {
499            command: "ls".into(),
500            send_to_llm: true,
501        };
502        assert!(format!("{c:?}").contains("RunInlineBash"));
503    }
504
505    #[test]
506    fn permission_protocol_variants_are_constructible() {
507        let command = Command::ResolvePermission(PermissionResolution {
508            tool: "bash".into(),
509            args: serde_json::json!({"command": "echo hi"}),
510            choice: PermissionChoice::AllowSession,
511        });
512        assert!(format!("{command:?}").contains("ResolvePermission"));
513        assert!(format!("{command:?}").contains("AllowSession"));
514
515        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
516        let event = UiEvent::PermissionRequested {
517            tool: "bash".into(),
518            args: serde_json::json!({"command": "echo hi"}),
519            resolver,
520        };
521        assert!(format!("{event:?}").contains("PermissionRequested"));
522    }
523
524    #[test]
525    fn command_round_trips_through_json() {
526        let cases = vec![
527            Command::SendUserMessage {
528                text: "hi".into(),
529                attachments: Vec::new(),
530            },
531            Command::CancelAgent,
532            Command::Quit,
533            Command::Compact,
534            Command::NewSession,
535            Command::CloneSession,
536            Command::RunInlineBash {
537                command: "ls".into(),
538                send_to_llm: true,
539            },
540            Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
541            Command::LoadSession("sess-1".into()),
542            Command::ForkFrom {
543                from: "e1".into(),
544                message: "hi".into(),
545            },
546            Command::ResolvePermission(PermissionResolution {
547                tool: "bash".into(),
548                args: serde_json::json!({"command": "echo hi"}),
549                choice: PermissionChoice::AllowSession,
550            }),
551        ];
552        for c in cases {
553            let line = serde_json::to_string(&c).expect("serialize");
554            assert!(line.contains("\"type\":"), "missing type tag: {line}");
555            let back: Command = serde_json::from_str(&line).expect("deserialize");
556            assert_eq!(c, back, "round-trip mismatch for {line}");
557        }
558    }
559
560    #[test]
561    fn command_send_user_message_round_trip() {
562        let line = serde_json::to_string(&Command::SendUserMessage {
563            text: "hi".into(),
564            attachments: Vec::new(),
565        })
566        .expect("serialize");
567        assert_eq!(
568            line,
569            r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
570        );
571        let back: Command = serde_json::from_str(&line).expect("deserialize");
572        assert_eq!(
573            back,
574            Command::SendUserMessage {
575                text: "hi".into(),
576                attachments: Vec::new(),
577            }
578        );
579    }
580
581    #[test]
582    fn command_send_user_message_with_attachment_round_trip() {
583        let cmd = Command::SendUserMessage {
584            text: "look".into(),
585            attachments: vec![crate::user_message::Attachment::Image {
586                path: std::path::PathBuf::from("/tmp/foo.png"),
587            }],
588        };
589        let line = serde_json::to_string(&cmd).expect("serialize");
590        assert_eq!(
591            line,
592            r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
593        );
594        let back: Command = serde_json::from_str(&line).expect("deserialize");
595        assert_eq!(back, cmd);
596    }
597
598    #[test]
599    fn unit_command_uses_adjacent_tagging() {
600        let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
601        assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
602    }
603
604    #[test]
605    fn command_invoke_extension_command_serde_round_trip() {
606        let cmd = Command::InvokeExtensionCommand {
607            name: "todo".into(),
608            args: "add buy milk".into(),
609        };
610        let json = serde_json::to_string(&cmd).expect("ok");
611        assert_eq!(
612            json,
613            r#"{"type":"invoke_extension_command","payload":{"name":"todo","args":"add buy milk"}}"#
614        );
615        let back: Command = serde_json::from_str(&json).expect("ok");
616        assert_eq!(back, cmd);
617    }
618
619    #[test]
620    fn ui_event_extension_cancelled_serializes() {
621        let ev = UiEvent::ExtensionCancelled {
622            extension_name: "dirty-repo-guard".into(),
623            reason: Some("uncommitted changes".into()),
624        };
625        let json = serde_json::to_string(&ev).expect("ok");
626        assert!(json.contains(r#""type":"extension_cancelled""#));
627        assert!(json.contains(r#""extension_name":"dirty-repo-guard""#));
628        assert!(json.contains(r#""reason":"uncommitted changes""#));
629    }
630
631    #[test]
632    fn ui_event_notice_serializes() {
633        let ev = UiEvent::Notice {
634            title: "/todo".into(),
635            body: "reply from fixture".into(),
636        };
637        let json = serde_json::to_string(&ev).expect("ok");
638        assert!(json.contains(r#""type":"notice""#));
639        assert!(json.contains(r#""title":"/todo""#));
640        assert!(json.contains(r#""body":"reply from fixture""#));
641    }
642
643    #[test]
644    fn ui_event_thinking_complete_serializes() {
645        let ev = UiEvent::ThinkingComplete {
646            text: "the model considered options A and B".into(),
647        };
648        let line = serde_json::to_string(&ev).expect("serialize");
649        assert_eq!(
650            line,
651            r#"{"type":"thinking_complete","payload":{"text":"the model considered options A and B"}}"#
652        );
653    }
654
655    #[test]
656    fn ui_event_thinking_delta_serializes() {
657        let ev = UiEvent::AgentThinkingDelta("partial thought ".into());
658        let json = serde_json::to_string(&ev).expect("serialize");
659        assert_eq!(
660            json,
661            r#"{"type":"agent_thinking_delta","payload":"partial thought "}"#
662        );
663    }
664
665    #[test]
666    fn ui_event_session_replaced_new_payload_serializes() {
667        let ev = UiEvent::SessionReplaced {
668            session_id: "01HK1234".into(),
669            history: Vec::new(),
670        };
671        let line = serde_json::to_string(&ev).expect("serialize");
672        assert!(line.contains(r#""type":"session_replaced""#));
673        assert!(line.contains(r#""session_id":"01HK1234""#));
674        assert!(line.contains(r#""history":[]"#));
675    }
676
677    #[test]
678    fn ui_event_turn_stats_round_trips() {
679        let ev = UiEvent::TurnStats {
680            input_tokens: 1203,
681            output_tokens: 412,
682            cumulative_input: 12543,
683            cumulative_output: 5231,
684            model: "claude-opus-4-7".into(),
685        };
686        let line = serde_json::to_string(&ev).expect("serialize");
687        assert_eq!(
688            line,
689            r#"{"type":"turn_stats","payload":{"input_tokens":1203,"output_tokens":412,"cumulative_input":12543,"cumulative_output":5231,"model":"claude-opus-4-7"}}"#
690        );
691    }
692
693    #[test]
694    fn ui_event_permission_mode_changed_serializes() {
695        use crate::permissions::PermissionMode;
696        let ev = UiEvent::PermissionModeChanged {
697            mode: PermissionMode::Bypass,
698        };
699        let line = serde_json::to_string(&ev).expect("serialize");
700        assert_eq!(
701            line,
702            r#"{"type":"permission_mode_changed","payload":{"mode":"bypass"}}"#
703        );
704        // Per memory's UiEvent-Serialize-only rule, NO from_str round-trip.
705    }
706
707    #[test]
708    fn command_set_permission_mode_round_trips() {
709        use crate::permissions::PermissionMode;
710        let cmd = Command::SetPermissionMode {
711            mode: PermissionMode::AcceptEdits,
712        };
713        let line = serde_json::to_string(&cmd).expect("serialize");
714        assert_eq!(
715            line,
716            r#"{"type":"set_permission_mode","payload":{"mode":"accept-edits"}}"#
717        );
718        let back: Command = serde_json::from_str(&line).expect("deserialize");
719        assert_eq!(back, cmd);
720    }
721
722    #[test]
723    fn command_still_exposes_eq_for_downstream_matchers() {
724        fn assert_eq_impl<T: Eq>() {}
725        assert_eq_impl::<Command>();
726    }
727
728    #[test]
729    fn command_apply_settings_round_trips() {
730        use crate::settings::Settings;
731        let cmd = Command::ApplySettings {
732            settings: Settings::default(),
733        };
734        let line = serde_json::to_string(&cmd).expect("serialize");
735        // Smoke: payload contains a structured Settings object (not a string)
736        assert!(line.contains(r#""type":"apply_settings""#));
737        assert!(line.contains(r#""payload":{"#));
738        assert!(line.contains(r#""model":"#));
739        let back: Command = serde_json::from_str(&line).expect("deserialize");
740        assert_eq!(back, cmd);
741    }
742
743    #[test]
744    fn command_reload_settings_round_trips() {
745        let cmd = Command::ReloadSettings;
746        let line = serde_json::to_string(&cmd).expect("serialize");
747        assert_eq!(line, r#"{"type":"reload_settings"}"#);
748        let back: Command = serde_json::from_str(&line).expect("deserialize");
749        assert_eq!(back, cmd);
750    }
751
752    #[test]
753    fn ui_event_settings_snapshot_serializes() {
754        use crate::settings::Settings;
755        let ev = UiEvent::SettingsSnapshot {
756            settings: Settings::default(),
757        };
758        let line = serde_json::to_string(&ev).expect("serialize");
759        assert!(line.contains(r#""type":"settings_snapshot""#));
760        assert!(line.contains(r#""payload":{"#));
761        assert!(line.contains(r#""model":"#));
762    }
763
764    #[test]
765    fn snapshot_events_serialize_with_type_tag() {
766        let m = UiEvent::MessagesSnapshot(Vec::new());
767        let line = serde_json::to_string(&m).expect("serialize");
768        assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
769
770        let s = UiEvent::StateSnapshot {
771            session_id: "sess-1".into(),
772            model: "claude-opus-4-7".into(),
773            active_turn: false,
774        };
775        let line = serde_json::to_string(&s).expect("serialize");
776        assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
777        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
778        assert!(line.contains(r#""active_turn":false"#), "{line}");
779    }
780
781    #[test]
782    fn ui_events_serialize_with_type_tag() {
783        let events = vec![
784            UiEvent::AgentTurnStarted,
785            UiEvent::AgentThinking,
786            UiEvent::AgentTextDelta("hi".into()),
787            UiEvent::AgentMessageComplete("done".into()),
788            UiEvent::AgentTurnComplete,
789            UiEvent::InlineBashOutput {
790                command: "ls".into(),
791                output: "x".into(),
792            },
793            UiEvent::Notice {
794                title: "note".into(),
795                body: "body".into(),
796            },
797            UiEvent::SessionReplaced {
798                session_id: "test".into(),
799                history: Vec::new(),
800            },
801            UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
802            UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
803            UiEvent::Error("bad".into()),
804            UiEvent::ToolCallStarted {
805                id: "t1".into(),
806                name: "bash".into(),
807                args: serde_json::json!("ls"),
808            },
809            UiEvent::ToolCallProgress {
810                id: "t1".into(),
811                chunk: ProgressChunk::Status("running".into()),
812            },
813            UiEvent::ToolCallCompleted {
814                id: "t1".into(),
815                result: UiToolResult {
816                    is_error: false,
817                    text: "ok".into(),
818                },
819            },
820        ];
821        for e in &events {
822            let line = serde_json::to_string(e).expect("serialize");
823            assert!(line.contains("\"type\":"), "missing type tag: {line}");
824        }
825    }
826
827    #[test]
828    fn permission_requested_serializes_without_the_resolver() {
829        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
830        let e = UiEvent::PermissionRequested {
831            tool: "bash".into(),
832            args: serde_json::json!({"command": "echo hi"}),
833            resolver,
834        };
835        let line = serde_json::to_string(&e).expect("serialize");
836        assert!(line.contains(r#""type":"permission_requested""#), "{line}");
837        assert!(!line.contains("resolver"), "resolver leaked: {line}");
838    }
839
840    #[test]
841    fn branch_tree_event_serializes_via_mapping() {
842        let tree = motosan_agent_loop::BranchTree {
843            nodes: vec![motosan_agent_loop::BranchNode {
844                id: "n0".into(),
845                parent: None,
846                children: vec![],
847                label: "root".into(),
848            }],
849            root: Some(0),
850            active_leaf: Some(0),
851        };
852        let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
853        assert!(line.contains(r#""type":"branch_tree""#), "{line}");
854        assert!(line.contains(r#""label":"root""#), "{line}");
855    }
856
857    #[test]
858    fn command_list_extensions_serde_round_trip() {
859        let cmd = Command::ListExtensions;
860        let line = serde_json::to_string(&cmd).expect("serialize");
861        assert_eq!(line, r#"{"type":"list_extensions"}"#);
862        let back: Command = serde_json::from_str(&line).expect("deserialize");
863        assert_eq!(back, cmd);
864    }
865
866    #[test]
867    fn ui_event_extension_list_serializes() {
868        let ev = UiEvent::ExtensionList {
869            extensions: vec![ExtensionInfo {
870                name: "dirty".into(),
871                hooks: vec!["session_before_switch".into()],
872                commands: Vec::new(),
873                healthy: true,
874                diagnostic: None,
875            }],
876        };
877        let line = serde_json::to_string(&ev).expect("serialize");
878        assert!(line.contains(r#""type":"extension_list""#));
879        assert!(line.contains(r#""name":"dirty""#));
880    }
881}