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