Skip to main content

oino_client/
protocol.rs

1//! RPC records (the core command set + Oino `_oino/*`, modes.md §3–§4).
2//!
3//! Commands are `{id?, type, …fields}` objects; replies are
4//! `{id?, type:"response", command, success, data? | error}`. The same [`RpcCommand`] enum
5//! travels in-process (no serialisation) and over JSONL.
6
7use 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
21/// Core command types, in protocol order.
22pub 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
58/// Image attachments travel as `{type:"image", data, mimeType}`.
59mod 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/// An agent session command (the RPC command without `id`).
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum AgentCommand {
89    /// Send a prompt; replies once it was handled, queued or started.
90    #[serde(rename_all = "camelCase")]
91    Prompt {
92        /// Text.
93        message: String,
94        /// Attachments.
95        #[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
96        images: Vec<ImageContent>,
97        /// Queueing while streaming.
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        streaming_behavior: Option<StreamingBehavior>,
100    },
101    /// Queue a steering message.
102    Steer {
103        /// Text.
104        message: String,
105        /// Attachments.
106        #[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
107        images: Vec<ImageContent>,
108    },
109    /// Queue a follow-up message.
110    FollowUp {
111        /// Text.
112        message: String,
113        /// Attachments.
114        #[serde(default, with = "wire_images", skip_serializing_if = "Vec::is_empty")]
115        images: Vec<ImageContent>,
116    },
117    /// Abort the current operation and wait for idle.
118    Abort,
119    /// Start a new session.
120    #[serde(rename_all = "camelCase")]
121    NewSession {
122        /// Parent session file recorded in the header.
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        parent_session: Option<String>,
125    },
126    /// Session state.
127    GetState,
128    /// Select a model.
129    #[serde(rename_all = "camelCase")]
130    SetModel {
131        /// Provider id.
132        provider: String,
133        /// Model id.
134        model_id: String,
135    },
136    /// Cycle to the next model.
137    CycleModel,
138    /// Models with credentials.
139    GetAvailableModels,
140    /// Set the thinking level.
141    SetThinkingLevel {
142        /// Level.
143        level: ThinkingLevel,
144    },
145    /// Cycle the thinking level.
146    CycleThinkingLevel,
147    /// Levels the model supports.
148    GetAvailableThinkingLevels,
149    /// Steering drain mode.
150    SetSteeringMode {
151        /// Mode.
152        mode: QueueMode,
153    },
154    /// Follow-up drain mode.
155    SetFollowUpMode {
156        /// Mode.
157        mode: QueueMode,
158    },
159    /// Manual compaction.
160    #[serde(rename_all = "camelCase")]
161    Compact {
162        /// Summariser instructions.
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        custom_instructions: Option<String>,
165    },
166    /// Toggle automatic compaction.
167    SetAutoCompaction {
168        /// Enabled.
169        enabled: bool,
170    },
171    /// Toggle automatic retry.
172    SetAutoRetry {
173        /// Enabled.
174        enabled: bool,
175    },
176    /// Cancel a pending retry.
177    AbortRetry,
178    /// Run a `!` command.
179    #[serde(rename_all = "camelCase")]
180    Bash {
181        /// Command line.
182        command: String,
183        /// `!!`: keep out of the model context.
184        #[serde(default, skip_serializing_if = "Option::is_none")]
185        exclude_from_context: Option<bool>,
186    },
187    /// Cancel running `!` commands.
188    AbortBash,
189    /// Token and cost statistics.
190    GetSessionStats,
191    /// Export the branch as HTML.
192    #[serde(rename_all = "camelCase")]
193    ExportHtml {
194        /// Output file.
195        #[serde(default, skip_serializing_if = "Option::is_none")]
196        output_path: Option<String>,
197    },
198    /// Open another session file.
199    #[serde(rename_all = "camelCase")]
200    SwitchSession {
201        /// Session file.
202        session_path: String,
203    },
204    /// Fork before a user message.
205    #[serde(rename_all = "camelCase")]
206    Fork {
207        /// User message entry.
208        entry_id: String,
209    },
210    /// Clone the session at its leaf.
211    Clone,
212    /// User messages available for forking.
213    GetForkMessages,
214    /// Session entries (optionally after `since`).
215    GetEntries {
216        /// Entry id to start after.
217        #[serde(default, skip_serializing_if = "Option::is_none")]
218        since: Option<String>,
219    },
220    /// Session tree.
221    GetTree,
222    /// Text of the last assistant message.
223    GetLastAssistantText,
224    /// Set the session display name.
225    SetSessionName {
226        /// Name.
227        name: String,
228    },
229    /// Transcript messages.
230    GetMessages,
231    /// Slash commands invocable through `prompt`.
232    GetCommands,
233    /// Clear both queues.
234    ClearQueue,
235}
236
237/// `_oino/hello` client identity.
238#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ClientInfo {
240    /// Client name.
241    #[serde(default)]
242    pub name: String,
243    /// Client version.
244    #[serde(default)]
245    pub version: String,
246}
247
248/// Oino RPC extensions (modes.md §4) implemented by this server.
249#[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    /// Opt into Oino events and UI methods.
257    #[serde(rename = "_oino/hello")]
258    Hello {
259        /// Client identity.
260        #[serde(default)]
261        client: ClientInfo,
262        /// UI capabilities (ui-protocol §10).
263        #[serde(default, skip_serializing_if = "Option::is_none")]
264        ui: Option<JsonValue>,
265        /// Protocol version (1).
266        #[serde(default = "protocol_version")]
267        protocol: u32,
268    },
269    /// The v2 registry snapshot (`UiSnapshot`, §9.2) plus the v1 replay list while the
270    /// TUI renderer (WP-U4) and `oino-view` (WP-U5) still read `requests`.
271    #[serde(rename = "_oino/ui_snapshot")]
272    UiSnapshot,
273    /// `_oino/ui_visible` (§6.5): a frontend reports an entry visible or hidden; core
274    /// aggregates over frontends and sends `$show`/`$hide` to the owner.
275    #[serde(rename = "_oino/ui_visible", rename_all = "camelCase")]
276    UiVisible {
277        /// Entry id.
278        entry_id: String,
279        /// Visible in this frontend?
280        visible: bool,
281        /// Frontend identity used for the aggregation (defaults to the RPC client).
282        #[serde(default, skip_serializing_if = "Option::is_none")]
283        frontend: Option<String>,
284    },
285    /// `_oino/ui_resync` (§9.2/§9.3): the frontend saw a `rev` gap (or failed an op) and
286    /// wants a fresh snapshot.
287    #[serde(rename = "_oino/ui_resync", rename_all = "camelCase")]
288    UiResync {
289        /// Registry scope (session when absent).
290        #[serde(default, skip_serializing_if = "Option::is_none")]
291        scope: Option<String>,
292        /// The last revision the frontend applied.
293        rev: u64,
294    },
295    /// Navigates the entry tree.
296    #[serde(rename = "_oino/navigate_tree", rename_all = "camelCase")]
297    NavigateTree {
298        /// Target entry.
299        target_id: String,
300        /// Options.
301        #[serde(flatten)]
302        options: NavigateTreeOptions,
303    },
304    /// Set or clear an entry label.
305    #[serde(rename = "_oino/set_label", rename_all = "camelCase")]
306    SetLabel {
307        /// Entry id.
308        entry_id: String,
309        /// Label (absent clears).
310        #[serde(default, skip_serializing_if = "Option::is_none")]
311        label: Option<String>,
312    },
313    /// Import a JSONL session and switch to it.
314    #[serde(rename = "_oino/import_jsonl")]
315    ImportJsonl {
316        /// File to import.
317        path: String,
318    },
319    /// Export the branch as JSONL.
320    #[serde(rename = "_oino/export_jsonl", rename_all = "camelCase")]
321    ExportJsonl {
322        /// Output file.
323        #[serde(default, skip_serializing_if = "Option::is_none")]
324        output_path: Option<String>,
325    },
326    /// Context window usage.
327    #[serde(rename = "_oino/get_context_usage")]
328    GetContextUsage,
329    /// Merged completions at the cursor (ui-protocol §7.3) → `{groups}`.
330    #[serde(rename = "_oino/get_completions")]
331    GetCompletions {
332        /// Composer text.
333        text: String,
334        /// Cursor (character offset into `text`).
335        cursor: usize,
336    },
337    /// Panel button / list / input action (ui-protocol §4.6) for the entry's owner.
338    #[serde(rename = "_oino/ui_action", rename_all = "camelCase")]
339    UiAction {
340        /// Entry id (field name kept from v1).
341        panel_id: String,
342        /// Action id.
343        action_id: String,
344        /// Item id, input text, bool or selected value.
345        #[serde(default, skip_serializing_if = "Option::is_none")]
346        value: Option<JsonValue>,
347        /// Current values of every keyed `Input`/`Toggle`/`Select` (§4.6).
348        #[serde(default, skip_serializing_if = "Option::is_none")]
349        values: Option<JsonValue>,
350        /// Path of the node that fired the action (§4.6).
351        #[serde(default, skip_serializing_if = "Option::is_none")]
352        path: Option<JsonValue>,
353        /// `tui` | `app` | `rpc` (§4.6).
354        #[serde(default, skip_serializing_if = "Option::is_none")]
355        source: Option<String>,
356    },
357    /// Composer intent (ui-protocol §7.2) → `{consumed}`.
358    #[serde(rename = "_oino/editor_event")]
359    EditorEvent(EditorEvent),
360    /// Run an extension shortcut (ui-protocol §8). The field is `shortcutId` because `id`
361    /// is the RPC correlation id.
362    #[serde(rename = "_oino/invoke_shortcut", rename_all = "camelCase")]
363    InvokeShortcut {
364        /// Shortcut action id.
365        shortcut_id: String,
366    },
367    /// Extension shortcuts → `{shortcuts: [{id, defaultKeys, description, extensionId}]}`.
368    #[serde(rename = "_oino/get_shortcuts")]
369    GetShortcuts,
370    /// Extension settings schemas → `{namespaces}` (ui-protocol §6.6).
371    #[serde(rename = "_oino/get_settings_schema")]
372    GetSettingsSchema,
373    /// One namespace's values (secrets masked) → `{values}`.
374    #[serde(rename = "_oino/get_extension_settings")]
375    GetExtensionSettings {
376        /// Namespace.
377        namespace: String,
378        /// `effective` (default) | `global` | `project`.
379        #[serde(default)]
380        scope: SettingsLayer,
381    },
382    /// Write one extension setting.
383    #[serde(rename = "_oino/set_extension_setting")]
384    SetExtensionSetting {
385        /// Namespace.
386        namespace: String,
387        /// Dot-path key.
388        key: String,
389        /// Value (absent or `null` deletes).
390        #[serde(default, skip_serializing_if = "Option::is_none")]
391        value: Option<JsonValue>,
392        /// Target file.
393        scope: SettingsScope,
394    },
395    /// Delete one extension setting ("use default").
396    #[serde(rename = "_oino/unset_extension_setting")]
397    UnsetExtensionSetting {
398        /// Namespace.
399        namespace: String,
400        /// Dot-path key.
401        key: String,
402        /// Target file.
403        scope: SettingsScope,
404    },
405    /// Core settings (merged; `key` = dot path) → `{settings}` or `{value}`.
406    #[serde(rename = "_oino/get_settings")]
407    GetSettings {
408        /// Dot path of one value.
409        #[serde(default, skip_serializing_if = "Option::is_none")]
410        key: Option<String>,
411    },
412    /// Write one core setting (frontend keys included).
413    #[serde(rename = "_oino/set_setting")]
414    SetSetting {
415        /// Dot path.
416        key: String,
417        /// Value (absent or `null` deletes).
418        #[serde(default, skip_serializing_if = "Option::is_none")]
419        value: Option<JsonValue>,
420        /// Target file.
421        scope: SettingsScope,
422    },
423    /// Call an extension service as the frontend → the service's result.
424    #[serde(rename = "_oino/service_call")]
425    ServiceCall {
426        /// Serving extension (first server of `name` when absent).
427        #[serde(default, skip_serializing_if = "Option::is_none")]
428        target: Option<String>,
429        /// Service name.
430        name: String,
431        /// Request payload.
432        #[serde(default)]
433        payload: JsonValue,
434    },
435    /// Providers with sign-in support and credential state → `{providers}`.
436    #[serde(rename = "_oino/auth_providers")]
437    AuthProviders,
438    /// Run a provider's OAuth sign-in (dialogs through the extension UI protocol).
439    #[serde(rename = "_oino/login")]
440    Login {
441        /// Provider id.
442        provider: String,
443    },
444    /// Remove a provider's stored credential.
445    #[serde(rename = "_oino/logout")]
446    Logout {
447        /// Provider id.
448        provider: String,
449    },
450    /// Store an API key in `auth.json`.
451    #[serde(rename = "_oino/set_api_key")]
452    SetApiKey {
453        /// Provider id.
454        provider: String,
455        /// Key.
456        key: String,
457    },
458    /// Sticky topic feed: changes the subscription → `{topics: [TopicValue], patterns}`;
459    /// matching publishes then arrive as `topic` events.
460    #[serde(rename = "_oino/topics")]
461    Topics {
462        /// Patterns to add (exact, `prefix*`, `*`).
463        #[serde(default, skip_serializing_if = "Vec::is_empty")]
464        subscribe: Vec<String>,
465        /// Patterns to remove.
466        #[serde(default, skip_serializing_if = "Vec::is_empty")]
467        unsubscribe: Vec<String>,
468    },
469    /// Last sticky value of one topic → `{data | null, publisher?}`.
470    #[serde(rename = "_oino/bus_get")]
471    BusGet {
472        /// Topic.
473        topic: String,
474    },
475    /// Nested agents of the session tree → `{agents: [AgentInfo], follow}`; `follow` turns
476    /// live `agent_event` forwarding on or off.
477    #[serde(rename = "_oino/agents")]
478    Agents {
479        /// Forward child events.
480        #[serde(default, skip_serializing_if = "Option::is_none")]
481        follow: Option<bool>,
482    },
483    /// Transcript of one nested agent → `{messages}`.
484    #[serde(rename = "_oino/agent_messages", rename_all = "camelCase")]
485    AgentMessages {
486        /// Agent id.
487        agent_id: String,
488    },
489    /// Aborts the current run of one nested agent.
490    #[serde(rename = "_oino/agent_abort", rename_all = "camelCase")]
491    AgentAbort {
492        /// Agent id.
493        agent_id: String,
494    },
495    /// One single-item queue edit (`{op: remove|edit|move|sendNow, queue, …}`).
496    #[serde(rename = "_oino/queue_edit")]
497    QueueEdit(QueueEdit),
498    /// File checkpoints → `{checkpoints: [Checkpoint]}`.
499    #[serde(rename = "_oino/checkpoints")]
500    Checkpoints,
501    /// Rewind to before a user message: restore files and/or fork the conversation →
502    /// `RewindResult`.
503    #[serde(rename = "_oino/rewind", rename_all = "camelCase")]
504    Rewind {
505        /// User message entry.
506        entry_id: String,
507        /// Restore the files changed since (default true).
508        #[serde(default = "yes")]
509        files: bool,
510        /// Fork the conversation before the message (default true).
511        #[serde(default = "yes")]
512        conversation: bool,
513    },
514    /// Running foreground `bash` calls → `{calls: [RunningBash]}`.
515    #[serde(rename = "_oino/running_bash")]
516    RunningBash,
517    /// Extensions → `{extensions: [ExtensionInfo]}`.
518    #[serde(rename = "_oino/extensions")]
519    Extensions,
520    /// Transcript row render (ui-protocol §6.4) → `{node | null}` from the owner of the
521    /// render spec for `target`/`key` (`guest.render`; built-in tool rows too).
522    #[serde(rename = "_oino/render", rename_all = "camelCase")]
523    Render {
524        /// `customMessage` | `customEntry` | `tool` | `turnFooter` (§6.7).
525        target: String,
526        /// Custom type, tool name or turn-footer renderer key.
527        key: String,
528        /// Entry or tool-call id.
529        #[serde(default, skip_serializing_if = "Option::is_none")]
530        entry_id: Option<String>,
531        /// Turn id of a `turnFooter` request (the user message entry, §6.7).
532        #[serde(default, skip_serializing_if = "Option::is_none")]
533        turn_entry_id: Option<String>,
534        /// Zero-based turn index.
535        #[serde(default, skip_serializing_if = "Option::is_none")]
536        turn_index: Option<u64>,
537        /// Whether the turn is still running (§6.7).
538        #[serde(default)]
539        running: bool,
540        /// `simple` | `regular` | `advanced`.
541        #[serde(default, skip_serializing_if = "Option::is_none")]
542        density: Option<String>,
543        /// Expanded row.
544        #[serde(default)]
545        expanded: bool,
546        /// `narrow` | `normal` | `wide`.
547        #[serde(default, skip_serializing_if = "Option::is_none")]
548        width: Option<String>,
549        /// Row payload.
550        #[serde(default)]
551        data: JsonValue,
552    },
553    /// Enable or disable an extension (`disabledExtensions`; applies to new sessions).
554    #[serde(rename = "_oino/set_extension_enabled")]
555    SetExtensionEnabled {
556        /// Extension id.
557        id: String,
558        /// Enabled.
559        enabled: bool,
560    },
561}
562
563const fn yes() -> bool {
564    true
565}
566
567const fn protocol_version() -> u32 {
568    1
569}
570
571/// Oino command types implemented by [`OinoCommand`].
572pub 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/// Any command a backend accepts.
613#[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 session command.
621    Agent(AgentCommand),
622    /// Oino extension.
623    Oino(OinoCommand),
624}
625
626impl AgentCommand {
627    /// The wire `type`.
628    #[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    /// The wire `type`.
670    #[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    /// The wire `type` of the command.
716    #[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/// A parsed input line that is not an `extension_ui_response`.
738#[derive(Clone, Debug, PartialEq)]
739pub struct RpcRequest {
740    /// Correlation id (echoed verbatim).
741    pub id: Option<JsonValue>,
742    /// The command.
743    pub command: RpcCommand,
744}
745
746/// A command that could not be decoded; answered with an error response.
747#[derive(Clone, Debug, PartialEq, Eq)]
748pub struct RejectedCommand {
749    /// Correlation id.
750    pub id: Option<JsonValue>,
751    /// Command type as sent (`None` when missing).
752    pub command: Option<String>,
753    /// Error text.
754    pub error: String,
755}
756
757impl RpcRequest {
758    /// Decodes a command object (`handleCommand` dispatch rules).
759    ///
760    /// # Errors
761    ///
762    /// `Unknown command: {type}` for unknown or missing types, the decoder message for bad
763    /// fields.
764    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    /// The command object with its `id`.
804    #[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
817/// Successful reply payload (`None` = no `data` key, `Some(Null)` = `"data": null`).
818pub type RpcResponseData = Option<JsonValue>;
819
820/// `{id?, type:"response", command, success, data? | error}` in wire key order.
821#[derive(Clone, Debug, PartialEq)]
822pub struct RpcResponse {
823    /// Correlation id.
824    pub id: Option<JsonValue>,
825    /// Command type (`None` for a command without `type`).
826    pub command: Option<String>,
827    /// Outcome.
828    pub result: Result<RpcResponseData, String>,
829}
830
831impl RpcResponse {
832    /// A success reply.
833    #[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    /// An error reply.
847    #[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    /// Decodes a reply object.
857    #[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/// Session state (`get_state` data).
911#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
912#[serde(rename_all = "camelCase")]
913pub struct RpcSessionState {
914    /// Selected model.
915    #[serde(default, skip_serializing_if = "Option::is_none")]
916    pub model: Option<Model>,
917    /// Thinking level.
918    pub thinking_level: ThinkingLevel,
919    /// A run is active.
920    pub is_streaming: bool,
921    /// Compaction or branch summary running.
922    pub is_compacting: bool,
923    /// Steering drain mode.
924    pub steering_mode: QueueMode,
925    /// Follow-up drain mode.
926    pub follow_up_mode: QueueMode,
927    /// Session file.
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub session_file: Option<String>,
930    /// Session id.
931    pub session_id: String,
932    /// Display name.
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub session_name: Option<String>,
935    /// Automatic compaction enabled.
936    pub auto_compaction_enabled: bool,
937    /// Transcript length.
938    pub message_count: usize,
939    /// Queued texts.
940    pub pending_message_count: usize,
941}
942
943/// The rpc slash command record.
944pub 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}