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, PartialEq, Eq, 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    /// `/new` — replace the live session with a fresh empty one.
63    NewSession,
64    /// `/clone` — copy the current session to a new independent file and
65    /// switch to it. Handled by `forward_commands`.
66    CloneSession,
67    /// `--rpc` read query: reply with the session's `Vec<Message>`
68    /// history via `UiEvent::MessagesSnapshot`. No effect on the agent.
69    GetMessages,
70    /// `--rpc` read query: reply with a small status snapshot via
71    /// `UiEvent::StateSnapshot`. No effect on the agent.
72    GetState,
73    /// `--rpc` read query: reply with the registered extensions + load
74    /// diagnostics via `UiEvent::ExtensionList`. No agent effect.
75    ListExtensions,
76    /// Invoke an extension-registered slash command.
77    /// Emitted by the TUI's slash palette when the user selects a command
78    /// owned by an extension (not a built-in).
79    InvokeExtensionCommand {
80        name: String,
81        args: String,
82    },
83    /// `/model` — rebuild the live session under a different model.
84    SwitchModel(crate::model::ModelId),
85    /// `/resume` — replace the live session with a stored one, by id.
86    LoadSession(String),
87    /// `/fork` — continue from an earlier entry, creating a branch.
88    ForkFrom {
89        from: String,
90        message: String,
91    },
92}
93
94#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
95pub struct ExtensionInfo {
96    pub name: String,
97    pub hooks: Vec<String>,
98    pub commands: Vec<String>,
99    /// `true` if the extension's binary exists and is executable at the
100    /// configured path. `false` if a load-time diagnostic blocked it.
101    pub healthy: bool,
102    /// Load-time diagnostic if `!healthy`. `None` otherwise.
103    pub diagnostic: Option<String>,
104}
105
106#[derive(Debug, Serialize)]
107#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
108pub enum UiEvent {
109    AgentTurnStarted,
110    AgentThinking,
111    AgentTextDelta(String),
112    AgentMessageComplete(String),
113    ToolCallStarted {
114        id: String,
115        name: String,
116        args: Value,
117    },
118    ToolCallProgress {
119        id: String,
120        chunk: ProgressChunk,
121    },
122    ToolCallCompleted {
123        id: String,
124        result: UiToolResult,
125    },
126    AgentTurnComplete,
127    PermissionRequested {
128        tool: String,
129        args: serde_json::Value,
130        #[serde(skip)]
131        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
132    },
133    /// Output of a `!!cmd` inline-bash run, for transcript display.
134    InlineBashOutput {
135        command: String,
136        output: String,
137    },
138    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
139    /// stored one). Carries the new transcript so the TUI can rebuild
140    /// `state.messages`. Empty Vec = a fresh session.
141    SessionReplaced(Vec<motosan_agent_loop::Message>),
142    /// The active model changed successfully.
143    ModelSwitched(crate::model::ModelId),
144    /// Refreshed `/fork` candidate list (active-branch user messages,
145    /// newest first) — `(EntryId, preview)` pairs. The binary emits this
146    /// at startup and after every turn so the picker is never stale.
147    ForkCandidates(Vec<(String, String)>),
148    /// Refreshed session branch tree — the binary emits this at startup
149    /// and after every turn so the `/tree` view is never stale.
150    BranchTree(
151        #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
152        motosan_agent_loop::BranchTree,
153    ),
154    /// Reply to `Command::GetMessages` — the session's persisted message
155    /// history. RPC clients use this to snapshot transcript state.
156    MessagesSnapshot(Vec<motosan_agent_loop::Message>),
157    /// Reply to `Command::GetState` — a small status snapshot for RPC
158    /// clients (the TUI holds these in `AppState` so does not need it).
159    StateSnapshot {
160        session_id: String,
161        model: String,
162        active_turn: bool,
163    },
164    /// Reply to `Command::ListExtensions` — the registered extensions
165    /// + per-extension health status.
166    ExtensionList {
167        extensions: Vec<ExtensionInfo>,
168    },
169    Error(String),
170    /// Emitted when an attachment in a `UserMessage` failed validation
171    /// (path missing, unsupported extension, oversize, unreadable). No turn
172    /// is started; the agent stays in `Idle`. See spec §3 / §4.4.
173    AttachmentError {
174        kind: crate::user_message::AttachmentErrorKind,
175        message: String,
176    },
177    /// An extension cancelled a `before_user_message` or
178    /// `session_before_switch` hook. The TUI rolls back any optimistic
179    /// transcript push (via `state.pending_turn_submit`) and restores
180    /// the composer buffer. See spec §4.4.
181    ExtensionCancelled {
182        extension_name: String,
183        reason: Option<String>,
184    },
185    /// Surface a transcript notice from an extension `reply` action,
186    /// `/image` errors, or other non-fatal informational events.
187    Notice {
188        title: String,
189        body: String,
190    },
191}
192
193#[derive(Debug, Clone, Serialize)]
194pub struct UiToolResult {
195    pub is_error: bool,
196    pub text: String,
197}
198
199impl From<&ToolResult> for UiToolResult {
200    fn from(r: &ToolResult) -> Self {
201        Self {
202            is_error: r.is_error,
203            text: format!("{r:?}"),
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn attachment_error_event_serializes_with_kind_and_message() {
214        use crate::user_message::AttachmentErrorKind;
215
216        let ev = UiEvent::AttachmentError {
217            kind: AttachmentErrorKind::NotFound,
218            message: "image not found: /tmp/foo.png".into(),
219        };
220        let json = serde_json::to_string(&ev).expect("serialize");
221        // UiEvent uses tag = "type", content = "payload", rename_all = "snake_case"
222        assert_eq!(
223            json,
224            r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
225        );
226    }
227
228    #[test]
229    fn compact_command_is_constructible() {
230        let c = Command::Compact;
231        assert_eq!(c, Command::Compact);
232        assert!(format!("{c:?}").contains("Compact"));
233    }
234
235    #[test]
236    fn d2_command_and_event_variants_construct() {
237        let _ = Command::NewSession;
238        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
239        let _ = Command::LoadSession("sess-id".into());
240        let e = UiEvent::SessionReplaced(Vec::new());
241        assert!(format!("{e:?}").contains("SessionReplaced"));
242        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
243        assert!(format!("{e:?}").contains("ModelSwitched"));
244    }
245
246    #[test]
247    fn clone_session_command_constructs() {
248        let c = Command::CloneSession;
249        assert!(format!("{c:?}").contains("CloneSession"));
250    }
251
252    #[test]
253    fn read_query_commands_round_trip() {
254        for c in [Command::GetMessages, Command::GetState] {
255            let line = serde_json::to_string(&c).expect("serialize");
256            assert!(line.contains("\"type\":"), "{line}");
257            let back: Command = serde_json::from_str(&line).expect("deserialize");
258            assert_eq!(c, back);
259        }
260        // Unit variants serialize as bare `{"type":"…"}`.
261        assert_eq!(
262            serde_json::to_string(&Command::GetMessages).expect("ser"),
263            r#"{"type":"get_messages"}"#
264        );
265    }
266
267    #[test]
268    fn fork_protocol_variants_construct() {
269        let c = Command::ForkFrom {
270            from: "e1".into(),
271            message: "hi".into(),
272        };
273        assert!(format!("{c:?}").contains("ForkFrom"));
274        let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
275        assert!(format!("{e:?}").contains("ForkCandidates"));
276    }
277
278    #[test]
279    fn branch_tree_event_constructs() {
280        let tree = motosan_agent_loop::BranchTree {
281            nodes: Vec::new(),
282            root: None,
283            active_leaf: None,
284        };
285        let e = UiEvent::BranchTree(tree);
286        assert!(format!("{e:?}").contains("BranchTree"));
287    }
288
289    #[test]
290    fn run_inline_bash_command_is_constructible() {
291        let c = Command::RunInlineBash {
292            command: "ls".into(),
293            send_to_llm: true,
294        };
295        assert!(format!("{c:?}").contains("RunInlineBash"));
296    }
297
298    #[test]
299    fn permission_protocol_variants_are_constructible() {
300        let command = Command::ResolvePermission(PermissionResolution {
301            tool: "bash".into(),
302            args: serde_json::json!({"command": "echo hi"}),
303            choice: PermissionChoice::AllowSession,
304        });
305        assert!(format!("{command:?}").contains("ResolvePermission"));
306        assert!(format!("{command:?}").contains("AllowSession"));
307
308        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
309        let event = UiEvent::PermissionRequested {
310            tool: "bash".into(),
311            args: serde_json::json!({"command": "echo hi"}),
312            resolver,
313        };
314        assert!(format!("{event:?}").contains("PermissionRequested"));
315    }
316
317    #[test]
318    fn command_round_trips_through_json() {
319        let cases = vec![
320            Command::SendUserMessage {
321                text: "hi".into(),
322                attachments: Vec::new(),
323            },
324            Command::CancelAgent,
325            Command::Quit,
326            Command::Compact,
327            Command::NewSession,
328            Command::CloneSession,
329            Command::RunInlineBash {
330                command: "ls".into(),
331                send_to_llm: true,
332            },
333            Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
334            Command::LoadSession("sess-1".into()),
335            Command::ForkFrom {
336                from: "e1".into(),
337                message: "hi".into(),
338            },
339            Command::ResolvePermission(PermissionResolution {
340                tool: "bash".into(),
341                args: serde_json::json!({"command": "echo hi"}),
342                choice: PermissionChoice::AllowSession,
343            }),
344        ];
345        for c in cases {
346            let line = serde_json::to_string(&c).expect("serialize");
347            assert!(line.contains("\"type\":"), "missing type tag: {line}");
348            let back: Command = serde_json::from_str(&line).expect("deserialize");
349            assert_eq!(c, back, "round-trip mismatch for {line}");
350        }
351    }
352
353    #[test]
354    fn command_send_user_message_round_trip() {
355        let line = serde_json::to_string(&Command::SendUserMessage {
356            text: "hi".into(),
357            attachments: Vec::new(),
358        })
359        .expect("serialize");
360        assert_eq!(
361            line,
362            r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
363        );
364        let back: Command = serde_json::from_str(&line).expect("deserialize");
365        assert_eq!(
366            back,
367            Command::SendUserMessage {
368                text: "hi".into(),
369                attachments: Vec::new(),
370            }
371        );
372    }
373
374    #[test]
375    fn command_send_user_message_with_attachment_round_trip() {
376        let cmd = Command::SendUserMessage {
377            text: "look".into(),
378            attachments: vec![crate::user_message::Attachment::Image {
379                path: std::path::PathBuf::from("/tmp/foo.png"),
380            }],
381        };
382        let line = serde_json::to_string(&cmd).expect("serialize");
383        assert_eq!(
384            line,
385            r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
386        );
387        let back: Command = serde_json::from_str(&line).expect("deserialize");
388        assert_eq!(back, cmd);
389    }
390
391    #[test]
392    fn unit_command_uses_adjacent_tagging() {
393        let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
394        assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
395    }
396
397    #[test]
398    fn command_invoke_extension_command_serde_round_trip() {
399        let cmd = Command::InvokeExtensionCommand {
400            name: "todo".into(),
401            args: "add buy milk".into(),
402        };
403        let json = serde_json::to_string(&cmd).expect("ok");
404        assert_eq!(
405            json,
406            r#"{"type":"invoke_extension_command","payload":{"name":"todo","args":"add buy milk"}}"#
407        );
408        let back: Command = serde_json::from_str(&json).expect("ok");
409        assert_eq!(back, cmd);
410    }
411
412    #[test]
413    fn ui_event_extension_cancelled_serializes() {
414        let ev = UiEvent::ExtensionCancelled {
415            extension_name: "dirty-repo-guard".into(),
416            reason: Some("uncommitted changes".into()),
417        };
418        let json = serde_json::to_string(&ev).expect("ok");
419        assert!(json.contains(r#""type":"extension_cancelled""#));
420        assert!(json.contains(r#""extension_name":"dirty-repo-guard""#));
421        assert!(json.contains(r#""reason":"uncommitted changes""#));
422    }
423
424    #[test]
425    fn ui_event_notice_serializes() {
426        let ev = UiEvent::Notice {
427            title: "/todo".into(),
428            body: "reply from fixture".into(),
429        };
430        let json = serde_json::to_string(&ev).expect("ok");
431        assert!(json.contains(r#""type":"notice""#));
432        assert!(json.contains(r#""title":"/todo""#));
433        assert!(json.contains(r#""body":"reply from fixture""#));
434    }
435
436    #[test]
437    fn snapshot_events_serialize_with_type_tag() {
438        let m = UiEvent::MessagesSnapshot(Vec::new());
439        let line = serde_json::to_string(&m).expect("serialize");
440        assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
441
442        let s = UiEvent::StateSnapshot {
443            session_id: "sess-1".into(),
444            model: "claude-opus-4-7".into(),
445            active_turn: false,
446        };
447        let line = serde_json::to_string(&s).expect("serialize");
448        assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
449        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
450        assert!(line.contains(r#""active_turn":false"#), "{line}");
451    }
452
453    #[test]
454    fn ui_events_serialize_with_type_tag() {
455        let events = vec![
456            UiEvent::AgentTurnStarted,
457            UiEvent::AgentThinking,
458            UiEvent::AgentTextDelta("hi".into()),
459            UiEvent::AgentMessageComplete("done".into()),
460            UiEvent::AgentTurnComplete,
461            UiEvent::InlineBashOutput {
462                command: "ls".into(),
463                output: "x".into(),
464            },
465            UiEvent::Notice {
466                title: "note".into(),
467                body: "body".into(),
468            },
469            UiEvent::SessionReplaced(Vec::new()),
470            UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
471            UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
472            UiEvent::Error("bad".into()),
473            UiEvent::ToolCallStarted {
474                id: "t1".into(),
475                name: "bash".into(),
476                args: serde_json::json!("ls"),
477            },
478            UiEvent::ToolCallProgress {
479                id: "t1".into(),
480                chunk: ProgressChunk::Status("running".into()),
481            },
482            UiEvent::ToolCallCompleted {
483                id: "t1".into(),
484                result: UiToolResult {
485                    is_error: false,
486                    text: "ok".into(),
487                },
488            },
489        ];
490        for e in &events {
491            let line = serde_json::to_string(e).expect("serialize");
492            assert!(line.contains("\"type\":"), "missing type tag: {line}");
493        }
494    }
495
496    #[test]
497    fn permission_requested_serializes_without_the_resolver() {
498        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
499        let e = UiEvent::PermissionRequested {
500            tool: "bash".into(),
501            args: serde_json::json!({"command": "echo hi"}),
502            resolver,
503        };
504        let line = serde_json::to_string(&e).expect("serialize");
505        assert!(line.contains(r#""type":"permission_requested""#), "{line}");
506        assert!(!line.contains("resolver"), "resolver leaked: {line}");
507    }
508
509    #[test]
510    fn branch_tree_event_serializes_via_mapping() {
511        let tree = motosan_agent_loop::BranchTree {
512            nodes: vec![motosan_agent_loop::BranchNode {
513                id: "n0".into(),
514                parent: None,
515                children: vec![],
516                label: "root".into(),
517            }],
518            root: Some(0),
519            active_leaf: Some(0),
520        };
521        let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
522        assert!(line.contains(r#""type":"branch_tree""#), "{line}");
523        assert!(line.contains(r#""label":"root""#), "{line}");
524    }
525
526    #[test]
527    fn command_list_extensions_serde_round_trip() {
528        let cmd = Command::ListExtensions;
529        let line = serde_json::to_string(&cmd).expect("serialize");
530        assert_eq!(line, r#"{"type":"list_extensions"}"#);
531        let back: Command = serde_json::from_str(&line).expect("deserialize");
532        assert_eq!(back, cmd);
533    }
534
535    #[test]
536    fn ui_event_extension_list_serializes() {
537        let ev = UiEvent::ExtensionList {
538            extensions: vec![ExtensionInfo {
539                name: "dirty".into(),
540                hooks: vec!["session_before_switch".into()],
541                commands: Vec::new(),
542                healthy: true,
543                diagnostic: None,
544            }],
545        };
546        let line = serde_json::to_string(&ev).expect("serialize");
547        assert!(line.contains(r#""type":"extension_list""#));
548        assert!(line.contains(r#""name":"dirty""#));
549    }
550}