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#[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 RunInlineBash {
53 command: String,
54 send_to_llm: bool,
55 },
56 Compact,
58 NewSession,
60 CloneSession,
63 GetMessages,
66 GetState,
69 SwitchModel(crate::model::ModelId),
71 LoadSession(String),
73 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 InlineBashOutput {
109 command: String,
110 output: String,
111 },
112 SessionReplaced(Vec<motosan_agent_loop::Message>),
116 ModelSwitched(crate::model::ModelId),
118 ForkCandidates(Vec<(String, String)>),
122 BranchTree(
125 #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
126 motosan_agent_loop::BranchTree,
127 ),
128 MessagesSnapshot(Vec<motosan_agent_loop::Message>),
131 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 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}