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    /// v0.9 Phase A: emitted by capo-agent after each `AssistantContent::Reasoning`
208    /// (thinking/chain-of-thought) ContentPart is observed in a completed
209    /// assistant Message. Fires BEFORE `AgentMessageComplete` for the same
210    /// turn so the TUI's transcript order is: thinking → text. Multiple
211    /// thinking phases per turn produce multiple events in stream order.
212    /// See spec §3.5.
213    ThinkingComplete {
214        text: String,
215    },
216    AgentMessageComplete(String),
217    ToolCallStarted {
218        id: String,
219        name: String,
220        args: Value,
221    },
222    ToolCallProgress {
223        id: String,
224        chunk: ProgressChunk,
225    },
226    ToolCallCompleted {
227        id: String,
228        result: UiToolResult,
229    },
230    AgentTurnComplete,
231    /// Token-count summary emitted after each LLM chat call. Cumulative
232    /// fields carry the running total for this session. Per-turn deltas
233    /// (`input_tokens` / `output_tokens`) are the increment from THIS
234    /// turn — 0 if the provider didn't surface counts (CLI shells may
235    /// not). See spec §3.1.
236    TurnStats {
237        input_tokens: u64,
238        output_tokens: u64,
239        cumulative_input: u64,
240        cumulative_output: u64,
241        model: String,
242    },
243    PermissionRequested {
244        tool: String,
245        args: serde_json::Value,
246        #[serde(skip)]
247        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
248    },
249    /// Output of a `!!cmd` inline-bash run, for transcript display.
250    InlineBashOutput {
251        command: String,
252        output: String,
253    },
254    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
255    /// stored one). Carries the new transcript so the TUI can rebuild
256    /// `state.messages`. Empty Vec = a fresh session.
257    SessionReplaced(Vec<motosan_agent_loop::Message>),
258    /// The active model changed successfully.
259    ModelSwitched(crate::model::ModelId),
260    /// Refreshed `/fork` candidate list (active-branch user messages,
261    /// newest first) — `(EntryId, preview)` pairs. The binary emits this
262    /// at startup and after every turn so the picker is never stale.
263    ForkCandidates(Vec<(String, String)>),
264    /// Refreshed session branch tree — the binary emits this at startup
265    /// and after every turn so the `/tree` view is never stale.
266    BranchTree(
267        #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
268        motosan_agent_loop::BranchTree,
269    ),
270    /// Reply to `Command::GetMessages` — the session's persisted message
271    /// history. RPC clients use this to snapshot transcript state.
272    MessagesSnapshot(Vec<motosan_agent_loop::Message>),
273    /// Reply to `Command::GetState` — a small status snapshot for RPC
274    /// clients (the TUI holds these in `AppState` so does not need it).
275    StateSnapshot {
276        session_id: String,
277        model: String,
278        active_turn: bool,
279    },
280    /// Reply to `Command::ListExtensions` — the registered extensions
281    /// + per-extension health status.
282    ExtensionList {
283        extensions: Vec<ExtensionInfo>,
284    },
285    /// Snapshot of the current settings. Emitted by the binary at
286    /// startup and after every successful `ApplySettings` / `ReloadSettings`
287    /// so the TUI can update `state.settings_snapshot`. RPC clients
288    /// can also consume this to observe live settings changes.
289    SettingsSnapshot {
290        settings: crate::settings::Settings,
291    },
292    Error(String),
293    /// Emitted when an attachment in a `UserMessage` failed validation
294    /// (path missing, unsupported extension, oversize, unreadable). No turn
295    /// is started; the agent stays in `Idle`. See spec §3 / §4.4.
296    AttachmentError {
297        kind: crate::user_message::AttachmentErrorKind,
298        message: String,
299    },
300    /// An extension cancelled a `before_user_message` or
301    /// `session_before_switch` hook. The TUI rolls back any optimistic
302    /// transcript push (via `state.pending_turn_submit`) and restores
303    /// the composer buffer. See spec §4.4.
304    ExtensionCancelled {
305        extension_name: String,
306        reason: Option<String>,
307    },
308    /// Surface a transcript notice from an extension `reply` action,
309    /// `/image` errors, or other non-fatal informational events.
310    Notice {
311        title: String,
312        body: String,
313    },
314}
315
316#[derive(Debug, Clone, Serialize)]
317pub struct UiToolResult {
318    pub is_error: bool,
319    pub text: String,
320}
321
322impl From<&ToolResult> for UiToolResult {
323    fn from(r: &ToolResult) -> Self {
324        Self {
325            is_error: r.is_error,
326            text: format!("{r:?}"),
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn attachment_error_event_serializes_with_kind_and_message() {
337        use crate::user_message::AttachmentErrorKind;
338
339        let ev = UiEvent::AttachmentError {
340            kind: AttachmentErrorKind::NotFound,
341            message: "image not found: /tmp/foo.png".into(),
342        };
343        let json = serde_json::to_string(&ev).expect("serialize");
344        // UiEvent uses tag = "type", content = "payload", rename_all = "snake_case"
345        assert_eq!(
346            json,
347            r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
348        );
349    }
350
351    #[test]
352    fn compact_command_is_constructible() {
353        let c = Command::Compact;
354        assert_eq!(c, Command::Compact);
355        assert!(format!("{c:?}").contains("Compact"));
356    }
357
358    #[test]
359    fn d2_command_and_event_variants_construct() {
360        let _ = Command::NewSession;
361        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
362        let _ = Command::LoadSession("sess-id".into());
363        let e = UiEvent::SessionReplaced(Vec::new());
364        assert!(format!("{e:?}").contains("SessionReplaced"));
365        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
366        assert!(format!("{e:?}").contains("ModelSwitched"));
367    }
368
369    #[test]
370    fn clone_session_command_constructs() {
371        let c = Command::CloneSession;
372        assert!(format!("{c:?}").contains("CloneSession"));
373    }
374
375    #[test]
376    fn read_query_commands_round_trip() {
377        for c in [Command::GetMessages, Command::GetState] {
378            let line = serde_json::to_string(&c).expect("serialize");
379            assert!(line.contains("\"type\":"), "{line}");
380            let back: Command = serde_json::from_str(&line).expect("deserialize");
381            assert_eq!(c, back);
382        }
383        // Unit variants serialize as bare `{"type":"…"}`.
384        assert_eq!(
385            serde_json::to_string(&Command::GetMessages).expect("ser"),
386            r#"{"type":"get_messages"}"#
387        );
388    }
389
390    #[test]
391    fn fork_protocol_variants_construct() {
392        let c = Command::ForkFrom {
393            from: "e1".into(),
394            message: "hi".into(),
395        };
396        assert!(format!("{c:?}").contains("ForkFrom"));
397        let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
398        assert!(format!("{e:?}").contains("ForkCandidates"));
399    }
400
401    #[test]
402    fn branch_tree_event_constructs() {
403        let tree = motosan_agent_loop::BranchTree {
404            nodes: Vec::new(),
405            root: None,
406            active_leaf: None,
407        };
408        let e = UiEvent::BranchTree(tree);
409        assert!(format!("{e:?}").contains("BranchTree"));
410    }
411
412    #[test]
413    fn run_inline_bash_command_is_constructible() {
414        let c = Command::RunInlineBash {
415            command: "ls".into(),
416            send_to_llm: true,
417        };
418        assert!(format!("{c:?}").contains("RunInlineBash"));
419    }
420
421    #[test]
422    fn permission_protocol_variants_are_constructible() {
423        let command = Command::ResolvePermission(PermissionResolution {
424            tool: "bash".into(),
425            args: serde_json::json!({"command": "echo hi"}),
426            choice: PermissionChoice::AllowSession,
427        });
428        assert!(format!("{command:?}").contains("ResolvePermission"));
429        assert!(format!("{command:?}").contains("AllowSession"));
430
431        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
432        let event = UiEvent::PermissionRequested {
433            tool: "bash".into(),
434            args: serde_json::json!({"command": "echo hi"}),
435            resolver,
436        };
437        assert!(format!("{event:?}").contains("PermissionRequested"));
438    }
439
440    #[test]
441    fn command_round_trips_through_json() {
442        let cases = vec![
443            Command::SendUserMessage {
444                text: "hi".into(),
445                attachments: Vec::new(),
446            },
447            Command::CancelAgent,
448            Command::Quit,
449            Command::Compact,
450            Command::NewSession,
451            Command::CloneSession,
452            Command::RunInlineBash {
453                command: "ls".into(),
454                send_to_llm: true,
455            },
456            Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
457            Command::LoadSession("sess-1".into()),
458            Command::ForkFrom {
459                from: "e1".into(),
460                message: "hi".into(),
461            },
462            Command::ResolvePermission(PermissionResolution {
463                tool: "bash".into(),
464                args: serde_json::json!({"command": "echo hi"}),
465                choice: PermissionChoice::AllowSession,
466            }),
467        ];
468        for c in cases {
469            let line = serde_json::to_string(&c).expect("serialize");
470            assert!(line.contains("\"type\":"), "missing type tag: {line}");
471            let back: Command = serde_json::from_str(&line).expect("deserialize");
472            assert_eq!(c, back, "round-trip mismatch for {line}");
473        }
474    }
475
476    #[test]
477    fn command_send_user_message_round_trip() {
478        let line = serde_json::to_string(&Command::SendUserMessage {
479            text: "hi".into(),
480            attachments: Vec::new(),
481        })
482        .expect("serialize");
483        assert_eq!(
484            line,
485            r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
486        );
487        let back: Command = serde_json::from_str(&line).expect("deserialize");
488        assert_eq!(
489            back,
490            Command::SendUserMessage {
491                text: "hi".into(),
492                attachments: Vec::new(),
493            }
494        );
495    }
496
497    #[test]
498    fn command_send_user_message_with_attachment_round_trip() {
499        let cmd = Command::SendUserMessage {
500            text: "look".into(),
501            attachments: vec![crate::user_message::Attachment::Image {
502                path: std::path::PathBuf::from("/tmp/foo.png"),
503            }],
504        };
505        let line = serde_json::to_string(&cmd).expect("serialize");
506        assert_eq!(
507            line,
508            r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
509        );
510        let back: Command = serde_json::from_str(&line).expect("deserialize");
511        assert_eq!(back, cmd);
512    }
513
514    #[test]
515    fn unit_command_uses_adjacent_tagging() {
516        let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
517        assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
518    }
519
520    #[test]
521    fn command_invoke_extension_command_serde_round_trip() {
522        let cmd = Command::InvokeExtensionCommand {
523            name: "todo".into(),
524            args: "add buy milk".into(),
525        };
526        let json = serde_json::to_string(&cmd).expect("ok");
527        assert_eq!(
528            json,
529            r#"{"type":"invoke_extension_command","payload":{"name":"todo","args":"add buy milk"}}"#
530        );
531        let back: Command = serde_json::from_str(&json).expect("ok");
532        assert_eq!(back, cmd);
533    }
534
535    #[test]
536    fn ui_event_extension_cancelled_serializes() {
537        let ev = UiEvent::ExtensionCancelled {
538            extension_name: "dirty-repo-guard".into(),
539            reason: Some("uncommitted changes".into()),
540        };
541        let json = serde_json::to_string(&ev).expect("ok");
542        assert!(json.contains(r#""type":"extension_cancelled""#));
543        assert!(json.contains(r#""extension_name":"dirty-repo-guard""#));
544        assert!(json.contains(r#""reason":"uncommitted changes""#));
545    }
546
547    #[test]
548    fn ui_event_notice_serializes() {
549        let ev = UiEvent::Notice {
550            title: "/todo".into(),
551            body: "reply from fixture".into(),
552        };
553        let json = serde_json::to_string(&ev).expect("ok");
554        assert!(json.contains(r#""type":"notice""#));
555        assert!(json.contains(r#""title":"/todo""#));
556        assert!(json.contains(r#""body":"reply from fixture""#));
557    }
558
559    #[test]
560    fn ui_event_thinking_complete_serializes() {
561        let ev = UiEvent::ThinkingComplete {
562            text: "the model considered options A and B".into(),
563        };
564        let line = serde_json::to_string(&ev).expect("serialize");
565        assert_eq!(
566            line,
567            r#"{"type":"thinking_complete","payload":{"text":"the model considered options A and B"}}"#
568        );
569    }
570
571    #[test]
572    fn ui_event_turn_stats_round_trips() {
573        let ev = UiEvent::TurnStats {
574            input_tokens: 1203,
575            output_tokens: 412,
576            cumulative_input: 12543,
577            cumulative_output: 5231,
578            model: "claude-opus-4-7".into(),
579        };
580        let line = serde_json::to_string(&ev).expect("serialize");
581        assert_eq!(
582            line,
583            r#"{"type":"turn_stats","payload":{"input_tokens":1203,"output_tokens":412,"cumulative_input":12543,"cumulative_output":5231,"model":"claude-opus-4-7"}}"#
584        );
585    }
586
587    #[test]
588    fn command_still_exposes_eq_for_downstream_matchers() {
589        fn assert_eq_impl<T: Eq>() {}
590        assert_eq_impl::<Command>();
591    }
592
593    #[test]
594    fn command_apply_settings_round_trips() {
595        use crate::settings::Settings;
596        let cmd = Command::ApplySettings {
597            settings: Settings::default(),
598        };
599        let line = serde_json::to_string(&cmd).expect("serialize");
600        // Smoke: payload contains a structured Settings object (not a string)
601        assert!(line.contains(r#""type":"apply_settings""#));
602        assert!(line.contains(r#""payload":{"#));
603        assert!(line.contains(r#""model":"#));
604        let back: Command = serde_json::from_str(&line).expect("deserialize");
605        assert_eq!(back, cmd);
606    }
607
608    #[test]
609    fn command_reload_settings_round_trips() {
610        let cmd = Command::ReloadSettings;
611        let line = serde_json::to_string(&cmd).expect("serialize");
612        assert_eq!(line, r#"{"type":"reload_settings"}"#);
613        let back: Command = serde_json::from_str(&line).expect("deserialize");
614        assert_eq!(back, cmd);
615    }
616
617    #[test]
618    fn ui_event_settings_snapshot_serializes() {
619        use crate::settings::Settings;
620        let ev = UiEvent::SettingsSnapshot {
621            settings: Settings::default(),
622        };
623        let line = serde_json::to_string(&ev).expect("serialize");
624        assert!(line.contains(r#""type":"settings_snapshot""#));
625        assert!(line.contains(r#""payload":{"#));
626        assert!(line.contains(r#""model":"#));
627    }
628
629    #[test]
630    fn snapshot_events_serialize_with_type_tag() {
631        let m = UiEvent::MessagesSnapshot(Vec::new());
632        let line = serde_json::to_string(&m).expect("serialize");
633        assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
634
635        let s = UiEvent::StateSnapshot {
636            session_id: "sess-1".into(),
637            model: "claude-opus-4-7".into(),
638            active_turn: false,
639        };
640        let line = serde_json::to_string(&s).expect("serialize");
641        assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
642        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
643        assert!(line.contains(r#""active_turn":false"#), "{line}");
644    }
645
646    #[test]
647    fn ui_events_serialize_with_type_tag() {
648        let events = vec![
649            UiEvent::AgentTurnStarted,
650            UiEvent::AgentThinking,
651            UiEvent::AgentTextDelta("hi".into()),
652            UiEvent::AgentMessageComplete("done".into()),
653            UiEvent::AgentTurnComplete,
654            UiEvent::InlineBashOutput {
655                command: "ls".into(),
656                output: "x".into(),
657            },
658            UiEvent::Notice {
659                title: "note".into(),
660                body: "body".into(),
661            },
662            UiEvent::SessionReplaced(Vec::new()),
663            UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
664            UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
665            UiEvent::Error("bad".into()),
666            UiEvent::ToolCallStarted {
667                id: "t1".into(),
668                name: "bash".into(),
669                args: serde_json::json!("ls"),
670            },
671            UiEvent::ToolCallProgress {
672                id: "t1".into(),
673                chunk: ProgressChunk::Status("running".into()),
674            },
675            UiEvent::ToolCallCompleted {
676                id: "t1".into(),
677                result: UiToolResult {
678                    is_error: false,
679                    text: "ok".into(),
680                },
681            },
682        ];
683        for e in &events {
684            let line = serde_json::to_string(e).expect("serialize");
685            assert!(line.contains("\"type\":"), "missing type tag: {line}");
686        }
687    }
688
689    #[test]
690    fn permission_requested_serializes_without_the_resolver() {
691        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
692        let e = UiEvent::PermissionRequested {
693            tool: "bash".into(),
694            args: serde_json::json!({"command": "echo hi"}),
695            resolver,
696        };
697        let line = serde_json::to_string(&e).expect("serialize");
698        assert!(line.contains(r#""type":"permission_requested""#), "{line}");
699        assert!(!line.contains("resolver"), "resolver leaked: {line}");
700    }
701
702    #[test]
703    fn branch_tree_event_serializes_via_mapping() {
704        let tree = motosan_agent_loop::BranchTree {
705            nodes: vec![motosan_agent_loop::BranchNode {
706                id: "n0".into(),
707                parent: None,
708                children: vec![],
709                label: "root".into(),
710            }],
711            root: Some(0),
712            active_leaf: Some(0),
713        };
714        let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
715        assert!(line.contains(r#""type":"branch_tree""#), "{line}");
716        assert!(line.contains(r#""label":"root""#), "{line}");
717    }
718
719    #[test]
720    fn command_list_extensions_serde_round_trip() {
721        let cmd = Command::ListExtensions;
722        let line = serde_json::to_string(&cmd).expect("serialize");
723        assert_eq!(line, r#"{"type":"list_extensions"}"#);
724        let back: Command = serde_json::from_str(&line).expect("deserialize");
725        assert_eq!(back, cmd);
726    }
727
728    #[test]
729    fn ui_event_extension_list_serializes() {
730        let ev = UiEvent::ExtensionList {
731            extensions: vec![ExtensionInfo {
732                name: "dirty".into(),
733                hooks: vec!["session_before_switch".into()],
734                commands: Vec::new(),
735                healthy: true,
736                diagnostic: None,
737            }],
738        };
739        let line = serde_json::to_string(&ev).expect("serialize");
740        assert!(line.contains(r#""type":"extension_list""#));
741        assert!(line.contains(r#""name":"dirty""#));
742    }
743}