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