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