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    /// `/model` — rebuild the live session under a different model.
74    SwitchModel(crate::model::ModelId),
75    /// `/resume` — replace the live session with a stored one, by id.
76    LoadSession(String),
77    /// `/fork` — continue from an earlier entry, creating a branch.
78    ForkFrom {
79        from: String,
80        message: String,
81    },
82}
83
84#[derive(Debug, Serialize)]
85#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
86pub enum UiEvent {
87    AgentTurnStarted,
88    AgentThinking,
89    AgentTextDelta(String),
90    AgentMessageComplete(String),
91    ToolCallStarted {
92        id: String,
93        name: String,
94        args: Value,
95    },
96    ToolCallProgress {
97        id: String,
98        chunk: ProgressChunk,
99    },
100    ToolCallCompleted {
101        id: String,
102        result: UiToolResult,
103    },
104    AgentTurnComplete,
105    PermissionRequested {
106        tool: String,
107        args: serde_json::Value,
108        #[serde(skip)]
109        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
110    },
111    /// Output of a `!!cmd` inline-bash run, for transcript display.
112    InlineBashOutput {
113        command: String,
114        output: String,
115    },
116    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
117    /// stored one). Carries the new transcript so the TUI can rebuild
118    /// `state.messages`. Empty Vec = a fresh session.
119    SessionReplaced(Vec<motosan_agent_loop::Message>),
120    /// The active model changed successfully.
121    ModelSwitched(crate::model::ModelId),
122    /// Refreshed `/fork` candidate list (active-branch user messages,
123    /// newest first) — `(EntryId, preview)` pairs. The binary emits this
124    /// at startup and after every turn so the picker is never stale.
125    ForkCandidates(Vec<(String, String)>),
126    /// Refreshed session branch tree — the binary emits this at startup
127    /// and after every turn so the `/tree` view is never stale.
128    BranchTree(
129        #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
130        motosan_agent_loop::BranchTree,
131    ),
132    /// Reply to `Command::GetMessages` — the session's persisted message
133    /// history. RPC clients use this to snapshot transcript state.
134    MessagesSnapshot(Vec<motosan_agent_loop::Message>),
135    /// Reply to `Command::GetState` — a small status snapshot for RPC
136    /// clients (the TUI holds these in `AppState` so does not need it).
137    StateSnapshot {
138        session_id: String,
139        model: String,
140        active_turn: bool,
141    },
142    Error(String),
143    /// Emitted when an attachment in a `UserMessage` failed validation
144    /// (path missing, unsupported extension, oversize, unreadable). No turn
145    /// is started; the agent stays in `Idle`. See spec §3 / §4.4.
146    AttachmentError {
147        kind: crate::user_message::AttachmentErrorKind,
148        message: String,
149    },
150}
151
152#[derive(Debug, Clone, Serialize)]
153pub struct UiToolResult {
154    pub is_error: bool,
155    pub text: String,
156}
157
158impl From<&ToolResult> for UiToolResult {
159    fn from(r: &ToolResult) -> Self {
160        Self {
161            is_error: r.is_error,
162            text: format!("{r:?}"),
163        }
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn attachment_error_event_serializes_with_kind_and_message() {
173        use crate::user_message::AttachmentErrorKind;
174
175        let ev = UiEvent::AttachmentError {
176            kind: AttachmentErrorKind::NotFound,
177            message: "image not found: /tmp/foo.png".into(),
178        };
179        let json = serde_json::to_string(&ev).expect("serialize");
180        // UiEvent uses tag = "type", content = "payload", rename_all = "snake_case"
181        assert_eq!(
182            json,
183            r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
184        );
185    }
186
187    #[test]
188    fn compact_command_is_constructible() {
189        let c = Command::Compact;
190        assert_eq!(c, Command::Compact);
191        assert!(format!("{c:?}").contains("Compact"));
192    }
193
194    #[test]
195    fn d2_command_and_event_variants_construct() {
196        let _ = Command::NewSession;
197        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
198        let _ = Command::LoadSession("sess-id".into());
199        let e = UiEvent::SessionReplaced(Vec::new());
200        assert!(format!("{e:?}").contains("SessionReplaced"));
201        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
202        assert!(format!("{e:?}").contains("ModelSwitched"));
203    }
204
205    #[test]
206    fn clone_session_command_constructs() {
207        let c = Command::CloneSession;
208        assert!(format!("{c:?}").contains("CloneSession"));
209    }
210
211    #[test]
212    fn read_query_commands_round_trip() {
213        for c in [Command::GetMessages, Command::GetState] {
214            let line = serde_json::to_string(&c).expect("serialize");
215            assert!(line.contains("\"type\":"), "{line}");
216            let back: Command = serde_json::from_str(&line).expect("deserialize");
217            assert_eq!(c, back);
218        }
219        // Unit variants serialize as bare `{"type":"…"}`.
220        assert_eq!(
221            serde_json::to_string(&Command::GetMessages).expect("ser"),
222            r#"{"type":"get_messages"}"#
223        );
224    }
225
226    #[test]
227    fn fork_protocol_variants_construct() {
228        let c = Command::ForkFrom {
229            from: "e1".into(),
230            message: "hi".into(),
231        };
232        assert!(format!("{c:?}").contains("ForkFrom"));
233        let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
234        assert!(format!("{e:?}").contains("ForkCandidates"));
235    }
236
237    #[test]
238    fn branch_tree_event_constructs() {
239        let tree = motosan_agent_loop::BranchTree {
240            nodes: Vec::new(),
241            root: None,
242            active_leaf: None,
243        };
244        let e = UiEvent::BranchTree(tree);
245        assert!(format!("{e:?}").contains("BranchTree"));
246    }
247
248    #[test]
249    fn run_inline_bash_command_is_constructible() {
250        let c = Command::RunInlineBash {
251            command: "ls".into(),
252            send_to_llm: true,
253        };
254        assert!(format!("{c:?}").contains("RunInlineBash"));
255    }
256
257    #[test]
258    fn permission_protocol_variants_are_constructible() {
259        let command = Command::ResolvePermission(PermissionResolution {
260            tool: "bash".into(),
261            args: serde_json::json!({"command": "echo hi"}),
262            choice: PermissionChoice::AllowSession,
263        });
264        assert!(format!("{command:?}").contains("ResolvePermission"));
265        assert!(format!("{command:?}").contains("AllowSession"));
266
267        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
268        let event = UiEvent::PermissionRequested {
269            tool: "bash".into(),
270            args: serde_json::json!({"command": "echo hi"}),
271            resolver,
272        };
273        assert!(format!("{event:?}").contains("PermissionRequested"));
274    }
275
276    #[test]
277    fn command_round_trips_through_json() {
278        let cases = vec![
279            Command::SendUserMessage {
280                text: "hi".into(),
281                attachments: Vec::new(),
282            },
283            Command::CancelAgent,
284            Command::Quit,
285            Command::Compact,
286            Command::NewSession,
287            Command::CloneSession,
288            Command::RunInlineBash {
289                command: "ls".into(),
290                send_to_llm: true,
291            },
292            Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
293            Command::LoadSession("sess-1".into()),
294            Command::ForkFrom {
295                from: "e1".into(),
296                message: "hi".into(),
297            },
298            Command::ResolvePermission(PermissionResolution {
299                tool: "bash".into(),
300                args: serde_json::json!({"command": "echo hi"}),
301                choice: PermissionChoice::AllowSession,
302            }),
303        ];
304        for c in cases {
305            let line = serde_json::to_string(&c).expect("serialize");
306            assert!(line.contains("\"type\":"), "missing type tag: {line}");
307            let back: Command = serde_json::from_str(&line).expect("deserialize");
308            assert_eq!(c, back, "round-trip mismatch for {line}");
309        }
310    }
311
312    #[test]
313    fn command_send_user_message_round_trip() {
314        let line = serde_json::to_string(&Command::SendUserMessage {
315            text: "hi".into(),
316            attachments: Vec::new(),
317        })
318        .expect("serialize");
319        assert_eq!(
320            line,
321            r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
322        );
323        let back: Command = serde_json::from_str(&line).expect("deserialize");
324        assert_eq!(
325            back,
326            Command::SendUserMessage {
327                text: "hi".into(),
328                attachments: Vec::new(),
329            }
330        );
331    }
332
333    #[test]
334    fn command_send_user_message_with_attachment_round_trip() {
335        let cmd = Command::SendUserMessage {
336            text: "look".into(),
337            attachments: vec![crate::user_message::Attachment::Image {
338                path: std::path::PathBuf::from("/tmp/foo.png"),
339            }],
340        };
341        let line = serde_json::to_string(&cmd).expect("serialize");
342        assert_eq!(
343            line,
344            r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
345        );
346        let back: Command = serde_json::from_str(&line).expect("deserialize");
347        assert_eq!(back, cmd);
348    }
349
350    #[test]
351    fn unit_command_uses_adjacent_tagging() {
352        let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
353        assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
354    }
355
356    #[test]
357    fn snapshot_events_serialize_with_type_tag() {
358        let m = UiEvent::MessagesSnapshot(Vec::new());
359        let line = serde_json::to_string(&m).expect("serialize");
360        assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
361
362        let s = UiEvent::StateSnapshot {
363            session_id: "sess-1".into(),
364            model: "claude-opus-4-7".into(),
365            active_turn: false,
366        };
367        let line = serde_json::to_string(&s).expect("serialize");
368        assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
369        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
370        assert!(line.contains(r#""active_turn":false"#), "{line}");
371    }
372
373    #[test]
374    fn ui_events_serialize_with_type_tag() {
375        let events = vec![
376            UiEvent::AgentTurnStarted,
377            UiEvent::AgentThinking,
378            UiEvent::AgentTextDelta("hi".into()),
379            UiEvent::AgentMessageComplete("done".into()),
380            UiEvent::AgentTurnComplete,
381            UiEvent::InlineBashOutput {
382                command: "ls".into(),
383                output: "x".into(),
384            },
385            UiEvent::SessionReplaced(Vec::new()),
386            UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
387            UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
388            UiEvent::Error("bad".into()),
389            UiEvent::ToolCallStarted {
390                id: "t1".into(),
391                name: "bash".into(),
392                args: serde_json::json!("ls"),
393            },
394            UiEvent::ToolCallProgress {
395                id: "t1".into(),
396                chunk: ProgressChunk::Status("running".into()),
397            },
398            UiEvent::ToolCallCompleted {
399                id: "t1".into(),
400                result: UiToolResult {
401                    is_error: false,
402                    text: "ok".into(),
403                },
404            },
405        ];
406        for e in &events {
407            let line = serde_json::to_string(e).expect("serialize");
408            assert!(line.contains("\"type\":"), "missing type tag: {line}");
409        }
410    }
411
412    #[test]
413    fn permission_requested_serializes_without_the_resolver() {
414        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
415        let e = UiEvent::PermissionRequested {
416            tool: "bash".into(),
417            args: serde_json::json!({"command": "echo hi"}),
418            resolver,
419        };
420        let line = serde_json::to_string(&e).expect("serialize");
421        assert!(line.contains(r#""type":"permission_requested""#), "{line}");
422        assert!(!line.contains("resolver"), "resolver leaked: {line}");
423    }
424
425    #[test]
426    fn branch_tree_event_serializes_via_mapping() {
427        let tree = motosan_agent_loop::BranchTree {
428            nodes: vec![motosan_agent_loop::BranchNode {
429                id: "n0".into(),
430                parent: None,
431                children: vec![],
432                label: "root".into(),
433            }],
434            root: Some(0),
435            active_leaf: Some(0),
436        };
437        let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
438        assert!(line.contains(r#""type":"branch_tree""#), "{line}");
439        assert!(line.contains(r#""label":"root""#), "{line}");
440    }
441}