use oino_agent::QueueMode;
use oino_ai::{ImageContent, JsonObject, JsonValue, Model, ThinkingLevel};
pub use oino_core::agents::{AgentInfo, AgentRunState};
use oino_core::extensions::settings::SettingsLayer;
pub use oino_core::session::{
AuthProviderInfo, Checkpoint, CheckpointFile, CompletionEntry, CompletionGroup, EditorEvent,
EditorIntent, ExtensionInfo, QueueEdit, QueueKind, RewindFailure, RewindResult, RunningBash,
};
use oino_core::session::{NavigateTreeOptions, StreamingBehavior};
pub use oino_core::topics::TopicValue;
pub use oino_settings::SettingsScope;
use serde::ser::SerializeMap;
use serde::{Deserialize, Serialize, Serializer};
pub const AGENT_COMMAND_TYPES: [&str; 33] = [
"prompt",
"steer",
"follow_up",
"abort",
"new_session",
"get_state",
"set_model",
"cycle_model",
"get_available_models",
"set_thinking_level",
"cycle_thinking_level",
"get_available_thinking_levels",
"set_steering_mode",
"set_follow_up_mode",
"compact",
"set_auto_compaction",
"set_auto_retry",
"abort_retry",
"bash",
"abort_bash",
"get_session_stats",
"export_html",
"switch_session",
"fork",
"clone",
"get_fork_messages",
"get_entries",
"get_tree",
"get_last_assistant_text",
"set_session_name",
"get_messages",
"get_commands",
"clear_queue",
];
mod wire_images {
use oino_ai::ImageContent;
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serializer};
use serde_json::json;
pub(super) fn serialize<S: Serializer>(
images: &[ImageContent],
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut seq = serializer.serialize_seq(Some(images.len()))?;
for image in images {
seq.serialize_element(
&json!({"type": "image", "data": image.data, "mimeType": image.mime_type}),
)?;
}
seq.end()
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<ImageContent>, D::Error> {
Ok(Option::<Vec<ImageContent>>::deserialize(deserializer)?.unwrap_or_default())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentCommand {
#[serde(rename_all = "camelCase")]
Prompt {
message: String,
#[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
streaming_behavior: Option<StreamingBehavior>,
},
Steer {
message: String,
#[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageContent>,
},
FollowUp {
message: String,
#[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
images: Vec<ImageContent>,
},
Abort,
#[serde(rename_all = "camelCase")]
NewSession {
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_session: Option<String>,
},
GetState,
#[serde(rename_all = "camelCase")]
SetModel {
provider: String,
model_id: String,
},
CycleModel,
GetAvailableModels,
SetThinkingLevel {
level: ThinkingLevel,
},
CycleThinkingLevel,
GetAvailableThinkingLevels,
SetSteeringMode {
mode: QueueMode,
},
SetFollowUpMode {
mode: QueueMode,
},
#[serde(rename_all = "camelCase")]
Compact {
#[serde(default, skip_serializing_if = "Option::is_none")]
custom_instructions: Option<String>,
},
SetAutoCompaction {
enabled: bool,
},
SetAutoRetry {
enabled: bool,
},
AbortRetry,
#[serde(rename_all = "camelCase")]
Bash {
command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
exclude_from_context: Option<bool>,
},
AbortBash,
GetSessionStats,
#[serde(rename_all = "camelCase")]
ExportHtml {
#[serde(default, skip_serializing_if = "Option::is_none")]
output_path: Option<String>,
},
#[serde(rename_all = "camelCase")]
SwitchSession {
session_path: String,
},
#[serde(rename_all = "camelCase")]
Fork {
entry_id: String,
},
Clone,
GetForkMessages,
GetEntries {
#[serde(default, skip_serializing_if = "Option::is_none")]
since: Option<String>,
},
GetTree,
GetLastAssistantText,
SetSessionName {
name: String,
},
GetMessages,
GetCommands,
ClearQueue,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientInfo {
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: String,
}
#[allow(
clippy::large_enum_variant,
reason = "commands are built once and moved whole; boxing every payload adds noise"
)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum OinoCommand {
#[serde(rename = "_oino/hello")]
Hello {
#[serde(default)]
client: ClientInfo,
#[serde(default, skip_serializing_if = "Option::is_none")]
ui: Option<JsonValue>,
#[serde(default = "protocol_version")]
protocol: u32,
},
#[serde(rename = "_oino/ui_snapshot")]
UiSnapshot,
#[serde(rename = "_oino/ui_visible", rename_all = "camelCase")]
UiVisible {
entry_id: String,
visible: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
frontend: Option<String>,
},
#[serde(rename = "_oino/ui_resync", rename_all = "camelCase")]
UiResync {
#[serde(default, skip_serializing_if = "Option::is_none")]
scope: Option<String>,
rev: u64,
},
#[serde(rename = "_oino/navigate_tree", rename_all = "camelCase")]
NavigateTree {
target_id: String,
#[serde(flatten)]
options: NavigateTreeOptions,
},
#[serde(rename = "_oino/set_label", rename_all = "camelCase")]
SetLabel {
entry_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
#[serde(rename = "_oino/import_jsonl")]
ImportJsonl {
path: String,
},
#[serde(rename = "_oino/export_jsonl", rename_all = "camelCase")]
ExportJsonl {
#[serde(default, skip_serializing_if = "Option::is_none")]
output_path: Option<String>,
},
#[serde(rename = "_oino/get_context_usage")]
GetContextUsage,
#[serde(rename = "_oino/get_completions")]
GetCompletions {
text: String,
cursor: usize,
},
#[serde(rename = "_oino/ui_action", rename_all = "camelCase")]
UiAction {
panel_id: String,
action_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
value: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
values: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
path: Option<JsonValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<String>,
},
#[serde(rename = "_oino/editor_event")]
EditorEvent(EditorEvent),
#[serde(rename = "_oino/invoke_shortcut", rename_all = "camelCase")]
InvokeShortcut {
shortcut_id: String,
},
#[serde(rename = "_oino/get_shortcuts")]
GetShortcuts,
#[serde(rename = "_oino/get_settings_schema")]
GetSettingsSchema,
#[serde(rename = "_oino/get_extension_settings")]
GetExtensionSettings {
namespace: String,
#[serde(default)]
scope: SettingsLayer,
},
#[serde(rename = "_oino/set_extension_setting")]
SetExtensionSetting {
namespace: String,
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
value: Option<JsonValue>,
scope: SettingsScope,
},
#[serde(rename = "_oino/unset_extension_setting")]
UnsetExtensionSetting {
namespace: String,
key: String,
scope: SettingsScope,
},
#[serde(rename = "_oino/get_settings")]
GetSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
},
#[serde(rename = "_oino/set_setting")]
SetSetting {
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
value: Option<JsonValue>,
scope: SettingsScope,
},
#[serde(rename = "_oino/service_call")]
ServiceCall {
#[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<String>,
name: String,
#[serde(default)]
payload: JsonValue,
},
#[serde(rename = "_oino/auth_providers")]
AuthProviders,
#[serde(rename = "_oino/login")]
Login {
provider: String,
},
#[serde(rename = "_oino/logout")]
Logout {
provider: String,
},
#[serde(rename = "_oino/set_api_key")]
SetApiKey {
provider: String,
key: String,
},
#[serde(rename = "_oino/topics")]
Topics {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
subscribe: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
unsubscribe: Vec<String>,
},
#[serde(rename = "_oino/bus_get")]
BusGet {
topic: String,
},
#[serde(rename = "_oino/agents")]
Agents {
#[serde(default, skip_serializing_if = "Option::is_none")]
follow: Option<bool>,
},
#[serde(rename = "_oino/agent_messages", rename_all = "camelCase")]
AgentMessages {
agent_id: String,
},
#[serde(rename = "_oino/agent_abort", rename_all = "camelCase")]
AgentAbort {
agent_id: String,
},
#[serde(rename = "_oino/queue_edit")]
QueueEdit(QueueEdit),
#[serde(rename = "_oino/checkpoints")]
Checkpoints,
#[serde(rename = "_oino/rewind", rename_all = "camelCase")]
Rewind {
entry_id: String,
#[serde(default = "yes")]
files: bool,
#[serde(default = "yes")]
conversation: bool,
},
#[serde(rename = "_oino/running_bash")]
RunningBash,
#[serde(rename = "_oino/extensions")]
Extensions,
#[serde(rename = "_oino/render", rename_all = "camelCase")]
Render {
target: String,
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_index: Option<u64>,
#[serde(default)]
running: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
density: Option<String>,
#[serde(default)]
expanded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
width: Option<String>,
#[serde(default)]
data: JsonValue,
},
#[serde(rename = "_oino/set_extension_enabled")]
SetExtensionEnabled {
id: String,
enabled: bool,
},
}
const fn yes() -> bool {
true
}
const fn protocol_version() -> u32 {
1
}
pub const OINO_COMMAND_TYPES: [&str; 37] = [
"_oino/hello",
"_oino/ui_snapshot",
"_oino/ui_visible",
"_oino/ui_resync",
"_oino/navigate_tree",
"_oino/set_label",
"_oino/import_jsonl",
"_oino/export_jsonl",
"_oino/get_context_usage",
"_oino/get_completions",
"_oino/ui_action",
"_oino/editor_event",
"_oino/invoke_shortcut",
"_oino/get_shortcuts",
"_oino/get_settings_schema",
"_oino/get_extension_settings",
"_oino/set_extension_setting",
"_oino/unset_extension_setting",
"_oino/get_settings",
"_oino/set_setting",
"_oino/service_call",
"_oino/auth_providers",
"_oino/login",
"_oino/logout",
"_oino/set_api_key",
"_oino/topics",
"_oino/bus_get",
"_oino/agents",
"_oino/agent_messages",
"_oino/agent_abort",
"_oino/queue_edit",
"_oino/checkpoints",
"_oino/rewind",
"_oino/running_bash",
"_oino/extensions",
"_oino/set_extension_enabled",
"_oino/render",
];
#[allow(
clippy::large_enum_variant,
reason = "one command in flight at a time; boxing the Oino variant adds a hop"
)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum RpcCommand {
Agent(AgentCommand),
Oino(OinoCommand),
}
impl AgentCommand {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Prompt { .. } => "prompt",
Self::Steer { .. } => "steer",
Self::FollowUp { .. } => "follow_up",
Self::Abort => "abort",
Self::NewSession { .. } => "new_session",
Self::GetState => "get_state",
Self::SetModel { .. } => "set_model",
Self::CycleModel => "cycle_model",
Self::GetAvailableModels => "get_available_models",
Self::SetThinkingLevel { .. } => "set_thinking_level",
Self::CycleThinkingLevel => "cycle_thinking_level",
Self::GetAvailableThinkingLevels => "get_available_thinking_levels",
Self::SetSteeringMode { .. } => "set_steering_mode",
Self::SetFollowUpMode { .. } => "set_follow_up_mode",
Self::Compact { .. } => "compact",
Self::SetAutoCompaction { .. } => "set_auto_compaction",
Self::SetAutoRetry { .. } => "set_auto_retry",
Self::AbortRetry => "abort_retry",
Self::Bash { .. } => "bash",
Self::AbortBash => "abort_bash",
Self::GetSessionStats => "get_session_stats",
Self::ExportHtml { .. } => "export_html",
Self::SwitchSession { .. } => "switch_session",
Self::Fork { .. } => "fork",
Self::Clone => "clone",
Self::GetForkMessages => "get_fork_messages",
Self::GetEntries { .. } => "get_entries",
Self::GetTree => "get_tree",
Self::GetLastAssistantText => "get_last_assistant_text",
Self::SetSessionName { .. } => "set_session_name",
Self::GetMessages => "get_messages",
Self::GetCommands => "get_commands",
Self::ClearQueue => "clear_queue",
}
}
}
impl OinoCommand {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Hello { .. } => "_oino/hello",
Self::UiSnapshot => "_oino/ui_snapshot",
Self::UiVisible { .. } => "_oino/ui_visible",
Self::UiResync { .. } => "_oino/ui_resync",
Self::NavigateTree { .. } => "_oino/navigate_tree",
Self::SetLabel { .. } => "_oino/set_label",
Self::ImportJsonl { .. } => "_oino/import_jsonl",
Self::ExportJsonl { .. } => "_oino/export_jsonl",
Self::GetContextUsage => "_oino/get_context_usage",
Self::GetCompletions { .. } => "_oino/get_completions",
Self::UiAction { .. } => "_oino/ui_action",
Self::EditorEvent(_) => "_oino/editor_event",
Self::InvokeShortcut { .. } => "_oino/invoke_shortcut",
Self::GetShortcuts => "_oino/get_shortcuts",
Self::GetSettingsSchema => "_oino/get_settings_schema",
Self::GetExtensionSettings { .. } => "_oino/get_extension_settings",
Self::SetExtensionSetting { .. } => "_oino/set_extension_setting",
Self::UnsetExtensionSetting { .. } => "_oino/unset_extension_setting",
Self::GetSettings { .. } => "_oino/get_settings",
Self::SetSetting { .. } => "_oino/set_setting",
Self::ServiceCall { .. } => "_oino/service_call",
Self::AuthProviders => "_oino/auth_providers",
Self::Login { .. } => "_oino/login",
Self::Logout { .. } => "_oino/logout",
Self::SetApiKey { .. } => "_oino/set_api_key",
Self::Topics { .. } => "_oino/topics",
Self::BusGet { .. } => "_oino/bus_get",
Self::Agents { .. } => "_oino/agents",
Self::AgentMessages { .. } => "_oino/agent_messages",
Self::AgentAbort { .. } => "_oino/agent_abort",
Self::QueueEdit(_) => "_oino/queue_edit",
Self::Checkpoints => "_oino/checkpoints",
Self::Rewind { .. } => "_oino/rewind",
Self::RunningBash => "_oino/running_bash",
Self::Extensions => "_oino/extensions",
Self::SetExtensionEnabled { .. } => "_oino/set_extension_enabled",
Self::Render { .. } => "_oino/render",
}
}
}
impl RpcCommand {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::Agent(command) => command.kind(),
Self::Oino(command) => command.kind(),
}
}
}
impl From<AgentCommand> for RpcCommand {
fn from(command: AgentCommand) -> Self {
Self::Agent(command)
}
}
impl From<OinoCommand> for RpcCommand {
fn from(command: OinoCommand) -> Self {
Self::Oino(command)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct RpcRequest {
pub id: Option<JsonValue>,
pub command: RpcCommand,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RejectedCommand {
pub id: Option<JsonValue>,
pub command: Option<String>,
pub error: String,
}
impl RpcRequest {
pub fn from_value(value: JsonValue) -> Result<Self, RejectedCommand> {
let id = value.get("id").filter(|id| !id.is_null()).cloned();
let kind = value
.get("type")
.and_then(JsonValue::as_str)
.map(str::to_owned);
let Some(kind) = kind else {
return Err(RejectedCommand {
id,
command: None,
error: "Unknown command: undefined".to_owned(),
});
};
let known = AGENT_COMMAND_TYPES.contains(&kind.as_str())
|| OINO_COMMAND_TYPES.contains(&kind.as_str());
if !known {
return Err(RejectedCommand {
id,
error: format!("Unknown command: {kind}"),
command: Some(kind),
});
}
let decoded = if kind.starts_with("_oino/") {
serde_json::from_value::<OinoCommand>(value).map(RpcCommand::Oino)
} else {
serde_json::from_value::<AgentCommand>(value).map(RpcCommand::Agent)
};
decoded
.map(|command| Self {
id: id.clone(),
command,
})
.map_err(|error| RejectedCommand {
id,
command: Some(kind),
error: error.to_string(),
})
}
#[must_use]
pub fn to_value(&self) -> JsonValue {
let mut object = JsonObject::new();
if let Some(id) = &self.id {
object.insert("id".to_owned(), id.clone());
}
if let Ok(JsonValue::Object(fields)) = serde_json::to_value(&self.command) {
object.extend(fields);
}
JsonValue::Object(object)
}
}
pub type RpcResponseData = Option<JsonValue>;
#[derive(Clone, Debug, PartialEq)]
pub struct RpcResponse {
pub id: Option<JsonValue>,
pub command: Option<String>,
pub result: Result<RpcResponseData, String>,
}
impl RpcResponse {
#[must_use]
pub fn success(
id: Option<JsonValue>,
command: impl Into<String>,
data: RpcResponseData,
) -> Self {
Self {
id,
command: Some(command.into()),
result: Ok(data),
}
}
#[must_use]
pub fn error(id: Option<JsonValue>, command: Option<String>, error: impl Into<String>) -> Self {
Self {
id,
command,
result: Err(error.into()),
}
}
#[must_use]
pub fn from_value(value: &JsonValue) -> Option<Self> {
if value.get("type").and_then(JsonValue::as_str) != Some("response") {
return None;
}
let id = value.get("id").cloned();
let command = value
.get("command")
.and_then(JsonValue::as_str)
.map(str::to_owned);
let result = if value.get("success") == Some(&JsonValue::Bool(true)) {
Ok(value.get("data").cloned())
} else {
Err(value
.get("error")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.to_owned())
};
Some(Self {
id,
command,
result,
})
}
}
impl Serialize for RpcResponse {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(None)?;
if let Some(id) = &self.id {
map.serialize_entry("id", id)?;
}
map.serialize_entry("type", "response")?;
if let Some(command) = &self.command {
map.serialize_entry("command", command)?;
}
match &self.result {
Ok(data) => {
map.serialize_entry("success", &true)?;
if let Some(data) = data {
map.serialize_entry("data", data)?;
}
}
Err(error) => {
map.serialize_entry("success", &false)?;
map.serialize_entry("error", error)?;
}
}
map.end()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RpcSessionState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<Model>,
pub thinking_level: ThinkingLevel,
pub is_streaming: bool,
pub is_compacting: bool,
pub steering_mode: QueueMode,
pub follow_up_mode: QueueMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_file: Option<String>,
pub session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_name: Option<String>,
pub auto_compaction_enabled: bool,
pub message_count: usize,
pub pending_message_count: usize,
}
pub type RpcSlashCommand = oino_core::session::SlashCommandInfo;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn decodes_pi_and_oino_commands() {
let request = RpcRequest::from_value(json!({"id":"1","type":"prompt","message":"hi","images":[{"type":"image","data":"AA","mimeType":"image/png"}],"streamingBehavior":"followUp"})).unwrap();
assert_eq!(request.id, Some(json!("1")));
assert_eq!(
request.command,
RpcCommand::Agent(AgentCommand::Prompt {
message: "hi".into(),
images: vec![ImageContent {
data: "AA".into(),
mime_type: "image/png".into()
}],
streaming_behavior: Some(StreamingBehavior::FollowUp),
})
);
assert_eq!(
request.to_value(),
json!({"id":"1","type":"prompt","message":"hi","images":[{"type":"image","data":"AA","mimeType":"image/png"}],"streamingBehavior":"followUp"})
);
let hello = RpcRequest::from_value(
json!({"type":"_oino/hello","client":{"name":"t","version":"1"}}),
)
.unwrap();
assert!(matches!(
hello.command,
RpcCommand::Oino(OinoCommand::Hello { protocol: 1, .. })
));
let navigate = RpcRequest::from_value(
json!({"type":"_oino/navigate_tree","targetId":"ab","summarize":true}),
)
.unwrap();
assert_eq!(navigate.command.kind(), "_oino/navigate_tree");
assert_eq!(
RpcRequest::from_value(json!({"type":"abort"}))
.unwrap()
.command,
RpcCommand::Agent(AgentCommand::Abort)
);
}
#[test]
fn rejects_unknown_and_malformed_commands() {
let unknown = RpcRequest::from_value(json!({"id":7,"type":"nope"})).unwrap_err();
assert_eq!(unknown.error, "Unknown command: nope");
assert_eq!(unknown.id, Some(json!(7)));
let missing = RpcRequest::from_value(json!({})).unwrap_err();
assert_eq!(missing.command, None);
assert_eq!(missing.error, "Unknown command: undefined");
let bad = RpcRequest::from_value(json!({"type":"set_model"})).unwrap_err();
assert_eq!(bad.command.as_deref(), Some("set_model"));
assert!(bad.error.contains("provider"), "{}", bad.error);
}
#[test]
fn responses_use_pi_key_order() {
let ok = RpcResponse::success(Some(json!("a")), "cycle_model", Some(JsonValue::Null));
assert_eq!(
serde_json::to_string(&ok).unwrap(),
r#"{"id":"a","type":"response","command":"cycle_model","success":true,"data":null}"#
);
let empty = RpcResponse::success(None, "abort", None);
assert_eq!(
serde_json::to_string(&empty).unwrap(),
r#"{"type":"response","command":"abort","success":true}"#
);
let error = RpcResponse::error(None, Some("parse".into()), "Failed to parse command: x");
let text = serde_json::to_string(&error).unwrap();
assert_eq!(
text,
r#"{"type":"response","command":"parse","success":false,"error":"Failed to parse command: x"}"#
);
let back = RpcResponse::from_value(&serde_json::from_str(&text).unwrap()).unwrap();
assert_eq!(back, error);
}
}