Skip to main content

yapi_types/
rpc.rs

1//! RPC mode: commands read from stdin and the responses written to stdout, as
2//! in `modes/rpc/rpc-types.ts` of pi `v1.0.0`. Events use the JSON mode shapes
3//! in [`crate::event`].
4#![allow(
5    missing_docs,
6    reason = "fields mirror pi's TypeScript types; contracts are noted where they differ from the name"
7)]
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::message::{ImageContent, ThinkingLevel};
13use crate::session::FileEntry;
14
15/// How a prompt sent while the agent runs is queued.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub enum StreamingBehavior {
19    /// Delivered after the current tool calls.
20    Steer,
21    /// Delivered when the run would otherwise end.
22    FollowUp,
23}
24
25/// What became of a prompt once it passed preflight.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum PromptDisposition {
29    /// A run started.
30    Started,
31    /// Queued behind the running agent.
32    Queued,
33    /// Consumed without a run, such as by an extension command.
34    Handled,
35}
36
37/// A command line on stdin, without its `id`.
38#[derive(Clone, Debug, PartialEq, Deserialize)]
39#[serde(
40    tag = "type",
41    rename_all = "snake_case",
42    rename_all_fields = "camelCase"
43)]
44pub enum RpcCommand {
45    Prompt {
46        message: String,
47        #[serde(default)]
48        images: Vec<ImageContent>,
49        #[serde(default)]
50        streaming_behavior: Option<StreamingBehavior>,
51    },
52    Steer {
53        message: String,
54        #[serde(default)]
55        images: Vec<ImageContent>,
56    },
57    FollowUp {
58        message: String,
59        #[serde(default)]
60        images: Vec<ImageContent>,
61    },
62    Abort,
63    ClearQueue,
64    NewSession {
65        #[serde(default)]
66        parent_session: Option<String>,
67    },
68    GetState,
69    SetModel {
70        provider: String,
71        model_id: String,
72    },
73    CycleModel,
74    GetAvailableModels,
75    /// pi takes any `level`: one it does not know becomes the model's
76    /// first level.
77    SetThinkingLevel {
78        #[serde(default)]
79        level: Value,
80    },
81    CycleThinkingLevel,
82    GetAvailableThinkingLevels,
83    /// pi saves `mode` as given, `null` included; an absent one removes the
84    /// setting.
85    SetSteeringMode {
86        #[serde(default, deserialize_with = "crate::present")]
87        mode: Option<Value>,
88    },
89    /// As [`RpcCommand::SetSteeringMode`].
90    SetFollowUpMode {
91        #[serde(default, deserialize_with = "crate::present")]
92        mode: Option<Value>,
93    },
94    Compact {
95        #[serde(default)]
96        custom_instructions: Option<String>,
97    },
98    /// pi saves `enabled` as given, `null` included, and reads it by
99    /// truthiness; an absent one removes the setting.
100    SetAutoCompaction {
101        #[serde(default, deserialize_with = "crate::present")]
102        enabled: Option<Value>,
103    },
104    /// As [`RpcCommand::SetAutoCompaction`].
105    SetAutoRetry {
106        #[serde(default, deserialize_with = "crate::present")]
107        enabled: Option<Value>,
108    },
109    AbortRetry,
110    Bash {
111        command: String,
112        #[serde(default)]
113        exclude_from_context: Option<bool>,
114    },
115    AbortBash,
116    GetSessionStats,
117    ExportHtml {
118        #[serde(default)]
119        output_path: Option<String>,
120    },
121    SwitchSession {
122        session_path: String,
123    },
124    Fork {
125        entry_id: String,
126    },
127    Clone,
128    GetForkMessages,
129    GetEntries {
130        #[serde(default)]
131        since: Option<String>,
132    },
133    GetTree,
134    GetLastAssistantText,
135    SetSessionName {
136        name: String,
137    },
138    GetMessages,
139    GetCommands,
140    /// Any other `type`.
141    #[serde(other)]
142    Unknown,
143}
144
145/// A response line: `data` is serialized JSON, or absent. A line without a
146/// `type` gets a response without `command`, as `JSON.stringify` drops pi's
147/// `undefined`.
148pub fn response_line(
149    id: Option<&Value>,
150    command: Option<&str>,
151    outcome: Result<Option<&str>, &str>,
152) -> String {
153    let mut line = String::from("{");
154    if let Some(id) = id
155        && let Ok(id) = crate::json::to_string(id)
156    {
157        line.push_str("\"id\":");
158        line.push_str(&id);
159        line.push(',');
160    }
161    line.push_str("\"type\":\"response\"");
162    if let Some(command) = command {
163        line.push_str(",\"command\":");
164        line.push_str(&crate::json::to_string(command).unwrap_or_else(|_| "\"\"".into()));
165    }
166    match outcome {
167        Ok(data) => {
168            line.push_str(",\"success\":true");
169            if let Some(data) = data {
170                line.push_str(",\"data\":");
171                line.push_str(data);
172            }
173        }
174        Err(error) => {
175            line.push_str(",\"success\":false,\"error\":");
176            line.push_str(&crate::json::to_string(error).unwrap_or_else(|_| "\"\"".into()));
177        }
178    }
179    line.push('}');
180    line
181}
182
183/// `get_state` data.
184#[derive(Clone, Debug, PartialEq, Serialize)]
185#[serde(rename_all = "camelCase")]
186pub struct SessionState {
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub model: Option<Value>,
189    pub thinking_level: ThinkingLevel,
190    pub is_streaming: bool,
191    pub is_compacting: bool,
192    /// The agent's mode as last set, any value; absent when set to nothing.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub steering_mode: Option<Value>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub follow_up_mode: Option<Value>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub session_file: Option<String>,
199    pub session_id: String,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub session_name: Option<String>,
202    /// The setting as saved, any value; `true` when unset or `null`.
203    pub auto_compaction_enabled: Value,
204    pub message_count: usize,
205    pub pending_message_count: usize,
206}
207
208/// Token totals in [`SessionStats`].
209#[derive(Clone, Debug, Default, PartialEq, Serialize)]
210#[serde(rename_all = "camelCase")]
211pub struct TokenTotals {
212    pub input: u64,
213    pub output: u64,
214    pub cache_read: u64,
215    pub cache_write: u64,
216    pub total: u64,
217}
218
219/// How full the context window is; `tokens` and `percent` are `null` while
220/// unknown.
221#[derive(Clone, Debug, PartialEq, Serialize)]
222#[serde(rename_all = "camelCase")]
223pub struct ContextUsage {
224    pub tokens: Option<u64>,
225    pub context_window: u64,
226    pub percent: Option<f64>,
227}
228
229/// `get_session_stats` data.
230#[derive(Clone, Debug, PartialEq, Serialize)]
231#[serde(rename_all = "camelCase")]
232pub struct SessionStats {
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub session_file: Option<String>,
235    pub session_id: String,
236    pub user_messages: usize,
237    pub assistant_messages: usize,
238    pub tool_calls: usize,
239    pub tool_results: usize,
240    pub total_messages: usize,
241    pub tokens: TokenTotals,
242    pub cost: f64,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub context_usage: Option<ContextUsage>,
245}
246
247/// What a user `!` command produced: the `bash` response data.
248#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "camelCase")]
250pub struct BashResult {
251    /// Combined, sanitized output; the tail when truncated.
252    pub output: String,
253    /// The exit code; absent when cancelled.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub exit_code: Option<i32>,
256    /// The command was cancelled.
257    pub cancelled: bool,
258    /// The output was truncated.
259    pub truncated: bool,
260    /// The full output, when it was too long to keep.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub full_output_path: Option<String>,
263}
264
265/// A fork point in `get_fork_messages` data.
266#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
267#[serde(rename_all = "camelCase")]
268pub struct ForkMessage {
269    pub entry_id: String,
270    pub text: String,
271}
272
273/// Where a resource was found, as pi's `SourceInfo`: `source` is `auto` for
274/// discovered files, `cli` for command-line paths and the package for
275/// package resources; `scope` is `user`, `project` or `temporary`.
276#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct SourceInfo {
279    pub path: String,
280    pub source: String,
281    pub scope: String,
282    pub origin: String,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub base_dir: Option<String>,
285}
286
287/// Where a [`SlashCommand`] comes from.
288#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
289#[serde(rename_all = "lowercase")]
290pub enum CommandSource {
291    Extension,
292    Prompt,
293    Skill,
294}
295
296/// A command a prompt can invoke, in `get_commands` data.
297#[derive(Clone, Debug, PartialEq, Serialize)]
298#[serde(rename_all = "camelCase")]
299pub struct SlashCommand {
300    pub name: String,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub description: Option<String>,
303    pub source: CommandSource,
304    pub source_info: SourceInfo,
305}
306
307/// One node of `get_tree` data, as pi's `SessionTreeNode`.
308pub struct TreeNode<'a> {
309    pub entry: &'a FileEntry,
310    /// Indexes into the same node list.
311    pub children: Vec<usize>,
312    pub label: Option<&'a str>,
313    pub label_timestamp: Option<&'a str>,
314}
315
316/// Serializes the trees under `roots` without recursion, so a long linear
317/// session cannot exhaust the stack.
318pub fn tree_json(nodes: &[TreeNode<'_>], roots: &[usize]) -> serde_json::Result<String> {
319    enum Step {
320        Open(usize),
321        Close(usize),
322        Comma,
323    }
324    let mut out = String::from("[");
325    let mut stack: Vec<Step> = Vec::new();
326    let push_list = |stack: &mut Vec<Step>, list: &[usize]| {
327        for (index, &node) in list.iter().enumerate().rev() {
328            stack.push(Step::Open(node));
329            if index > 0 {
330                stack.push(Step::Comma);
331            }
332        }
333    };
334    push_list(&mut stack, roots);
335    while let Some(step) = stack.pop() {
336        match step {
337            Step::Comma => out.push(','),
338            Step::Open(index) => {
339                let node = &nodes[index];
340                out.push_str("{\"entry\":");
341                out.push_str(&crate::json::to_string(node.entry)?);
342                out.push_str(",\"children\":[");
343                stack.push(Step::Close(index));
344                push_list(&mut stack, &node.children);
345            }
346            Step::Close(index) => {
347                out.push(']');
348                let node = &nodes[index];
349                if let Some(label) = node.label {
350                    out.push_str(",\"label\":");
351                    out.push_str(&crate::json::to_string(label)?);
352                }
353                if let Some(timestamp) = node.label_timestamp {
354                    out.push_str(",\"labelTimestamp\":");
355                    out.push_str(&crate::json::to_string(timestamp)?);
356                }
357                out.push('}');
358            }
359        }
360    }
361    out.push(']');
362    Ok(out)
363}
364
365/// An `extension_ui_response` line on stdin.
366#[derive(Clone, Debug, PartialEq, Deserialize)]
367pub struct ExtensionUiResponse {
368    pub id: String,
369    #[serde(default)]
370    pub value: Option<String>,
371    #[serde(default)]
372    pub confirmed: Option<bool>,
373    #[serde(default)]
374    pub cancelled: Option<bool>,
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn commands_parse_like_pi() {
383        let prompt: RpcCommand = serde_json::from_str(
384            r#"{"type":"prompt","message":"hi","streamingBehavior":"followUp"}"#,
385        )
386        .unwrap();
387        assert_eq!(
388            prompt,
389            RpcCommand::Prompt {
390                message: "hi".into(),
391                images: Vec::new(),
392                streaming_behavior: Some(StreamingBehavior::FollowUp),
393            }
394        );
395        let mode: RpcCommand =
396            serde_json::from_str(r#"{"type":"set_steering_mode","mode":"one-at-a-time"}"#).unwrap();
397        assert_eq!(
398            mode,
399            RpcCommand::SetSteeringMode {
400                mode: Some("one-at-a-time".into())
401            }
402        );
403        let unset: RpcCommand = serde_json::from_str(r#"{"type":"set_auto_retry"}"#).unwrap();
404        assert_eq!(unset, RpcCommand::SetAutoRetry { enabled: None });
405        let unknown: RpcCommand = serde_json::from_str(r#"{"type":"dance"}"#).unwrap();
406        assert_eq!(unknown, RpcCommand::Unknown);
407    }
408
409    #[test]
410    fn responses_match_pi() {
411        let id = Value::from("7");
412        assert_eq!(
413            response_line(Some(&id), Some("abort"), Ok(None)),
414            r#"{"id":"7","type":"response","command":"abort","success":true}"#
415        );
416        assert_eq!(
417            response_line(None, Some("cycle_model"), Ok(Some("null"))),
418            r#"{"type":"response","command":"cycle_model","success":true,"data":null}"#
419        );
420        assert_eq!(
421            response_line(Some(&Value::from(3)), Some("x"), Err("Unknown command: x")),
422            r#"{"id":3,"type":"response","command":"x","success":false,"error":"Unknown command: x"}"#
423        );
424    }
425
426    #[test]
427    fn deep_trees_no_recursion() {
428        let entry: FileEntry = serde_json::from_str(
429            r#"{"type":"label","id":"a","parentId":null,"timestamp":"t","targetId":"b"}"#,
430        )
431        .unwrap();
432        let depth = 200_000;
433        let nodes: Vec<TreeNode<'_>> = (0..depth)
434            .map(|index| TreeNode {
435                entry: &entry,
436                children: if index + 1 < depth {
437                    vec![index + 1]
438                } else {
439                    Vec::new()
440                },
441                label: (index == 0).then_some("start"),
442                label_timestamp: None,
443            })
444            .collect();
445        let json = tree_json(&nodes, &[0]).unwrap();
446        assert!(json.ends_with(&format!(
447            "{}],\"label\":\"start\"}}]",
448            "]}".repeat(depth - 1)
449        )));
450        let small = tree_json(&nodes[depth - 2..depth - 1], &[]).unwrap();
451        assert_eq!(small, "[]");
452    }
453}