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, 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 ApplySettings {
69 settings: crate::settings::Settings,
70 },
71 ReloadSettings,
76 NewSession,
78 CloneSession,
81 GetMessages,
84 GetState,
87 ListExtensions,
90 InvokeExtensionCommand {
94 name: String,
95 args: String,
96 },
97 SwitchModel(crate::model::ModelId),
99 LoadSession(String),
101 ForkFrom {
103 from: String,
104 message: String,
105 },
106}
107
108impl PartialEq for Command {
109 fn eq(&self, other: &Self) -> bool {
110 use Command as C;
111 match (self, other) {
112 (
113 C::SendUserMessage {
114 text: left_text,
115 attachments: left_attachments,
116 },
117 C::SendUserMessage {
118 text: right_text,
119 attachments: right_attachments,
120 },
121 ) => left_text == right_text && left_attachments == right_attachments,
122 (C::CancelAgent, C::CancelAgent)
123 | (C::Quit, C::Quit)
124 | (C::Compact, C::Compact)
125 | (C::ReloadSettings, C::ReloadSettings)
126 | (C::NewSession, C::NewSession)
127 | (C::CloneSession, C::CloneSession)
128 | (C::GetMessages, C::GetMessages)
129 | (C::GetState, C::GetState)
130 | (C::ListExtensions, C::ListExtensions) => true,
131 (C::ResolvePermission(left), C::ResolvePermission(right)) => left == right,
132 (
133 C::RunInlineBash {
134 command: left_command,
135 send_to_llm: left_send_to_llm,
136 },
137 C::RunInlineBash {
138 command: right_command,
139 send_to_llm: right_send_to_llm,
140 },
141 ) => left_command == right_command && left_send_to_llm == right_send_to_llm,
142 (C::ApplySettings { settings: left }, C::ApplySettings { settings: right }) => {
143 settings_command_eq(left, right)
144 }
145 (
146 C::InvokeExtensionCommand {
147 name: left_name,
148 args: left_args,
149 },
150 C::InvokeExtensionCommand {
151 name: right_name,
152 args: right_args,
153 },
154 ) => left_name == right_name && left_args == right_args,
155 (C::SwitchModel(left), C::SwitchModel(right)) => left == right,
156 (C::LoadSession(left), C::LoadSession(right)) => left == right,
157 (
158 C::ForkFrom {
159 from: left_from,
160 message: left_message,
161 },
162 C::ForkFrom {
163 from: right_from,
164 message: right_message,
165 },
166 ) => left_from == right_from && left_message == right_message,
167 _ => false,
168 }
169 }
170}
171
172impl Eq for Command {}
173
174fn settings_command_eq(
175 left: &crate::settings::Settings,
176 right: &crate::settings::Settings,
177) -> bool {
178 left.model == right.model
179 && left.anthropic == right.anthropic
180 && left.ui == right.ui
181 && left.session.autosave == right.session.autosave
182 && left.session.compact_at_context_pct.to_bits()
183 == right.session.compact_at_context_pct.to_bits()
184 && left.session.max_context_tokens == right.session.max_context_tokens
185 && left.session.keep_turns == right.session.keep_turns
186 && left.logging == right.logging
187}
188
189#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
190pub struct ExtensionInfo {
191 pub name: String,
192 pub hooks: Vec<String>,
193 pub commands: Vec<String>,
194 pub healthy: bool,
197 pub diagnostic: Option<String>,
199}
200
201#[derive(Debug, Serialize)]
202#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
203pub enum UiEvent {
204 AgentTurnStarted,
205 AgentThinking,
206 AgentTextDelta(String),
207 ThinkingComplete {
214 text: String,
215 },
216 AgentMessageComplete(String),
217 ToolCallStarted {
218 id: String,
219 name: String,
220 args: Value,
221 },
222 ToolCallProgress {
223 id: String,
224 chunk: ProgressChunk,
225 },
226 ToolCallCompleted {
227 id: String,
228 result: UiToolResult,
229 },
230 AgentTurnComplete,
231 TurnStats {
237 input_tokens: u64,
238 output_tokens: u64,
239 cumulative_input: u64,
240 cumulative_output: u64,
241 model: String,
242 },
243 PermissionRequested {
244 tool: String,
245 args: serde_json::Value,
246 #[serde(skip)]
247 resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
248 },
249 InlineBashOutput {
251 command: String,
252 output: String,
253 },
254 SessionReplaced(Vec<motosan_agent_loop::Message>),
258 ModelSwitched(crate::model::ModelId),
260 ForkCandidates(Vec<(String, String)>),
264 BranchTree(
267 #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
268 motosan_agent_loop::BranchTree,
269 ),
270 MessagesSnapshot(Vec<motosan_agent_loop::Message>),
273 StateSnapshot {
276 session_id: String,
277 model: String,
278 active_turn: bool,
279 },
280 ExtensionList {
283 extensions: Vec<ExtensionInfo>,
284 },
285 SettingsSnapshot {
290 settings: crate::settings::Settings,
291 },
292 Error(String),
293 AttachmentError {
297 kind: crate::user_message::AttachmentErrorKind,
298 message: String,
299 },
300 ExtensionCancelled {
305 extension_name: String,
306 reason: Option<String>,
307 },
308 Notice {
311 title: String,
312 body: String,
313 },
314}
315
316#[derive(Debug, Clone, Serialize)]
317pub struct UiToolResult {
318 pub is_error: bool,
319 pub text: String,
320}
321
322impl From<&ToolResult> for UiToolResult {
323 fn from(r: &ToolResult) -> Self {
324 Self {
325 is_error: r.is_error,
326 text: format!("{r:?}"),
327 }
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn attachment_error_event_serializes_with_kind_and_message() {
337 use crate::user_message::AttachmentErrorKind;
338
339 let ev = UiEvent::AttachmentError {
340 kind: AttachmentErrorKind::NotFound,
341 message: "image not found: /tmp/foo.png".into(),
342 };
343 let json = serde_json::to_string(&ev).expect("serialize");
344 assert_eq!(
346 json,
347 r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
348 );
349 }
350
351 #[test]
352 fn compact_command_is_constructible() {
353 let c = Command::Compact;
354 assert_eq!(c, Command::Compact);
355 assert!(format!("{c:?}").contains("Compact"));
356 }
357
358 #[test]
359 fn d2_command_and_event_variants_construct() {
360 let _ = Command::NewSession;
361 let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
362 let _ = Command::LoadSession("sess-id".into());
363 let e = UiEvent::SessionReplaced(Vec::new());
364 assert!(format!("{e:?}").contains("SessionReplaced"));
365 let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
366 assert!(format!("{e:?}").contains("ModelSwitched"));
367 }
368
369 #[test]
370 fn clone_session_command_constructs() {
371 let c = Command::CloneSession;
372 assert!(format!("{c:?}").contains("CloneSession"));
373 }
374
375 #[test]
376 fn read_query_commands_round_trip() {
377 for c in [Command::GetMessages, Command::GetState] {
378 let line = serde_json::to_string(&c).expect("serialize");
379 assert!(line.contains("\"type\":"), "{line}");
380 let back: Command = serde_json::from_str(&line).expect("deserialize");
381 assert_eq!(c, back);
382 }
383 assert_eq!(
385 serde_json::to_string(&Command::GetMessages).expect("ser"),
386 r#"{"type":"get_messages"}"#
387 );
388 }
389
390 #[test]
391 fn fork_protocol_variants_construct() {
392 let c = Command::ForkFrom {
393 from: "e1".into(),
394 message: "hi".into(),
395 };
396 assert!(format!("{c:?}").contains("ForkFrom"));
397 let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
398 assert!(format!("{e:?}").contains("ForkCandidates"));
399 }
400
401 #[test]
402 fn branch_tree_event_constructs() {
403 let tree = motosan_agent_loop::BranchTree {
404 nodes: Vec::new(),
405 root: None,
406 active_leaf: None,
407 };
408 let e = UiEvent::BranchTree(tree);
409 assert!(format!("{e:?}").contains("BranchTree"));
410 }
411
412 #[test]
413 fn run_inline_bash_command_is_constructible() {
414 let c = Command::RunInlineBash {
415 command: "ls".into(),
416 send_to_llm: true,
417 };
418 assert!(format!("{c:?}").contains("RunInlineBash"));
419 }
420
421 #[test]
422 fn permission_protocol_variants_are_constructible() {
423 let command = Command::ResolvePermission(PermissionResolution {
424 tool: "bash".into(),
425 args: serde_json::json!({"command": "echo hi"}),
426 choice: PermissionChoice::AllowSession,
427 });
428 assert!(format!("{command:?}").contains("ResolvePermission"));
429 assert!(format!("{command:?}").contains("AllowSession"));
430
431 let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
432 let event = UiEvent::PermissionRequested {
433 tool: "bash".into(),
434 args: serde_json::json!({"command": "echo hi"}),
435 resolver,
436 };
437 assert!(format!("{event:?}").contains("PermissionRequested"));
438 }
439
440 #[test]
441 fn command_round_trips_through_json() {
442 let cases = vec![
443 Command::SendUserMessage {
444 text: "hi".into(),
445 attachments: Vec::new(),
446 },
447 Command::CancelAgent,
448 Command::Quit,
449 Command::Compact,
450 Command::NewSession,
451 Command::CloneSession,
452 Command::RunInlineBash {
453 command: "ls".into(),
454 send_to_llm: true,
455 },
456 Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
457 Command::LoadSession("sess-1".into()),
458 Command::ForkFrom {
459 from: "e1".into(),
460 message: "hi".into(),
461 },
462 Command::ResolvePermission(PermissionResolution {
463 tool: "bash".into(),
464 args: serde_json::json!({"command": "echo hi"}),
465 choice: PermissionChoice::AllowSession,
466 }),
467 ];
468 for c in cases {
469 let line = serde_json::to_string(&c).expect("serialize");
470 assert!(line.contains("\"type\":"), "missing type tag: {line}");
471 let back: Command = serde_json::from_str(&line).expect("deserialize");
472 assert_eq!(c, back, "round-trip mismatch for {line}");
473 }
474 }
475
476 #[test]
477 fn command_send_user_message_round_trip() {
478 let line = serde_json::to_string(&Command::SendUserMessage {
479 text: "hi".into(),
480 attachments: Vec::new(),
481 })
482 .expect("serialize");
483 assert_eq!(
484 line,
485 r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
486 );
487 let back: Command = serde_json::from_str(&line).expect("deserialize");
488 assert_eq!(
489 back,
490 Command::SendUserMessage {
491 text: "hi".into(),
492 attachments: Vec::new(),
493 }
494 );
495 }
496
497 #[test]
498 fn command_send_user_message_with_attachment_round_trip() {
499 let cmd = Command::SendUserMessage {
500 text: "look".into(),
501 attachments: vec![crate::user_message::Attachment::Image {
502 path: std::path::PathBuf::from("/tmp/foo.png"),
503 }],
504 };
505 let line = serde_json::to_string(&cmd).expect("serialize");
506 assert_eq!(
507 line,
508 r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
509 );
510 let back: Command = serde_json::from_str(&line).expect("deserialize");
511 assert_eq!(back, cmd);
512 }
513
514 #[test]
515 fn unit_command_uses_adjacent_tagging() {
516 let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
517 assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
518 }
519
520 #[test]
521 fn command_invoke_extension_command_serde_round_trip() {
522 let cmd = Command::InvokeExtensionCommand {
523 name: "todo".into(),
524 args: "add buy milk".into(),
525 };
526 let json = serde_json::to_string(&cmd).expect("ok");
527 assert_eq!(
528 json,
529 r#"{"type":"invoke_extension_command","payload":{"name":"todo","args":"add buy milk"}}"#
530 );
531 let back: Command = serde_json::from_str(&json).expect("ok");
532 assert_eq!(back, cmd);
533 }
534
535 #[test]
536 fn ui_event_extension_cancelled_serializes() {
537 let ev = UiEvent::ExtensionCancelled {
538 extension_name: "dirty-repo-guard".into(),
539 reason: Some("uncommitted changes".into()),
540 };
541 let json = serde_json::to_string(&ev).expect("ok");
542 assert!(json.contains(r#""type":"extension_cancelled""#));
543 assert!(json.contains(r#""extension_name":"dirty-repo-guard""#));
544 assert!(json.contains(r#""reason":"uncommitted changes""#));
545 }
546
547 #[test]
548 fn ui_event_notice_serializes() {
549 let ev = UiEvent::Notice {
550 title: "/todo".into(),
551 body: "reply from fixture".into(),
552 };
553 let json = serde_json::to_string(&ev).expect("ok");
554 assert!(json.contains(r#""type":"notice""#));
555 assert!(json.contains(r#""title":"/todo""#));
556 assert!(json.contains(r#""body":"reply from fixture""#));
557 }
558
559 #[test]
560 fn ui_event_thinking_complete_serializes() {
561 let ev = UiEvent::ThinkingComplete {
562 text: "the model considered options A and B".into(),
563 };
564 let line = serde_json::to_string(&ev).expect("serialize");
565 assert_eq!(
566 line,
567 r#"{"type":"thinking_complete","payload":{"text":"the model considered options A and B"}}"#
568 );
569 }
570
571 #[test]
572 fn ui_event_turn_stats_round_trips() {
573 let ev = UiEvent::TurnStats {
574 input_tokens: 1203,
575 output_tokens: 412,
576 cumulative_input: 12543,
577 cumulative_output: 5231,
578 model: "claude-opus-4-7".into(),
579 };
580 let line = serde_json::to_string(&ev).expect("serialize");
581 assert_eq!(
582 line,
583 r#"{"type":"turn_stats","payload":{"input_tokens":1203,"output_tokens":412,"cumulative_input":12543,"cumulative_output":5231,"model":"claude-opus-4-7"}}"#
584 );
585 }
586
587 #[test]
588 fn command_still_exposes_eq_for_downstream_matchers() {
589 fn assert_eq_impl<T: Eq>() {}
590 assert_eq_impl::<Command>();
591 }
592
593 #[test]
594 fn command_apply_settings_round_trips() {
595 use crate::settings::Settings;
596 let cmd = Command::ApplySettings {
597 settings: Settings::default(),
598 };
599 let line = serde_json::to_string(&cmd).expect("serialize");
600 assert!(line.contains(r#""type":"apply_settings""#));
602 assert!(line.contains(r#""payload":{"#));
603 assert!(line.contains(r#""model":"#));
604 let back: Command = serde_json::from_str(&line).expect("deserialize");
605 assert_eq!(back, cmd);
606 }
607
608 #[test]
609 fn command_reload_settings_round_trips() {
610 let cmd = Command::ReloadSettings;
611 let line = serde_json::to_string(&cmd).expect("serialize");
612 assert_eq!(line, r#"{"type":"reload_settings"}"#);
613 let back: Command = serde_json::from_str(&line).expect("deserialize");
614 assert_eq!(back, cmd);
615 }
616
617 #[test]
618 fn ui_event_settings_snapshot_serializes() {
619 use crate::settings::Settings;
620 let ev = UiEvent::SettingsSnapshot {
621 settings: Settings::default(),
622 };
623 let line = serde_json::to_string(&ev).expect("serialize");
624 assert!(line.contains(r#""type":"settings_snapshot""#));
625 assert!(line.contains(r#""payload":{"#));
626 assert!(line.contains(r#""model":"#));
627 }
628
629 #[test]
630 fn snapshot_events_serialize_with_type_tag() {
631 let m = UiEvent::MessagesSnapshot(Vec::new());
632 let line = serde_json::to_string(&m).expect("serialize");
633 assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
634
635 let s = UiEvent::StateSnapshot {
636 session_id: "sess-1".into(),
637 model: "claude-opus-4-7".into(),
638 active_turn: false,
639 };
640 let line = serde_json::to_string(&s).expect("serialize");
641 assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
642 assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
643 assert!(line.contains(r#""active_turn":false"#), "{line}");
644 }
645
646 #[test]
647 fn ui_events_serialize_with_type_tag() {
648 let events = vec![
649 UiEvent::AgentTurnStarted,
650 UiEvent::AgentThinking,
651 UiEvent::AgentTextDelta("hi".into()),
652 UiEvent::AgentMessageComplete("done".into()),
653 UiEvent::AgentTurnComplete,
654 UiEvent::InlineBashOutput {
655 command: "ls".into(),
656 output: "x".into(),
657 },
658 UiEvent::Notice {
659 title: "note".into(),
660 body: "body".into(),
661 },
662 UiEvent::SessionReplaced(Vec::new()),
663 UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
664 UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
665 UiEvent::Error("bad".into()),
666 UiEvent::ToolCallStarted {
667 id: "t1".into(),
668 name: "bash".into(),
669 args: serde_json::json!("ls"),
670 },
671 UiEvent::ToolCallProgress {
672 id: "t1".into(),
673 chunk: ProgressChunk::Status("running".into()),
674 },
675 UiEvent::ToolCallCompleted {
676 id: "t1".into(),
677 result: UiToolResult {
678 is_error: false,
679 text: "ok".into(),
680 },
681 },
682 ];
683 for e in &events {
684 let line = serde_json::to_string(e).expect("serialize");
685 assert!(line.contains("\"type\":"), "missing type tag: {line}");
686 }
687 }
688
689 #[test]
690 fn permission_requested_serializes_without_the_resolver() {
691 let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
692 let e = UiEvent::PermissionRequested {
693 tool: "bash".into(),
694 args: serde_json::json!({"command": "echo hi"}),
695 resolver,
696 };
697 let line = serde_json::to_string(&e).expect("serialize");
698 assert!(line.contains(r#""type":"permission_requested""#), "{line}");
699 assert!(!line.contains("resolver"), "resolver leaked: {line}");
700 }
701
702 #[test]
703 fn branch_tree_event_serializes_via_mapping() {
704 let tree = motosan_agent_loop::BranchTree {
705 nodes: vec![motosan_agent_loop::BranchNode {
706 id: "n0".into(),
707 parent: None,
708 children: vec![],
709 label: "root".into(),
710 }],
711 root: Some(0),
712 active_leaf: Some(0),
713 };
714 let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
715 assert!(line.contains(r#""type":"branch_tree""#), "{line}");
716 assert!(line.contains(r#""label":"root""#), "{line}");
717 }
718
719 #[test]
720 fn command_list_extensions_serde_round_trip() {
721 let cmd = Command::ListExtensions;
722 let line = serde_json::to_string(&cmd).expect("serialize");
723 assert_eq!(line, r#"{"type":"list_extensions"}"#);
724 let back: Command = serde_json::from_str(&line).expect("deserialize");
725 assert_eq!(back, cmd);
726 }
727
728 #[test]
729 fn ui_event_extension_list_serializes() {
730 let ev = UiEvent::ExtensionList {
731 extensions: vec![ExtensionInfo {
732 name: "dirty".into(),
733 hooks: vec!["session_before_switch".into()],
734 commands: Vec::new(),
735 healthy: true,
736 diagnostic: None,
737 }],
738 };
739 let line = serde_json::to_string(&ev).expect("serialize");
740 assert!(line.contains(r#""type":"extension_list""#));
741 assert!(line.contains(r#""name":"dirty""#));
742 }
743}