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 ListExtensions,
76 InvokeExtensionCommand {
80 name: String,
81 args: String,
82 },
83 SwitchModel(crate::model::ModelId),
85 LoadSession(String),
87 ForkFrom {
89 from: String,
90 message: String,
91 },
92}
93
94#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
95pub struct ExtensionInfo {
96 pub name: String,
97 pub hooks: Vec<String>,
98 pub commands: Vec<String>,
99 pub healthy: bool,
102 pub diagnostic: Option<String>,
104}
105
106#[derive(Debug, Serialize)]
107#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
108pub enum UiEvent {
109 AgentTurnStarted,
110 AgentThinking,
111 AgentTextDelta(String),
112 AgentMessageComplete(String),
113 ToolCallStarted {
114 id: String,
115 name: String,
116 args: Value,
117 },
118 ToolCallProgress {
119 id: String,
120 chunk: ProgressChunk,
121 },
122 ToolCallCompleted {
123 id: String,
124 result: UiToolResult,
125 },
126 AgentTurnComplete,
127 PermissionRequested {
128 tool: String,
129 args: serde_json::Value,
130 #[serde(skip)]
131 resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
132 },
133 InlineBashOutput {
135 command: String,
136 output: String,
137 },
138 SessionReplaced(Vec<motosan_agent_loop::Message>),
142 ModelSwitched(crate::model::ModelId),
144 ForkCandidates(Vec<(String, String)>),
148 BranchTree(
151 #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
152 motosan_agent_loop::BranchTree,
153 ),
154 MessagesSnapshot(Vec<motosan_agent_loop::Message>),
157 StateSnapshot {
160 session_id: String,
161 model: String,
162 active_turn: bool,
163 },
164 ExtensionList {
167 extensions: Vec<ExtensionInfo>,
168 },
169 Error(String),
170 AttachmentError {
174 kind: crate::user_message::AttachmentErrorKind,
175 message: String,
176 },
177 ExtensionCancelled {
182 extension_name: String,
183 reason: Option<String>,
184 },
185 Notice {
188 title: String,
189 body: String,
190 },
191}
192
193#[derive(Debug, Clone, Serialize)]
194pub struct UiToolResult {
195 pub is_error: bool,
196 pub text: String,
197}
198
199impl From<&ToolResult> for UiToolResult {
200 fn from(r: &ToolResult) -> Self {
201 Self {
202 is_error: r.is_error,
203 text: format!("{r:?}"),
204 }
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn attachment_error_event_serializes_with_kind_and_message() {
214 use crate::user_message::AttachmentErrorKind;
215
216 let ev = UiEvent::AttachmentError {
217 kind: AttachmentErrorKind::NotFound,
218 message: "image not found: /tmp/foo.png".into(),
219 };
220 let json = serde_json::to_string(&ev).expect("serialize");
221 assert_eq!(
223 json,
224 r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
225 );
226 }
227
228 #[test]
229 fn compact_command_is_constructible() {
230 let c = Command::Compact;
231 assert_eq!(c, Command::Compact);
232 assert!(format!("{c:?}").contains("Compact"));
233 }
234
235 #[test]
236 fn d2_command_and_event_variants_construct() {
237 let _ = Command::NewSession;
238 let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
239 let _ = Command::LoadSession("sess-id".into());
240 let e = UiEvent::SessionReplaced(Vec::new());
241 assert!(format!("{e:?}").contains("SessionReplaced"));
242 let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
243 assert!(format!("{e:?}").contains("ModelSwitched"));
244 }
245
246 #[test]
247 fn clone_session_command_constructs() {
248 let c = Command::CloneSession;
249 assert!(format!("{c:?}").contains("CloneSession"));
250 }
251
252 #[test]
253 fn read_query_commands_round_trip() {
254 for c in [Command::GetMessages, Command::GetState] {
255 let line = serde_json::to_string(&c).expect("serialize");
256 assert!(line.contains("\"type\":"), "{line}");
257 let back: Command = serde_json::from_str(&line).expect("deserialize");
258 assert_eq!(c, back);
259 }
260 assert_eq!(
262 serde_json::to_string(&Command::GetMessages).expect("ser"),
263 r#"{"type":"get_messages"}"#
264 );
265 }
266
267 #[test]
268 fn fork_protocol_variants_construct() {
269 let c = Command::ForkFrom {
270 from: "e1".into(),
271 message: "hi".into(),
272 };
273 assert!(format!("{c:?}").contains("ForkFrom"));
274 let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
275 assert!(format!("{e:?}").contains("ForkCandidates"));
276 }
277
278 #[test]
279 fn branch_tree_event_constructs() {
280 let tree = motosan_agent_loop::BranchTree {
281 nodes: Vec::new(),
282 root: None,
283 active_leaf: None,
284 };
285 let e = UiEvent::BranchTree(tree);
286 assert!(format!("{e:?}").contains("BranchTree"));
287 }
288
289 #[test]
290 fn run_inline_bash_command_is_constructible() {
291 let c = Command::RunInlineBash {
292 command: "ls".into(),
293 send_to_llm: true,
294 };
295 assert!(format!("{c:?}").contains("RunInlineBash"));
296 }
297
298 #[test]
299 fn permission_protocol_variants_are_constructible() {
300 let command = Command::ResolvePermission(PermissionResolution {
301 tool: "bash".into(),
302 args: serde_json::json!({"command": "echo hi"}),
303 choice: PermissionChoice::AllowSession,
304 });
305 assert!(format!("{command:?}").contains("ResolvePermission"));
306 assert!(format!("{command:?}").contains("AllowSession"));
307
308 let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
309 let event = UiEvent::PermissionRequested {
310 tool: "bash".into(),
311 args: serde_json::json!({"command": "echo hi"}),
312 resolver,
313 };
314 assert!(format!("{event:?}").contains("PermissionRequested"));
315 }
316
317 #[test]
318 fn command_round_trips_through_json() {
319 let cases = vec![
320 Command::SendUserMessage {
321 text: "hi".into(),
322 attachments: Vec::new(),
323 },
324 Command::CancelAgent,
325 Command::Quit,
326 Command::Compact,
327 Command::NewSession,
328 Command::CloneSession,
329 Command::RunInlineBash {
330 command: "ls".into(),
331 send_to_llm: true,
332 },
333 Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
334 Command::LoadSession("sess-1".into()),
335 Command::ForkFrom {
336 from: "e1".into(),
337 message: "hi".into(),
338 },
339 Command::ResolvePermission(PermissionResolution {
340 tool: "bash".into(),
341 args: serde_json::json!({"command": "echo hi"}),
342 choice: PermissionChoice::AllowSession,
343 }),
344 ];
345 for c in cases {
346 let line = serde_json::to_string(&c).expect("serialize");
347 assert!(line.contains("\"type\":"), "missing type tag: {line}");
348 let back: Command = serde_json::from_str(&line).expect("deserialize");
349 assert_eq!(c, back, "round-trip mismatch for {line}");
350 }
351 }
352
353 #[test]
354 fn command_send_user_message_round_trip() {
355 let line = serde_json::to_string(&Command::SendUserMessage {
356 text: "hi".into(),
357 attachments: Vec::new(),
358 })
359 .expect("serialize");
360 assert_eq!(
361 line,
362 r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
363 );
364 let back: Command = serde_json::from_str(&line).expect("deserialize");
365 assert_eq!(
366 back,
367 Command::SendUserMessage {
368 text: "hi".into(),
369 attachments: Vec::new(),
370 }
371 );
372 }
373
374 #[test]
375 fn command_send_user_message_with_attachment_round_trip() {
376 let cmd = Command::SendUserMessage {
377 text: "look".into(),
378 attachments: vec![crate::user_message::Attachment::Image {
379 path: std::path::PathBuf::from("/tmp/foo.png"),
380 }],
381 };
382 let line = serde_json::to_string(&cmd).expect("serialize");
383 assert_eq!(
384 line,
385 r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
386 );
387 let back: Command = serde_json::from_str(&line).expect("deserialize");
388 assert_eq!(back, cmd);
389 }
390
391 #[test]
392 fn unit_command_uses_adjacent_tagging() {
393 let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
394 assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
395 }
396
397 #[test]
398 fn command_invoke_extension_command_serde_round_trip() {
399 let cmd = Command::InvokeExtensionCommand {
400 name: "todo".into(),
401 args: "add buy milk".into(),
402 };
403 let json = serde_json::to_string(&cmd).expect("ok");
404 assert_eq!(
405 json,
406 r#"{"type":"invoke_extension_command","payload":{"name":"todo","args":"add buy milk"}}"#
407 );
408 let back: Command = serde_json::from_str(&json).expect("ok");
409 assert_eq!(back, cmd);
410 }
411
412 #[test]
413 fn ui_event_extension_cancelled_serializes() {
414 let ev = UiEvent::ExtensionCancelled {
415 extension_name: "dirty-repo-guard".into(),
416 reason: Some("uncommitted changes".into()),
417 };
418 let json = serde_json::to_string(&ev).expect("ok");
419 assert!(json.contains(r#""type":"extension_cancelled""#));
420 assert!(json.contains(r#""extension_name":"dirty-repo-guard""#));
421 assert!(json.contains(r#""reason":"uncommitted changes""#));
422 }
423
424 #[test]
425 fn ui_event_notice_serializes() {
426 let ev = UiEvent::Notice {
427 title: "/todo".into(),
428 body: "reply from fixture".into(),
429 };
430 let json = serde_json::to_string(&ev).expect("ok");
431 assert!(json.contains(r#""type":"notice""#));
432 assert!(json.contains(r#""title":"/todo""#));
433 assert!(json.contains(r#""body":"reply from fixture""#));
434 }
435
436 #[test]
437 fn snapshot_events_serialize_with_type_tag() {
438 let m = UiEvent::MessagesSnapshot(Vec::new());
439 let line = serde_json::to_string(&m).expect("serialize");
440 assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
441
442 let s = UiEvent::StateSnapshot {
443 session_id: "sess-1".into(),
444 model: "claude-opus-4-7".into(),
445 active_turn: false,
446 };
447 let line = serde_json::to_string(&s).expect("serialize");
448 assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
449 assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
450 assert!(line.contains(r#""active_turn":false"#), "{line}");
451 }
452
453 #[test]
454 fn ui_events_serialize_with_type_tag() {
455 let events = vec![
456 UiEvent::AgentTurnStarted,
457 UiEvent::AgentThinking,
458 UiEvent::AgentTextDelta("hi".into()),
459 UiEvent::AgentMessageComplete("done".into()),
460 UiEvent::AgentTurnComplete,
461 UiEvent::InlineBashOutput {
462 command: "ls".into(),
463 output: "x".into(),
464 },
465 UiEvent::Notice {
466 title: "note".into(),
467 body: "body".into(),
468 },
469 UiEvent::SessionReplaced(Vec::new()),
470 UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
471 UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
472 UiEvent::Error("bad".into()),
473 UiEvent::ToolCallStarted {
474 id: "t1".into(),
475 name: "bash".into(),
476 args: serde_json::json!("ls"),
477 },
478 UiEvent::ToolCallProgress {
479 id: "t1".into(),
480 chunk: ProgressChunk::Status("running".into()),
481 },
482 UiEvent::ToolCallCompleted {
483 id: "t1".into(),
484 result: UiToolResult {
485 is_error: false,
486 text: "ok".into(),
487 },
488 },
489 ];
490 for e in &events {
491 let line = serde_json::to_string(e).expect("serialize");
492 assert!(line.contains("\"type\":"), "missing type tag: {line}");
493 }
494 }
495
496 #[test]
497 fn permission_requested_serializes_without_the_resolver() {
498 let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
499 let e = UiEvent::PermissionRequested {
500 tool: "bash".into(),
501 args: serde_json::json!({"command": "echo hi"}),
502 resolver,
503 };
504 let line = serde_json::to_string(&e).expect("serialize");
505 assert!(line.contains(r#""type":"permission_requested""#), "{line}");
506 assert!(!line.contains("resolver"), "resolver leaked: {line}");
507 }
508
509 #[test]
510 fn branch_tree_event_serializes_via_mapping() {
511 let tree = motosan_agent_loop::BranchTree {
512 nodes: vec![motosan_agent_loop::BranchNode {
513 id: "n0".into(),
514 parent: None,
515 children: vec![],
516 label: "root".into(),
517 }],
518 root: Some(0),
519 active_leaf: Some(0),
520 };
521 let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
522 assert!(line.contains(r#""type":"branch_tree""#), "{line}");
523 assert!(line.contains(r#""label":"root""#), "{line}");
524 }
525
526 #[test]
527 fn command_list_extensions_serde_round_trip() {
528 let cmd = Command::ListExtensions;
529 let line = serde_json::to_string(&cmd).expect("serialize");
530 assert_eq!(line, r#"{"type":"list_extensions"}"#);
531 let back: Command = serde_json::from_str(&line).expect("deserialize");
532 assert_eq!(back, cmd);
533 }
534
535 #[test]
536 fn ui_event_extension_list_serializes() {
537 let ev = UiEvent::ExtensionList {
538 extensions: vec![ExtensionInfo {
539 name: "dirty".into(),
540 hooks: vec!["session_before_switch".into()],
541 commands: Vec::new(),
542 healthy: true,
543 diagnostic: None,
544 }],
545 };
546 let line = serde_json::to_string(&ev).expect("serialize");
547 assert!(line.contains(r#""type":"extension_list""#));
548 assert!(line.contains(r#""name":"dirty""#));
549 }
550}