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 {
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 RunInlineBash {
57 command: String,
58 send_to_llm: bool,
59 },
60 Compact,
62 NewSession,
64 CloneSession,
67 GetMessages,
70 GetState,
73 SwitchModel(crate::model::ModelId),
75 LoadSession(String),
77 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 InlineBashOutput {
113 command: String,
114 output: String,
115 },
116 SessionReplaced(Vec<motosan_agent_loop::Message>),
120 ModelSwitched(crate::model::ModelId),
122 ForkCandidates(Vec<(String, String)>),
126 BranchTree(
129 #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
130 motosan_agent_loop::BranchTree,
131 ),
132 MessagesSnapshot(Vec<motosan_agent_loop::Message>),
135 StateSnapshot {
138 session_id: String,
139 model: String,
140 active_turn: bool,
141 },
142 Error(String),
143 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 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 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}