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