Skip to main content

capo_agent/
events.rs

1use motosan_agent_tool::ToolResult;
2use serde_json::Value;
3
4#[derive(Debug, Clone)]
5pub enum ProgressChunk {
6    Stdout(Vec<u8>),
7    Stderr(Vec<u8>),
8    Status(String),
9}
10
11impl From<crate::tools::ToolProgressChunk> for ProgressChunk {
12    fn from(c: crate::tools::ToolProgressChunk) -> Self {
13        use crate::tools::ToolProgressChunk as TPC;
14        match c {
15            TPC::Stdout(b) => Self::Stdout(b),
16            TPC::Stderr(b) => Self::Stderr(b),
17            TPC::Status(s) => Self::Status(s),
18        }
19    }
20}
21
22/// User's answer to a `PermissionRequested` prompt, carried back from the
23/// front-end to the binary so the binary can update the session cache.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PermissionResolution {
26    pub tool: String,
27    pub args: serde_json::Value,
28    pub choice: PermissionChoice,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PermissionChoice {
33    AllowOnce,
34    AllowSession,
35    Deny,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Command {
40    SendUserMessage(String),
41    CancelAgent,
42    Quit,
43    ResolvePermission(PermissionResolution),
44    /// `!cmd` / `!!cmd` — run a shell command typed directly by the user.
45    /// `send_to_llm` is true for `!cmd` (output becomes a user message),
46    /// false for `!!cmd` (output shown in the transcript only).
47    RunInlineBash {
48        command: String,
49        send_to_llm: bool,
50    },
51    /// Manual context compaction, triggered by the `/compact` slash command.
52    Compact,
53    /// `/new` — replace the live session with a fresh empty one.
54    NewSession,
55    /// `/clone` — copy the current session to a new independent file and
56    /// switch to it. Handled by `forward_commands`.
57    CloneSession,
58    /// `/model` — rebuild the live session under a different model.
59    SwitchModel(crate::model::ModelId),
60    /// `/resume` — replace the live session with a stored one, by id.
61    LoadSession(String),
62    /// `/fork` — continue from an earlier entry, creating a branch.
63    ForkFrom {
64        from: String,
65        message: String,
66    },
67}
68
69#[derive(Debug)]
70pub enum UiEvent {
71    AgentTurnStarted,
72    AgentThinking,
73    AgentTextDelta(String),
74    AgentMessageComplete(String),
75    ToolCallStarted {
76        id: String,
77        name: String,
78        args: Value,
79    },
80    ToolCallProgress {
81        id: String,
82        chunk: ProgressChunk,
83    },
84    ToolCallCompleted {
85        id: String,
86        result: UiToolResult,
87    },
88    AgentTurnComplete,
89    PermissionRequested {
90        tool: String,
91        args: serde_json::Value,
92        resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
93    },
94    /// Output of a `!!cmd` inline-bash run, for transcript display.
95    InlineBashOutput {
96        command: String,
97        output: String,
98    },
99    /// The session was replaced (`/new` cleared it, or `/resume` loaded a
100    /// stored one). Carries the new transcript so the TUI can rebuild
101    /// `state.messages`. Empty Vec = a fresh session.
102    SessionReplaced(Vec<motosan_agent_loop::Message>),
103    /// The active model changed successfully.
104    ModelSwitched(crate::model::ModelId),
105    /// Refreshed `/fork` candidate list (active-branch user messages,
106    /// newest first) — `(EntryId, preview)` pairs. The binary emits this
107    /// at startup and after every turn so the picker is never stale.
108    ForkCandidates(Vec<(String, String)>),
109    /// Refreshed session branch tree — the binary emits this at startup
110    /// and after every turn so the `/tree` view is never stale.
111    BranchTree(motosan_agent_loop::BranchTree),
112    Error(String),
113}
114
115#[derive(Debug, Clone)]
116pub struct UiToolResult {
117    pub is_error: bool,
118    pub text: String,
119}
120
121impl From<&ToolResult> for UiToolResult {
122    fn from(r: &ToolResult) -> Self {
123        Self {
124            is_error: r.is_error,
125            text: format!("{r:?}"),
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn compact_command_is_constructible() {
136        let c = Command::Compact;
137        assert_eq!(c, Command::Compact);
138        assert!(format!("{c:?}").contains("Compact"));
139    }
140
141    #[test]
142    fn d2_command_and_event_variants_construct() {
143        let _ = Command::NewSession;
144        let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
145        let _ = Command::LoadSession("sess-id".into());
146        let e = UiEvent::SessionReplaced(Vec::new());
147        assert!(format!("{e:?}").contains("SessionReplaced"));
148        let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
149        assert!(format!("{e:?}").contains("ModelSwitched"));
150    }
151
152    #[test]
153    fn clone_session_command_constructs() {
154        let c = Command::CloneSession;
155        assert!(format!("{c:?}").contains("CloneSession"));
156    }
157
158    #[test]
159    fn fork_protocol_variants_construct() {
160        let c = Command::ForkFrom {
161            from: "e1".into(),
162            message: "hi".into(),
163        };
164        assert!(format!("{c:?}").contains("ForkFrom"));
165        let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
166        assert!(format!("{e:?}").contains("ForkCandidates"));
167    }
168
169    #[test]
170    fn branch_tree_event_constructs() {
171        let tree = motosan_agent_loop::BranchTree {
172            nodes: Vec::new(),
173            root: None,
174            active_leaf: None,
175        };
176        let e = UiEvent::BranchTree(tree);
177        assert!(format!("{e:?}").contains("BranchTree"));
178    }
179
180    #[test]
181    fn run_inline_bash_command_is_constructible() {
182        let c = Command::RunInlineBash {
183            command: "ls".into(),
184            send_to_llm: true,
185        };
186        assert!(format!("{c:?}").contains("RunInlineBash"));
187    }
188
189    #[test]
190    fn permission_protocol_variants_are_constructible() {
191        let command = Command::ResolvePermission(PermissionResolution {
192            tool: "bash".into(),
193            args: serde_json::json!({"command": "echo hi"}),
194            choice: PermissionChoice::AllowSession,
195        });
196        assert!(format!("{command:?}").contains("ResolvePermission"));
197        assert!(format!("{command:?}").contains("AllowSession"));
198
199        let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
200        let event = UiEvent::PermissionRequested {
201            tool: "bash".into(),
202            args: serde_json::json!({"command": "echo hi"}),
203            resolver,
204        };
205        assert!(format!("{event:?}").contains("PermissionRequested"));
206    }
207}