Skip to main content

pi/modes/rpc/
types.rs

1//! RPC protocol wire types for headless operation.
2//!
3//! Port of `.references/pi/packages/coding-agent/src/modes/rpc/rpc-types.ts`.
4//!
5//! Commands arrive as JSON lines on stdin. Responses, extension UI requests, and
6//! agent events leave as JSON lines on stdout. Unknown command discriminants are
7//! retained as [`RpcCommand::Unknown`] so the server can echo `id` + `type`
8//! without a serde hard-fail.
9
10use 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// ---------------------------------------------------------------------------
22// Local payload types not yet owned by product-core modules
23// ---------------------------------------------------------------------------
24
25/// Bash execution result returned by the `bash` RPC command.
26///
27/// Matches `.references/pi/packages/coding-agent/src/core/bash-executor.ts`
28/// `BashResult`. Defined here until the product bash-executor surface lands.
29#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct BashResult {
32    /// Combined stdout + stderr (sanitized, possibly truncated).
33    pub output: String,
34    /// Process exit code (`None` when killed/cancelled).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub exit_code: Option<i32>,
37    /// Whether the command was cancelled via signal.
38    pub cancelled: bool,
39    /// Whether the output was truncated.
40    pub truncated: bool,
41    /// Path to a spill file holding full output when truncated.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub full_output_path: Option<String>,
44}
45
46/// Context-window usage snapshot embedded in [`SessionStats`].
47///
48/// Matches `ContextUsage` from coding-agent `extensions/types.ts`.
49#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct ContextUsage {
52    /// Estimated context tokens, or `null` when unknown (e.g. right after compaction).
53    #[serde(default)]
54    pub tokens: Option<u64>,
55    /// Model context-window size.
56    pub context_window: u64,
57    /// Usage as a percentage of the context window, or `null` when tokens unknown.
58    #[serde(default)]
59    pub percent: Option<f64>,
60}
61
62/// Token counters inside [`SessionStats`].
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct SessionStatsTokens {
66    /// Input tokens.
67    pub input: u64,
68    /// Output tokens.
69    pub output: u64,
70    /// Cache-read tokens.
71    pub cache_read: u64,
72    /// Cache-write tokens.
73    pub cache_write: u64,
74    /// Total tokens.
75    pub total: u64,
76}
77
78/// Session statistics for the `get_session_stats` RPC command.
79///
80/// Matches `SessionStats` from coding-agent `agent-session.ts`.
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct SessionStats {
84    /// Absolute session file path, when persisted.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub session_file: Option<String>,
87    /// Session id.
88    pub session_id: String,
89    /// Count of user messages.
90    pub user_messages: u64,
91    /// Count of assistant messages.
92    pub assistant_messages: u64,
93    /// Count of tool-call content blocks.
94    pub tool_calls: u64,
95    /// Count of tool-result messages.
96    pub tool_results: u64,
97    /// Total messages in the session.
98    pub total_messages: u64,
99    /// Aggregated token usage.
100    pub tokens: SessionStatsTokens,
101    /// Aggregated cost.
102    pub cost: f64,
103    /// Optional context-window usage.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub context_usage: Option<ContextUsage>,
106}
107
108/// Tree node returned by `get_tree`.
109///
110/// Mirrors `SessionTreeNode` from coding-agent `session-manager.ts`. Product
111/// `SessionTreeNode` is not yet `Serialize`; this wire-facing twin reuses
112/// [`SessionEntry`] which already round-trips JSONL.
113#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct RpcSessionTreeNode {
116    /// Entry at this node.
117    pub entry: SessionEntry,
118    /// Children sorted by timestamp ascending.
119    pub children: Vec<RpcSessionTreeNode>,
120    /// Resolved label for this entry, if any.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub label: Option<String>,
123    /// Timestamp of the latest label change for this entry, if any.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub label_timestamp: Option<String>,
126}
127
128/// Wire-facing [`SourceInfo`] with serde (product `SourceInfo` is not yet serde).
129#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct RpcSourceInfo {
132    /// Absolute (or synthetic) path of the resource.
133    pub path: String,
134    /// Source label (`local`, `auto`, `cli`, package id, …).
135    pub source: String,
136    /// Scope relative to the project boundary.
137    pub scope: RpcSourceScope,
138    /// Package vs top-level origin.
139    pub origin: RpcSourceOrigin,
140    /// Optional base directory used for relative resolution.
141    #[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/// Wire discriminant for [`SourceScope`].
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub enum RpcSourceScope {
167    /// Global agent directory.
168    User,
169    /// Project-local.
170    Project,
171    /// Temporary/CLI or synthetic.
172    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/// Wire discriminant for [`SourceOrigin`].
186#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
187pub enum RpcSourceOrigin {
188    /// Installed or local package root.
189    #[serde(rename = "package")]
190    Package,
191    /// Settings array, auto-discovery, or CLI temporary path.
192    #[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/// Streaming behavior for a prompt that arrives while the agent is busy.
206#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub enum StreamingBehavior {
209    /// Inject as a steering message.
210    Steer,
211    /// Queue as a follow-up after the current turn.
212    FollowUp,
213}
214
215// ---------------------------------------------------------------------------
216// RpcCommand (stdin)
217// ---------------------------------------------------------------------------
218
219/// All 31 known RPC commands, plus an unknown catch-all.
220///
221/// Each known variant carries an optional correlation `id`. The unknown arm
222/// preserves the raw `type` string and remaining fields so the server can echo
223/// `id`/`type` on error without serde hard-failing the line.
224#[derive(Clone, Debug, PartialEq)]
225pub enum RpcCommand {
226    /// Submit a user prompt (async — events follow; response at preflight).
227    Prompt {
228        /// Correlation id.
229        id: Option<String>,
230        /// Prompt text.
231        message: String,
232        /// Optional inline images.
233        images: Option<Vec<ImageContent>>,
234        /// How to handle the prompt while streaming.
235        streaming_behavior: Option<StreamingBehavior>,
236    },
237    /// Steer into the current turn.
238    Steer {
239        /// Correlation id.
240        id: Option<String>,
241        /// Steering text.
242        message: String,
243        /// Optional inline images.
244        images: Option<Vec<ImageContent>>,
245    },
246    /// Queue a follow-up after the current turn.
247    FollowUp {
248        /// Correlation id.
249        id: Option<String>,
250        /// Follow-up text.
251        message: String,
252        /// Optional inline images.
253        images: Option<Vec<ImageContent>>,
254    },
255    /// Abort the current agent turn.
256    Abort {
257        /// Correlation id.
258        id: Option<String>,
259    },
260    /// Start a new session, optionally forked from a parent session file.
261    NewSession {
262        /// Correlation id.
263        id: Option<String>,
264        /// Optional parent session path.
265        parent_session: Option<String>,
266    },
267    /// Snapshot current session state.
268    GetState {
269        /// Correlation id.
270        id: Option<String>,
271    },
272    /// Select a model by provider + id.
273    SetModel {
274        /// Correlation id.
275        id: Option<String>,
276        /// Provider identifier.
277        provider: String,
278        /// Model identifier.
279        model_id: String,
280    },
281    /// Cycle to the next available model.
282    CycleModel {
283        /// Correlation id.
284        id: Option<String>,
285    },
286    /// List available models.
287    GetAvailableModels {
288        /// Correlation id.
289        id: Option<String>,
290    },
291    /// Set the reasoning/thinking level.
292    SetThinkingLevel {
293        /// Correlation id.
294        id: Option<String>,
295        /// Target level (includes `off`).
296        level: ModelThinkingLevel,
297    },
298    /// Cycle to the next thinking level.
299    CycleThinkingLevel {
300        /// Correlation id.
301        id: Option<String>,
302    },
303    /// Set the steering queue drain mode.
304    SetSteeringMode {
305        /// Correlation id.
306        id: Option<String>,
307        /// Drain mode.
308        mode: QueueMode,
309    },
310    /// Set the follow-up queue drain mode.
311    SetFollowUpMode {
312        /// Correlation id.
313        id: Option<String>,
314        /// Drain mode.
315        mode: QueueMode,
316    },
317    /// Compact the session.
318    Compact {
319        /// Correlation id.
320        id: Option<String>,
321        /// Optional custom instructions for the summarizer.
322        custom_instructions: Option<String>,
323    },
324    /// Enable or disable auto-compaction.
325    SetAutoCompaction {
326        /// Correlation id.
327        id: Option<String>,
328        /// Whether auto-compaction is enabled.
329        enabled: bool,
330    },
331    /// Enable or disable auto-retry.
332    SetAutoRetry {
333        /// Correlation id.
334        id: Option<String>,
335        /// Whether auto-retry is enabled.
336        enabled: bool,
337    },
338    /// Abort an in-flight auto-retry.
339    AbortRetry {
340        /// Correlation id.
341        id: Option<String>,
342    },
343    /// Execute a bash command via the session.
344    Bash {
345        /// Correlation id.
346        id: Option<String>,
347        /// Shell command.
348        command: String,
349        /// When true, exclude output from model context.
350        exclude_from_context: Option<bool>,
351    },
352    /// Abort a running bash command.
353    AbortBash {
354        /// Correlation id.
355        id: Option<String>,
356    },
357    /// Return session statistics.
358    GetSessionStats {
359        /// Correlation id.
360        id: Option<String>,
361    },
362    /// Export the session to HTML.
363    ExportHtml {
364        /// Correlation id.
365        id: Option<String>,
366        /// Optional output path.
367        output_path: Option<String>,
368    },
369    /// Switch to another session file.
370    SwitchSession {
371        /// Correlation id.
372        id: Option<String>,
373        /// Path of the session to open.
374        session_path: String,
375    },
376    /// Fork the session before `entry_id`.
377    Fork {
378        /// Correlation id.
379        id: Option<String>,
380        /// Entry id to fork before.
381        entry_id: String,
382    },
383    /// Clone the session at the current leaf.
384    Clone {
385        /// Correlation id.
386        id: Option<String>,
387    },
388    /// List user messages available for forking.
389    GetForkMessages {
390        /// Correlation id.
391        id: Option<String>,
392    },
393    /// List session entries, optionally after `since`.
394    GetEntries {
395        /// Correlation id.
396        id: Option<String>,
397        /// Optional entry id; returns entries strictly after this id.
398        since: Option<String>,
399    },
400    /// Return the session tree.
401    GetTree {
402        /// Correlation id.
403        id: Option<String>,
404    },
405    /// Return the last assistant text content, if any.
406    GetLastAssistantText {
407        /// Correlation id.
408        id: Option<String>,
409    },
410    /// Set the display name of the current session.
411    SetSessionName {
412        /// Correlation id.
413        id: Option<String>,
414        /// New session name.
415        name: String,
416    },
417    /// Return all agent messages in the current session.
418    GetMessages {
419        /// Correlation id.
420        id: Option<String>,
421    },
422    /// List available slash commands (extension/prompt/skill).
423    GetCommands {
424        /// Correlation id.
425        id: Option<String>,
426    },
427    /// Unknown command discriminant preserved for error echo.
428    Unknown {
429        /// Correlation id when present.
430        id: Option<String>,
431        /// Raw `type` string from the wire.
432        command_type: String,
433        /// Remaining fields (excluding `type` and `id`).
434        payload: Map<String, Value>,
435    },
436}
437
438impl RpcCommand {
439    /// Optional correlation id shared by every command variant.
440    #[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    /// Wire `type` discriminant for this command.
479    #[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// ---------------------------------------------------------------------------
1097// RpcSessionState / RpcSlashCommand
1098// ---------------------------------------------------------------------------
1099
1100/// Snapshot returned by `get_state`.
1101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1102#[serde(rename_all = "camelCase")]
1103pub struct RpcSessionState {
1104    /// Active model, if any.
1105    #[serde(default, skip_serializing_if = "Option::is_none")]
1106    pub model: Option<Model>,
1107    /// Current thinking level (includes `off`).
1108    pub thinking_level: ModelThinkingLevel,
1109    /// Whether the agent is currently streaming.
1110    pub is_streaming: bool,
1111    /// Whether compaction is in progress.
1112    pub is_compacting: bool,
1113    /// Steering queue drain mode.
1114    pub steering_mode: QueueMode,
1115    /// Follow-up queue drain mode.
1116    pub follow_up_mode: QueueMode,
1117    /// Absolute session file path, when persisted.
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub session_file: Option<String>,
1120    /// Session id.
1121    pub session_id: String,
1122    /// Optional display name.
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub session_name: Option<String>,
1125    /// Whether auto-compaction is enabled.
1126    pub auto_compaction_enabled: bool,
1127    /// Number of messages currently in the session.
1128    pub message_count: u64,
1129    /// Number of pending queued messages.
1130    pub pending_message_count: u64,
1131}
1132
1133/// Kind of slash command exposed by `get_commands`.
1134#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1135#[serde(rename_all = "camelCase")]
1136pub enum RpcSlashCommandSource {
1137    /// Registered by a TypeScript extension.
1138    Extension,
1139    /// Prompt template.
1140    Prompt,
1141    /// Skill (`skill:{name}`).
1142    Skill,
1143}
1144
1145/// A command available for invocation via prompt.
1146#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1147#[serde(rename_all = "camelCase")]
1148pub struct RpcSlashCommand {
1149    /// Command name (without leading slash).
1150    pub name: String,
1151    /// Human-readable description.
1152    #[serde(default, skip_serializing_if = "Option::is_none")]
1153    pub description: Option<String>,
1154    /// What kind of command this is.
1155    pub source: RpcSlashCommandSource,
1156    /// Source metadata for the owning resource.
1157    pub source_info: RpcSourceInfo,
1158}
1159
1160// ---------------------------------------------------------------------------
1161// RpcResponse (stdout)
1162// ---------------------------------------------------------------------------
1163
1164/// Typed success payload for each command that returns `data`.
1165///
1166/// Serialization is untagged (payload is the raw `data` object). Deserialization
1167/// of full responses is command-directed via [`RpcResponse`] so overlapping
1168/// shapes (e.g. `{cancelled}` vs bash `{cancelled,...}`) never collide.
1169#[derive(Clone, Debug, PartialEq, Serialize)]
1170#[serde(untagged)]
1171pub enum RpcResponseData {
1172    /// `{ cancelled }` for `new_session` / `switch_session` / `clone`.
1173    Cancelled {
1174        /// Whether the user cancelled the operation.
1175        cancelled: bool,
1176    },
1177    /// Full session state.
1178    SessionState(RpcSessionState),
1179    /// Selected model from `set_model`.
1180    Model(Model),
1181    /// `cycle_model` result (or JSON `null`).
1182    CycleModel(Option<CycleModelData>),
1183    /// `{ models: [...] }` from `get_available_models`.
1184    AvailableModels {
1185        /// Available models.
1186        models: Vec<Model>,
1187    },
1188    /// `cycle_thinking_level` result (or JSON `null`).
1189    CycleThinkingLevel(Option<CycleThinkingLevelData>),
1190    /// Compaction result.
1191    Compaction(CompactionResult),
1192    /// Bash result.
1193    Bash(BashResult),
1194    /// Session stats.
1195    SessionStats(SessionStats),
1196    /// `{ path }` from `export_html`.
1197    ExportHtml {
1198        /// Written HTML path.
1199        path: String,
1200    },
1201    /// `{ text, cancelled }` from `fork`.
1202    Fork {
1203        /// Selected user text at the fork point.
1204        text: String,
1205        /// Whether the user cancelled.
1206        cancelled: bool,
1207    },
1208    /// `{ messages: [{ entryId, text }] }` from `get_fork_messages`.
1209    ForkMessages {
1210        /// Forkable user messages.
1211        messages: Vec<ForkMessage>,
1212    },
1213    /// `{ entries, leafId }` from `get_entries`.
1214    Entries {
1215        /// Session entries.
1216        entries: Vec<SessionEntry>,
1217        /// Current leaf id.
1218        #[serde(rename = "leafId")]
1219        leaf_id: Option<String>,
1220    },
1221    /// `{ tree, leafId }` from `get_tree`.
1222    Tree {
1223        /// Session tree.
1224        tree: Vec<RpcSessionTreeNode>,
1225        /// Current leaf id.
1226        #[serde(rename = "leafId")]
1227        leaf_id: Option<String>,
1228    },
1229    /// `{ text }` from `get_last_assistant_text` (text may be null).
1230    LastAssistantText {
1231        /// Last assistant text, or null.
1232        text: Option<String>,
1233    },
1234    /// `{ messages }` from `get_messages`.
1235    Messages {
1236        /// Agent messages.
1237        messages: Vec<AgentMessage>,
1238    },
1239    /// `{ commands }` from `get_commands`.
1240    Commands {
1241        /// Available slash commands.
1242        commands: Vec<RpcSlashCommand>,
1243    },
1244}
1245
1246impl RpcResponseData {
1247    /// Deserialize a `data` payload using the echoed command discriminant.
1248    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/// Payload for a successful `cycle_model` response.
1400#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1401#[serde(rename_all = "camelCase")]
1402pub struct CycleModelData {
1403    /// Newly selected model.
1404    pub model: Model,
1405    /// Thinking level after the cycle.
1406    pub thinking_level: ModelThinkingLevel,
1407    /// Whether the model is scoped.
1408    pub is_scoped: bool,
1409}
1410
1411/// Payload for a successful `cycle_thinking_level` response.
1412#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1413#[serde(rename_all = "camelCase")]
1414pub struct CycleThinkingLevelData {
1415    /// New thinking level.
1416    pub level: ModelThinkingLevel,
1417}
1418
1419/// One forkable user message.
1420#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1421#[serde(rename_all = "camelCase")]
1422pub struct ForkMessage {
1423    /// Entry id.
1424    pub entry_id: String,
1425    /// User text.
1426    pub text: String,
1427}
1428
1429/// Success or error response envelope (`type: "response"`).
1430#[derive(Clone, Debug, PartialEq)]
1431pub enum RpcResponse {
1432    /// Successful command response.
1433    Success {
1434        /// Correlation id.
1435        id: Option<String>,
1436        /// Command discriminant echoed back.
1437        command: String,
1438        /// Optional typed data payload (boxed: variants differ widely in size).
1439        data: Option<Box<RpcResponseData>>,
1440    },
1441    /// Failed command response.
1442    Error {
1443        /// Correlation id (echoed when known).
1444        id: Option<String>,
1445        /// Command discriminant or `"parse"`.
1446        command: String,
1447        /// Error message.
1448        error: String,
1449    },
1450}
1451
1452impl RpcResponse {
1453    /// Build a success response with no data.
1454    #[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    /// Build a success response with data.
1464    #[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    /// Build an error response.
1474    #[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    /// Correlation id.
1484    #[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    /// Echoed command discriminant.
1492    #[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    /// Whether this is a success response.
1500    #[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// ---------------------------------------------------------------------------
1580// Extension UI request / response
1581// ---------------------------------------------------------------------------
1582
1583/// Notify severity for [`RpcExtensionUiRequest::Notify`].
1584#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1585#[serde(rename_all = "camelCase")]
1586pub enum NotifyType {
1587    /// Informational.
1588    Info,
1589    /// Warning.
1590    Warning,
1591    /// Error.
1592    Error,
1593}
1594
1595/// Widget placement for [`RpcExtensionUiRequest::SetWidget`].
1596#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1597#[serde(rename_all = "camelCase")]
1598pub enum WidgetPlacement {
1599    /// Above the editor.
1600    AboveEditor,
1601    /// Below the editor.
1602    BelowEditor,
1603}
1604
1605/// Extension UI request emitted on stdout (`type: "extension_ui_request"`).
1606#[derive(Clone, Debug, PartialEq)]
1607pub enum RpcExtensionUiRequest {
1608    /// Present a select list.
1609    Select {
1610        /// Correlation id.
1611        id: String,
1612        /// Dialog title.
1613        title: String,
1614        /// Options to choose from.
1615        options: Vec<String>,
1616        /// Optional timeout in milliseconds.
1617        timeout: Option<u64>,
1618    },
1619    /// Present a confirm dialog.
1620    Confirm {
1621        /// Correlation id.
1622        id: String,
1623        /// Dialog title.
1624        title: String,
1625        /// Dialog message.
1626        message: String,
1627        /// Optional timeout in milliseconds.
1628        timeout: Option<u64>,
1629    },
1630    /// Present a single-line input.
1631    Input {
1632        /// Correlation id.
1633        id: String,
1634        /// Dialog title.
1635        title: String,
1636        /// Optional placeholder.
1637        placeholder: Option<String>,
1638        /// Optional timeout in milliseconds.
1639        timeout: Option<u64>,
1640    },
1641    /// Present a multi-line editor.
1642    Editor {
1643        /// Correlation id.
1644        id: String,
1645        /// Dialog title.
1646        title: String,
1647        /// Optional prefilled text.
1648        prefill: Option<String>,
1649    },
1650    /// Fire-and-forget notification.
1651    Notify {
1652        /// Correlation id.
1653        id: String,
1654        /// Notification text.
1655        message: String,
1656        /// Optional severity.
1657        notify_type: Option<NotifyType>,
1658    },
1659    /// Set or clear a status key.
1660    SetStatus {
1661        /// Correlation id.
1662        id: String,
1663        /// Status key.
1664        status_key: String,
1665        /// Status text (`undefined` clears).
1666        status_text: Option<String>,
1667    },
1668    /// Set or clear a widget.
1669    SetWidget {
1670        /// Correlation id.
1671        id: String,
1672        /// Widget key.
1673        widget_key: String,
1674        /// Widget lines (`undefined` clears).
1675        widget_lines: Option<Vec<String>>,
1676        /// Optional placement.
1677        widget_placement: Option<WidgetPlacement>,
1678    },
1679    /// Set the window/tab title.
1680    SetTitle {
1681        /// Correlation id.
1682        id: String,
1683        /// New title.
1684        title: String,
1685    },
1686    /// Replace editor text.
1687    SetEditorText {
1688        /// Correlation id.
1689        id: String,
1690        /// New editor text.
1691        text: String,
1692    },
1693}
1694
1695impl RpcExtensionUiRequest {
1696    /// Correlation id.
1697    #[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    /// Wire `method` discriminant.
1713    #[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", &notify_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/// Response to an extension UI request (`type: "extension_ui_response"`).
2072#[derive(Clone, Debug, PartialEq)]
2073pub enum RpcExtensionUiResponse {
2074    /// Select/input/editor value.
2075    Value {
2076        /// Correlation id.
2077        id: String,
2078        /// Selected or entered value.
2079        value: String,
2080    },
2081    /// Confirm result.
2082    Confirmed {
2083        /// Correlation id.
2084        id: String,
2085        /// Whether confirmed.
2086        confirmed: bool,
2087    },
2088    /// User cancelled the dialog.
2089    Cancelled {
2090        /// Correlation id.
2091        id: String,
2092    },
2093}
2094
2095impl RpcExtensionUiResponse {
2096    /// Correlation id.
2097    #[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
2182// ---------------------------------------------------------------------------
2183// Serde helpers
2184// ---------------------------------------------------------------------------
2185
2186fn 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// ---------------------------------------------------------------------------
2250// Tests
2251// ---------------------------------------------------------------------------
2252
2253#[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}