1use pi_agent::{AgentMessage, QueueMode};
11use pi_ai::{ImageContent, Model, ModelThinkingLevel};
12use serde::de::{self, Deserializer};
13use serde::ser::{SerializeMap, Serializer};
14use serde::{Deserialize, Serialize};
15use serde_json::{Map, Value};
16
17use crate::core::compaction::CompactionResult;
18use crate::core::resources::{SourceInfo, SourceOrigin, SourceScope};
19use crate::core::sessions::SessionEntry;
20
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct BashResult {
32 pub output: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub exit_code: Option<i32>,
37 pub cancelled: bool,
39 pub truncated: bool,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub full_output_path: Option<String>,
44}
45
46#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct ContextUsage {
52 #[serde(default)]
54 pub tokens: Option<u64>,
55 pub context_window: u64,
57 #[serde(default)]
59 pub percent: Option<f64>,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct SessionStatsTokens {
66 pub input: u64,
68 pub output: u64,
70 pub cache_read: u64,
72 pub cache_write: u64,
74 pub total: u64,
76}
77
78#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct SessionStats {
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub session_file: Option<String>,
87 pub session_id: String,
89 pub user_messages: u64,
91 pub assistant_messages: u64,
93 pub tool_calls: u64,
95 pub tool_results: u64,
97 pub total_messages: u64,
99 pub tokens: SessionStatsTokens,
101 pub cost: f64,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub context_usage: Option<ContextUsage>,
106}
107
108#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct RpcSessionTreeNode {
116 pub entry: SessionEntry,
118 pub children: Vec<RpcSessionTreeNode>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub label: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub label_timestamp: Option<String>,
126}
127
128#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct RpcSourceInfo {
132 pub path: String,
134 pub source: String,
136 pub scope: RpcSourceScope,
138 pub origin: RpcSourceOrigin,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub base_dir: Option<String>,
143}
144
145impl From<&SourceInfo> for RpcSourceInfo {
146 fn from(value: &SourceInfo) -> Self {
147 Self {
148 path: value.path.clone(),
149 source: value.source.clone(),
150 scope: RpcSourceScope::from(value.scope),
151 origin: RpcSourceOrigin::from(value.origin),
152 base_dir: value.base_dir.clone(),
153 }
154 }
155}
156
157impl From<SourceInfo> for RpcSourceInfo {
158 fn from(value: SourceInfo) -> Self {
159 Self::from(&value)
160 }
161}
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub enum RpcSourceScope {
167 User,
169 Project,
171 Temporary,
173}
174
175impl From<SourceScope> for RpcSourceScope {
176 fn from(value: SourceScope) -> Self {
177 match value {
178 SourceScope::User => Self::User,
179 SourceScope::Project => Self::Project,
180 SourceScope::Temporary => Self::Temporary,
181 }
182 }
183}
184
185#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
187pub enum RpcSourceOrigin {
188 #[serde(rename = "package")]
190 Package,
191 #[serde(rename = "top-level")]
193 TopLevel,
194}
195
196impl From<SourceOrigin> for RpcSourceOrigin {
197 fn from(value: SourceOrigin) -> Self {
198 match value {
199 SourceOrigin::Package => Self::Package,
200 SourceOrigin::TopLevel => Self::TopLevel,
201 }
202 }
203}
204
205#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub enum StreamingBehavior {
209 Steer,
211 FollowUp,
213}
214
215#[derive(Clone, Debug, PartialEq)]
225pub enum RpcCommand {
226 Prompt {
228 id: Option<String>,
230 message: String,
232 images: Option<Vec<ImageContent>>,
234 streaming_behavior: Option<StreamingBehavior>,
236 },
237 Steer {
239 id: Option<String>,
241 message: String,
243 images: Option<Vec<ImageContent>>,
245 },
246 FollowUp {
248 id: Option<String>,
250 message: String,
252 images: Option<Vec<ImageContent>>,
254 },
255 Abort {
257 id: Option<String>,
259 },
260 NewSession {
262 id: Option<String>,
264 parent_session: Option<String>,
266 },
267 GetState {
269 id: Option<String>,
271 },
272 SetModel {
274 id: Option<String>,
276 provider: String,
278 model_id: String,
280 },
281 CycleModel {
283 id: Option<String>,
285 },
286 GetAvailableModels {
288 id: Option<String>,
290 },
291 SetThinkingLevel {
293 id: Option<String>,
295 level: ModelThinkingLevel,
297 },
298 CycleThinkingLevel {
300 id: Option<String>,
302 },
303 SetSteeringMode {
305 id: Option<String>,
307 mode: QueueMode,
309 },
310 SetFollowUpMode {
312 id: Option<String>,
314 mode: QueueMode,
316 },
317 Compact {
319 id: Option<String>,
321 custom_instructions: Option<String>,
323 },
324 SetAutoCompaction {
326 id: Option<String>,
328 enabled: bool,
330 },
331 SetAutoRetry {
333 id: Option<String>,
335 enabled: bool,
337 },
338 AbortRetry {
340 id: Option<String>,
342 },
343 Bash {
345 id: Option<String>,
347 command: String,
349 exclude_from_context: Option<bool>,
351 },
352 AbortBash {
354 id: Option<String>,
356 },
357 GetSessionStats {
359 id: Option<String>,
361 },
362 ExportHtml {
364 id: Option<String>,
366 output_path: Option<String>,
368 },
369 SwitchSession {
371 id: Option<String>,
373 session_path: String,
375 },
376 Fork {
378 id: Option<String>,
380 entry_id: String,
382 },
383 Clone {
385 id: Option<String>,
387 },
388 GetForkMessages {
390 id: Option<String>,
392 },
393 GetEntries {
395 id: Option<String>,
397 since: Option<String>,
399 },
400 GetTree {
402 id: Option<String>,
404 },
405 GetLastAssistantText {
407 id: Option<String>,
409 },
410 SetSessionName {
412 id: Option<String>,
414 name: String,
416 },
417 GetMessages {
419 id: Option<String>,
421 },
422 GetCommands {
424 id: Option<String>,
426 },
427 Unknown {
429 id: Option<String>,
431 command_type: String,
433 payload: Map<String, Value>,
435 },
436}
437
438impl RpcCommand {
439 #[must_use]
441 pub fn id(&self) -> Option<&str> {
442 match self {
443 Self::Prompt { id, .. }
444 | Self::Steer { id, .. }
445 | Self::FollowUp { id, .. }
446 | Self::Abort { id }
447 | Self::NewSession { id, .. }
448 | Self::GetState { id }
449 | Self::SetModel { id, .. }
450 | Self::CycleModel { id }
451 | Self::GetAvailableModels { id }
452 | Self::SetThinkingLevel { id, .. }
453 | Self::CycleThinkingLevel { id }
454 | Self::SetSteeringMode { id, .. }
455 | Self::SetFollowUpMode { id, .. }
456 | Self::Compact { id, .. }
457 | Self::SetAutoCompaction { id, .. }
458 | Self::SetAutoRetry { id, .. }
459 | Self::AbortRetry { id }
460 | Self::Bash { id, .. }
461 | Self::AbortBash { id }
462 | Self::GetSessionStats { id }
463 | Self::ExportHtml { id, .. }
464 | Self::SwitchSession { id, .. }
465 | Self::Fork { id, .. }
466 | Self::Clone { id }
467 | Self::GetForkMessages { id }
468 | Self::GetEntries { id, .. }
469 | Self::GetTree { id }
470 | Self::GetLastAssistantText { id }
471 | Self::SetSessionName { id, .. }
472 | Self::GetMessages { id }
473 | Self::GetCommands { id }
474 | Self::Unknown { id, .. } => id.as_deref(),
475 }
476 }
477
478 #[must_use]
480 pub fn command_type(&self) -> &str {
481 match self {
482 Self::Prompt { .. } => "prompt",
483 Self::Steer { .. } => "steer",
484 Self::FollowUp { .. } => "follow_up",
485 Self::Abort { .. } => "abort",
486 Self::NewSession { .. } => "new_session",
487 Self::GetState { .. } => "get_state",
488 Self::SetModel { .. } => "set_model",
489 Self::CycleModel { .. } => "cycle_model",
490 Self::GetAvailableModels { .. } => "get_available_models",
491 Self::SetThinkingLevel { .. } => "set_thinking_level",
492 Self::CycleThinkingLevel { .. } => "cycle_thinking_level",
493 Self::SetSteeringMode { .. } => "set_steering_mode",
494 Self::SetFollowUpMode { .. } => "set_follow_up_mode",
495 Self::Compact { .. } => "compact",
496 Self::SetAutoCompaction { .. } => "set_auto_compaction",
497 Self::SetAutoRetry { .. } => "set_auto_retry",
498 Self::AbortRetry { .. } => "abort_retry",
499 Self::Bash { .. } => "bash",
500 Self::AbortBash { .. } => "abort_bash",
501 Self::GetSessionStats { .. } => "get_session_stats",
502 Self::ExportHtml { .. } => "export_html",
503 Self::SwitchSession { .. } => "switch_session",
504 Self::Fork { .. } => "fork",
505 Self::Clone { .. } => "clone",
506 Self::GetForkMessages { .. } => "get_fork_messages",
507 Self::GetEntries { .. } => "get_entries",
508 Self::GetTree { .. } => "get_tree",
509 Self::GetLastAssistantText { .. } => "get_last_assistant_text",
510 Self::SetSessionName { .. } => "set_session_name",
511 Self::GetMessages { .. } => "get_messages",
512 Self::GetCommands { .. } => "get_commands",
513 Self::Unknown { command_type, .. } => command_type.as_str(),
514 }
515 }
516}
517
518impl Serialize for RpcCommand {
519 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
520 where
521 S: Serializer,
522 {
523 serialize_rpc_command(self, serializer)
524 }
525}
526
527fn serialize_rpc_command<S: Serializer>(
528 command: &RpcCommand,
529 serializer: S,
530) -> Result<S::Ok, S::Error> {
531 match command {
532 RpcCommand::Prompt {
533 id,
534 message,
535 images,
536 streaming_behavior,
537 } => serialize_prompt(
538 serializer,
539 id.as_deref(),
540 message,
541 images.as_deref(),
542 *streaming_behavior,
543 ),
544 RpcCommand::Steer {
545 id,
546 message,
547 images,
548 } => serialize_message_images(
549 serializer,
550 id.as_deref(),
551 "steer",
552 message,
553 images.as_deref(),
554 ),
555 RpcCommand::FollowUp {
556 id,
557 message,
558 images,
559 } => serialize_message_images(
560 serializer,
561 id.as_deref(),
562 "follow_up",
563 message,
564 images.as_deref(),
565 ),
566 RpcCommand::Abort { id } => serialize_type_only(serializer, id.as_deref(), "abort"),
567 RpcCommand::NewSession { id, parent_session } => {
568 serialize_new_session(serializer, id.as_deref(), parent_session.as_deref())
569 }
570 RpcCommand::GetState { id } => serialize_type_only(serializer, id.as_deref(), "get_state"),
571 RpcCommand::SetModel {
572 id,
573 provider,
574 model_id,
575 } => serialize_set_model(serializer, id.as_deref(), provider, model_id),
576 RpcCommand::CycleModel { id } => {
577 serialize_type_only(serializer, id.as_deref(), "cycle_model")
578 }
579 RpcCommand::GetAvailableModels { id } => {
580 serialize_type_only(serializer, id.as_deref(), "get_available_models")
581 }
582 RpcCommand::SetThinkingLevel { id, level } => {
583 serialize_set_thinking_level(serializer, id.as_deref(), *level)
584 }
585 RpcCommand::CycleThinkingLevel { id } => {
586 serialize_type_only(serializer, id.as_deref(), "cycle_thinking_level")
587 }
588 RpcCommand::SetSteeringMode { id, mode } => {
589 serialize_queue_mode(serializer, id.as_deref(), "set_steering_mode", *mode)
590 }
591 RpcCommand::SetFollowUpMode { id, mode } => {
592 serialize_queue_mode(serializer, id.as_deref(), "set_follow_up_mode", *mode)
593 }
594 other => serialize_rpc_command_rest(other, serializer),
595 }
596}
597
598fn serialize_rpc_command_rest<S: Serializer>(
599 command: &RpcCommand,
600 serializer: S,
601) -> Result<S::Ok, S::Error> {
602 match command {
603 RpcCommand::Compact {
604 id,
605 custom_instructions,
606 } => serialize_compact(serializer, id.as_deref(), custom_instructions.as_deref()),
607 RpcCommand::SetAutoCompaction { id, enabled } => {
608 serialize_enabled(serializer, id.as_deref(), "set_auto_compaction", *enabled)
609 }
610 RpcCommand::SetAutoRetry { id, enabled } => {
611 serialize_enabled(serializer, id.as_deref(), "set_auto_retry", *enabled)
612 }
613 RpcCommand::AbortRetry { id } => {
614 serialize_type_only(serializer, id.as_deref(), "abort_retry")
615 }
616 RpcCommand::Bash {
617 id,
618 command,
619 exclude_from_context,
620 } => serialize_bash(serializer, id.as_deref(), command, *exclude_from_context),
621 RpcCommand::AbortBash { id } => {
622 serialize_type_only(serializer, id.as_deref(), "abort_bash")
623 }
624 RpcCommand::GetSessionStats { id } => {
625 serialize_type_only(serializer, id.as_deref(), "get_session_stats")
626 }
627 RpcCommand::ExportHtml { id, output_path } => {
628 serialize_export_html(serializer, id.as_deref(), output_path.as_deref())
629 }
630 RpcCommand::SwitchSession { id, session_path } => {
631 serialize_switch_session(serializer, id.as_deref(), session_path)
632 }
633 RpcCommand::Fork { id, entry_id } => serialize_fork(serializer, id.as_deref(), entry_id),
634 RpcCommand::Clone { id } => serialize_type_only(serializer, id.as_deref(), "clone"),
635 RpcCommand::GetForkMessages { id } => {
636 serialize_type_only(serializer, id.as_deref(), "get_fork_messages")
637 }
638 RpcCommand::GetEntries { id, since } => {
639 serialize_get_entries(serializer, id.as_deref(), since.as_deref())
640 }
641 RpcCommand::GetTree { id } => serialize_type_only(serializer, id.as_deref(), "get_tree"),
642 RpcCommand::GetLastAssistantText { id } => {
643 serialize_type_only(serializer, id.as_deref(), "get_last_assistant_text")
644 }
645 RpcCommand::SetSessionName { id, name } => {
646 serialize_set_session_name(serializer, id.as_deref(), name)
647 }
648 RpcCommand::GetMessages { id } => {
649 serialize_type_only(serializer, id.as_deref(), "get_messages")
650 }
651 RpcCommand::GetCommands { id } => {
652 serialize_type_only(serializer, id.as_deref(), "get_commands")
653 }
654 RpcCommand::Unknown {
655 id,
656 command_type,
657 payload,
658 } => serialize_unknown(serializer, id.as_deref(), command_type, payload),
659 _ => serialize_type_only(serializer, None, "abort"),
660 }
661}
662
663fn serialize_prompt<S: Serializer>(
664 serializer: S,
665 id: Option<&str>,
666 message: &str,
667 images: Option<&[ImageContent]>,
668 streaming_behavior: Option<StreamingBehavior>,
669) -> Result<S::Ok, S::Error> {
670 let mut map = serializer.serialize_map(None)?;
671 serialize_id(&mut map, id)?;
672 map.serialize_entry("type", "prompt")?;
673 map.serialize_entry("message", message)?;
674 if let Some(images) = images {
675 map.serialize_entry("images", images)?;
676 }
677 if let Some(behavior) = streaming_behavior {
678 map.serialize_entry("streamingBehavior", &behavior)?;
679 }
680 map.end()
681}
682
683fn serialize_message_images<S: Serializer>(
684 serializer: S,
685 id: Option<&str>,
686 type_name: &str,
687 message: &str,
688 images: Option<&[ImageContent]>,
689) -> Result<S::Ok, S::Error> {
690 let mut map = serializer.serialize_map(None)?;
691 serialize_id(&mut map, id)?;
692 map.serialize_entry("type", type_name)?;
693 map.serialize_entry("message", message)?;
694 if let Some(images) = images {
695 map.serialize_entry("images", images)?;
696 }
697 map.end()
698}
699
700fn serialize_new_session<S: Serializer>(
701 serializer: S,
702 id: Option<&str>,
703 parent_session: Option<&str>,
704) -> Result<S::Ok, S::Error> {
705 let mut map = serializer.serialize_map(None)?;
706 serialize_id(&mut map, id)?;
707 map.serialize_entry("type", "new_session")?;
708 if let Some(parent) = parent_session {
709 map.serialize_entry("parentSession", parent)?;
710 }
711 map.end()
712}
713
714fn serialize_set_model<S: Serializer>(
715 serializer: S,
716 id: Option<&str>,
717 provider: &str,
718 model_id: &str,
719) -> Result<S::Ok, S::Error> {
720 let mut map = serializer.serialize_map(None)?;
721 serialize_id(&mut map, id)?;
722 map.serialize_entry("type", "set_model")?;
723 map.serialize_entry("provider", provider)?;
724 map.serialize_entry("modelId", model_id)?;
725 map.end()
726}
727
728fn serialize_set_thinking_level<S: Serializer>(
729 serializer: S,
730 id: Option<&str>,
731 level: ModelThinkingLevel,
732) -> Result<S::Ok, S::Error> {
733 let mut map = serializer.serialize_map(None)?;
734 serialize_id(&mut map, id)?;
735 map.serialize_entry("type", "set_thinking_level")?;
736 map.serialize_entry("level", &level)?;
737 map.end()
738}
739
740fn serialize_queue_mode<S: Serializer>(
741 serializer: S,
742 id: Option<&str>,
743 type_name: &str,
744 mode: QueueMode,
745) -> Result<S::Ok, S::Error> {
746 let mut map = serializer.serialize_map(None)?;
747 serialize_id(&mut map, id)?;
748 map.serialize_entry("type", type_name)?;
749 map.serialize_entry("mode", &mode)?;
750 map.end()
751}
752
753fn serialize_compact<S: Serializer>(
754 serializer: S,
755 id: Option<&str>,
756 custom_instructions: Option<&str>,
757) -> Result<S::Ok, S::Error> {
758 let mut map = serializer.serialize_map(None)?;
759 serialize_id(&mut map, id)?;
760 map.serialize_entry("type", "compact")?;
761 if let Some(custom) = custom_instructions {
762 map.serialize_entry("customInstructions", custom)?;
763 }
764 map.end()
765}
766
767fn serialize_enabled<S: Serializer>(
768 serializer: S,
769 id: Option<&str>,
770 type_name: &str,
771 enabled: bool,
772) -> Result<S::Ok, S::Error> {
773 let mut map = serializer.serialize_map(None)?;
774 serialize_id(&mut map, id)?;
775 map.serialize_entry("type", type_name)?;
776 map.serialize_entry("enabled", &enabled)?;
777 map.end()
778}
779
780fn serialize_bash<S: Serializer>(
781 serializer: S,
782 id: Option<&str>,
783 command: &str,
784 exclude_from_context: Option<bool>,
785) -> Result<S::Ok, S::Error> {
786 let mut map = serializer.serialize_map(None)?;
787 serialize_id(&mut map, id)?;
788 map.serialize_entry("type", "bash")?;
789 map.serialize_entry("command", command)?;
790 if let Some(exclude) = exclude_from_context {
791 map.serialize_entry("excludeFromContext", &exclude)?;
792 }
793 map.end()
794}
795
796fn serialize_export_html<S: Serializer>(
797 serializer: S,
798 id: Option<&str>,
799 output_path: Option<&str>,
800) -> Result<S::Ok, S::Error> {
801 let mut map = serializer.serialize_map(None)?;
802 serialize_id(&mut map, id)?;
803 map.serialize_entry("type", "export_html")?;
804 if let Some(path) = output_path {
805 map.serialize_entry("outputPath", path)?;
806 }
807 map.end()
808}
809
810fn serialize_switch_session<S: Serializer>(
811 serializer: S,
812 id: Option<&str>,
813 session_path: &str,
814) -> Result<S::Ok, S::Error> {
815 let mut map = serializer.serialize_map(None)?;
816 serialize_id(&mut map, id)?;
817 map.serialize_entry("type", "switch_session")?;
818 map.serialize_entry("sessionPath", session_path)?;
819 map.end()
820}
821
822fn serialize_fork<S: Serializer>(
823 serializer: S,
824 id: Option<&str>,
825 entry_id: &str,
826) -> Result<S::Ok, S::Error> {
827 let mut map = serializer.serialize_map(None)?;
828 serialize_id(&mut map, id)?;
829 map.serialize_entry("type", "fork")?;
830 map.serialize_entry("entryId", entry_id)?;
831 map.end()
832}
833
834fn serialize_get_entries<S: Serializer>(
835 serializer: S,
836 id: Option<&str>,
837 since: Option<&str>,
838) -> Result<S::Ok, S::Error> {
839 let mut map = serializer.serialize_map(None)?;
840 serialize_id(&mut map, id)?;
841 map.serialize_entry("type", "get_entries")?;
842 if let Some(since) = since {
843 map.serialize_entry("since", since)?;
844 }
845 map.end()
846}
847
848fn serialize_set_session_name<S: Serializer>(
849 serializer: S,
850 id: Option<&str>,
851 name: &str,
852) -> Result<S::Ok, S::Error> {
853 let mut map = serializer.serialize_map(None)?;
854 serialize_id(&mut map, id)?;
855 map.serialize_entry("type", "set_session_name")?;
856 map.serialize_entry("name", name)?;
857 map.end()
858}
859
860fn serialize_unknown<S: Serializer>(
861 serializer: S,
862 id: Option<&str>,
863 command_type: &str,
864 payload: &Map<String, Value>,
865) -> Result<S::Ok, S::Error> {
866 let mut map = serializer.serialize_map(None)?;
867 serialize_id(&mut map, id)?;
868 map.serialize_entry("type", command_type)?;
869 for (key, value) in payload {
870 map.serialize_entry(key, value)?;
871 }
872 map.end()
873}
874
875impl<'de> Deserialize<'de> for RpcCommand {
876 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
877 where
878 D: Deserializer<'de>,
879 {
880 let value = Value::deserialize(deserializer)?;
881 Self::parse_value(&value).map_err(|error| de::Error::custom(error.message))
882 }
883}
884
885#[derive(Debug)]
886pub(crate) struct RpcCommandParseError {
887 pub(crate) id: Option<String>,
888 pub(crate) message: String,
889}
890
891impl RpcCommand {
892 pub(crate) fn parse_value(value: &Value) -> Result<Self, RpcCommandParseError> {
893 let obj = value.as_object().ok_or_else(|| RpcCommandParseError {
894 id: None,
895 message: "rpc command must be a JSON object".to_owned(),
896 })?;
897 let id = optional_string(obj, "id")
898 .map_err(|message| RpcCommandParseError { id: None, message })?;
899 let command_type = obj
900 .get("type")
901 .and_then(Value::as_str)
902 .ok_or_else(|| RpcCommandParseError {
903 id: id.clone(),
904 message: "rpc command missing type".to_owned(),
905 })?
906 .to_owned();
907 parse_known_command(obj, id.clone(), command_type)
908 .map_err(|message| RpcCommandParseError { id, message })
909 }
910}
911
912fn parse_known_command(
913 obj: &Map<String, Value>,
914 id: Option<String>,
915 command_type: String,
916) -> Result<RpcCommand, String> {
917 match command_type.as_str() {
918 "prompt" => parse_prompt(obj, id),
919 "steer" => parse_message_images_cmd(obj, id, "steer"),
920 "follow_up" => parse_message_images_cmd(obj, id, "follow_up"),
921 "abort" => Ok(RpcCommand::Abort { id }),
922 "new_session" => Ok(RpcCommand::NewSession {
923 id,
924 parent_session: optional_string(obj, "parentSession")?,
925 }),
926 "get_state" => Ok(RpcCommand::GetState { id }),
927 "set_model" => Ok(RpcCommand::SetModel {
928 id,
929 provider: required_string_owned(obj, "provider")?,
930 model_id: required_string_owned(obj, "modelId")?,
931 }),
932 "cycle_model" => Ok(RpcCommand::CycleModel { id }),
933 "get_available_models" => Ok(RpcCommand::GetAvailableModels { id }),
934 "set_thinking_level" => parse_set_thinking_level(obj, id),
935 "cycle_thinking_level" => Ok(RpcCommand::CycleThinkingLevel { id }),
936 "set_steering_mode" => parse_queue_mode_cmd(obj, id, true),
937 "set_follow_up_mode" => parse_queue_mode_cmd(obj, id, false),
938 "compact" => Ok(RpcCommand::Compact {
939 id,
940 custom_instructions: optional_string(obj, "customInstructions")?,
941 }),
942 "set_auto_compaction" => Ok(RpcCommand::SetAutoCompaction {
943 id,
944 enabled: required_bool_owned(obj, "enabled")?,
945 }),
946 "set_auto_retry" => Ok(RpcCommand::SetAutoRetry {
947 id,
948 enabled: required_bool_owned(obj, "enabled")?,
949 }),
950 "abort_retry" => Ok(RpcCommand::AbortRetry { id }),
951 "bash" => Ok(RpcCommand::Bash {
952 id,
953 command: required_string_owned(obj, "command")?,
954 exclude_from_context: optional_bool(obj, "excludeFromContext")?,
955 }),
956 "abort_bash" => Ok(RpcCommand::AbortBash { id }),
957 "get_session_stats" => Ok(RpcCommand::GetSessionStats { id }),
958 "export_html" => Ok(RpcCommand::ExportHtml {
959 id,
960 output_path: optional_string(obj, "outputPath")?,
961 }),
962 "switch_session" => Ok(RpcCommand::SwitchSession {
963 id,
964 session_path: required_string_owned(obj, "sessionPath")?,
965 }),
966 "fork" => Ok(RpcCommand::Fork {
967 id,
968 entry_id: required_string_owned(obj, "entryId")?,
969 }),
970 "clone" => Ok(RpcCommand::Clone { id }),
971 "get_fork_messages" => Ok(RpcCommand::GetForkMessages { id }),
972 "get_entries" => Ok(RpcCommand::GetEntries {
973 id,
974 since: optional_string(obj, "since")?,
975 }),
976 "get_tree" => Ok(RpcCommand::GetTree { id }),
977 "get_last_assistant_text" => Ok(RpcCommand::GetLastAssistantText { id }),
978 "set_session_name" => Ok(RpcCommand::SetSessionName {
979 id,
980 name: required_string_owned(obj, "name")?,
981 }),
982 "get_messages" => Ok(RpcCommand::GetMessages { id }),
983 "get_commands" => Ok(RpcCommand::GetCommands { id }),
984 _ => Ok(parse_unknown_command(obj, id, command_type)),
985 }
986}
987
988fn parse_prompt(obj: &Map<String, Value>, id: Option<String>) -> Result<RpcCommand, String> {
989 let message = required_string_owned(obj, "message")?;
990 let images = optional_images_owned(obj)?;
991 let streaming_behavior = match obj.get("streamingBehavior") {
992 None | Some(Value::Null) => None,
993 Some(v) => Some(StreamingBehavior::deserialize(v).map_err(|e| e.to_string())?),
994 };
995 Ok(RpcCommand::Prompt {
996 id,
997 message,
998 images,
999 streaming_behavior,
1000 })
1001}
1002
1003fn parse_message_images_cmd(
1004 obj: &Map<String, Value>,
1005 id: Option<String>,
1006 kind: &str,
1007) -> Result<RpcCommand, String> {
1008 let message = required_string_owned(obj, "message")?;
1009 let images = optional_images_owned(obj)?;
1010 match kind {
1011 "steer" => Ok(RpcCommand::Steer {
1012 id,
1013 message,
1014 images,
1015 }),
1016 _ => Ok(RpcCommand::FollowUp {
1017 id,
1018 message,
1019 images,
1020 }),
1021 }
1022}
1023
1024fn parse_set_thinking_level(
1025 obj: &Map<String, Value>,
1026 id: Option<String>,
1027) -> Result<RpcCommand, String> {
1028 let level = obj
1029 .get("level")
1030 .ok_or_else(|| "set_thinking_level missing level".to_owned())?;
1031 let level = ModelThinkingLevel::deserialize(level).map_err(|e| e.to_string())?;
1032 Ok(RpcCommand::SetThinkingLevel { id, level })
1033}
1034
1035fn parse_queue_mode_cmd(
1036 obj: &Map<String, Value>,
1037 id: Option<String>,
1038 steering: bool,
1039) -> Result<RpcCommand, String> {
1040 let mode = obj
1041 .get("mode")
1042 .ok_or_else(|| "queue mode command missing mode".to_owned())?;
1043 let mode = QueueMode::deserialize(mode).map_err(|e| e.to_string())?;
1044 if steering {
1045 Ok(RpcCommand::SetSteeringMode { id, mode })
1046 } else {
1047 Ok(RpcCommand::SetFollowUpMode { id, mode })
1048 }
1049}
1050
1051fn parse_unknown_command(
1052 obj: &Map<String, Value>,
1053 id: Option<String>,
1054 command_type: String,
1055) -> RpcCommand {
1056 let mut payload = Map::new();
1057 for (key, value) in obj {
1058 if key == "type" || key == "id" {
1059 continue;
1060 }
1061 payload.insert(key.clone(), value.clone());
1062 }
1063 RpcCommand::Unknown {
1064 id,
1065 command_type,
1066 payload,
1067 }
1068}
1069
1070fn required_string_owned(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
1071 match obj.get(key) {
1072 Some(Value::String(s)) => Ok(s.clone()),
1073 Some(other) => Err(format!("field {key} must be a string, got {other}")),
1074 None => Err(format!("missing field {key}")),
1075 }
1076}
1077
1078fn required_bool_owned(obj: &Map<String, Value>, key: &str) -> Result<bool, String> {
1079 match obj.get(key) {
1080 Some(Value::Bool(b)) => Ok(*b),
1081 Some(other) => Err(format!("field {key} must be a boolean, got {other}")),
1082 None => Err(format!("missing field {key}")),
1083 }
1084}
1085
1086fn optional_images_owned(obj: &Map<String, Value>) -> Result<Option<Vec<ImageContent>>, String> {
1087 match obj.get("images") {
1088 None | Some(Value::Null) => Ok(None),
1089 Some(v) => {
1090 let images = Vec::<ImageContent>::deserialize(v).map_err(|e| e.to_string())?;
1091 Ok(Some(images))
1092 }
1093 }
1094}
1095
1096#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1102#[serde(rename_all = "camelCase")]
1103pub struct RpcSessionState {
1104 #[serde(default, skip_serializing_if = "Option::is_none")]
1106 pub model: Option<Model>,
1107 pub thinking_level: ModelThinkingLevel,
1109 pub is_streaming: bool,
1111 pub is_compacting: bool,
1113 pub steering_mode: QueueMode,
1115 pub follow_up_mode: QueueMode,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1119 pub session_file: Option<String>,
1120 pub session_id: String,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub session_name: Option<String>,
1125 pub auto_compaction_enabled: bool,
1127 pub message_count: u64,
1129 pub pending_message_count: u64,
1131}
1132
1133#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1135#[serde(rename_all = "camelCase")]
1136pub enum RpcSlashCommandSource {
1137 Extension,
1139 Prompt,
1141 Skill,
1143}
1144
1145#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1147#[serde(rename_all = "camelCase")]
1148pub struct RpcSlashCommand {
1149 pub name: String,
1151 #[serde(default, skip_serializing_if = "Option::is_none")]
1153 pub description: Option<String>,
1154 pub source: RpcSlashCommandSource,
1156 pub source_info: RpcSourceInfo,
1158}
1159
1160#[derive(Clone, Debug, PartialEq, Serialize)]
1170#[serde(untagged)]
1171pub enum RpcResponseData {
1172 Cancelled {
1174 cancelled: bool,
1176 },
1177 SessionState(RpcSessionState),
1179 Model(Model),
1181 CycleModel(Option<CycleModelData>),
1183 AvailableModels {
1185 models: Vec<Model>,
1187 },
1188 CycleThinkingLevel(Option<CycleThinkingLevelData>),
1190 Compaction(CompactionResult),
1192 Bash(BashResult),
1194 SessionStats(SessionStats),
1196 ExportHtml {
1198 path: String,
1200 },
1201 Fork {
1203 text: String,
1205 cancelled: bool,
1207 },
1208 ForkMessages {
1210 messages: Vec<ForkMessage>,
1212 },
1213 Entries {
1215 entries: Vec<SessionEntry>,
1217 #[serde(rename = "leafId")]
1219 leaf_id: Option<String>,
1220 },
1221 Tree {
1223 tree: Vec<RpcSessionTreeNode>,
1225 #[serde(rename = "leafId")]
1227 leaf_id: Option<String>,
1228 },
1229 LastAssistantText {
1231 text: Option<String>,
1233 },
1234 Messages {
1236 messages: Vec<AgentMessage>,
1238 },
1239 Commands {
1241 commands: Vec<RpcSlashCommand>,
1243 },
1244}
1245
1246impl RpcResponseData {
1247 fn deserialize_for_command(command: &str, value: &Value) -> Result<Self, String> {
1249 match command {
1250 "new_session" | "switch_session" | "clone" => parse_cancelled_data(command, value),
1251 "get_state" => Ok(Self::SessionState(
1252 RpcSessionState::deserialize(value).map_err(|e| e.to_string())?,
1253 )),
1254 "set_model" => Ok(Self::Model(
1255 Model::deserialize(value).map_err(|e| e.to_string())?,
1256 )),
1257 "cycle_model" => parse_cycle_model_data(value),
1258 "get_available_models" => parse_available_models_data(value),
1259 "cycle_thinking_level" => parse_cycle_thinking_data(value),
1260 "compact" => Ok(Self::Compaction(
1261 CompactionResult::deserialize(value).map_err(|e| e.to_string())?,
1262 )),
1263 "bash" => Ok(Self::Bash(
1264 BashResult::deserialize(value).map_err(|e| e.to_string())?,
1265 )),
1266 "get_session_stats" => Ok(Self::SessionStats(
1267 SessionStats::deserialize(value).map_err(|e| e.to_string())?,
1268 )),
1269 "export_html" => parse_export_html_data(value),
1270 "fork" => parse_fork_data(value),
1271 "get_fork_messages" => parse_fork_messages_data(value),
1272 "get_entries" => parse_entries_data(value),
1273 "get_tree" => parse_tree_data(value),
1274 "get_last_assistant_text" => parse_last_assistant_text_data(value),
1275 "get_messages" => parse_messages_data(value),
1276 "get_commands" => parse_commands_data(value),
1277 other => Err(format!("no typed data parser for command {other}")),
1278 }
1279 }
1280}
1281
1282fn parse_cancelled_data(command: &str, value: &Value) -> Result<RpcResponseData, String> {
1283 let cancelled = value
1284 .get("cancelled")
1285 .and_then(Value::as_bool)
1286 .ok_or_else(|| format!("{command} data missing cancelled"))?;
1287 Ok(RpcResponseData::Cancelled { cancelled })
1288}
1289
1290fn parse_cycle_model_data(value: &Value) -> Result<RpcResponseData, String> {
1291 if value.is_null() {
1292 return Ok(RpcResponseData::CycleModel(None));
1293 }
1294 let data = CycleModelData::deserialize(value).map_err(|e| e.to_string())?;
1295 Ok(RpcResponseData::CycleModel(Some(data)))
1296}
1297
1298fn parse_available_models_data(value: &Value) -> Result<RpcResponseData, String> {
1299 let models = value
1300 .get("models")
1301 .ok_or_else(|| "get_available_models data missing models".to_owned())?;
1302 let models = Vec::<Model>::deserialize(models).map_err(|e| e.to_string())?;
1303 Ok(RpcResponseData::AvailableModels { models })
1304}
1305
1306fn parse_cycle_thinking_data(value: &Value) -> Result<RpcResponseData, String> {
1307 if value.is_null() {
1308 return Ok(RpcResponseData::CycleThinkingLevel(None));
1309 }
1310 let data = CycleThinkingLevelData::deserialize(value).map_err(|e| e.to_string())?;
1311 Ok(RpcResponseData::CycleThinkingLevel(Some(data)))
1312}
1313
1314fn parse_export_html_data(value: &Value) -> Result<RpcResponseData, String> {
1315 let path = value
1316 .get("path")
1317 .and_then(Value::as_str)
1318 .ok_or_else(|| "export_html data missing path".to_owned())?
1319 .to_owned();
1320 Ok(RpcResponseData::ExportHtml { path })
1321}
1322
1323fn parse_fork_data(value: &Value) -> Result<RpcResponseData, String> {
1324 let text = value
1325 .get("text")
1326 .and_then(Value::as_str)
1327 .ok_or_else(|| "fork data missing text".to_owned())?
1328 .to_owned();
1329 let cancelled = value
1330 .get("cancelled")
1331 .and_then(Value::as_bool)
1332 .ok_or_else(|| "fork data missing cancelled".to_owned())?;
1333 Ok(RpcResponseData::Fork { text, cancelled })
1334}
1335
1336fn parse_fork_messages_data(value: &Value) -> Result<RpcResponseData, String> {
1337 let messages = value
1338 .get("messages")
1339 .ok_or_else(|| "get_fork_messages data missing messages".to_owned())?;
1340 let messages = Vec::<ForkMessage>::deserialize(messages).map_err(|e| e.to_string())?;
1341 Ok(RpcResponseData::ForkMessages { messages })
1342}
1343
1344fn parse_leaf_id(value: &Value, command: &str) -> Result<Option<String>, String> {
1345 match value.get("leafId") {
1346 None | Some(Value::Null) => Ok(None),
1347 Some(Value::String(s)) => Ok(Some(s.clone())),
1348 Some(other) => Err(format!("{command} leafId must be string|null, got {other}")),
1349 }
1350}
1351
1352fn parse_entries_data(value: &Value) -> Result<RpcResponseData, String> {
1353 let entries = value
1354 .get("entries")
1355 .ok_or_else(|| "get_entries data missing entries".to_owned())?;
1356 let entries = Vec::<SessionEntry>::deserialize(entries).map_err(|e| e.to_string())?;
1357 let leaf_id = parse_leaf_id(value, "get_entries")?;
1358 Ok(RpcResponseData::Entries { entries, leaf_id })
1359}
1360
1361fn parse_tree_data(value: &Value) -> Result<RpcResponseData, String> {
1362 let tree = value
1363 .get("tree")
1364 .ok_or_else(|| "get_tree data missing tree".to_owned())?;
1365 let tree = Vec::<RpcSessionTreeNode>::deserialize(tree).map_err(|e| e.to_string())?;
1366 let leaf_id = parse_leaf_id(value, "get_tree")?;
1367 Ok(RpcResponseData::Tree { tree, leaf_id })
1368}
1369
1370fn parse_last_assistant_text_data(value: &Value) -> Result<RpcResponseData, String> {
1371 let text = match value.get("text") {
1372 None | Some(Value::Null) => None,
1373 Some(Value::String(s)) => Some(s.clone()),
1374 Some(other) => {
1375 return Err(format!(
1376 "get_last_assistant_text text must be string|null, got {other}"
1377 ));
1378 }
1379 };
1380 Ok(RpcResponseData::LastAssistantText { text })
1381}
1382
1383fn parse_messages_data(value: &Value) -> Result<RpcResponseData, String> {
1384 let messages = value
1385 .get("messages")
1386 .ok_or_else(|| "get_messages data missing messages".to_owned())?;
1387 let messages = Vec::<AgentMessage>::deserialize(messages).map_err(|e| e.to_string())?;
1388 Ok(RpcResponseData::Messages { messages })
1389}
1390
1391fn parse_commands_data(value: &Value) -> Result<RpcResponseData, String> {
1392 let commands = value
1393 .get("commands")
1394 .ok_or_else(|| "get_commands data missing commands".to_owned())?;
1395 let commands = Vec::<RpcSlashCommand>::deserialize(commands).map_err(|e| e.to_string())?;
1396 Ok(RpcResponseData::Commands { commands })
1397}
1398
1399#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1401#[serde(rename_all = "camelCase")]
1402pub struct CycleModelData {
1403 pub model: Model,
1405 pub thinking_level: ModelThinkingLevel,
1407 pub is_scoped: bool,
1409}
1410
1411#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1413#[serde(rename_all = "camelCase")]
1414pub struct CycleThinkingLevelData {
1415 pub level: ModelThinkingLevel,
1417}
1418
1419#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1421#[serde(rename_all = "camelCase")]
1422pub struct ForkMessage {
1423 pub entry_id: String,
1425 pub text: String,
1427}
1428
1429#[derive(Clone, Debug, PartialEq)]
1431pub enum RpcResponse {
1432 Success {
1434 id: Option<String>,
1436 command: String,
1438 data: Option<Box<RpcResponseData>>,
1440 },
1441 Error {
1443 id: Option<String>,
1445 command: String,
1447 error: String,
1449 },
1450}
1451
1452impl RpcResponse {
1453 #[must_use]
1455 pub fn ok(id: Option<String>, command: impl Into<String>) -> Self {
1456 Self::Success {
1457 id,
1458 command: command.into(),
1459 data: None,
1460 }
1461 }
1462
1463 #[must_use]
1465 pub fn ok_data(id: Option<String>, command: impl Into<String>, data: RpcResponseData) -> Self {
1466 Self::Success {
1467 id,
1468 command: command.into(),
1469 data: Some(Box::new(data)),
1470 }
1471 }
1472
1473 #[must_use]
1475 pub fn err(id: Option<String>, command: impl Into<String>, error: impl Into<String>) -> Self {
1476 Self::Error {
1477 id,
1478 command: command.into(),
1479 error: error.into(),
1480 }
1481 }
1482
1483 #[must_use]
1485 pub fn id(&self) -> Option<&str> {
1486 match self {
1487 Self::Success { id, .. } | Self::Error { id, .. } => id.as_deref(),
1488 }
1489 }
1490
1491 #[must_use]
1493 pub fn command(&self) -> &str {
1494 match self {
1495 Self::Success { command, .. } | Self::Error { command, .. } => command.as_str(),
1496 }
1497 }
1498
1499 #[must_use]
1501 pub const fn is_success(&self) -> bool {
1502 matches!(self, Self::Success { .. })
1503 }
1504}
1505
1506impl Serialize for RpcResponse {
1507 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1508 where
1509 S: Serializer,
1510 {
1511 match self {
1512 Self::Success { id, command, data } => {
1513 let mut map = serializer.serialize_map(None)?;
1514 serialize_id(&mut map, id.as_deref())?;
1515 map.serialize_entry("type", "response")?;
1516 map.serialize_entry("command", command)?;
1517 map.serialize_entry("success", &true)?;
1518 if let Some(data) = data {
1519 map.serialize_entry("data", data.as_ref())?;
1520 }
1521 map.end()
1522 }
1523 Self::Error { id, command, error } => {
1524 let mut map = serializer.serialize_map(None)?;
1525 serialize_id(&mut map, id.as_deref())?;
1526 map.serialize_entry("type", "response")?;
1527 map.serialize_entry("command", command)?;
1528 map.serialize_entry("success", &false)?;
1529 map.serialize_entry("error", error)?;
1530 map.end()
1531 }
1532 }
1533 }
1534}
1535
1536impl<'de> Deserialize<'de> for RpcResponse {
1537 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1538 where
1539 D: Deserializer<'de>,
1540 {
1541 let value = Value::deserialize(deserializer)?;
1542 let obj = value
1543 .as_object()
1544 .ok_or_else(|| de::Error::custom("rpc response must be a JSON object"))?;
1545
1546 let type_field = obj
1547 .get("type")
1548 .and_then(Value::as_str)
1549 .ok_or_else(|| de::Error::custom("rpc response missing type"))?;
1550 if type_field != "response" {
1551 return Err(de::Error::custom(format!(
1552 "expected type \"response\", got {type_field:?}"
1553 )));
1554 }
1555
1556 let id = optional_string(obj, "id").map_err(de::Error::custom)?;
1557 let command = required_string(obj, "command")?;
1558 let success = obj
1559 .get("success")
1560 .and_then(Value::as_bool)
1561 .ok_or_else(|| de::Error::custom("rpc response missing success"))?;
1562
1563 if success {
1564 let data = match obj.get("data") {
1565 None => None,
1566 Some(v) => Some(Box::new(
1567 RpcResponseData::deserialize_for_command(command.as_str(), v)
1568 .map_err(de::Error::custom)?,
1569 )),
1570 };
1571 Ok(Self::Success { id, command, data })
1572 } else {
1573 let error = required_string(obj, "error")?;
1574 Ok(Self::Error { id, command, error })
1575 }
1576 }
1577}
1578
1579#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1585#[serde(rename_all = "camelCase")]
1586pub enum NotifyType {
1587 Info,
1589 Warning,
1591 Error,
1593}
1594
1595#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1597#[serde(rename_all = "camelCase")]
1598pub enum WidgetPlacement {
1599 AboveEditor,
1601 BelowEditor,
1603}
1604
1605#[derive(Clone, Debug, PartialEq)]
1607pub enum RpcExtensionUiRequest {
1608 Select {
1610 id: String,
1612 title: String,
1614 options: Vec<String>,
1616 timeout: Option<u64>,
1618 },
1619 Confirm {
1621 id: String,
1623 title: String,
1625 message: String,
1627 timeout: Option<u64>,
1629 },
1630 Input {
1632 id: String,
1634 title: String,
1636 placeholder: Option<String>,
1638 timeout: Option<u64>,
1640 },
1641 Editor {
1643 id: String,
1645 title: String,
1647 prefill: Option<String>,
1649 },
1650 Notify {
1652 id: String,
1654 message: String,
1656 notify_type: Option<NotifyType>,
1658 },
1659 SetStatus {
1661 id: String,
1663 status_key: String,
1665 status_text: Option<String>,
1667 },
1668 SetWidget {
1670 id: String,
1672 widget_key: String,
1674 widget_lines: Option<Vec<String>>,
1676 widget_placement: Option<WidgetPlacement>,
1678 },
1679 SetTitle {
1681 id: String,
1683 title: String,
1685 },
1686 SetEditorText {
1688 id: String,
1690 text: String,
1692 },
1693}
1694
1695impl RpcExtensionUiRequest {
1696 #[must_use]
1698 pub fn id(&self) -> &str {
1699 match self {
1700 Self::Select { id, .. }
1701 | Self::Confirm { id, .. }
1702 | Self::Input { id, .. }
1703 | Self::Editor { id, .. }
1704 | Self::Notify { id, .. }
1705 | Self::SetStatus { id, .. }
1706 | Self::SetWidget { id, .. }
1707 | Self::SetTitle { id, .. }
1708 | Self::SetEditorText { id, .. } => id.as_str(),
1709 }
1710 }
1711
1712 #[must_use]
1714 pub fn method(&self) -> &str {
1715 match self {
1716 Self::Select { .. } => "select",
1717 Self::Confirm { .. } => "confirm",
1718 Self::Input { .. } => "input",
1719 Self::Editor { .. } => "editor",
1720 Self::Notify { .. } => "notify",
1721 Self::SetStatus { .. } => "setStatus",
1722 Self::SetWidget { .. } => "setWidget",
1723 Self::SetTitle { .. } => "setTitle",
1724 Self::SetEditorText { .. } => "set_editor_text",
1725 }
1726 }
1727}
1728
1729impl Serialize for RpcExtensionUiRequest {
1730 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1731 where
1732 S: Serializer,
1733 {
1734 match self {
1735 Self::Select {
1736 id,
1737 title,
1738 options,
1739 timeout,
1740 } => serialize_ui_select(serializer, id, title, options, *timeout),
1741 Self::Confirm {
1742 id,
1743 title,
1744 message,
1745 timeout,
1746 } => serialize_ui_confirm(serializer, id, title, message, *timeout),
1747 Self::Input {
1748 id,
1749 title,
1750 placeholder,
1751 timeout,
1752 } => serialize_ui_input(serializer, id, title, placeholder.as_deref(), *timeout),
1753 Self::Editor { id, title, prefill } => {
1754 serialize_ui_editor(serializer, id, title, prefill.as_deref())
1755 }
1756 Self::Notify {
1757 id,
1758 message,
1759 notify_type,
1760 } => serialize_ui_notify(serializer, id, message, *notify_type),
1761 Self::SetStatus {
1762 id,
1763 status_key,
1764 status_text,
1765 } => serialize_ui_set_status(serializer, id, status_key, status_text.as_deref()),
1766 Self::SetWidget {
1767 id,
1768 widget_key,
1769 widget_lines,
1770 widget_placement,
1771 } => serialize_ui_set_widget(
1772 serializer,
1773 id,
1774 widget_key,
1775 widget_lines.as_deref(),
1776 *widget_placement,
1777 ),
1778 Self::SetTitle { id, title } => serialize_ui_set_title(serializer, id, title),
1779 Self::SetEditorText { id, text } => serialize_ui_set_editor_text(serializer, id, text),
1780 }
1781 }
1782}
1783
1784fn serialize_ui_select<S: Serializer>(
1785 serializer: S,
1786 id: &str,
1787 title: &str,
1788 options: &[String],
1789 timeout: Option<u64>,
1790) -> Result<S::Ok, S::Error> {
1791 let mut map = serializer.serialize_map(None)?;
1792 map.serialize_entry("type", "extension_ui_request")?;
1793 map.serialize_entry("id", id)?;
1794 map.serialize_entry("method", "select")?;
1795 map.serialize_entry("title", title)?;
1796 map.serialize_entry("options", options)?;
1797 if let Some(timeout) = timeout {
1798 map.serialize_entry("timeout", &timeout)?;
1799 }
1800 map.end()
1801}
1802
1803fn serialize_ui_confirm<S: Serializer>(
1804 serializer: S,
1805 id: &str,
1806 title: &str,
1807 message: &str,
1808 timeout: Option<u64>,
1809) -> Result<S::Ok, S::Error> {
1810 let mut map = serializer.serialize_map(None)?;
1811 map.serialize_entry("type", "extension_ui_request")?;
1812 map.serialize_entry("id", id)?;
1813 map.serialize_entry("method", "confirm")?;
1814 map.serialize_entry("title", title)?;
1815 map.serialize_entry("message", message)?;
1816 if let Some(timeout) = timeout {
1817 map.serialize_entry("timeout", &timeout)?;
1818 }
1819 map.end()
1820}
1821
1822fn serialize_ui_input<S: Serializer>(
1823 serializer: S,
1824 id: &str,
1825 title: &str,
1826 placeholder: Option<&str>,
1827 timeout: Option<u64>,
1828) -> Result<S::Ok, S::Error> {
1829 let mut map = serializer.serialize_map(None)?;
1830 map.serialize_entry("type", "extension_ui_request")?;
1831 map.serialize_entry("id", id)?;
1832 map.serialize_entry("method", "input")?;
1833 map.serialize_entry("title", title)?;
1834 if let Some(placeholder) = placeholder {
1835 map.serialize_entry("placeholder", placeholder)?;
1836 }
1837 if let Some(timeout) = timeout {
1838 map.serialize_entry("timeout", &timeout)?;
1839 }
1840 map.end()
1841}
1842
1843fn serialize_ui_editor<S: Serializer>(
1844 serializer: S,
1845 id: &str,
1846 title: &str,
1847 prefill: Option<&str>,
1848) -> Result<S::Ok, S::Error> {
1849 let mut map = serializer.serialize_map(None)?;
1850 map.serialize_entry("type", "extension_ui_request")?;
1851 map.serialize_entry("id", id)?;
1852 map.serialize_entry("method", "editor")?;
1853 map.serialize_entry("title", title)?;
1854 if let Some(prefill) = prefill {
1855 map.serialize_entry("prefill", prefill)?;
1856 }
1857 map.end()
1858}
1859
1860fn serialize_ui_notify<S: Serializer>(
1861 serializer: S,
1862 id: &str,
1863 message: &str,
1864 notify_type: Option<NotifyType>,
1865) -> Result<S::Ok, S::Error> {
1866 let mut map = serializer.serialize_map(None)?;
1867 map.serialize_entry("type", "extension_ui_request")?;
1868 map.serialize_entry("id", id)?;
1869 map.serialize_entry("method", "notify")?;
1870 map.serialize_entry("message", message)?;
1871 if let Some(notify_type) = notify_type {
1872 map.serialize_entry("notifyType", ¬ify_type)?;
1873 }
1874 map.end()
1875}
1876
1877fn serialize_ui_set_status<S: Serializer>(
1878 serializer: S,
1879 id: &str,
1880 status_key: &str,
1881 status_text: Option<&str>,
1882) -> Result<S::Ok, S::Error> {
1883 let mut map = serializer.serialize_map(None)?;
1884 map.serialize_entry("type", "extension_ui_request")?;
1885 map.serialize_entry("id", id)?;
1886 map.serialize_entry("method", "setStatus")?;
1887 map.serialize_entry("statusKey", status_key)?;
1888 if let Some(text) = status_text {
1889 map.serialize_entry("statusText", text)?;
1890 } else {
1891 map.serialize_entry("statusText", &Value::Null)?;
1892 }
1893 map.end()
1894}
1895
1896fn serialize_ui_set_widget<S: Serializer>(
1897 serializer: S,
1898 id: &str,
1899 widget_key: &str,
1900 widget_lines: Option<&[String]>,
1901 widget_placement: Option<WidgetPlacement>,
1902) -> Result<S::Ok, S::Error> {
1903 let mut map = serializer.serialize_map(None)?;
1904 map.serialize_entry("type", "extension_ui_request")?;
1905 map.serialize_entry("id", id)?;
1906 map.serialize_entry("method", "setWidget")?;
1907 map.serialize_entry("widgetKey", widget_key)?;
1908 match widget_lines {
1909 Some(lines) => map.serialize_entry("widgetLines", lines)?,
1910 None => map.serialize_entry("widgetLines", &Value::Null)?,
1911 }
1912 if let Some(placement) = widget_placement {
1913 map.serialize_entry("widgetPlacement", &placement)?;
1914 }
1915 map.end()
1916}
1917
1918fn serialize_ui_set_title<S: Serializer>(
1919 serializer: S,
1920 id: &str,
1921 title: &str,
1922) -> Result<S::Ok, S::Error> {
1923 let mut map = serializer.serialize_map(None)?;
1924 map.serialize_entry("type", "extension_ui_request")?;
1925 map.serialize_entry("id", id)?;
1926 map.serialize_entry("method", "setTitle")?;
1927 map.serialize_entry("title", title)?;
1928 map.end()
1929}
1930
1931fn serialize_ui_set_editor_text<S: Serializer>(
1932 serializer: S,
1933 id: &str,
1934 text: &str,
1935) -> Result<S::Ok, S::Error> {
1936 let mut map = serializer.serialize_map(None)?;
1937 map.serialize_entry("type", "extension_ui_request")?;
1938 map.serialize_entry("id", id)?;
1939 map.serialize_entry("method", "set_editor_text")?;
1940 map.serialize_entry("text", text)?;
1941 map.end()
1942}
1943
1944impl<'de> Deserialize<'de> for RpcExtensionUiRequest {
1945 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1946 where
1947 D: Deserializer<'de>,
1948 {
1949 let value = Value::deserialize(deserializer)?;
1950 let obj = value
1951 .as_object()
1952 .ok_or_else(|| de::Error::custom("extension_ui_request must be a JSON object"))?;
1953 let type_field = obj
1954 .get("type")
1955 .and_then(Value::as_str)
1956 .ok_or_else(|| de::Error::custom("extension_ui_request missing type"))?;
1957 if type_field != "extension_ui_request" {
1958 return Err(de::Error::custom(format!(
1959 "expected type \"extension_ui_request\", got {type_field:?}"
1960 )));
1961 }
1962 let id = required_string(obj, "id")?;
1963 let method = required_string(obj, "method")?;
1964 parse_ui_request(obj, id, &method).map_err(de::Error::custom)
1965 }
1966}
1967
1968fn parse_ui_request(
1969 obj: &Map<String, Value>,
1970 id: String,
1971 method: &str,
1972) -> Result<RpcExtensionUiRequest, String> {
1973 match method {
1974 "select" => parse_ui_select(obj, id),
1975 "confirm" => Ok(RpcExtensionUiRequest::Confirm {
1976 id,
1977 title: required_string_owned(obj, "title")?,
1978 message: required_string_owned(obj, "message")?,
1979 timeout: optional_u64(obj, "timeout")?,
1980 }),
1981 "input" => Ok(RpcExtensionUiRequest::Input {
1982 id,
1983 title: required_string_owned(obj, "title")?,
1984 placeholder: optional_string(obj, "placeholder")?,
1985 timeout: optional_u64(obj, "timeout")?,
1986 }),
1987 "editor" => Ok(RpcExtensionUiRequest::Editor {
1988 id,
1989 title: required_string_owned(obj, "title")?,
1990 prefill: optional_string(obj, "prefill")?,
1991 }),
1992 "notify" => parse_ui_notify(obj, id),
1993 "setStatus" => parse_ui_set_status(obj, id),
1994 "setWidget" => parse_ui_set_widget(obj, id),
1995 "setTitle" => Ok(RpcExtensionUiRequest::SetTitle {
1996 id,
1997 title: required_string_owned(obj, "title")?,
1998 }),
1999 "set_editor_text" => Ok(RpcExtensionUiRequest::SetEditorText {
2000 id,
2001 text: required_string_owned(obj, "text")?,
2002 }),
2003 other => Err(format!("unknown extension_ui_request method: {other}")),
2004 }
2005}
2006
2007fn parse_ui_select(obj: &Map<String, Value>, id: String) -> Result<RpcExtensionUiRequest, String> {
2008 let title = required_string_owned(obj, "title")?;
2009 let options = obj
2010 .get("options")
2011 .ok_or_else(|| "select missing options".to_owned())?;
2012 let options = Vec::<String>::deserialize(options).map_err(|e| e.to_string())?;
2013 Ok(RpcExtensionUiRequest::Select {
2014 id,
2015 title,
2016 options,
2017 timeout: optional_u64(obj, "timeout")?,
2018 })
2019}
2020
2021fn parse_ui_notify(obj: &Map<String, Value>, id: String) -> Result<RpcExtensionUiRequest, String> {
2022 let notify_type = match obj.get("notifyType") {
2023 None | Some(Value::Null) => None,
2024 Some(v) => Some(NotifyType::deserialize(v).map_err(|e| e.to_string())?),
2025 };
2026 Ok(RpcExtensionUiRequest::Notify {
2027 id,
2028 message: required_string_owned(obj, "message")?,
2029 notify_type,
2030 })
2031}
2032
2033fn parse_ui_set_status(
2034 obj: &Map<String, Value>,
2035 id: String,
2036) -> Result<RpcExtensionUiRequest, String> {
2037 let status_text = match obj.get("statusText") {
2038 None | Some(Value::Null) => None,
2039 Some(Value::String(s)) => Some(s.clone()),
2040 Some(other) => {
2041 return Err(format!("statusText must be string or null, got {other}"));
2042 }
2043 };
2044 Ok(RpcExtensionUiRequest::SetStatus {
2045 id,
2046 status_key: required_string_owned(obj, "statusKey")?,
2047 status_text,
2048 })
2049}
2050
2051fn parse_ui_set_widget(
2052 obj: &Map<String, Value>,
2053 id: String,
2054) -> Result<RpcExtensionUiRequest, String> {
2055 let widget_lines = match obj.get("widgetLines") {
2056 None | Some(Value::Null) => None,
2057 Some(v) => Some(Vec::<String>::deserialize(v).map_err(|e| e.to_string())?),
2058 };
2059 let widget_placement = match obj.get("widgetPlacement") {
2060 None | Some(Value::Null) => None,
2061 Some(v) => Some(WidgetPlacement::deserialize(v).map_err(|e| e.to_string())?),
2062 };
2063 Ok(RpcExtensionUiRequest::SetWidget {
2064 id,
2065 widget_key: required_string_owned(obj, "widgetKey")?,
2066 widget_lines,
2067 widget_placement,
2068 })
2069}
2070
2071#[derive(Clone, Debug, PartialEq)]
2073pub enum RpcExtensionUiResponse {
2074 Value {
2076 id: String,
2078 value: String,
2080 },
2081 Confirmed {
2083 id: String,
2085 confirmed: bool,
2087 },
2088 Cancelled {
2090 id: String,
2092 },
2093}
2094
2095impl RpcExtensionUiResponse {
2096 #[must_use]
2098 pub fn id(&self) -> &str {
2099 match self {
2100 Self::Value { id, .. } | Self::Confirmed { id, .. } | Self::Cancelled { id } => {
2101 id.as_str()
2102 }
2103 }
2104 }
2105}
2106
2107impl Serialize for RpcExtensionUiResponse {
2108 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2109 where
2110 S: Serializer,
2111 {
2112 match self {
2113 Self::Value { id, value } => {
2114 let mut map = serializer.serialize_map(None)?;
2115 map.serialize_entry("type", "extension_ui_response")?;
2116 map.serialize_entry("id", id)?;
2117 map.serialize_entry("value", value)?;
2118 map.end()
2119 }
2120 Self::Confirmed { id, confirmed } => {
2121 let mut map = serializer.serialize_map(None)?;
2122 map.serialize_entry("type", "extension_ui_response")?;
2123 map.serialize_entry("id", id)?;
2124 map.serialize_entry("confirmed", confirmed)?;
2125 map.end()
2126 }
2127 Self::Cancelled { id } => {
2128 let mut map = serializer.serialize_map(None)?;
2129 map.serialize_entry("type", "extension_ui_response")?;
2130 map.serialize_entry("id", id)?;
2131 map.serialize_entry("cancelled", &true)?;
2132 map.end()
2133 }
2134 }
2135 }
2136}
2137
2138impl<'de> Deserialize<'de> for RpcExtensionUiResponse {
2139 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2140 where
2141 D: Deserializer<'de>,
2142 {
2143 let value = Value::deserialize(deserializer)?;
2144 let obj = value
2145 .as_object()
2146 .ok_or_else(|| de::Error::custom("extension_ui_response must be a JSON object"))?;
2147
2148 let type_field = obj
2149 .get("type")
2150 .and_then(Value::as_str)
2151 .ok_or_else(|| de::Error::custom("extension_ui_response missing type"))?;
2152 if type_field != "extension_ui_response" {
2153 return Err(de::Error::custom(format!(
2154 "expected type \"extension_ui_response\", got {type_field:?}"
2155 )));
2156 }
2157
2158 let id = required_string(obj, "id")?;
2159
2160 if let Some(Value::Bool(true)) = obj.get("cancelled") {
2161 return Ok(Self::Cancelled { id });
2162 }
2163 if let Some(Value::Bool(confirmed)) = obj.get("confirmed") {
2164 return Ok(Self::Confirmed {
2165 id,
2166 confirmed: *confirmed,
2167 });
2168 }
2169 if let Some(Value::String(value)) = obj.get("value") {
2170 return Ok(Self::Value {
2171 id,
2172 value: value.clone(),
2173 });
2174 }
2175
2176 Err(de::Error::custom(
2177 "extension_ui_response must include value, confirmed, or cancelled:true",
2178 ))
2179 }
2180}
2181
2182fn serialize_id<S>(map: &mut S, id: Option<&str>) -> Result<(), S::Error>
2187where
2188 S: SerializeMap,
2189{
2190 if let Some(id) = id {
2191 map.serialize_entry("id", id)?;
2192 }
2193 Ok(())
2194}
2195
2196fn serialize_type_only<S>(
2197 serializer: S,
2198 id: Option<&str>,
2199 type_name: &str,
2200) -> Result<S::Ok, S::Error>
2201where
2202 S: Serializer,
2203{
2204 let mut map = serializer.serialize_map(None)?;
2205 serialize_id(&mut map, id)?;
2206 map.serialize_entry("type", type_name)?;
2207 map.end()
2208}
2209
2210fn optional_string(obj: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
2211 match obj.get(key) {
2212 None | Some(Value::Null) => Ok(None),
2213 Some(Value::String(value)) => Ok(Some(value.clone())),
2214 Some(other) => Err(format!("field {key} must be a string, got {other}")),
2215 }
2216}
2217
2218fn required_string<E: de::Error>(obj: &Map<String, Value>, key: &str) -> Result<String, E> {
2219 match obj.get(key) {
2220 Some(Value::String(s)) => Ok(s.clone()),
2221 Some(other) => Err(E::custom(format!(
2222 "field {key} must be a string, got {other}"
2223 ))),
2224 None => Err(E::custom(format!("missing field {key}"))),
2225 }
2226}
2227
2228fn optional_bool(obj: &Map<String, Value>, key: &str) -> Result<Option<bool>, String> {
2229 match obj.get(key) {
2230 None | Some(Value::Null) => Ok(None),
2231 Some(Value::Bool(value)) => Ok(Some(*value)),
2232 Some(other) => Err(format!("field {key} must be a boolean, got {other}")),
2233 }
2234}
2235
2236fn optional_u64(obj: &Map<String, Value>, key: &str) -> Result<Option<u64>, String> {
2237 match obj.get(key) {
2238 None | Some(Value::Null) => Ok(None),
2239 Some(Value::Number(number)) => number
2240 .as_u64()
2241 .map(Some)
2242 .ok_or_else(|| format!("field {key} must be an unsigned integer, got {number}")),
2243 Some(other) => Err(format!(
2244 "field {key} must be an unsigned integer, got {other}"
2245 )),
2246 }
2247}
2248
2249#[cfg(test)]
2254mod tests {
2255 use super::*;
2256 use pi_ai::{ModelCost, ModelInput};
2257 use serde_json::json;
2258 use std::collections::BTreeMap;
2259
2260 type TestResult = Result<(), String>;
2261
2262 fn fail(msg: impl Into<String>) -> String {
2263 msg.into()
2264 }
2265
2266 fn sample_model() -> Model {
2267 Model {
2268 id: "gpt-4o".into(),
2269 name: "GPT-4o".into(),
2270 api: "openai-completions".into(),
2271 provider: "openai".into(),
2272 base_url: "https://api.openai.com/v1".into(),
2273 reasoning: false,
2274 thinking_level_map: None,
2275 input: vec![ModelInput::Text],
2276 cost: ModelCost {
2277 input: 0.0,
2278 output: 0.0,
2279 cache_read: 0.0,
2280 cache_write: 0.0,
2281 tiers: None,
2282 },
2283 context_window: 128_000,
2284 max_tokens: 16_384,
2285 headers: None,
2286 compat: None,
2287 extra: BTreeMap::default(),
2288 }
2289 }
2290
2291 fn assert_json_eq(actual: &Value, expected: &Value) -> TestResult {
2292 if actual == expected {
2293 Ok(())
2294 } else {
2295 Err(fail(format!(
2296 "JSON mismatch\n actual: {actual}\n expected: {expected}"
2297 )))
2298 }
2299 }
2300
2301 fn roundtrip_command(cmd: &RpcCommand) -> Result<RpcCommand, String> {
2302 let value = serde_json::to_value(cmd).map_err(|e| fail(e.to_string()))?;
2303 serde_json::from_value(value).map_err(|e| fail(e.to_string()))
2304 }
2305
2306 fn roundtrip_response(resp: &RpcResponse) -> Result<RpcResponse, String> {
2307 let value = serde_json::to_value(resp).map_err(|e| fail(e.to_string()))?;
2308 serde_json::from_value(value).map_err(|e| fail(e.to_string()))
2309 }
2310
2311 fn to_value<T: serde::Serialize>(v: &T) -> Result<Value, String> {
2312 serde_json::to_value(v).map_err(|e| fail(e.to_string()))
2313 }
2314
2315 fn from_value<T: serde::de::DeserializeOwned>(v: Value) -> Result<T, String> {
2316 serde_json::from_value(v).map_err(|e| fail(e.to_string()))
2317 }
2318
2319 #[test]
2320 fn command_prompt_wire_fields() -> TestResult {
2321 let cmd = RpcCommand::Prompt {
2322 id: Some("1".into()),
2323 message: "hi".into(),
2324 images: None,
2325 streaming_behavior: Some(StreamingBehavior::FollowUp),
2326 };
2327 let value = to_value(&cmd)?;
2328 assert_json_eq(
2329 &value,
2330 &json!({
2331 "id": "1",
2332 "type": "prompt",
2333 "message": "hi",
2334 "streamingBehavior": "followUp"
2335 }),
2336 )?;
2337 if roundtrip_command(&cmd)? != cmd {
2338 return Err(fail("prompt roundtrip mismatch"));
2339 }
2340 Ok(())
2341 }
2342
2343 #[test]
2344 fn command_set_model_camel_case() -> TestResult {
2345 let cmd = RpcCommand::SetModel {
2346 id: None,
2347 provider: "openai".into(),
2348 model_id: "gpt-4o".into(),
2349 };
2350 let value = to_value(&cmd)?;
2351 assert_json_eq(
2352 &value,
2353 &json!({
2354 "type": "set_model",
2355 "provider": "openai",
2356 "modelId": "gpt-4o"
2357 }),
2358 )?;
2359 if roundtrip_command(&cmd)? != cmd {
2360 return Err(fail("set_model roundtrip mismatch"));
2361 }
2362 Ok(())
2363 }
2364
2365 #[test]
2366 fn command_queue_modes_use_kebab_wire() -> TestResult {
2367 let cmd = RpcCommand::SetSteeringMode {
2368 id: Some("q".into()),
2369 mode: QueueMode::OneAtATime,
2370 };
2371 let value = to_value(&cmd)?;
2372 if value["mode"] != "one-at-a-time" {
2373 return Err(fail(format!("mode wire: {}", value["mode"])));
2374 }
2375 if roundtrip_command(&cmd)? != cmd {
2376 return Err(fail("queue mode roundtrip mismatch"));
2377 }
2378 Ok(())
2379 }
2380
2381 #[test]
2382 fn command_set_thinking_level_includes_off() -> TestResult {
2383 let cmd = RpcCommand::SetThinkingLevel {
2384 id: None,
2385 level: ModelThinkingLevel::Off,
2386 };
2387 let value = to_value(&cmd)?;
2388 if value["level"] != "off" {
2389 return Err(fail(format!("level wire: {}", value["level"])));
2390 }
2391 if roundtrip_command(&cmd)? != cmd {
2392 return Err(fail("thinking level roundtrip mismatch"));
2393 }
2394 Ok(())
2395 }
2396
2397 #[test]
2398 fn command_bash_exclude_from_context() -> TestResult {
2399 let cmd = RpcCommand::Bash {
2400 id: Some("b".into()),
2401 command: "echo hi".into(),
2402 exclude_from_context: Some(true),
2403 };
2404 let value = to_value(&cmd)?;
2405 assert_json_eq(
2406 &value,
2407 &json!({
2408 "id": "b",
2409 "type": "bash",
2410 "command": "echo hi",
2411 "excludeFromContext": true
2412 }),
2413 )?;
2414 if roundtrip_command(&cmd)? != cmd {
2415 return Err(fail("bash roundtrip mismatch"));
2416 }
2417 Ok(())
2418 }
2419
2420 fn sample_commands() -> Vec<RpcCommand> {
2421 vec![
2422 RpcCommand::Prompt {
2423 id: Some("1".into()),
2424 message: "m".into(),
2425 images: None,
2426 streaming_behavior: Some(StreamingBehavior::Steer),
2427 },
2428 RpcCommand::Steer {
2429 id: None,
2430 message: "s".into(),
2431 images: None,
2432 },
2433 RpcCommand::FollowUp {
2434 id: None,
2435 message: "f".into(),
2436 images: None,
2437 },
2438 RpcCommand::Abort { id: None },
2439 RpcCommand::NewSession {
2440 id: None,
2441 parent_session: Some("/tmp/s".into()),
2442 },
2443 RpcCommand::GetState { id: None },
2444 RpcCommand::SetModel {
2445 id: None,
2446 provider: "p".into(),
2447 model_id: "m".into(),
2448 },
2449 RpcCommand::CycleModel { id: None },
2450 RpcCommand::GetAvailableModels { id: None },
2451 RpcCommand::SetThinkingLevel {
2452 id: None,
2453 level: ModelThinkingLevel::High,
2454 },
2455 RpcCommand::CycleThinkingLevel { id: None },
2456 RpcCommand::SetSteeringMode {
2457 id: None,
2458 mode: QueueMode::All,
2459 },
2460 RpcCommand::SetFollowUpMode {
2461 id: None,
2462 mode: QueueMode::OneAtATime,
2463 },
2464 RpcCommand::Compact {
2465 id: None,
2466 custom_instructions: Some("x".into()),
2467 },
2468 RpcCommand::SetAutoCompaction {
2469 id: None,
2470 enabled: true,
2471 },
2472 RpcCommand::SetAutoRetry {
2473 id: None,
2474 enabled: false,
2475 },
2476 RpcCommand::AbortRetry { id: None },
2477 RpcCommand::Bash {
2478 id: None,
2479 command: "true".into(),
2480 exclude_from_context: None,
2481 },
2482 RpcCommand::AbortBash { id: None },
2483 RpcCommand::GetSessionStats { id: None },
2484 RpcCommand::ExportHtml {
2485 id: None,
2486 output_path: Some("out.html".into()),
2487 },
2488 RpcCommand::SwitchSession {
2489 id: None,
2490 session_path: "/s".into(),
2491 },
2492 RpcCommand::Fork {
2493 id: None,
2494 entry_id: "e1".into(),
2495 },
2496 RpcCommand::Clone { id: None },
2497 RpcCommand::GetForkMessages { id: None },
2498 RpcCommand::GetEntries {
2499 id: None,
2500 since: Some("e0".into()),
2501 },
2502 RpcCommand::GetTree { id: None },
2503 RpcCommand::GetLastAssistantText { id: None },
2504 RpcCommand::SetSessionName {
2505 id: None,
2506 name: "n".into(),
2507 },
2508 RpcCommand::GetMessages { id: None },
2509 RpcCommand::GetCommands { id: None },
2510 ]
2511 }
2512
2513 #[test]
2514 fn all_31_known_command_types_roundtrip() -> TestResult {
2515 let samples = sample_commands();
2516 if samples.len() != 31 {
2517 return Err(fail(format!("expected 31 samples, got {}", samples.len())));
2518 }
2519 for cmd in &samples {
2520 let rt = roundtrip_command(cmd)?;
2521 if &rt != cmd {
2522 return Err(fail(format!("roundtrip failed for {}", cmd.command_type())));
2523 }
2524 if rt.command_type() != cmd.command_type() {
2525 return Err(fail(format!("type mismatch for {}", cmd.command_type())));
2526 }
2527 }
2528 Ok(())
2529 }
2530
2531 #[test]
2532 fn unknown_command_preserves_type_id_and_payload() -> TestResult {
2533 let raw = json!({
2534 "id": "42",
2535 "type": "future_command",
2536 "foo": 1,
2537 "bar": "x"
2538 });
2539 let cmd: RpcCommand = from_value(raw)?;
2540 match &cmd {
2541 RpcCommand::Unknown {
2542 id,
2543 command_type,
2544 payload,
2545 } => {
2546 if id.as_deref() != Some("42") {
2547 return Err(fail(format!("id={id:?}")));
2548 }
2549 if command_type != "future_command" {
2550 return Err(fail(format!("type={command_type}")));
2551 }
2552 if payload.get("foo") != Some(&json!(1)) {
2553 return Err(fail("missing foo"));
2554 }
2555 if payload.get("bar") != Some(&json!("x")) {
2556 return Err(fail("missing bar"));
2557 }
2558 if payload.contains_key("type") || payload.contains_key("id") {
2559 return Err(fail("payload should exclude type/id"));
2560 }
2561 }
2562 other => return Err(fail(format!("expected Unknown, got {other:?}"))),
2563 }
2564 let re = to_value(&cmd)?;
2565 if re["id"] != "42" || re["type"] != "future_command" || re["foo"] != 1 || re["bar"] != "x"
2566 {
2567 return Err(fail(format!("reserialized unknown: {re}")));
2568 }
2569 Ok(())
2570 }
2571
2572 #[test]
2573 fn known_optional_command_fields_reject_wrong_types() -> TestResult {
2574 for raw in [
2575 json!({"type": "get_state", "id": 123}),
2576 json!({"type": "new_session", "parentSession": false}),
2577 json!({"type": "compact", "customInstructions": 7}),
2578 json!({"type": "bash", "command": "true", "excludeFromContext": "true"}),
2579 json!({"type": "export_html", "outputPath": []}),
2580 json!({"type": "get_entries", "since": {}}),
2581 ] {
2582 if from_value::<RpcCommand>(raw.clone()).is_ok() {
2583 return Err(fail(format!("wrongly accepted optional field: {raw}")));
2584 }
2585 }
2586 Ok(())
2587 }
2588
2589 #[test]
2590 fn known_optional_command_fields_accept_null() -> TestResult {
2591 let command = from_value::<RpcCommand>(json!({
2592 "type": "bash",
2593 "id": null,
2594 "command": "true",
2595 "excludeFromContext": null
2596 }))?;
2597 if !matches!(
2598 command,
2599 RpcCommand::Bash {
2600 id: None,
2601 exclude_from_context: None,
2602 ..
2603 }
2604 ) {
2605 return Err(fail(format!("unexpected parsed command: {command:?}")));
2606 }
2607 Ok(())
2608 }
2609
2610 #[test]
2611 fn response_success_without_data() -> TestResult {
2612 let resp = RpcResponse::ok(Some("1".into()), "prompt");
2613 let value = to_value(&resp)?;
2614 assert_json_eq(
2615 &value,
2616 &json!({
2617 "id": "1",
2618 "type": "response",
2619 "command": "prompt",
2620 "success": true
2621 }),
2622 )?;
2623 if roundtrip_response(&resp)? != resp {
2624 return Err(fail("success response roundtrip mismatch"));
2625 }
2626 Ok(())
2627 }
2628
2629 #[test]
2630 fn response_error_wire() -> TestResult {
2631 let resp = RpcResponse::err(
2632 Some("9".into()),
2633 "future_command",
2634 "Unknown command: future_command",
2635 );
2636 let value = to_value(&resp)?;
2637 assert_json_eq(
2638 &value,
2639 &json!({
2640 "id": "9",
2641 "type": "response",
2642 "command": "future_command",
2643 "success": false,
2644 "error": "Unknown command: future_command"
2645 }),
2646 )?;
2647 if roundtrip_response(&resp)? != resp {
2648 return Err(fail("error response roundtrip mismatch"));
2649 }
2650 Ok(())
2651 }
2652
2653 #[test]
2654 fn response_cancelled_data() -> TestResult {
2655 let resp = RpcResponse::ok_data(
2656 None,
2657 "new_session",
2658 RpcResponseData::Cancelled { cancelled: true },
2659 );
2660 let value = to_value(&resp)?;
2661 if value["success"] != true || value["data"]["cancelled"] != true {
2662 return Err(fail(format!("cancelled wire: {value}")));
2663 }
2664 if roundtrip_response(&resp)? != resp {
2665 return Err(fail("cancelled roundtrip mismatch"));
2666 }
2667 Ok(())
2668 }
2669
2670 #[test]
2671 fn response_cycle_model_null_data() -> TestResult {
2672 let resp = RpcResponse::ok_data(None, "cycle_model", RpcResponseData::CycleModel(None));
2673 let value = to_value(&resp)?;
2674 if value["data"] != Value::Null {
2675 return Err(fail(format!("expected null data, got {}", value["data"])));
2676 }
2677 let de: RpcResponse = from_value(value)?;
2678 match de {
2679 RpcResponse::Success {
2680 data: Some(boxed), ..
2681 } if matches!(boxed.as_ref(), RpcResponseData::CycleModel(None)) => Ok(()),
2682 other => Err(fail(format!("expected CycleModel(None), got {other:?}"))),
2683 }
2684 }
2685
2686 #[test]
2687 fn response_get_state_session_state_fields() -> TestResult {
2688 let state = RpcSessionState {
2689 model: Some(sample_model()),
2690 thinking_level: ModelThinkingLevel::Low,
2691 is_streaming: false,
2692 is_compacting: false,
2693 steering_mode: QueueMode::All,
2694 follow_up_mode: QueueMode::OneAtATime,
2695 session_file: Some("/tmp/s.jsonl".into()),
2696 session_id: "sid".into(),
2697 session_name: Some("work".into()),
2698 auto_compaction_enabled: true,
2699 message_count: 3,
2700 pending_message_count: 0,
2701 };
2702 let resp = RpcResponse::ok_data(
2703 Some("g".into()),
2704 "get_state",
2705 RpcResponseData::SessionState(state),
2706 );
2707 let value = to_value(&resp)?;
2708 if value["data"]["thinkingLevel"] != "low"
2709 || value["data"]["isStreaming"] != false
2710 || value["data"]["steeringMode"] != "all"
2711 || value["data"]["followUpMode"] != "one-at-a-time"
2712 || value["data"]["sessionId"] != "sid"
2713 || value["data"]["messageCount"] != 3
2714 || value["data"]["pendingMessageCount"] != 0
2715 || value["data"]["autoCompactionEnabled"] != true
2716 {
2717 return Err(fail(format!("get_state fields: {}", value["data"])));
2718 }
2719 if roundtrip_response(&resp)? != resp {
2720 return Err(fail("get_state roundtrip mismatch"));
2721 }
2722 Ok(())
2723 }
2724
2725 #[test]
2726 fn response_bash_result_camel_case() -> TestResult {
2727 let data = RpcResponseData::Bash(BashResult {
2728 output: "ok".into(),
2729 exit_code: Some(0),
2730 cancelled: false,
2731 truncated: false,
2732 full_output_path: None,
2733 });
2734 let resp = RpcResponse::ok_data(None, "bash", data);
2735 let value = to_value(&resp)?;
2736 if value["data"]["exitCode"] != 0
2737 || value["data"]["cancelled"] != false
2738 || value["data"]["truncated"] != false
2739 || value["data"].get("fullOutputPath").is_some()
2740 {
2741 return Err(fail(format!("bash data: {}", value["data"])));
2742 }
2743 if roundtrip_response(&resp)? != resp {
2744 return Err(fail("bash response roundtrip mismatch"));
2745 }
2746 Ok(())
2747 }
2748
2749 #[test]
2750 fn response_session_stats_tokens() -> TestResult {
2751 let data = RpcResponseData::SessionStats(SessionStats {
2752 session_file: None,
2753 session_id: "s".into(),
2754 user_messages: 1,
2755 assistant_messages: 1,
2756 tool_calls: 0,
2757 tool_results: 0,
2758 total_messages: 2,
2759 tokens: SessionStatsTokens {
2760 input: 10,
2761 output: 20,
2762 cache_read: 0,
2763 cache_write: 0,
2764 total: 30,
2765 },
2766 cost: 0.01,
2767 context_usage: Some(ContextUsage {
2768 tokens: Some(30),
2769 context_window: 128_000,
2770 percent: Some(0.02),
2771 }),
2772 });
2773 let resp = RpcResponse::ok_data(None, "get_session_stats", data);
2774 let value = to_value(&resp)?;
2775 if value["data"]["sessionId"] != "s"
2776 || value["data"]["userMessages"] != 1
2777 || value["data"]["tokens"]["cacheRead"] != 0
2778 || value["data"]["contextUsage"]["contextWindow"] != 128_000
2779 {
2780 return Err(fail(format!("stats data: {}", value["data"])));
2781 }
2782 if roundtrip_response(&resp)? != resp {
2783 return Err(fail("stats roundtrip mismatch"));
2784 }
2785 Ok(())
2786 }
2787
2788 #[test]
2789 fn response_get_commands_slash_command() -> TestResult {
2790 let cmd = RpcSlashCommand {
2791 name: "skill:foo".into(),
2792 description: Some("Foo skill".into()),
2793 source: RpcSlashCommandSource::Skill,
2794 source_info: RpcSourceInfo {
2795 path: "/skills/foo".into(),
2796 source: "local".into(),
2797 scope: RpcSourceScope::Project,
2798 origin: RpcSourceOrigin::TopLevel,
2799 base_dir: None,
2800 },
2801 };
2802 let resp = RpcResponse::ok_data(
2803 None,
2804 "get_commands",
2805 RpcResponseData::Commands {
2806 commands: vec![cmd],
2807 },
2808 );
2809 let value = to_value(&resp)?;
2810 if value["data"]["commands"][0]["name"] != "skill:foo"
2811 || value["data"]["commands"][0]["source"] != "skill"
2812 || value["data"]["commands"][0]["sourceInfo"]["origin"] != "top-level"
2813 || value["data"]["commands"][0]["sourceInfo"]["scope"] != "project"
2814 {
2815 return Err(fail(format!("commands data: {}", value["data"])));
2816 }
2817 if roundtrip_response(&resp)? != resp {
2818 return Err(fail("get_commands roundtrip mismatch"));
2819 }
2820 Ok(())
2821 }
2822
2823 #[test]
2824 fn extension_ui_request_select_wire() -> TestResult {
2825 let req = RpcExtensionUiRequest::Select {
2826 id: "ui1".into(),
2827 title: "Pick".into(),
2828 options: vec!["a".into(), "b".into()],
2829 timeout: Some(1000),
2830 };
2831 let value = to_value(&req)?;
2832 assert_json_eq(
2833 &value,
2834 &json!({
2835 "type": "extension_ui_request",
2836 "id": "ui1",
2837 "method": "select",
2838 "title": "Pick",
2839 "options": ["a", "b"],
2840 "timeout": 1000
2841 }),
2842 )?;
2843 let de: RpcExtensionUiRequest = from_value(value)?;
2844 if de != req {
2845 return Err(fail("select UI request roundtrip mismatch"));
2846 }
2847 Ok(())
2848 }
2849
2850 #[test]
2851 fn extension_ui_request_set_status_null_text() -> TestResult {
2852 let req = RpcExtensionUiRequest::SetStatus {
2853 id: "s1".into(),
2854 status_key: "k".into(),
2855 status_text: None,
2856 };
2857 let value = to_value(&req)?;
2858 if value["method"] != "setStatus"
2859 || value["statusKey"] != "k"
2860 || value["statusText"] != Value::Null
2861 {
2862 return Err(fail(format!("setStatus wire: {value}")));
2863 }
2864 let de: RpcExtensionUiRequest = from_value(value)?;
2865 if de != req {
2866 return Err(fail("setStatus roundtrip mismatch"));
2867 }
2868 Ok(())
2869 }
2870
2871 #[test]
2872 fn extension_ui_request_set_widget_and_editor_text() -> TestResult {
2873 let widget = RpcExtensionUiRequest::SetWidget {
2874 id: "w".into(),
2875 widget_key: "wk".into(),
2876 widget_lines: Some(vec!["l1".into()]),
2877 widget_placement: Some(WidgetPlacement::AboveEditor),
2878 };
2879 let value = to_value(&widget)?;
2880 if value["method"] != "setWidget" || value["widgetPlacement"] != "aboveEditor" {
2881 return Err(fail(format!("setWidget wire: {value}")));
2882 }
2883 let de: RpcExtensionUiRequest = from_value(value)?;
2884 if de != widget {
2885 return Err(fail("setWidget roundtrip mismatch"));
2886 }
2887
2888 let editor = RpcExtensionUiRequest::SetEditorText {
2889 id: "e".into(),
2890 text: "hello".into(),
2891 };
2892 let value = to_value(&editor)?;
2893 if value["method"] != "set_editor_text" {
2894 return Err(fail(format!("set_editor_text wire: {value}")));
2895 }
2896 let de: RpcExtensionUiRequest = from_value(value)?;
2897 if de != editor {
2898 return Err(fail("set_editor_text roundtrip mismatch"));
2899 }
2900 Ok(())
2901 }
2902
2903 #[test]
2904 fn extension_ui_response_variants() -> TestResult {
2905 let cases = [
2906 RpcExtensionUiResponse::Value {
2907 id: "1".into(),
2908 value: "x".into(),
2909 },
2910 RpcExtensionUiResponse::Confirmed {
2911 id: "2".into(),
2912 confirmed: false,
2913 },
2914 RpcExtensionUiResponse::Cancelled { id: "3".into() },
2915 ];
2916 for case in &cases {
2917 let value = to_value(case)?;
2918 if value["type"] != "extension_ui_response" {
2919 return Err(fail(format!("ui response type: {value}")));
2920 }
2921 let de: RpcExtensionUiResponse = from_value(value)?;
2922 if &de != case {
2923 return Err(fail(format!("ui response roundtrip mismatch: {case:?}")));
2924 }
2925 }
2926 Ok(())
2927 }
2928
2929 #[test]
2930 fn response_fork_messages_entry_id_camel_case() -> TestResult {
2931 let resp = RpcResponse::ok_data(
2932 None,
2933 "get_fork_messages",
2934 RpcResponseData::ForkMessages {
2935 messages: vec![ForkMessage {
2936 entry_id: "e1".into(),
2937 text: "hello".into(),
2938 }],
2939 },
2940 );
2941 let value = to_value(&resp)?;
2942 if value["data"]["messages"][0]["entryId"] != "e1" {
2943 return Err(fail(format!("fork messages: {}", value["data"])));
2944 }
2945 if roundtrip_response(&resp)? != resp {
2946 return Err(fail("fork messages roundtrip mismatch"));
2947 }
2948 Ok(())
2949 }
2950
2951 #[test]
2952 fn response_last_assistant_text_null() -> TestResult {
2953 let resp = RpcResponse::ok_data(
2954 None,
2955 "get_last_assistant_text",
2956 RpcResponseData::LastAssistantText { text: None },
2957 );
2958 let value = to_value(&resp)?;
2959 if value["data"]["text"] != Value::Null {
2960 return Err(fail(format!("last assistant text: {}", value["data"])));
2961 }
2962 if roundtrip_response(&resp)? != resp {
2963 return Err(fail("last assistant text roundtrip mismatch"));
2964 }
2965 Ok(())
2966 }
2967
2968 #[test]
2969 fn command_id_and_type_accessors() -> TestResult {
2970 let cmd = RpcCommand::Unknown {
2971 id: Some("x".into()),
2972 command_type: "nope".into(),
2973 payload: Map::new(),
2974 };
2975 if cmd.id() != Some("x") || cmd.command_type() != "nope" {
2976 return Err(fail("unknown accessors mismatch"));
2977 }
2978 Ok(())
2979 }
2980
2981 #[test]
2982 fn image_content_roundtrip_inside_prompt() -> TestResult {
2983 let img = ImageContent::new("AAAA", "image/png");
2984 let cmd = RpcCommand::Prompt {
2985 id: None,
2986 message: "see".into(),
2987 images: Some(vec![img]),
2988 streaming_behavior: None,
2989 };
2990 let value = to_value(&cmd)?;
2991 if value["images"][0]["type"] != "image" || value["images"][0]["mimeType"] != "image/png" {
2992 return Err(fail(format!("image wire: {}", value["images"])));
2993 }
2994 if roundtrip_command(&cmd)? != cmd {
2995 return Err(fail("image prompt roundtrip mismatch"));
2996 }
2997 Ok(())
2998 }
2999}