Skip to main content

rpi_cli/remote/
protocol.rs

1//! Shared wire protocol for rpi's headless RPC mode (`--mode rpc`, the server)
2//! and the remote client (`--connect`).
3//!
4//! Single source of truth: the server serializes these types and the client
5//! deserializes them, so the contract cannot drift between the two ends.
6//!
7//! Wire shape (one JSON object per line):
8//! - the server opens with `{"type":"ready"}`, then
9//! - streams [`RemoteEvent`]s (`{"type":"agent_start"}`, `{"type":"message_update",…}`, …), and
10//! - answers each [`RemoteCommand`] with a [`RemoteResponse`] (`{"type":"response",…}`).
11//!
12//! These are intentionally **not** the core `pi-agent`/`pi-ai` types: `AgentEvent`
13//! is documented as process-local (see `pi-agent/src/events.rs`) and deliberately
14//! has no serde impl. This module projects the process-local events into a
15//! stable, wire-safe shape, and translates back only the subset the client needs.
16//! That keeps the core crates untouched and the client free of local resources.
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use rpi_agent::events::AgentEvent;
22use rpi_agent::types::AgentToolResult;
23
24/// A fine-grained lifecycle event streamed from the server to the client.
25///
26/// Mirrors the shape produced by the previous hand-rolled projection in
27/// `modes.rs`; kept field-for-field compatible so existing clients keep working.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum RemoteEvent {
31    UiDialogRequest {
32        #[serde(rename = "requestId")]
33        request_id: String,
34        #[serde(rename = "toolCallId", default)]
35        tool_call_id: Option<String>,
36        ui: Value,
37    },
38    UiDialogClosed {
39        #[serde(rename = "requestId")]
40        request_id: String,
41    },
42    AgentStart,
43    AgentEnd {
44        #[serde(rename = "messageCount")]
45        message_count: usize,
46        messages: Value,
47    },
48    RetryScheduled {
49        attempt: u32,
50        #[serde(rename = "maxRetries")]
51        max_retries: u32,
52        #[serde(rename = "delayMs")]
53        delay_ms: u64,
54        error: String,
55    },
56    TurnStart,
57    TurnEnd {
58        message: Value,
59        #[serde(rename = "toolResultCount")]
60        tool_result_count: usize,
61        #[serde(rename = "toolResults")]
62        tool_results: Value,
63        /// Queue depth when this turn settled, so a client can render
64        /// "N queued". Defaulted for wire compatibility with older peers.
65        #[serde(default, rename = "pendingMessages")]
66        pending_messages: usize,
67    },
68    MessageStart {
69        #[serde(rename = "messageId", default, skip_serializing_if = "Option::is_none")]
70        message_id: Option<String>,
71        #[serde(default)]
72        message: Value,
73    },
74    MessageEnd {
75        #[serde(rename = "messageId", default, skip_serializing_if = "Option::is_none")]
76        message_id: Option<String>,
77        #[serde(default)]
78        message: Value,
79    },
80    MessageUpdate {
81        #[serde(rename = "messageId", default, skip_serializing_if = "Option::is_none")]
82        message_id: Option<String>,
83        #[serde(default)]
84        message: Value,
85        #[serde(rename = "assistantMessageEvent", default)]
86        assistant_message_event: Value,
87        #[serde(rename = "eventType", default)]
88        event_type: String,
89        /// Convenience mirror of the delta's content index (text/thinking/tool-call
90        /// deltas only), so the client can apply deltas without re-parsing the
91        /// nested event.
92        #[serde(
93            rename = "contentIndex",
94            default,
95            skip_serializing_if = "Option::is_none"
96        )]
97        content_index: Option<u64>,
98        /// Convenience mirror of the appended text for text/thinking deltas.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        delta: Option<String>,
101    },
102    ToolExecutionStart {
103        #[serde(rename = "toolCallId")]
104        tool_call_id: String,
105        #[serde(rename = "toolName")]
106        tool_name: String,
107        #[serde(default)]
108        args: Value,
109    },
110    ToolExecutionUpdate {
111        #[serde(rename = "toolCallId")]
112        tool_call_id: String,
113        #[serde(rename = "toolName")]
114        tool_name: String,
115        #[serde(default)]
116        args: Value,
117        #[serde(rename = "partialResult")]
118        partial_result: Value,
119    },
120    ToolExecutionEnd {
121        #[serde(rename = "toolCallId")]
122        tool_call_id: String,
123        #[serde(rename = "toolName")]
124        tool_name: String,
125        #[serde(rename = "isError")]
126        is_error: bool,
127        result: Value,
128    },
129}
130
131impl RemoteEvent {
132    /// The wire `type` tag (kept in sync with the serde `rename_all`).
133    pub fn type_tag(&self) -> &'static str {
134        match self {
135            RemoteEvent::UiDialogRequest { .. } => "ui_dialog_request",
136            RemoteEvent::UiDialogClosed { .. } => "ui_dialog_closed",
137            RemoteEvent::AgentStart => "agent_start",
138            RemoteEvent::AgentEnd { .. } => "agent_end",
139            RemoteEvent::RetryScheduled { .. } => "retry_scheduled",
140            RemoteEvent::TurnStart => "turn_start",
141            RemoteEvent::TurnEnd { .. } => "turn_end",
142            RemoteEvent::MessageStart { .. } => "message_start",
143            RemoteEvent::MessageUpdate { .. } => "message_update",
144            RemoteEvent::MessageEnd { .. } => "message_end",
145            RemoteEvent::ToolExecutionStart { .. } => "tool_execution_start",
146            RemoteEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
147            RemoteEvent::ToolExecutionEnd { .. } => "tool_execution_end",
148        }
149    }
150
151    /// True for the terminal `AgentEnd` event.
152    pub fn is_terminal(&self) -> bool {
153        matches!(self, RemoteEvent::AgentEnd { .. })
154    }
155
156    /// Project a process-local [`AgentEvent`] onto the wire shape.
157    pub fn from_agent_event(event: &AgentEvent) -> Self {
158        use rpi_ai::types::AssistantMessageEvent;
159        match event {
160            AgentEvent::AgentStart => RemoteEvent::AgentStart,
161            AgentEvent::AgentEnd { messages } => RemoteEvent::AgentEnd {
162                message_count: messages.len(),
163                messages: serde_json::to_value(messages).unwrap_or(Value::Null),
164            },
165            AgentEvent::RetryScheduled {
166                attempt,
167                max_retries,
168                delay_ms,
169                error,
170            } => RemoteEvent::RetryScheduled {
171                attempt: *attempt,
172                max_retries: *max_retries,
173                delay_ms: *delay_ms,
174                error: error.clone(),
175            },
176            AgentEvent::TurnStart => RemoteEvent::TurnStart,
177            AgentEvent::TurnEnd {
178                message,
179                tool_results,
180                pending_messages,
181                ..
182            } => RemoteEvent::TurnEnd {
183                message: serde_json::to_value(message).unwrap_or(Value::Null),
184                tool_result_count: tool_results.len(),
185                tool_results: serde_json::to_value(tool_results).unwrap_or(Value::Null),
186                pending_messages: *pending_messages,
187            },
188            AgentEvent::MessageStart {
189                message,
190                message_id,
191            } => RemoteEvent::MessageStart {
192                message_id: message_id.clone(),
193                message: serde_json::to_value(message).unwrap_or(Value::Null),
194            },
195            AgentEvent::MessageEnd {
196                message,
197                message_id,
198            } => RemoteEvent::MessageEnd {
199                message_id: message_id.clone(),
200                message: serde_json::to_value(message).unwrap_or(Value::Null),
201            },
202            AgentEvent::MessageUpdate {
203                message_id,
204                message,
205                assistant_message_event,
206            } => {
207                let (content_index, delta) = match assistant_message_event {
208                    AssistantMessageEvent::TextDelta {
209                        content_index,
210                        delta,
211                        ..
212                    }
213                    | AssistantMessageEvent::ThinkingDelta {
214                        content_index,
215                        delta,
216                        ..
217                    }
218                    | AssistantMessageEvent::ToolCallDelta {
219                        content_index,
220                        delta,
221                        ..
222                    } => (Some(*content_index as u64), Some(delta.clone())),
223                    _ => (None, None),
224                };
225                RemoteEvent::MessageUpdate {
226                    message_id: message_id.clone(),
227                    // `assistant_json` keeps this identical to the
228                    // `AgentMessage` encoding without copying the message.
229                    message: rpi_agent::message::assistant_json(message),
230                    assistant_message_event: serde_json::to_value(assistant_message_event)
231                        .unwrap_or(Value::Null),
232                    event_type: assistant_message_event.type_tag().to_string(),
233                    content_index,
234                    delta,
235                }
236            }
237            AgentEvent::ToolExecutionStart {
238                tool_call_id,
239                tool_name,
240                args,
241            } => RemoteEvent::ToolExecutionStart {
242                tool_call_id: tool_call_id.clone(),
243                tool_name: tool_name.clone(),
244                args: args.clone(),
245            },
246            AgentEvent::ToolExecutionUpdate {
247                tool_call_id,
248                tool_name,
249                args,
250                partial_result,
251            } => RemoteEvent::ToolExecutionUpdate {
252                tool_call_id: tool_call_id.clone(),
253                tool_name: tool_name.clone(),
254                args: args.clone(),
255                partial_result: tool_result_json(partial_result),
256            },
257            AgentEvent::ToolExecutionEnd {
258                tool_call_id,
259                tool_name,
260                result,
261                is_error,
262            } => RemoteEvent::ToolExecutionEnd {
263                tool_call_id: tool_call_id.clone(),
264                tool_name: tool_name.clone(),
265                is_error: *is_error,
266                result: tool_result_json(result),
267            },
268        }
269    }
270}
271
272/// Serialize a tool result into the stable wire shape (content blocks, details,
273/// usage, and the batch hints).
274pub fn tool_result_json(result: &AgentToolResult) -> Value {
275    serde_json::to_value(result)
276        .expect("AgentToolResult contains only JSON-serializable native content, details and usage")
277}
278
279/// A command sent from the client to the server (`{"id":…,"type":…,…}`).
280///
281/// The `id` is attached by the client when sending (see
282/// [`with_id`](RemoteCommand::with_id)) so responses can be correlated.
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(tag = "type", rename_all = "snake_case")]
285pub enum RemoteCommand {
286    UiDialogResponse {
287        #[serde(rename = "requestId")]
288        request_id: String,
289        #[serde(default, skip_serializing_if = "Option::is_none")]
290        answer: Option<Value>,
291        #[serde(default)]
292        cancelled: bool,
293    },
294    /// Run a prompt on the remote `main` lane; events stream until `agent_end`.
295    Prompt { content: String },
296    /// Abort the in-flight run.
297    Abort,
298    /// Fetch the current session state (model, thinking, tools, ids).
299    GetState,
300    /// Switch the active model by id.
301    SetModel { model: String },
302    /// Set the thinking level (`off`…`max`).
303    SetThinkingLevel { level: String },
304    /// Replace the active tool set.
305    SetActiveTools { tools: Vec<String> },
306    /// Liveness probe.
307    Ping,
308    /// Ask the server to end the session loop.
309    Stop,
310}
311
312impl RemoteCommand {
313    /// Serialize the command with an `id` field for request/response correlation.
314    pub fn with_id(&self, id: impl Into<Value>) -> Value {
315        let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
316        if let Some(object) = value.as_object_mut() {
317            object.insert("id".to_string(), id.into());
318        }
319        value
320    }
321}
322
323/// Response status for a [`RemoteResponse`].
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "lowercase")]
326pub enum ResponseStatus {
327    Ok,
328    Error,
329}
330
331/// The server's answer to a single [`RemoteCommand`].
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333pub struct RemoteResponse {
334    #[serde(default)]
335    pub id: Value,
336    pub status: ResponseStatus,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub result: Option<Value>,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub error: Option<String>,
341}
342
343impl RemoteResponse {
344    /// A successful response carrying an optional result payload.
345    pub fn ok(id: Value, result: Value) -> Self {
346        Self {
347            id,
348            status: ResponseStatus::Ok,
349            result: Some(result),
350            error: None,
351        }
352    }
353
354    /// A failed response carrying an error message.
355    pub fn error(id: Value, error: impl Into<String>) -> Self {
356        Self {
357            id,
358            status: ResponseStatus::Error,
359            result: None,
360            error: Some(error.into()),
361        }
362    }
363
364    /// True when the request succeeded.
365    pub fn is_ok(&self) -> bool {
366        matches!(self.status, ResponseStatus::Ok)
367    }
368
369    /// Serialize to the wire shape, adding the `"type":"response"` tag.
370    pub fn to_json(&self) -> Value {
371        let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
372        if let Some(object) = value.as_object_mut() {
373            object.insert("type".to_string(), Value::String("response".to_string()));
374        }
375        value
376    }
377}
378
379/// The `get_state` result payload, as a typed view over the response.
380#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
381pub struct SessionState {
382    #[serde(default)]
383    pub model: Option<String>,
384    #[serde(rename = "thinkingLevel", default)]
385    pub thinking_level: Option<Value>,
386    #[serde(rename = "activeTools", default)]
387    pub active_tools: Vec<String>,
388    #[serde(rename = "leafId", default)]
389    pub leaf_id: Option<String>,
390    #[serde(rename = "sessionId", default)]
391    pub session_id: Option<String>,
392}
393
394/// The `prompt` result payload (`{outcome, finalText}`).
395#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
396pub struct PromptOutcome {
397    #[serde(default)]
398    pub outcome: Option<String>,
399    #[serde(rename = "finalText", default)]
400    pub final_text: Option<String>,
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use rpi_agent::types::AgentToolResult;
407
408    fn signed_native_result() -> AgentToolResult {
409        use rpi_agent::types::TextContentOrImage;
410        AgentToolResult {
411            content: vec![
412                TextContentOrImage::Text(
413                    serde_json::from_value(serde_json::json!({
414                        "type":"text", "text":"signed 界😀", "textSignature":
415                            "{ \"version\": 1, \"id\": \"sig-😀\" }",
416                    }))
417                    .unwrap(),
418                ),
419                TextContentOrImage::Image(
420                    serde_json::from_value(serde_json::json!({
421                        "type":"image", "data":"AAEC+/==", "mimeType":"image/png",
422                    }))
423                    .unwrap(),
424                ),
425            ],
426            details: serde_json::json!({"runtimeKey":"business", "runtimeHandle":{"id":7},
427                "__rpi":{"owner":"customer"}, "__rpiRuntimeKey":"unchanged"}),
428            usage: Some(rpi_ai::types::Usage::zero()),
429            added_tool_names: vec!["read".into(), "read".into()],
430            terminate: true,
431        }
432    }
433
434    #[test]
435    fn tool_update_preserves_canonical_native_result_and_text_signature() {
436        let result = signed_native_result();
437        let expected = serde_json::to_value(&result).unwrap();
438        let event = AgentEvent::ToolExecutionUpdate {
439            tool_call_id: "call".into(),
440            tool_name: "read".into(),
441            args: serde_json::json!({"path":"file"}),
442            partial_result: result.into(),
443        };
444        let wire = serde_json::to_value(RemoteEvent::from_agent_event(&event)).unwrap();
445        assert_eq!(wire["type"], "tool_execution_update");
446        assert_eq!(wire["partialResult"], expected);
447        assert_eq!(
448            wire["partialResult"]["content"][0]["textSignature"],
449            "{ \"version\": 1, \"id\": \"sig-😀\" }"
450        );
451        assert!(wire["partialResult"].get("isError").is_none());
452    }
453
454    #[test]
455    fn tool_end_preserves_canonical_native_result_and_separate_error_authority() {
456        let result = signed_native_result();
457        let expected = serde_json::to_value(&result).unwrap();
458        let event = AgentEvent::ToolExecutionEnd {
459            tool_call_id: "call".into(),
460            tool_name: "read".into(),
461            result,
462            is_error: true,
463        };
464        let wire = serde_json::to_value(RemoteEvent::from_agent_event(&event)).unwrap();
465        assert_eq!(wire["type"], "tool_execution_end");
466        assert_eq!(wire["result"], expected);
467        assert!(wire["result"]["content"][0]["textSignature"].is_string());
468        assert_eq!(wire["isError"], true);
469        assert!(wire["result"].get("isError").is_none());
470        let plain = tool_result_json(&AgentToolResult::text("unsigned"));
471        assert!(plain["content"][0].get("textSignature").is_none());
472        assert!(plain["usage"].is_null());
473    }
474
475    /// The queue depth must survive the native remote projection: a client that
476    /// never sees it cannot render the "N queued" indicator.
477    #[test]
478    fn turn_end_projects_pending_messages() {
479        let event = AgentEvent::TurnEnd {
480            message: rpi_agent::AgentMessage::Assistant(Box::new(
481                rpi_ai::types::AssistantMessage::empty(
482                    rpi_ai::types::Api::AnthropicMessages,
483                    "p",
484                    "m",
485                    0,
486                ),
487            )),
488            tool_results: vec![],
489            pending_messages: 3,
490        };
491        let projected = RemoteEvent::from_agent_event(&event);
492        assert_eq!(projected.type_tag(), "turn_end");
493        let wire = serde_json::to_value(&projected).unwrap();
494        assert_eq!(wire["pendingMessages"], 3, "{wire}");
495
496        // Zero is a real value, not an absent field.
497        let zero = AgentEvent::TurnEnd {
498            message: rpi_agent::AgentMessage::Assistant(Box::new(
499                rpi_ai::types::AssistantMessage::empty(
500                    rpi_ai::types::Api::AnthropicMessages,
501                    "p",
502                    "m",
503                    0,
504                ),
505            )),
506            tool_results: vec![],
507            pending_messages: 0,
508        };
509        let wire = serde_json::to_value(RemoteEvent::from_agent_event(&zero)).unwrap();
510        assert_eq!(wire["pendingMessages"], 0, "{wire}");
511
512        // A peer that predates the field still deserializes.
513        let legacy: RemoteEvent = serde_json::from_value(serde_json::json!({
514            "type":"turn_end", "message":null, "toolResultCount":0, "toolResults":[],
515        }))
516        .unwrap();
517        assert!(matches!(
518            legacy,
519            RemoteEvent::TurnEnd {
520                pending_messages: 0,
521                ..
522            }
523        ));
524    }
525
526    #[test]
527    fn dialog_wire_shapes_round_trip_with_request_ids() {
528        let event: RemoteEvent = serde_json::from_value(serde_json::json!({
529            "type":"ui_dialog_request", "requestId":"q", "toolCallId":"c",
530            "ui":{"question":"Allow?", "options":["Yes","No"]},
531        }))
532        .unwrap();
533        assert_eq!(event.type_tag(), "ui_dialog_request");
534        assert_eq!(serde_json::to_value(&event).unwrap()["requestId"], "q");
535        let answer = RemoteCommand::UiDialogResponse {
536            request_id: "q".into(),
537            answer: Some(serde_json::json!({"values":["No"]})),
538            cancelled: false,
539        }
540        .with_id(7);
541        assert_eq!(answer["type"], "ui_dialog_response");
542        assert_eq!(answer["requestId"], "q");
543        assert_eq!(answer["answer"]["values"][0], "No");
544        let cancelled = RemoteCommand::UiDialogResponse {
545            request_id: "q".into(),
546            answer: None,
547            cancelled: true,
548        }
549        .with_id(8);
550        assert_eq!(cancelled["cancelled"], true);
551        assert!(cancelled.get("answer").is_none());
552        assert!(
553            matches!(serde_json::from_value::<RemoteEvent>(serde_json::json!({
554            "type":"ui_dialog_closed", "requestId":"q",
555        })).unwrap(), RemoteEvent::UiDialogClosed { request_id } if request_id == "q")
556        );
557    }
558
559    #[test]
560    fn event_projection_matches_wire_shape() {
561        let end = serde_json::to_value(RemoteEvent::from_agent_event(&AgentEvent::AgentEnd {
562            messages: vec![],
563        }))
564        .unwrap();
565        assert_eq!(end["type"], "agent_end");
566        assert_eq!(end["messages"], serde_json::json!([]));
567        assert_eq!(end["messageCount"], 0);
568
569        let tool = serde_json::to_value(RemoteEvent::from_agent_event(
570            &AgentEvent::ToolExecutionEnd {
571                tool_call_id: "call-1".into(),
572                tool_name: "read".into(),
573                result: AgentToolResult::text("hello"),
574                is_error: false,
575            },
576        ))
577        .unwrap();
578        assert_eq!(tool["type"], "tool_execution_end");
579        assert_eq!(tool["result"]["content"][0]["text"], "hello");
580        assert_eq!(tool["result"]["terminate"], false);
581
582        let retry =
583            serde_json::to_value(RemoteEvent::from_agent_event(&AgentEvent::RetryScheduled {
584                attempt: 3,
585                max_retries: 10,
586                delay_ms: 8_000,
587                error: "503 service unavailable".into(),
588            }))
589            .unwrap();
590        assert_eq!(retry["type"], "retry_scheduled");
591        assert_eq!(retry["attempt"], 3);
592        assert_eq!(retry["maxRetries"], 10);
593        assert_eq!(retry["delayMs"], 8_000);
594    }
595
596    #[test]
597    fn native_message_ids_round_trip_and_legacy_messages_remain_optional() {
598        for kind in ["message_start", "message_update", "message_end"] {
599            let legacy = serde_json::json!({"type": kind, "message": {"role":"assistant"}});
600            let parsed: RemoteEvent = serde_json::from_value(legacy).unwrap();
601            assert!(serde_json::to_value(parsed)
602                .unwrap()
603                .get("messageId")
604                .is_none());
605            let value = serde_json::json!({"type": kind, "messageId": "native-\"🐈", "message": {"role":"assistant"}});
606            let parsed: RemoteEvent = serde_json::from_value(value).unwrap();
607            assert_eq!(
608                serde_json::to_value(parsed).unwrap()["messageId"],
609                "native-\"🐈"
610            );
611        }
612    }
613
614    #[test]
615    fn events_round_trip_over_the_wire() {
616        for event in [
617            RemoteEvent::AgentStart,
618            RemoteEvent::MessageUpdate {
619                message_id: Some("entry-1".into()),
620                message: serde_json::json!({"role": "assistant"}),
621                assistant_message_event: serde_json::json!({"type": "text_delta"}),
622                event_type: "text_delta".into(),
623                content_index: Some(0),
624                delta: Some("hi".into()),
625            },
626            RemoteEvent::ToolExecutionEnd {
627                tool_call_id: "c1".into(),
628                tool_name: "read".into(),
629                is_error: true,
630                result: tool_result_json(&AgentToolResult::text("nope")),
631            },
632        ] {
633            let json = serde_json::to_value(&event).unwrap();
634            let back: RemoteEvent = serde_json::from_value(json).unwrap();
635            assert_eq!(event, back);
636        }
637    }
638
639    #[test]
640    fn command_and_response_round_trip() {
641        let cmd = RemoteCommand::Prompt {
642            content: "hi".into(),
643        };
644        let value = cmd.with_id("r1");
645        assert_eq!(value["type"], "prompt");
646        assert_eq!(value["content"], "hi");
647        assert_eq!(value["id"], "r1");
648
649        let response =
650            RemoteResponse::ok(serde_json::json!("r1"), serde_json::json!({"pong": true}));
651        let json = response.to_json();
652        assert_eq!(json["type"], "response");
653        assert_eq!(json["id"], "r1");
654        assert_eq!(json["status"], "ok");
655        let parsed: RemoteResponse = serde_json::from_value(json).unwrap();
656        assert!(parsed.is_ok());
657
658        let err = RemoteResponse::error(Value::Null, "boom").to_json();
659        assert_eq!(err["status"], "error");
660        assert_eq!(err["error"], "boom");
661    }
662}