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 AgentMessageComplete(String),
208 ToolCallStarted {
209 id: String,
210 name: String,
211 args: Value,
212 },
213 ToolCallProgress {
214 id: String,
215 chunk: ProgressChunk,
216 },
217 ToolCallCompleted {
218 id: String,
219 result: UiToolResult,
220 },
221 AgentTurnComplete,
222 TurnStats {
228 input_tokens: u64,
229 output_tokens: u64,
230 cumulative_input: u64,
231 cumulative_output: u64,
232 model: String,
233 },
234 PermissionRequested {
235 tool: String,
236 args: serde_json::Value,
237 #[serde(skip)]
238 resolver: tokio::sync::oneshot::Sender<crate::permissions::Decision>,
239 },
240 InlineBashOutput {
242 command: String,
243 output: String,
244 },
245 SessionReplaced(Vec<motosan_agent_loop::Message>),
249 ModelSwitched(crate::model::ModelId),
251 ForkCandidates(Vec<(String, String)>),
255 BranchTree(
258 #[serde(serialize_with = "crate::protocol::serialize_branch_tree")]
259 motosan_agent_loop::BranchTree,
260 ),
261 MessagesSnapshot(Vec<motosan_agent_loop::Message>),
264 StateSnapshot {
267 session_id: String,
268 model: String,
269 active_turn: bool,
270 },
271 ExtensionList {
274 extensions: Vec<ExtensionInfo>,
275 },
276 SettingsSnapshot {
281 settings: crate::settings::Settings,
282 },
283 Error(String),
284 AttachmentError {
288 kind: crate::user_message::AttachmentErrorKind,
289 message: String,
290 },
291 ExtensionCancelled {
296 extension_name: String,
297 reason: Option<String>,
298 },
299 Notice {
302 title: String,
303 body: String,
304 },
305}
306
307#[derive(Debug, Clone, Serialize)]
308pub struct UiToolResult {
309 pub is_error: bool,
310 pub text: String,
311}
312
313impl From<&ToolResult> for UiToolResult {
314 fn from(r: &ToolResult) -> Self {
315 Self {
316 is_error: r.is_error,
317 text: format!("{r:?}"),
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn attachment_error_event_serializes_with_kind_and_message() {
328 use crate::user_message::AttachmentErrorKind;
329
330 let ev = UiEvent::AttachmentError {
331 kind: AttachmentErrorKind::NotFound,
332 message: "image not found: /tmp/foo.png".into(),
333 };
334 let json = serde_json::to_string(&ev).expect("serialize");
335 assert_eq!(
337 json,
338 r#"{"type":"attachment_error","payload":{"kind":"not_found","message":"image not found: /tmp/foo.png"}}"#
339 );
340 }
341
342 #[test]
343 fn compact_command_is_constructible() {
344 let c = Command::Compact;
345 assert_eq!(c, Command::Compact);
346 assert!(format!("{c:?}").contains("Compact"));
347 }
348
349 #[test]
350 fn d2_command_and_event_variants_construct() {
351 let _ = Command::NewSession;
352 let _ = Command::SwitchModel(crate::model::ModelId::from("m"));
353 let _ = Command::LoadSession("sess-id".into());
354 let e = UiEvent::SessionReplaced(Vec::new());
355 assert!(format!("{e:?}").contains("SessionReplaced"));
356 let e = UiEvent::ModelSwitched(crate::model::ModelId::from("m"));
357 assert!(format!("{e:?}").contains("ModelSwitched"));
358 }
359
360 #[test]
361 fn clone_session_command_constructs() {
362 let c = Command::CloneSession;
363 assert!(format!("{c:?}").contains("CloneSession"));
364 }
365
366 #[test]
367 fn read_query_commands_round_trip() {
368 for c in [Command::GetMessages, Command::GetState] {
369 let line = serde_json::to_string(&c).expect("serialize");
370 assert!(line.contains("\"type\":"), "{line}");
371 let back: Command = serde_json::from_str(&line).expect("deserialize");
372 assert_eq!(c, back);
373 }
374 assert_eq!(
376 serde_json::to_string(&Command::GetMessages).expect("ser"),
377 r#"{"type":"get_messages"}"#
378 );
379 }
380
381 #[test]
382 fn fork_protocol_variants_construct() {
383 let c = Command::ForkFrom {
384 from: "e1".into(),
385 message: "hi".into(),
386 };
387 assert!(format!("{c:?}").contains("ForkFrom"));
388 let e = UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]);
389 assert!(format!("{e:?}").contains("ForkCandidates"));
390 }
391
392 #[test]
393 fn branch_tree_event_constructs() {
394 let tree = motosan_agent_loop::BranchTree {
395 nodes: Vec::new(),
396 root: None,
397 active_leaf: None,
398 };
399 let e = UiEvent::BranchTree(tree);
400 assert!(format!("{e:?}").contains("BranchTree"));
401 }
402
403 #[test]
404 fn run_inline_bash_command_is_constructible() {
405 let c = Command::RunInlineBash {
406 command: "ls".into(),
407 send_to_llm: true,
408 };
409 assert!(format!("{c:?}").contains("RunInlineBash"));
410 }
411
412 #[test]
413 fn permission_protocol_variants_are_constructible() {
414 let command = Command::ResolvePermission(PermissionResolution {
415 tool: "bash".into(),
416 args: serde_json::json!({"command": "echo hi"}),
417 choice: PermissionChoice::AllowSession,
418 });
419 assert!(format!("{command:?}").contains("ResolvePermission"));
420 assert!(format!("{command:?}").contains("AllowSession"));
421
422 let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
423 let event = UiEvent::PermissionRequested {
424 tool: "bash".into(),
425 args: serde_json::json!({"command": "echo hi"}),
426 resolver,
427 };
428 assert!(format!("{event:?}").contains("PermissionRequested"));
429 }
430
431 #[test]
432 fn command_round_trips_through_json() {
433 let cases = vec![
434 Command::SendUserMessage {
435 text: "hi".into(),
436 attachments: Vec::new(),
437 },
438 Command::CancelAgent,
439 Command::Quit,
440 Command::Compact,
441 Command::NewSession,
442 Command::CloneSession,
443 Command::RunInlineBash {
444 command: "ls".into(),
445 send_to_llm: true,
446 },
447 Command::SwitchModel(crate::model::ModelId::from("claude-opus-4-7")),
448 Command::LoadSession("sess-1".into()),
449 Command::ForkFrom {
450 from: "e1".into(),
451 message: "hi".into(),
452 },
453 Command::ResolvePermission(PermissionResolution {
454 tool: "bash".into(),
455 args: serde_json::json!({"command": "echo hi"}),
456 choice: PermissionChoice::AllowSession,
457 }),
458 ];
459 for c in cases {
460 let line = serde_json::to_string(&c).expect("serialize");
461 assert!(line.contains("\"type\":"), "missing type tag: {line}");
462 let back: Command = serde_json::from_str(&line).expect("deserialize");
463 assert_eq!(c, back, "round-trip mismatch for {line}");
464 }
465 }
466
467 #[test]
468 fn command_send_user_message_round_trip() {
469 let line = serde_json::to_string(&Command::SendUserMessage {
470 text: "hi".into(),
471 attachments: Vec::new(),
472 })
473 .expect("serialize");
474 assert_eq!(
475 line,
476 r#"{"type":"send_user_message","payload":{"text":"hi"}}"#
477 );
478 let back: Command = serde_json::from_str(&line).expect("deserialize");
479 assert_eq!(
480 back,
481 Command::SendUserMessage {
482 text: "hi".into(),
483 attachments: Vec::new(),
484 }
485 );
486 }
487
488 #[test]
489 fn command_send_user_message_with_attachment_round_trip() {
490 let cmd = Command::SendUserMessage {
491 text: "look".into(),
492 attachments: vec![crate::user_message::Attachment::Image {
493 path: std::path::PathBuf::from("/tmp/foo.png"),
494 }],
495 };
496 let line = serde_json::to_string(&cmd).expect("serialize");
497 assert_eq!(
498 line,
499 r#"{"type":"send_user_message","payload":{"text":"look","attachments":[{"type":"image","path":"/tmp/foo.png"}]}}"#
500 );
501 let back: Command = serde_json::from_str(&line).expect("deserialize");
502 assert_eq!(back, cmd);
503 }
504
505 #[test]
506 fn unit_command_uses_adjacent_tagging() {
507 let unit = serde_json::to_string(&Command::CancelAgent).expect("serialize");
508 assert_eq!(unit, r#"{"type":"cancel_agent"}"#);
509 }
510
511 #[test]
512 fn command_invoke_extension_command_serde_round_trip() {
513 let cmd = Command::InvokeExtensionCommand {
514 name: "todo".into(),
515 args: "add buy milk".into(),
516 };
517 let json = serde_json::to_string(&cmd).expect("ok");
518 assert_eq!(
519 json,
520 r#"{"type":"invoke_extension_command","payload":{"name":"todo","args":"add buy milk"}}"#
521 );
522 let back: Command = serde_json::from_str(&json).expect("ok");
523 assert_eq!(back, cmd);
524 }
525
526 #[test]
527 fn ui_event_extension_cancelled_serializes() {
528 let ev = UiEvent::ExtensionCancelled {
529 extension_name: "dirty-repo-guard".into(),
530 reason: Some("uncommitted changes".into()),
531 };
532 let json = serde_json::to_string(&ev).expect("ok");
533 assert!(json.contains(r#""type":"extension_cancelled""#));
534 assert!(json.contains(r#""extension_name":"dirty-repo-guard""#));
535 assert!(json.contains(r#""reason":"uncommitted changes""#));
536 }
537
538 #[test]
539 fn ui_event_notice_serializes() {
540 let ev = UiEvent::Notice {
541 title: "/todo".into(),
542 body: "reply from fixture".into(),
543 };
544 let json = serde_json::to_string(&ev).expect("ok");
545 assert!(json.contains(r#""type":"notice""#));
546 assert!(json.contains(r#""title":"/todo""#));
547 assert!(json.contains(r#""body":"reply from fixture""#));
548 }
549
550 #[test]
551 fn ui_event_turn_stats_round_trips() {
552 let ev = UiEvent::TurnStats {
553 input_tokens: 1203,
554 output_tokens: 412,
555 cumulative_input: 12543,
556 cumulative_output: 5231,
557 model: "claude-opus-4-7".into(),
558 };
559 let line = serde_json::to_string(&ev).expect("serialize");
560 assert_eq!(
561 line,
562 r#"{"type":"turn_stats","payload":{"input_tokens":1203,"output_tokens":412,"cumulative_input":12543,"cumulative_output":5231,"model":"claude-opus-4-7"}}"#
563 );
564 }
565
566 #[test]
567 fn command_still_exposes_eq_for_downstream_matchers() {
568 fn assert_eq_impl<T: Eq>() {}
569 assert_eq_impl::<Command>();
570 }
571
572 #[test]
573 fn command_apply_settings_round_trips() {
574 use crate::settings::Settings;
575 let cmd = Command::ApplySettings {
576 settings: Settings::default(),
577 };
578 let line = serde_json::to_string(&cmd).expect("serialize");
579 assert!(line.contains(r#""type":"apply_settings""#));
581 assert!(line.contains(r#""payload":{"#));
582 assert!(line.contains(r#""model":"#));
583 let back: Command = serde_json::from_str(&line).expect("deserialize");
584 assert_eq!(back, cmd);
585 }
586
587 #[test]
588 fn command_reload_settings_round_trips() {
589 let cmd = Command::ReloadSettings;
590 let line = serde_json::to_string(&cmd).expect("serialize");
591 assert_eq!(line, r#"{"type":"reload_settings"}"#);
592 let back: Command = serde_json::from_str(&line).expect("deserialize");
593 assert_eq!(back, cmd);
594 }
595
596 #[test]
597 fn ui_event_settings_snapshot_serializes() {
598 use crate::settings::Settings;
599 let ev = UiEvent::SettingsSnapshot {
600 settings: Settings::default(),
601 };
602 let line = serde_json::to_string(&ev).expect("serialize");
603 assert!(line.contains(r#""type":"settings_snapshot""#));
604 assert!(line.contains(r#""payload":{"#));
605 assert!(line.contains(r#""model":"#));
606 }
607
608 #[test]
609 fn snapshot_events_serialize_with_type_tag() {
610 let m = UiEvent::MessagesSnapshot(Vec::new());
611 let line = serde_json::to_string(&m).expect("serialize");
612 assert!(line.contains(r#""type":"messages_snapshot""#), "{line}");
613
614 let s = UiEvent::StateSnapshot {
615 session_id: "sess-1".into(),
616 model: "claude-opus-4-7".into(),
617 active_turn: false,
618 };
619 let line = serde_json::to_string(&s).expect("serialize");
620 assert!(line.contains(r#""type":"state_snapshot""#), "{line}");
621 assert!(line.contains(r#""session_id":"sess-1""#), "{line}");
622 assert!(line.contains(r#""active_turn":false"#), "{line}");
623 }
624
625 #[test]
626 fn ui_events_serialize_with_type_tag() {
627 let events = vec![
628 UiEvent::AgentTurnStarted,
629 UiEvent::AgentThinking,
630 UiEvent::AgentTextDelta("hi".into()),
631 UiEvent::AgentMessageComplete("done".into()),
632 UiEvent::AgentTurnComplete,
633 UiEvent::InlineBashOutput {
634 command: "ls".into(),
635 output: "x".into(),
636 },
637 UiEvent::Notice {
638 title: "note".into(),
639 body: "body".into(),
640 },
641 UiEvent::SessionReplaced(Vec::new()),
642 UiEvent::ModelSwitched(crate::model::ModelId::from("m")),
643 UiEvent::ForkCandidates(vec![("e1".into(), "first".into())]),
644 UiEvent::Error("bad".into()),
645 UiEvent::ToolCallStarted {
646 id: "t1".into(),
647 name: "bash".into(),
648 args: serde_json::json!("ls"),
649 },
650 UiEvent::ToolCallProgress {
651 id: "t1".into(),
652 chunk: ProgressChunk::Status("running".into()),
653 },
654 UiEvent::ToolCallCompleted {
655 id: "t1".into(),
656 result: UiToolResult {
657 is_error: false,
658 text: "ok".into(),
659 },
660 },
661 ];
662 for e in &events {
663 let line = serde_json::to_string(e).expect("serialize");
664 assert!(line.contains("\"type\":"), "missing type tag: {line}");
665 }
666 }
667
668 #[test]
669 fn permission_requested_serializes_without_the_resolver() {
670 let (resolver, _rx) = tokio::sync::oneshot::channel::<crate::permissions::Decision>();
671 let e = UiEvent::PermissionRequested {
672 tool: "bash".into(),
673 args: serde_json::json!({"command": "echo hi"}),
674 resolver,
675 };
676 let line = serde_json::to_string(&e).expect("serialize");
677 assert!(line.contains(r#""type":"permission_requested""#), "{line}");
678 assert!(!line.contains("resolver"), "resolver leaked: {line}");
679 }
680
681 #[test]
682 fn branch_tree_event_serializes_via_mapping() {
683 let tree = motosan_agent_loop::BranchTree {
684 nodes: vec![motosan_agent_loop::BranchNode {
685 id: "n0".into(),
686 parent: None,
687 children: vec![],
688 label: "root".into(),
689 }],
690 root: Some(0),
691 active_leaf: Some(0),
692 };
693 let line = serde_json::to_string(&UiEvent::BranchTree(tree)).expect("serialize");
694 assert!(line.contains(r#""type":"branch_tree""#), "{line}");
695 assert!(line.contains(r#""label":"root""#), "{line}");
696 }
697
698 #[test]
699 fn command_list_extensions_serde_round_trip() {
700 let cmd = Command::ListExtensions;
701 let line = serde_json::to_string(&cmd).expect("serialize");
702 assert_eq!(line, r#"{"type":"list_extensions"}"#);
703 let back: Command = serde_json::from_str(&line).expect("deserialize");
704 assert_eq!(back, cmd);
705 }
706
707 #[test]
708 fn ui_event_extension_list_serializes() {
709 let ev = UiEvent::ExtensionList {
710 extensions: vec![ExtensionInfo {
711 name: "dirty".into(),
712 hooks: vec!["session_before_switch".into()],
713 commands: Vec::new(),
714 healthy: true,
715 diagnostic: None,
716 }],
717 };
718 let line = serde_json::to_string(&ev).expect("serialize");
719 assert!(line.contains(r#""type":"extension_list""#));
720 assert!(line.contains(r#""name":"dirty""#));
721 }
722}