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