1use oino_agent::QueueMode;
8use oino_ai::{ImageContent, JsonObject, JsonValue, Model, ThinkingLevel};
9pub use oino_core::agents::{AgentInfo, AgentRunState};
10use oino_core::extensions::settings::SettingsLayer;
11pub use oino_core::session::{
12 AuthProviderInfo, Checkpoint, CheckpointFile, CompletionEntry, CompletionGroup, EditorEvent,
13 EditorIntent, ExtensionInfo, QueueEdit, QueueKind, RewindFailure, RewindResult, RunningBash,
14};
15use oino_core::session::{NavigateTreeOptions, StreamingBehavior};
16pub use oino_core::topics::TopicValue;
17pub use oino_settings::SettingsScope;
18use serde::ser::SerializeMap;
19use serde::{Deserialize, Serialize, Serializer};
20
21pub const AGENT_COMMAND_TYPES: [&str; 33] = [
23 "prompt",
24 "steer",
25 "follow_up",
26 "abort",
27 "new_session",
28 "get_state",
29 "set_model",
30 "cycle_model",
31 "get_available_models",
32 "set_thinking_level",
33 "cycle_thinking_level",
34 "get_available_thinking_levels",
35 "set_steering_mode",
36 "set_follow_up_mode",
37 "compact",
38 "set_auto_compaction",
39 "set_auto_retry",
40 "abort_retry",
41 "bash",
42 "abort_bash",
43 "get_session_stats",
44 "export_html",
45 "switch_session",
46 "fork",
47 "clone",
48 "get_fork_messages",
49 "get_entries",
50 "get_tree",
51 "get_last_assistant_text",
52 "set_session_name",
53 "get_messages",
54 "get_commands",
55 "clear_queue",
56];
57
58mod wire_images {
60 use oino_ai::ImageContent;
61 use serde::ser::SerializeSeq;
62 use serde::{Deserialize, Deserializer, Serializer};
63 use serde_json::json;
64
65 pub(super) fn serialize<S: Serializer>(
66 images: &[ImageContent],
67 serializer: S,
68 ) -> Result<S::Ok, S::Error> {
69 let mut seq = serializer.serialize_seq(Some(images.len()))?;
70 for image in images {
71 seq.serialize_element(
72 &json!({"type": "image", "data": image.data, "mimeType": image.mime_type}),
73 )?;
74 }
75 seq.end()
76 }
77
78 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
79 deserializer: D,
80 ) -> Result<Vec<ImageContent>, D::Error> {
81 Ok(Option::<Vec<ImageContent>>::deserialize(deserializer)?.unwrap_or_default())
82 }
83}
84
85#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum AgentCommand {
89 #[serde(rename_all = "camelCase")]
91 Prompt {
92 message: String,
94 #[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
96 images: Vec<ImageContent>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 streaming_behavior: Option<StreamingBehavior>,
100 },
101 Steer {
103 message: String,
105 #[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
107 images: Vec<ImageContent>,
108 },
109 FollowUp {
111 message: String,
113 #[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
115 images: Vec<ImageContent>,
116 },
117 Abort,
119 #[serde(rename_all = "camelCase")]
121 NewSession {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 parent_session: Option<String>,
125 },
126 GetState,
128 #[serde(rename_all = "camelCase")]
130 SetModel {
131 provider: String,
133 model_id: String,
135 },
136 CycleModel,
138 GetAvailableModels,
140 SetThinkingLevel {
142 level: ThinkingLevel,
144 },
145 CycleThinkingLevel,
147 GetAvailableThinkingLevels,
149 SetSteeringMode {
151 mode: QueueMode,
153 },
154 SetFollowUpMode {
156 mode: QueueMode,
158 },
159 #[serde(rename_all = "camelCase")]
161 Compact {
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 custom_instructions: Option<String>,
165 },
166 SetAutoCompaction {
168 enabled: bool,
170 },
171 SetAutoRetry {
173 enabled: bool,
175 },
176 AbortRetry,
178 #[serde(rename_all = "camelCase")]
180 Bash {
181 command: String,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 exclude_from_context: Option<bool>,
186 },
187 AbortBash,
189 GetSessionStats,
191 #[serde(rename_all = "camelCase")]
193 ExportHtml {
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 output_path: Option<String>,
197 },
198 #[serde(rename_all = "camelCase")]
200 SwitchSession {
201 session_path: String,
203 },
204 #[serde(rename_all = "camelCase")]
206 Fork {
207 entry_id: String,
209 },
210 Clone,
212 GetForkMessages,
214 GetEntries {
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 since: Option<String>,
219 },
220 GetTree,
222 GetLastAssistantText,
224 SetSessionName {
226 name: String,
228 },
229 GetMessages,
231 GetCommands,
233 ClearQueue,
235}
236
237#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ClientInfo {
240 #[serde(default)]
242 pub name: String,
243 #[serde(default)]
245 pub version: String,
246}
247
248#[allow(
250 clippy::large_enum_variant,
251 reason = "commands are built once and moved whole; boxing every payload adds noise"
252)]
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
254#[serde(tag = "type")]
255pub enum OinoCommand {
256 #[serde(rename = "_oino/hello")]
258 Hello {
259 #[serde(default)]
261 client: ClientInfo,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 ui: Option<JsonValue>,
265 #[serde(default = "protocol_version")]
267 protocol: u32,
268 },
269 #[serde(rename = "_oino/ui_snapshot")]
272 UiSnapshot,
273 #[serde(rename = "_oino/ui_visible", rename_all = "camelCase")]
276 UiVisible {
277 entry_id: String,
279 visible: bool,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 frontend: Option<String>,
284 },
285 #[serde(rename = "_oino/ui_resync", rename_all = "camelCase")]
288 UiResync {
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 scope: Option<String>,
292 rev: u64,
294 },
295 #[serde(rename = "_oino/navigate_tree", rename_all = "camelCase")]
297 NavigateTree {
298 target_id: String,
300 #[serde(flatten)]
302 options: NavigateTreeOptions,
303 },
304 #[serde(rename = "_oino/set_label", rename_all = "camelCase")]
306 SetLabel {
307 entry_id: String,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 label: Option<String>,
312 },
313 #[serde(rename = "_oino/import_jsonl")]
315 ImportJsonl {
316 path: String,
318 },
319 #[serde(rename = "_oino/export_jsonl", rename_all = "camelCase")]
321 ExportJsonl {
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 output_path: Option<String>,
325 },
326 #[serde(rename = "_oino/get_context_usage")]
328 GetContextUsage,
329 #[serde(rename = "_oino/get_completions")]
331 GetCompletions {
332 text: String,
334 cursor: usize,
336 },
337 #[serde(rename = "_oino/ui_action", rename_all = "camelCase")]
339 UiAction {
340 panel_id: String,
342 action_id: String,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
346 value: Option<JsonValue>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 values: Option<JsonValue>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 path: Option<JsonValue>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 source: Option<String>,
356 },
357 #[serde(rename = "_oino/editor_event")]
359 EditorEvent(EditorEvent),
360 #[serde(rename = "_oino/invoke_shortcut", rename_all = "camelCase")]
363 InvokeShortcut {
364 shortcut_id: String,
366 },
367 #[serde(rename = "_oino/get_shortcuts")]
369 GetShortcuts,
370 #[serde(rename = "_oino/get_settings_schema")]
372 GetSettingsSchema,
373 #[serde(rename = "_oino/get_extension_settings")]
375 GetExtensionSettings {
376 namespace: String,
378 #[serde(default)]
380 scope: SettingsLayer,
381 },
382 #[serde(rename = "_oino/set_extension_setting")]
384 SetExtensionSetting {
385 namespace: String,
387 key: String,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 value: Option<JsonValue>,
392 scope: SettingsScope,
394 },
395 #[serde(rename = "_oino/unset_extension_setting")]
397 UnsetExtensionSetting {
398 namespace: String,
400 key: String,
402 scope: SettingsScope,
404 },
405 #[serde(rename = "_oino/get_settings")]
407 GetSettings {
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 key: Option<String>,
411 },
412 #[serde(rename = "_oino/set_setting")]
414 SetSetting {
415 key: String,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 value: Option<JsonValue>,
420 scope: SettingsScope,
422 },
423 #[serde(rename = "_oino/service_call")]
425 ServiceCall {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 target: Option<String>,
429 name: String,
431 #[serde(default)]
433 payload: JsonValue,
434 },
435 #[serde(rename = "_oino/auth_providers")]
437 AuthProviders,
438 #[serde(rename = "_oino/login")]
440 Login {
441 provider: String,
443 },
444 #[serde(rename = "_oino/logout")]
446 Logout {
447 provider: String,
449 },
450 #[serde(rename = "_oino/set_api_key")]
452 SetApiKey {
453 provider: String,
455 key: String,
457 },
458 #[serde(rename = "_oino/topics")]
461 Topics {
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
464 subscribe: Vec<String>,
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 unsubscribe: Vec<String>,
468 },
469 #[serde(rename = "_oino/bus_get")]
471 BusGet {
472 topic: String,
474 },
475 #[serde(rename = "_oino/agents")]
478 Agents {
479 #[serde(default, skip_serializing_if = "Option::is_none")]
481 follow: Option<bool>,
482 },
483 #[serde(rename = "_oino/agent_messages", rename_all = "camelCase")]
485 AgentMessages {
486 agent_id: String,
488 },
489 #[serde(rename = "_oino/agent_abort", rename_all = "camelCase")]
491 AgentAbort {
492 agent_id: String,
494 },
495 #[serde(rename = "_oino/queue_edit")]
497 QueueEdit(QueueEdit),
498 #[serde(rename = "_oino/checkpoints")]
500 Checkpoints,
501 #[serde(rename = "_oino/rewind", rename_all = "camelCase")]
504 Rewind {
505 entry_id: String,
507 #[serde(default = "yes")]
509 files: bool,
510 #[serde(default = "yes")]
512 conversation: bool,
513 },
514 #[serde(rename = "_oino/running_bash")]
516 RunningBash,
517 #[serde(rename = "_oino/extensions")]
519 Extensions,
520 #[serde(rename = "_oino/render", rename_all = "camelCase")]
523 Render {
524 target: String,
526 key: String,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 entry_id: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
533 turn_entry_id: Option<String>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 turn_index: Option<u64>,
537 #[serde(default)]
539 running: bool,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 density: Option<String>,
543 #[serde(default)]
545 expanded: bool,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 width: Option<String>,
549 #[serde(default)]
551 data: JsonValue,
552 },
553 #[serde(rename = "_oino/set_extension_enabled")]
555 SetExtensionEnabled {
556 id: String,
558 enabled: bool,
560 },
561}
562
563const fn yes() -> bool {
564 true
565}
566
567const fn protocol_version() -> u32 {
568 1
569}
570
571pub const OINO_COMMAND_TYPES: [&str; 37] = [
573 "_oino/hello",
574 "_oino/ui_snapshot",
575 "_oino/ui_visible",
576 "_oino/ui_resync",
577 "_oino/navigate_tree",
578 "_oino/set_label",
579 "_oino/import_jsonl",
580 "_oino/export_jsonl",
581 "_oino/get_context_usage",
582 "_oino/get_completions",
583 "_oino/ui_action",
584 "_oino/editor_event",
585 "_oino/invoke_shortcut",
586 "_oino/get_shortcuts",
587 "_oino/get_settings_schema",
588 "_oino/get_extension_settings",
589 "_oino/set_extension_setting",
590 "_oino/unset_extension_setting",
591 "_oino/get_settings",
592 "_oino/set_setting",
593 "_oino/service_call",
594 "_oino/auth_providers",
595 "_oino/login",
596 "_oino/logout",
597 "_oino/set_api_key",
598 "_oino/topics",
599 "_oino/bus_get",
600 "_oino/agents",
601 "_oino/agent_messages",
602 "_oino/agent_abort",
603 "_oino/queue_edit",
604 "_oino/checkpoints",
605 "_oino/rewind",
606 "_oino/running_bash",
607 "_oino/extensions",
608 "_oino/set_extension_enabled",
609 "_oino/render",
610];
611
612#[allow(
614 clippy::large_enum_variant,
615 reason = "one command in flight at a time; boxing the Oino variant adds a hop"
616)]
617#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
618#[serde(untagged)]
619pub enum RpcCommand {
620 Agent(AgentCommand),
622 Oino(OinoCommand),
624}
625
626impl AgentCommand {
627 #[must_use]
629 pub const fn kind(&self) -> &'static str {
630 match self {
631 Self::Prompt { .. } => "prompt",
632 Self::Steer { .. } => "steer",
633 Self::FollowUp { .. } => "follow_up",
634 Self::Abort => "abort",
635 Self::NewSession { .. } => "new_session",
636 Self::GetState => "get_state",
637 Self::SetModel { .. } => "set_model",
638 Self::CycleModel => "cycle_model",
639 Self::GetAvailableModels => "get_available_models",
640 Self::SetThinkingLevel { .. } => "set_thinking_level",
641 Self::CycleThinkingLevel => "cycle_thinking_level",
642 Self::GetAvailableThinkingLevels => "get_available_thinking_levels",
643 Self::SetSteeringMode { .. } => "set_steering_mode",
644 Self::SetFollowUpMode { .. } => "set_follow_up_mode",
645 Self::Compact { .. } => "compact",
646 Self::SetAutoCompaction { .. } => "set_auto_compaction",
647 Self::SetAutoRetry { .. } => "set_auto_retry",
648 Self::AbortRetry => "abort_retry",
649 Self::Bash { .. } => "bash",
650 Self::AbortBash => "abort_bash",
651 Self::GetSessionStats => "get_session_stats",
652 Self::ExportHtml { .. } => "export_html",
653 Self::SwitchSession { .. } => "switch_session",
654 Self::Fork { .. } => "fork",
655 Self::Clone => "clone",
656 Self::GetForkMessages => "get_fork_messages",
657 Self::GetEntries { .. } => "get_entries",
658 Self::GetTree => "get_tree",
659 Self::GetLastAssistantText => "get_last_assistant_text",
660 Self::SetSessionName { .. } => "set_session_name",
661 Self::GetMessages => "get_messages",
662 Self::GetCommands => "get_commands",
663 Self::ClearQueue => "clear_queue",
664 }
665 }
666}
667
668impl OinoCommand {
669 #[must_use]
671 pub const fn kind(&self) -> &'static str {
672 match self {
673 Self::Hello { .. } => "_oino/hello",
674 Self::UiSnapshot => "_oino/ui_snapshot",
675 Self::UiVisible { .. } => "_oino/ui_visible",
676 Self::UiResync { .. } => "_oino/ui_resync",
677 Self::NavigateTree { .. } => "_oino/navigate_tree",
678 Self::SetLabel { .. } => "_oino/set_label",
679 Self::ImportJsonl { .. } => "_oino/import_jsonl",
680 Self::ExportJsonl { .. } => "_oino/export_jsonl",
681 Self::GetContextUsage => "_oino/get_context_usage",
682 Self::GetCompletions { .. } => "_oino/get_completions",
683 Self::UiAction { .. } => "_oino/ui_action",
684 Self::EditorEvent(_) => "_oino/editor_event",
685 Self::InvokeShortcut { .. } => "_oino/invoke_shortcut",
686 Self::GetShortcuts => "_oino/get_shortcuts",
687 Self::GetSettingsSchema => "_oino/get_settings_schema",
688 Self::GetExtensionSettings { .. } => "_oino/get_extension_settings",
689 Self::SetExtensionSetting { .. } => "_oino/set_extension_setting",
690 Self::UnsetExtensionSetting { .. } => "_oino/unset_extension_setting",
691 Self::GetSettings { .. } => "_oino/get_settings",
692 Self::SetSetting { .. } => "_oino/set_setting",
693 Self::ServiceCall { .. } => "_oino/service_call",
694 Self::AuthProviders => "_oino/auth_providers",
695 Self::Login { .. } => "_oino/login",
696 Self::Logout { .. } => "_oino/logout",
697 Self::SetApiKey { .. } => "_oino/set_api_key",
698 Self::Topics { .. } => "_oino/topics",
699 Self::BusGet { .. } => "_oino/bus_get",
700 Self::Agents { .. } => "_oino/agents",
701 Self::AgentMessages { .. } => "_oino/agent_messages",
702 Self::AgentAbort { .. } => "_oino/agent_abort",
703 Self::QueueEdit(_) => "_oino/queue_edit",
704 Self::Checkpoints => "_oino/checkpoints",
705 Self::Rewind { .. } => "_oino/rewind",
706 Self::RunningBash => "_oino/running_bash",
707 Self::Extensions => "_oino/extensions",
708 Self::SetExtensionEnabled { .. } => "_oino/set_extension_enabled",
709 Self::Render { .. } => "_oino/render",
710 }
711 }
712}
713
714impl RpcCommand {
715 #[must_use]
717 pub const fn kind(&self) -> &'static str {
718 match self {
719 Self::Agent(command) => command.kind(),
720 Self::Oino(command) => command.kind(),
721 }
722 }
723}
724
725impl From<AgentCommand> for RpcCommand {
726 fn from(command: AgentCommand) -> Self {
727 Self::Agent(command)
728 }
729}
730
731impl From<OinoCommand> for RpcCommand {
732 fn from(command: OinoCommand) -> Self {
733 Self::Oino(command)
734 }
735}
736
737#[derive(Clone, Debug, PartialEq)]
739pub struct RpcRequest {
740 pub id: Option<JsonValue>,
742 pub command: RpcCommand,
744}
745
746#[derive(Clone, Debug, PartialEq, Eq)]
748pub struct RejectedCommand {
749 pub id: Option<JsonValue>,
751 pub command: Option<String>,
753 pub error: String,
755}
756
757impl RpcRequest {
758 pub fn from_value(value: JsonValue) -> Result<Self, RejectedCommand> {
765 let id = value.get("id").filter(|id| !id.is_null()).cloned();
766 let kind = value
767 .get("type")
768 .and_then(JsonValue::as_str)
769 .map(str::to_owned);
770 let Some(kind) = kind else {
771 return Err(RejectedCommand {
772 id,
773 command: None,
774 error: "Unknown command: undefined".to_owned(),
775 });
776 };
777 let known = AGENT_COMMAND_TYPES.contains(&kind.as_str())
778 || OINO_COMMAND_TYPES.contains(&kind.as_str());
779 if !known {
780 return Err(RejectedCommand {
781 id,
782 error: format!("Unknown command: {kind}"),
783 command: Some(kind),
784 });
785 }
786 let decoded = if kind.starts_with("_oino/") {
787 serde_json::from_value::<OinoCommand>(value).map(RpcCommand::Oino)
788 } else {
789 serde_json::from_value::<AgentCommand>(value).map(RpcCommand::Agent)
790 };
791 decoded
792 .map(|command| Self {
793 id: id.clone(),
794 command,
795 })
796 .map_err(|error| RejectedCommand {
797 id,
798 command: Some(kind),
799 error: error.to_string(),
800 })
801 }
802
803 #[must_use]
805 pub fn to_value(&self) -> JsonValue {
806 let mut object = JsonObject::new();
807 if let Some(id) = &self.id {
808 object.insert("id".to_owned(), id.clone());
809 }
810 if let Ok(JsonValue::Object(fields)) = serde_json::to_value(&self.command) {
811 object.extend(fields);
812 }
813 JsonValue::Object(object)
814 }
815}
816
817pub type RpcResponseData = Option<JsonValue>;
819
820#[derive(Clone, Debug, PartialEq)]
822pub struct RpcResponse {
823 pub id: Option<JsonValue>,
825 pub command: Option<String>,
827 pub result: Result<RpcResponseData, String>,
829}
830
831impl RpcResponse {
832 #[must_use]
834 pub fn success(
835 id: Option<JsonValue>,
836 command: impl Into<String>,
837 data: RpcResponseData,
838 ) -> Self {
839 Self {
840 id,
841 command: Some(command.into()),
842 result: Ok(data),
843 }
844 }
845
846 #[must_use]
848 pub fn error(id: Option<JsonValue>, command: Option<String>, error: impl Into<String>) -> Self {
849 Self {
850 id,
851 command,
852 result: Err(error.into()),
853 }
854 }
855
856 #[must_use]
858 pub fn from_value(value: &JsonValue) -> Option<Self> {
859 if value.get("type").and_then(JsonValue::as_str) != Some("response") {
860 return None;
861 }
862 let id = value.get("id").cloned();
863 let command = value
864 .get("command")
865 .and_then(JsonValue::as_str)
866 .map(str::to_owned);
867 let result = if value.get("success") == Some(&JsonValue::Bool(true)) {
868 Ok(value.get("data").cloned())
869 } else {
870 Err(value
871 .get("error")
872 .and_then(JsonValue::as_str)
873 .unwrap_or_default()
874 .to_owned())
875 };
876 Some(Self {
877 id,
878 command,
879 result,
880 })
881 }
882}
883
884impl Serialize for RpcResponse {
885 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
886 let mut map = serializer.serialize_map(None)?;
887 if let Some(id) = &self.id {
888 map.serialize_entry("id", id)?;
889 }
890 map.serialize_entry("type", "response")?;
891 if let Some(command) = &self.command {
892 map.serialize_entry("command", command)?;
893 }
894 match &self.result {
895 Ok(data) => {
896 map.serialize_entry("success", &true)?;
897 if let Some(data) = data {
898 map.serialize_entry("data", data)?;
899 }
900 }
901 Err(error) => {
902 map.serialize_entry("success", &false)?;
903 map.serialize_entry("error", error)?;
904 }
905 }
906 map.end()
907 }
908}
909
910#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
912#[serde(rename_all = "camelCase")]
913pub struct RpcSessionState {
914 #[serde(default, skip_serializing_if = "Option::is_none")]
916 pub model: Option<Model>,
917 pub thinking_level: ThinkingLevel,
919 pub is_streaming: bool,
921 pub is_compacting: bool,
923 pub steering_mode: QueueMode,
925 pub follow_up_mode: QueueMode,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub session_file: Option<String>,
930 pub session_id: String,
932 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub session_name: Option<String>,
935 pub auto_compaction_enabled: bool,
937 pub message_count: usize,
939 pub pending_message_count: usize,
941}
942
943pub type RpcSlashCommand = oino_core::session::SlashCommandInfo;
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949 use serde_json::json;
950
951 #[test]
952 fn decodes_pi_and_oino_commands() {
953 let request = RpcRequest::from_value(json!({"id":"1","type":"prompt","message":"hi","images":[{"type":"image","data":"AA","mimeType":"image/png"}],"streamingBehavior":"followUp"})).unwrap();
954 assert_eq!(request.id, Some(json!("1")));
955 assert_eq!(
956 request.command,
957 RpcCommand::Agent(AgentCommand::Prompt {
958 message: "hi".into(),
959 images: vec![ImageContent {
960 data: "AA".into(),
961 mime_type: "image/png".into()
962 }],
963 streaming_behavior: Some(StreamingBehavior::FollowUp),
964 })
965 );
966 assert_eq!(
967 request.to_value(),
968 json!({"id":"1","type":"prompt","message":"hi","images":[{"type":"image","data":"AA","mimeType":"image/png"}],"streamingBehavior":"followUp"})
969 );
970 let hello = RpcRequest::from_value(
971 json!({"type":"_oino/hello","client":{"name":"t","version":"1"}}),
972 )
973 .unwrap();
974 assert!(matches!(
975 hello.command,
976 RpcCommand::Oino(OinoCommand::Hello { protocol: 1, .. })
977 ));
978 let navigate = RpcRequest::from_value(
979 json!({"type":"_oino/navigate_tree","targetId":"ab","summarize":true}),
980 )
981 .unwrap();
982 assert_eq!(navigate.command.kind(), "_oino/navigate_tree");
983 assert_eq!(
984 RpcRequest::from_value(json!({"type":"abort"}))
985 .unwrap()
986 .command,
987 RpcCommand::Agent(AgentCommand::Abort)
988 );
989 }
990
991 #[test]
992 fn rejects_unknown_and_malformed_commands() {
993 let unknown = RpcRequest::from_value(json!({"id":7,"type":"nope"})).unwrap_err();
994 assert_eq!(unknown.error, "Unknown command: nope");
995 assert_eq!(unknown.id, Some(json!(7)));
996 let missing = RpcRequest::from_value(json!({})).unwrap_err();
997 assert_eq!(missing.command, None);
998 assert_eq!(missing.error, "Unknown command: undefined");
999 let bad = RpcRequest::from_value(json!({"type":"set_model"})).unwrap_err();
1000 assert_eq!(bad.command.as_deref(), Some("set_model"));
1001 assert!(bad.error.contains("provider"), "{}", bad.error);
1002 }
1003
1004 #[test]
1005 fn responses_use_pi_key_order() {
1006 let ok = RpcResponse::success(Some(json!("a")), "cycle_model", Some(JsonValue::Null));
1007 assert_eq!(
1008 serde_json::to_string(&ok).unwrap(),
1009 r#"{"id":"a","type":"response","command":"cycle_model","success":true,"data":null}"#
1010 );
1011 let empty = RpcResponse::success(None, "abort", None);
1012 assert_eq!(
1013 serde_json::to_string(&empty).unwrap(),
1014 r#"{"type":"response","command":"abort","success":true}"#
1015 );
1016 let error = RpcResponse::error(None, Some("parse".into()), "Failed to parse command: x");
1017 let text = serde_json::to_string(&error).unwrap();
1018 assert_eq!(
1019 text,
1020 r#"{"type":"response","command":"parse","success":false,"error":"Failed to parse command: x"}"#
1021 );
1022 let back = RpcResponse::from_value(&serde_json::from_str(&text).unwrap()).unwrap();
1023 assert_eq!(back, error);
1024 }
1025}