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(String),
46    CancelAgent,
47    Quit,
48    ResolvePermission(PermissionResolution),
49    /// `!cmd` / `!!cmd` — run a shell command typed directly by the user.
50    /// `send_to_llm` is true for `!cmd` (output becomes a user message),
51    /// false for `!!cmd` (output shown in the transcript only).
52    RunInlineBash {
53        command: String,
54        send_to_llm: bool,
55    },
56    /// Manual context compaction, triggered by the `/compact` slash command.
57    Compact,
58    /// `/new` — replace the live session with a fresh empty one.
59    NewSession,
60    /// `/clone` — copy the current session to a new independent file and
61    /// switch to it. Handled by `forward_commands`.
62    CloneSession,
63    /// `--rpc` read query: reply with the session's `Vec<Message>`
64    /// history via `UiEvent::MessagesSnapshot`. No effect on the agent.
65    GetMessages,
66    /// `--rpc` read query: reply with a small status snapshot via
67    /// `UiEvent::StateSnapshot`. No effect on the agent.
68    GetState,
69    /// `/model` — rebuild the live session under a different model.
70    SwitchModel(crate::model::ModelId),
71    /// `/resume` — replace the live session with a stored one, by id.
72    LoadSession(String),
73    /// `/fork` — continue from an earlier entry, creating a branch.
74    ForkFrom {
75        from: String,
76        message: String,
77    },
78}
79
80#[derive(Debug, Serialize)]
81#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
82pub enum UiEvent {
83    AgentTurnStarted,
84    AgentThinking,
85    AgentTextDelta(String),
86    AgentMessageComplete(String),
87    ToolCallStarted {
88        id: String,
89        name: String,
90        args: Value,
91    },
92    ToolCallProgress {
93        id: String,
94        chunk: ProgressChunk,
95    },
96    ToolCallCompleted {
97        id: String,
98        result: UiToolResult,
99    },
100    AgentTurnComplete,
101    PermissionRequested {
102        tool: String,
103        args: serde_json::Value,
104        #[serde(skip)]
105        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
106    },
107    /// Output of a `!!cmd` inline-bash run, for transcript display.
108    InlineBashOutput {
109        command: String,
110        output: String,
111    },
112    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
113    /// stored one). Carries the new transcript so the TUI can rebuild
114    /// `state.messages`. Empty Vec = a fresh session.
115    SessionReplaced(Vec<motosan_agent_loop::Message>),
116    /// The active model changed successfully.
117    ModelSwitched(crate::model::ModelId),
118    /// Refreshed `/fork` candidate list (active-branch user messages,
119    /// newest first) — `(EntryId, preview)` pairs. The binary emits this
120    /// at startup and after every turn so the picker is never stale.
121    ForkCandidates(Vec<(String, String)>),
122    /// Refreshed session branch tree — the binary emits this at startup
123    /// and after every turn so the `/tree` view is never stale.
124    BranchTree(
125        #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
126        motosan_agent_loop::BranchTree,
127    ),
128    /// Reply to `Command::GetMessages` — the session's persisted message
129    /// history. RPC clients use this to snapshot transcript state.
130    MessagesSnapshot(Vec<motosan_agent_loop::Message>),
131    /// Reply to `Command::GetState` — a small status snapshot for RPC
132    /// clients (the TUI holds these in `AppState` so does not need it).
133    StateSnapshot {
134        session_id: String,
135        model: String,
136        active_turn: bool,
137    },
138    Error(String),
139}
140
141#[derive(Debug, Clone, Serialize)]
142pub struct UiToolResult {
143    pub is_error: bool,
144    pub text: String,
145}
146
147impl From<&ToolResult> for UiToolResult {
148    fn from(r: &ToolResult) -> Self {
149        Self {
150            is_error: r.is_error,
151            text: format!("{r:?}"),
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn compact_command_is_constructible() {
162        let c = Command::Compact;
163        assert_eq!(c, Command::Compact);
164        assert!(format!("{c:?}").contains("Compact"));
165    }
166
167    #[test]
168    fn d2_command_and_event_variants_construct() {
169        let _ = Command::NewSession;
170        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
171        let _ = Command::LoadSession("sess-id".into());
172        let e = UiEvent::SessionReplaced(Vec::new());
173        assert!(format!("{e:?}").contains("SessionReplaced"));
174        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
175        assert!(format!("{e:?}").contains("ModelSwitched"));
176    }
177
178    #[test]
179    fn clone_session_command_constructs() {
180        let c = Command::CloneSession;
181        assert!(format!("{c:?}").contains("CloneSession"));
182    }
183
184    #[test]
185    fn read_query_commands_round_trip() {
186        for c in [Command::GetMessages, Command::GetState] {
187            let line = serde_json::to_string(&c).expect("serialize");
188            assert!(line.contains("\"type\":"), "{line}");
189            let back: Command = serde_json::from_str(&line).expect("deserialize");
190            assert_eq!(c, back);
191        }
192        // Unit variants serialize as bare `{"type":"…"}`.
193        assert_eq!(
194            serde_json::to_string(&Command::GetMessages).expect("ser"),
195            r#"{"type":"get_messages"}"#
196        );
197    }
198
199    #[test]
200    fn fork_protocol_variants_construct() {
201        let c = Command::ForkFrom {
202            from: "e1".into(),
203            message: "hi".into(),
204        };
205        assert!(format!("{c:?}").contains("ForkFrom"));
206        let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
207        assert!(format!("{e:?}").contains("ForkCandidates"));
208    }
209
210    #[test]
211    fn branch_tree_event_constructs() {
212        let tree = motosan_agent_loop::BranchTree {
213            nodes: Vec::new(),
214            root: None,
215            active_leaf: None,
216        };
217        let e = UiEvent::BranchTree(tree);
218        assert!(format!("{e:?}").contains("BranchTree"));
219    }
220
221    #[test]
222    fn run_inline_bash_command_is_constructible() {
223        let c = Command::RunInlineBash {
224            command: "ls".into(),
225            send_to_llm: true,
226        };
227        assert!(format!("{c:?}").contains("RunInlineBash"));
228    }
229
230    #[test]
231    fn permission_protocol_variants_are_constructible() {
232        let command = Command::ResolvePermission(PermissionResolution {
233            tool: "bash".into(),
234            args: serde_json::json!({"command": "echo hi"}),
235            choice: PermissionChoice::AllowSession,
236        });
237        assert!(format!("{command:?}").contains("ResolvePermission"));
238        assert!(format!("{command:?}").contains("AllowSession"));
239
240        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
241        let event = UiEvent::PermissionRequested {
242            tool: "bash".into(),
243            args: serde_json::json!({"command": "echo hi"}),
244            resolver,
245        };
246        assert!(format!("{event:?}").contains("PermissionRequested"));
247    }
248
249    #[test]
250    fn command_round_trips_through_json() {
251        let cases = vec![
252            Command::SendUserMessage("hi".into()),
253            Command::CancelAgent,
254            Command::Quit,
255            Command::Compact,
256            Command::NewSession,
257            Command::CloneSession,
258            Command::RunInlineBash {
259                command: "ls".into(),
260                send_to_llm: true,
261            },
262            Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
263            Command::LoadSession("sess-1".into()),
264            Command::ForkFrom {
265                from: "e1".into(),
266                message: "hi".into(),
267            },
268            Command::ResolvePermission(PermissionResolution {
269                tool: "bash".into(),
270                args: serde_json::json!({"command": "echo hi"}),
271                choice: PermissionChoice::AllowSession,
272            }),
273        ];
274        for c in cases {
275            let line = serde_json::to_string(&c).expect("serialize");
276            assert!(line.contains("\"type\":"), "missing type tag: {line}");
277            let back: Command = serde_json::from_str(&line).expect("deserialize");
278            assert_eq!(c, back, "round-trip mismatch for {line}");
279        }
280    }
281
282    #[test]
283    fn command_uses_adjacent_tagging() {
284        let line =
285            serde_json::to_string(&Command::SendUserMessage("hi".into())).expect("serialize");
286        assert_eq!(line, r#"{"type":"send_user_message","payload":"hi"}"#);
287        let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
288        assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
289    }
290
291    #[test]
292    fn snapshot_events_serialize_with_type_tag() {
293        let m = UiEvent::MessagesSnapshot(Vec::new());
294        let line = serde_json::to_string(&m).expect("serialize");
295        assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
296
297        let s = UiEvent::StateSnapshot {
298            session_id: "sess-1".into(),
299            model: "claude-opus-4-7".into(),
300            active_turn: false,
301        };
302        let line = serde_json::to_string(&s).expect("serialize");
303        assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
304        assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
305        assert!(line.contains(r#""active_turn":false"#), "{line}");
306    }
307
308    #[test]
309    fn ui_events_serialize_with_type_tag() {
310        let events = vec![
311            UiEvent::AgentTurnStarted,
312            UiEvent::AgentThinking,
313            UiEvent::AgentTextDelta("hi".into()),
314            UiEvent::AgentMessageComplete("done".into()),
315            UiEvent::AgentTurnComplete,
316            UiEvent::InlineBashOutput {
317                command: "ls".into(),
318                output: "x".into(),
319            },
320            UiEvent::SessionReplaced(Vec::new()),
321            UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
322            UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
323            UiEvent::Error("bad".into()),
324            UiEvent::ToolCallStarted {
325                id: "t1".into(),
326                name: "bash".into(),
327                args: serde_json::json!("ls"),
328            },
329            UiEvent::ToolCallProgress {
330                id: "t1".into(),
331                chunk: ProgressChunk::Status("running".into()),
332            },
333            UiEvent::ToolCallCompleted {
334                id: "t1".into(),
335                result: UiToolResult {
336                    is_error: false,
337                    text: "ok".into(),
338                },
339            },
340        ];
341        for e in &events {
342            let line = serde_json::to_string(e).expect("serialize");
343            assert!(line.contains("\"type\":"), "missing type tag: {line}");
344        }
345    }
346
347    #[test]
348    fn permission_requested_serializes_without_the_resolver() {
349        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
350        let e = UiEvent::PermissionRequested {
351            tool: "bash".into(),
352            args: serde_json::json!({"command": "echo hi"}),
353            resolver,
354        };
355        let line = serde_json::to_string(&e).expect("serialize");
356        assert!(line.contains(r#""type":"permission_requested""#), "{line}");
357        assert!(!line.contains("resolver"), "resolver leaked: {line}");
358    }
359
360    #[test]
361    fn branch_tree_event_serializes_via_mapping() {
362        let tree = motosan_agent_loop::BranchTree {
363            nodes: vec![motosan_agent_loop::BranchNode {
364                id: "n0".into(),
365                parent: None,
366                children: vec![],
367                label: "root".into(),
368            }],
369            root: Some(0),
370            active_leaf: Some(0),
371        };
372        let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
373        assert!(line.contains(r#""type":"branch_tree""#), "{line}");
374        assert!(line.contains(r#""label":"root""#), "{line}");
375    }
376}