1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
6pub enum Role {
7 Assistant,
8 User,
9}
10
11#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
12pub enum RuntimeMode {
13 Agent,
14 Plan,
15}
16
17#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
18pub enum ToolExecutionTarget {
19 Unspecified,
20 ClientLocal,
22 ServerAgents,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27pub enum ToolCallApproval {
28 Unspecified,
29 Pending,
30 Approved,
31 AutoApproved,
32 Rejected,
33}
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
36pub struct ModelConfig {
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub temperature: Option<f32>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub top_p: Option<f32>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub presence_penalty: Option<f32>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub frequency_penalty: Option<f32>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub max_tokens: Option<i32>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub reasoning_effort: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub allow_long_context: Option<bool>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct ThreadModelOverride {
56 pub model_id: Uuid,
57 pub model_config: ModelConfig,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ToolCallOutput {
62 pub id: String,
64
65 pub is_error: bool,
66 pub output: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub duration_seconds: Option<i32>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub enum SubagentEscalationResolution {
73 Approved,
74 Rejected {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 reason: Option<String>,
77 },
78 ResolvedWithOutput {
79 #[serde(default)]
80 is_error: bool,
81 output: String,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 duration_seconds: Option<i32>,
84 },
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub struct Skill {
89 pub name: String,
90 pub description: String,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct Rule {
95 pub name: String,
96 pub description: String,
97 pub text: Option<String>,
98 #[serde(default)]
99 pub always_apply: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103pub struct WorkingDirectory {
104 pub path: String,
105 #[serde(default)]
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub git_branch: Option<String>,
108 #[serde(default)]
109 pub agents_md: String,
110 #[serde(default)]
111 pub rules: Vec<Rule>,
112 #[serde(default)]
113 pub skills: Vec<Skill>,
114}
115
116#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118pub enum LocalMcpServerSource {
119 UserGlobal,
120 UserProject,
121 Project,
122}
123
124#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
125#[serde(rename_all = "snake_case")]
126pub enum LocalMcpServerStatus {
127 Connected,
128 Disconnected,
129 Error,
130 Shadowed,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct LocalMcpTool {
135 pub name: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub title: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub description: Option<String>,
140 #[serde(default)]
141 pub input_schema: serde_json::Value,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145pub struct LocalMcpResource {
146 pub uri: String,
147 pub name: String,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub title: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub description: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub mime_type: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub size_bytes: Option<i64>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159pub struct LocalMcpResourceTemplate {
160 pub uri_template: String,
161 pub name: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub title: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub description: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub mime_type: Option<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct LocalMcpServer {
172 pub id: Uuid,
173 pub name: String,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub title: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub description: Option<String>,
178 pub source: LocalMcpServerSource,
179 pub status: LocalMcpServerStatus,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub status_detail: Option<String>,
182 #[serde(default)]
183 pub tools: Vec<LocalMcpTool>,
184 #[serde(default)]
185 pub resources: Vec<LocalMcpResource>,
186 #[serde(default)]
187 pub resource_templates: Vec<LocalMcpResourceTemplate>,
188}
189
190#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
191#[serde(rename_all = "snake_case")]
192pub enum BackgroundShellStatus {
193 Running,
194 Exited,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198pub struct BackgroundShellSnapshot {
199 pub shell_id: String,
200 pub command: String,
201 pub status: BackgroundShellStatus,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub exit_code: Option<i32>,
204 pub log_lines: u64,
205 pub duration_seconds: u64,
206}
207
208trait BoolExt {
209 fn is_false(&self) -> bool;
210}
211
212impl BoolExt for bool {
213 fn is_false(&self) -> bool {
214 !*self
215 }
216}
217
218#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
220pub enum Os {
221 #[default]
223 #[serde(rename = "other")]
224 Other,
225 #[serde(rename = "linux")]
227 Linux,
228 #[serde(rename = "macos")]
230 MacOS,
231 #[serde(rename = "windows")]
233 Windows,
234}
235
236#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
238pub enum Arch {
239 #[default]
241 #[serde(rename = "other")]
242 Other,
243 #[serde(rename = "x86")]
245 X86,
246 #[serde(rename = "amd64")]
248 Amd64,
249 #[serde(rename = "aarch64")]
251 Aarch64,
252}
253
254#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
256#[serde(default)]
257pub struct ClientSystemInfo {
258 pub os: Os,
260 pub os_version: String,
262 pub arch: Arch,
264 pub cpu_cores: u16,
266 pub ram_mb: u32,
268}
269
270impl ClientSystemInfo {
271 fn is_unknown(&self) -> bool {
272 self == &Self::default()
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub enum ClientMessage {
278 HelloMath {
279 client_instance_id: String,
283 version: String,
285 min_supported_version: String,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 resume_thread_id: Option<Uuid>,
290 #[serde(default, skip_serializing_if = "BoolExt::is_false")]
292 automagic: bool,
293 #[serde(default, skip_serializing_if = "ClientSystemInfo::is_unknown")]
297 system_info: ClientSystemInfo,
298 },
299 SendMessage {
304 request_id: Uuid,
305 thread_id: Option<Uuid>,
306 text: String,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 runtime_mode: Option<RuntimeMode>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
315 model_override: Option<ThreadModelOverride>,
316 },
317 UpdateAuthToken {
319 token: String,
320 },
321 UpdateWorkingDirectories {
325 working_directories: Vec<WorkingDirectory>,
326 },
327 UpdateMcpServers {
333 servers: Vec<LocalMcpServer>,
334 },
335 UpdateBackgroundShells {
337 shells: Vec<BackgroundShellSnapshot>,
338 },
339 RejectToolCall {
340 id: String,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
342 reason: Option<String>,
343 },
344 AcceptToolCall {
345 id: String,
346 },
347 ResolveSubagentEscalation {
348 parent_message_id: Uuid,
349 subagent_run_id: Uuid,
350 escalation_id: String,
351 resolution: SubagentEscalationResolution,
352 },
353 ToolCallOutputs {
354 outputs: Vec<ToolCallOutput>,
355 },
356 CancelGeneration {
358 message_id: Uuid,
359 },
360}
361
362#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
363pub struct Usage {
364 pub input_tokens: i32,
365 pub output_tokens: i32,
366 #[serde(default)]
367 pub cache_read_input_tokens: i32,
368 #[serde(default)]
369 pub cache_creation_input_tokens: i32,
370 #[serde(default)]
371 pub cache_creation_input_tokens_5m: i32,
372 #[serde(default)]
373 pub cache_creation_input_tokens_1h: i32,
374}
375
376#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
377pub enum MessageStatus {
378 Completed,
379 WaitingForUser,
382 Failed,
383 Cancelled,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub enum ServerMessage {
388 HelloMagic {
389 version: String,
390 min_supported_version: String,
391 },
392 VersionMismatch {
393 server_version: String,
394 server_min_supported_version: String,
395 },
396 Goodbye {
397 reconnect: bool,
398 },
399 SendMessageAck {
400 request_id: Uuid,
401 thread_id: Uuid,
402 user_message_id: Uuid,
403 },
404 AuthUpdated,
405 RuntimeModeUpdated {
406 thread_id: Uuid,
407 mode: RuntimeMode,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 changed_by_client_instance_id: Option<String>,
410 },
411 ThreadModelUpdated {
412 thread_id: Uuid,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 model_override: Option<ThreadModelOverride>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 changed_by_client_instance_id: Option<String>,
417 },
418 MessageHeader {
419 message_id: Uuid,
420 thread_id: Uuid,
421 role: Role,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
423 request_id: Option<Uuid>,
424 },
425 ReasoningDelta {
426 message_id: Uuid,
427 content: String,
428 },
429 TextDelta {
430 message_id: Uuid,
431 content: String,
432 },
433 ToolCallHeader {
434 message_id: Uuid,
435 tool_call_id: String,
436 name: String,
437 execution_target: ToolExecutionTarget,
438 approval: ToolCallApproval,
439 },
440 ToolCallArgumentsDelta {
441 message_id: Uuid,
442 tool_call_id: String,
443 delta: String,
444 },
445 ToolCall {
446 message_id: Uuid,
447 tool_call_id: String,
448 args: Value,
449 },
450 ToolCallResult {
451 message_id: Uuid,
452 tool_call_id: String,
453 is_error: bool,
454 output: String,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 duration_seconds: Option<i32>,
457 },
458 ToolCallClaimed {
459 message_id: Uuid,
460 tool_call_id: String,
461 claimed_by_client_instance_id: String,
462 },
463 ToolCallApprovalUpdated {
464 message_id: Uuid,
465 tool_call_id: String,
466 approval: ToolCallApproval,
467 },
468 MessageDone {
469 message_id: Uuid,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 usage: Option<Usage>,
472 status: MessageStatus,
473 },
474 Error {
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 request_id: Option<Uuid>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 message_id: Option<Uuid>,
479 code: String,
480 message: String,
481 },
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use serde_json::json;
488
489 fn background_shell_snapshot(
490 shell_id: &str,
491 status: BackgroundShellStatus,
492 ) -> BackgroundShellSnapshot {
493 BackgroundShellSnapshot {
494 shell_id: shell_id.to_string(),
495 command: "sleep 30".to_string(),
496 status,
497 exit_code: (status == BackgroundShellStatus::Exited).then_some(0),
498 log_lines: 12,
499 duration_seconds: 18,
500 }
501 }
502
503 fn hello_math(resume_thread_id: Option<Uuid>) -> ClientMessage {
504 ClientMessage::HelloMath {
505 client_instance_id: "client-a".to_string(),
506 version: "1.2.3".to_string(),
507 min_supported_version: "1.0.0".to_string(),
508 resume_thread_id,
509 automagic: false,
510 system_info: ClientSystemInfo::default(),
511 }
512 }
513
514 #[test]
515 fn model_config_omits_allow_long_context_when_not_set() {
516 let config = ModelConfig::default();
517
518 let value = serde_json::to_value(config).expect("serialize");
519 let body = value.as_object().expect("model config body");
520
521 assert!(body.get("allow_long_context").is_none());
522 }
523
524 #[test]
525 fn model_config_round_trips_allow_long_context_when_set() {
526 for expected in [true, false] {
527 let config = ModelConfig {
528 allow_long_context: Some(expected),
529 ..ModelConfig::default()
530 };
531
532 let value = serde_json::to_value(&config).expect("serialize");
533 let body = value.as_object().expect("model config body");
534 assert_eq!(body.get("allow_long_context"), Some(&json!(expected)));
535
536 let back: ModelConfig = serde_json::from_value(value).expect("deserialize");
537 assert_eq!(back.allow_long_context, Some(expected));
538 }
539 }
540
541 #[test]
542 fn model_config_defaults_allow_long_context_to_none_when_missing() {
543 let back: ModelConfig = serde_json::from_value(json!({
544 "temperature": 0.3
545 }))
546 .expect("deserialize");
547
548 assert_eq!(back.temperature, Some(0.3));
549 assert_eq!(back.allow_long_context, None);
550 }
551
552 #[test]
553 fn send_message_omits_optional_updates_when_not_set() {
554 let msg = ClientMessage::SendMessage {
555 request_id: Uuid::nil(),
556 thread_id: None,
557 text: "hello".to_string(),
558 runtime_mode: None,
559 model_override: None,
560 };
561
562 let value = serde_json::to_value(msg).expect("serialize");
563 let body = value
564 .get("SendMessage")
565 .and_then(|v| v.as_object())
566 .expect("SendMessage body");
567
568 assert!(body.get("runtime_mode").is_none());
569 assert!(body.get("model_override").is_none());
570 }
571
572 #[test]
573 fn hello_math_omits_resume_thread_id_when_not_set() {
574 let msg = hello_math(None);
575
576 let value = serde_json::to_value(msg).expect("serialize");
577 let body = value
578 .get("HelloMath")
579 .and_then(|v| v.as_object())
580 .expect("HelloMath body");
581
582 assert!(body.get("resume_thread_id").is_none());
583 assert!(body.get("automagic").is_none());
584 assert!(body.get("system_info").is_none());
585 }
586
587 #[test]
588 fn hello_math_round_trip_resume_thread_id() {
589 let thread_id = Uuid::new_v4();
590 let msg = hello_math(Some(thread_id));
591
592 let value = serde_json::to_value(&msg).expect("serialize");
593 let body = value
594 .get("HelloMath")
595 .and_then(|v| v.as_object())
596 .expect("HelloMath body");
597 assert_eq!(
598 body.get("resume_thread_id"),
599 Some(&serde_json::Value::String(thread_id.to_string()))
600 );
601
602 let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
603 match back {
604 ClientMessage::HelloMath {
605 resume_thread_id, ..
606 } => assert_eq!(resume_thread_id, Some(thread_id)),
607 _ => panic!("expected HelloMath"),
608 }
609 }
610
611 #[test]
612 fn hello_math_deserializes_defaults_for_new_fields() {
613 let value = json!({
614 "HelloMath": {
615 "client_instance_id": "client-a",
616 "version": "1.2.3",
617 "min_supported_version": "1.0.0"
618 }
619 });
620
621 let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
622 match back {
623 ClientMessage::HelloMath {
624 automagic,
625 system_info,
626 ..
627 } => {
628 assert!(!automagic);
629 assert_eq!(system_info, ClientSystemInfo::default());
630 }
631 _ => panic!("expected HelloMath"),
632 }
633 }
634
635 #[test]
636 fn hello_math_serializes_non_default_system_info() {
637 let msg = ClientMessage::HelloMath {
638 client_instance_id: "client-a".to_string(),
639 version: "1.2.3".to_string(),
640 min_supported_version: "1.0.0".to_string(),
641 resume_thread_id: None,
642 automagic: true,
643 system_info: ClientSystemInfo {
644 os: Os::MacOS,
645 os_version: "15.5".to_string(),
646 arch: Arch::Amd64,
647 cpu_cores: 10,
648 ram_mb: 32768,
649 },
650 };
651
652 let value = serde_json::to_value(msg).expect("serialize");
653 let body = value
654 .get("HelloMath")
655 .and_then(|v| v.as_object())
656 .expect("HelloMath body");
657
658 assert_eq!(body.get("automagic"), Some(&json!(true)));
659 assert_eq!(
660 body.get("system_info"),
661 Some(&json!({
662 "os": "macos",
663 "os_version": "15.5",
664 "arch": "amd64",
665 "cpu_cores": 10,
666 "ram_mb": 32768
667 }))
668 );
669 }
670
671 #[test]
672 fn hello_math_deserializes_canonical_arch_names() {
673 let value = json!({
674 "HelloMath": {
675 "client_instance_id": "client-a",
676 "version": "1.2.3",
677 "min_supported_version": "1.0.0",
678 "system_info": {
679 "os": "linux",
680 "os_version": "6.8",
681 "arch": "amd64",
682 "cpu_cores": 8,
683 "ram_mb": 16384
684 }
685 }
686 });
687
688 let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
689 match back {
690 ClientMessage::HelloMath { system_info, .. } => {
691 assert_eq!(system_info.arch, Arch::Amd64);
692 }
693 _ => panic!("expected HelloMath"),
694 }
695 }
696
697 #[test]
698 fn send_message_serializes_model_override_when_set() {
699 let msg = ClientMessage::SendMessage {
700 request_id: Uuid::nil(),
701 thread_id: Some(Uuid::nil()),
702 text: "hello".to_string(),
703 runtime_mode: Some(RuntimeMode::Plan),
704 model_override: Some(ThreadModelOverride {
705 model_id: Uuid::nil(),
706 model_config: ModelConfig::default(),
707 }),
708 };
709
710 let value = serde_json::to_value(msg).expect("serialize");
711 let body = value
712 .get("SendMessage")
713 .and_then(|v| v.as_object())
714 .expect("SendMessage body");
715
716 assert_eq!(body.get("runtime_mode"), Some(&json!("Plan")));
717 assert!(body.get("model_override").is_some());
718 }
719
720 #[test]
721 fn send_message_deserializes_model_override_states() {
722 let set_json = json!({
723 "SendMessage": {
724 "request_id": Uuid::nil(),
725 "thread_id": Uuid::nil(),
726 "text": "hello",
727 "runtime_mode": "Agent",
728 "model_override": {
729 "model_id": Uuid::nil(),
730 "model_config": {}
731 }
732 }
733 });
734 let keep_json = json!({
735 "SendMessage": {
736 "request_id": Uuid::nil(),
737 "thread_id": Uuid::nil(),
738 "text": "hello"
739 }
740 });
741
742 let set_msg: ClientMessage = serde_json::from_value(set_json).expect("deserialize set");
743 let keep_msg: ClientMessage = serde_json::from_value(keep_json).expect("deserialize keep");
744
745 match set_msg {
746 ClientMessage::SendMessage {
747 runtime_mode,
748 model_override,
749 ..
750 } => {
751 assert_eq!(runtime_mode, Some(RuntimeMode::Agent));
752 assert!(model_override.is_some());
753 }
754 _ => panic!("expected SendMessage"),
755 }
756
757 match keep_msg {
758 ClientMessage::SendMessage { model_override, .. } => {
759 assert_eq!(model_override, None);
760 }
761 _ => panic!("expected SendMessage"),
762 }
763 }
764
765 #[test]
766 fn update_working_directories_round_trip_full_and_empty() {
767 let demo_agents_md = r#"# Demo project
768
769- Keep changes small.
770- Run `cargo test`.
771"#
772 .trim()
773 .to_string();
774
775 let full = ClientMessage::UpdateWorkingDirectories {
776 working_directories: vec![WorkingDirectory {
777 path: "/Users/dev/project".to_string(),
778 git_branch: None,
779 agents_md: demo_agents_md.clone(),
780 rules: vec![Rule {
781 name: "Test after changes".to_string(),
782 description: "Run the relevant tests before finishing.".to_string(),
783 text: None,
784 always_apply: true,
785 }],
786 skills: vec![Skill {
787 name: "Build skill".to_string(),
788 description: "Run and fix build failures".to_string(),
789 }],
790 }],
791 };
792 let empty = ClientMessage::UpdateWorkingDirectories {
793 working_directories: vec![],
794 };
795
796 let full_json = serde_json::to_value(&full).expect("serialize full");
797 let empty_json = serde_json::to_value(&empty).expect("serialize empty");
798
799 let full_back: ClientMessage = serde_json::from_value(full_json).expect("deserialize full");
800 let empty_back: ClientMessage =
801 serde_json::from_value(empty_json).expect("deserialize empty");
802
803 match full_back {
804 ClientMessage::UpdateWorkingDirectories {
805 working_directories,
806 } => {
807 assert_eq!(working_directories.len(), 1);
808 assert_eq!(working_directories[0].path, "/Users/dev/project");
809 assert_eq!(working_directories[0].agents_md, demo_agents_md);
810 assert_eq!(working_directories[0].rules.len(), 1);
811 assert_eq!(working_directories[0].rules[0].name, "Test after changes");
812 assert_eq!(
813 working_directories[0].rules[0].description,
814 "Run the relevant tests before finishing."
815 );
816 assert!(working_directories[0].rules[0].always_apply);
817 assert_eq!(working_directories[0].skills.len(), 1);
818 assert_eq!(working_directories[0].skills[0].name, "Build skill");
819 assert_eq!(
820 working_directories[0].skills[0].description,
821 "Run and fix build failures"
822 );
823 }
824 _ => panic!("expected UpdateWorkingDirectories"),
825 }
826
827 match empty_back {
828 ClientMessage::UpdateWorkingDirectories {
829 working_directories,
830 } => {
831 assert!(working_directories.is_empty());
832 }
833 _ => panic!("expected UpdateWorkingDirectories"),
834 }
835 }
836
837 #[test]
838 fn update_working_directories_defaults_missing_nested_fields() {
839 let json = json!({
840 "UpdateWorkingDirectories": {
841 "working_directories": [{
842 "path": "/Users/dev/project"
843 }]
844 }
845 });
846
847 let back: ClientMessage = serde_json::from_value(json).expect("deserialize");
848
849 match back {
850 ClientMessage::UpdateWorkingDirectories {
851 working_directories,
852 } => {
853 assert_eq!(working_directories.len(), 1);
854 assert_eq!(working_directories[0].path, "/Users/dev/project");
855 assert!(working_directories[0].agents_md.is_empty());
856 assert!(working_directories[0].rules.is_empty());
857 assert!(working_directories[0].skills.is_empty());
858 }
859 _ => panic!("expected UpdateWorkingDirectories"),
860 }
861 }
862
863 #[test]
864 fn update_background_shells_round_trip_full_and_empty() {
865 let full = ClientMessage::UpdateBackgroundShells {
866 shells: vec![
867 background_shell_snapshot("bg_1", BackgroundShellStatus::Running),
868 background_shell_snapshot("bg_2", BackgroundShellStatus::Exited),
869 ],
870 };
871 let empty = ClientMessage::UpdateBackgroundShells { shells: vec![] };
872
873 let full_json = serde_json::to_value(&full).expect("serialize full");
874 let empty_json = serde_json::to_value(&empty).expect("serialize empty");
875
876 let full_back: ClientMessage = serde_json::from_value(full_json).expect("deserialize full");
877 let empty_back: ClientMessage =
878 serde_json::from_value(empty_json).expect("deserialize empty");
879
880 match full_back {
881 ClientMessage::UpdateBackgroundShells { shells } => {
882 assert_eq!(shells.len(), 2);
883 assert_eq!(shells[0].shell_id, "bg_1");
884 assert_eq!(shells[0].status, BackgroundShellStatus::Running);
885 assert_eq!(shells[0].exit_code, None);
886 assert_eq!(shells[1].status, BackgroundShellStatus::Exited);
887 assert_eq!(shells[1].exit_code, Some(0));
888 }
889 _ => panic!("expected UpdateBackgroundShells"),
890 }
891
892 match empty_back {
893 ClientMessage::UpdateBackgroundShells { shells } => {
894 assert!(shells.is_empty());
895 }
896 _ => panic!("expected UpdateBackgroundShells"),
897 }
898 }
899
900 #[test]
901 fn resolve_subagent_escalation_approved_round_trip() {
902 let msg = ClientMessage::ResolveSubagentEscalation {
903 parent_message_id: Uuid::nil(),
904 subagent_run_id: Uuid::nil(),
905 escalation_id: "esc-0".to_string(),
906 resolution: SubagentEscalationResolution::Approved,
907 };
908
909 let value = serde_json::to_value(&msg).expect("serialize");
910 let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
911 match back {
912 ClientMessage::ResolveSubagentEscalation {
913 escalation_id,
914 resolution: SubagentEscalationResolution::Approved,
915 ..
916 } => assert_eq!(escalation_id, "esc-0"),
917 _ => panic!("expected ResolveSubagentEscalation::Approved"),
918 }
919 }
920
921 #[test]
922 fn resolve_subagent_escalation_rejected_round_trip() {
923 let msg = ClientMessage::ResolveSubagentEscalation {
924 parent_message_id: Uuid::nil(),
925 subagent_run_id: Uuid::nil(),
926 escalation_id: "esc-1".to_string(),
927 resolution: SubagentEscalationResolution::Rejected {
928 reason: Some("not now".to_string()),
929 },
930 };
931
932 let value = serde_json::to_value(&msg).expect("serialize");
933 let body = value
934 .get("ResolveSubagentEscalation")
935 .and_then(|v| v.as_object())
936 .expect("ResolveSubagentEscalation body");
937 assert_eq!(body.get("escalation_id"), Some(&json!("esc-1")));
938
939 let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
940 match back {
941 ClientMessage::ResolveSubagentEscalation {
942 resolution:
943 SubagentEscalationResolution::Rejected {
944 reason: Some(reason),
945 },
946 ..
947 } => assert_eq!(reason, "not now"),
948 _ => panic!("expected ResolveSubagentEscalation::Rejected"),
949 }
950 }
951
952 #[test]
953 fn resolve_subagent_escalation_resolved_with_output_round_trip() {
954 let msg = ClientMessage::ResolveSubagentEscalation {
955 parent_message_id: Uuid::nil(),
956 subagent_run_id: Uuid::nil(),
957 escalation_id: "esc-2".to_string(),
958 resolution: SubagentEscalationResolution::ResolvedWithOutput {
959 is_error: false,
960 output: "ok".to_string(),
961 duration_seconds: Some(3),
962 },
963 };
964
965 let value = serde_json::to_value(&msg).expect("serialize");
966 let back: ClientMessage = serde_json::from_value(value).expect("deserialize");
967 match back {
968 ClientMessage::ResolveSubagentEscalation {
969 escalation_id,
970 resolution:
971 SubagentEscalationResolution::ResolvedWithOutput {
972 is_error,
973 output,
974 duration_seconds,
975 },
976 ..
977 } => {
978 assert_eq!(escalation_id, "esc-2");
979 assert!(!is_error);
980 assert_eq!(output, "ok");
981 assert_eq!(duration_seconds, Some(3));
982 }
983 _ => panic!("expected ResolveSubagentEscalation::ResolvedWithOutput"),
984 }
985 }
986}