Skip to main content

rpi_agent/
message.rs

1//! Mirrors `packages/agent/src/types.ts` (open enum portion) — the
2//! `AgentMessage = Message | Custom` declaration-merging model.
3//!
4//! TS lets apps add custom message kinds via declaration merging on
5//! `CustomAgentMessages`. Rust has no declaration merging, so we model the same
6//! openness as a four-arm enum: the three base [`Message`] arms plus
7//! [`CustomMessage`]. Apps extend by populating `Custom`, not by adding arms.
8//!
9//! Serialization mirrors the wire shape: `#[serde(tag="kind")]` with
10//! `{kind:"user"|"assistant"|"toolResult"|"custom", ...}`. The three base arms
11//! flatten their `Message` (which itself is tagged on `role`); we serialize
12//! them via a custom impl so the outer `kind` discriminator stays consistent
13//! and the inner role-specific fields survive a round-trip. `Custom` carries a
14//! free-form `data: Value` so each role's structured payload rides along.
15
16use rpi_ai::types::{AssistantMessage, Content, Message, ToolResultMessage, UserMessage};
17use serde::{Deserialize, Serialize};
18
19/// `AgentMessage = Message | Custom`. The open-enum port of TS
20/// `AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]`.
21///
22/// `PartialEq` is derived so `pi-harness` entry equality (and tests) can compare
23/// persisted messages by value. `rpi_ai`'s base message types already implement
24/// `PartialEq`; `CustomMessage` does too, so the four-arm enum derives cleanly.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum AgentMessage {
28    User(UserMessage),
29    Assistant(Box<AssistantMessage>),
30    ToolResult(Box<ToolResultMessage>),
31    Custom(CustomMessage),
32}
33
34impl AgentMessage {
35    /// The logical role tag — mirrors `message.role` on the TS side. For base
36    /// messages this is `"user"`/`"assistant"`/`"toolResult"`; for custom it is
37    /// the custom role string. Used by the loop's role checks (e.g. "can't
38    /// continue from an assistant message").
39    pub fn role(&self) -> AgentMessageRole {
40        match self {
41            AgentMessage::User(_) => AgentMessageRole::User,
42            AgentMessage::Assistant(_) => AgentMessageRole::Assistant,
43            AgentMessage::ToolResult(_) => AgentMessageRole::ToolResult,
44            AgentMessage::Custom(c) => AgentMessageRole::Custom(c.role.clone()),
45        }
46    }
47
48    pub fn is_assistant(&self) -> bool {
49        matches!(self, AgentMessage::Assistant(_))
50    }
51
52    /// If this is an assistant message, borrow it.
53    pub fn as_assistant(&self) -> Option<&AssistantMessage> {
54        match self {
55            AgentMessage::Assistant(a) => Some(a),
56            _ => None,
57        }
58    }
59
60    /// If this is an assistant message, own a clone of it.
61    pub fn into_assistant(self) -> Option<AssistantMessage> {
62        match self {
63            AgentMessage::Assistant(a) => Some(*a),
64            _ => None,
65        }
66    }
67}
68
69/// Serialize an `AssistantMessage` in the exact shape `AgentMessage::Assistant`
70/// produces (`{"kind":"assistant", …}`), without materializing an owned
71/// `AgentMessage` copy of it.
72///
73/// [`crate::AgentEvent::MessageUpdate`] carries the streaming partial as an
74/// `Arc<AssistantMessage>` so neither the loop nor its consumers deep-clone the
75/// whole growing message per delta. Consumers that need the message as JSON (the
76/// remote protocol, the extension bridge) use this to keep the wire/plugin shape
77/// byte-identical to a real `AgentMessage` — a deep clone here would reintroduce
78/// exactly the per-delta copy the `Arc` exists to avoid. The equivalence is
79/// pinned by `assistant_json_matches_the_agent_message_shape`.
80///
81/// [`crate::AgentEvent::MessageUpdate`]: crate::events::AgentEvent::MessageUpdate
82pub fn assistant_json(message: &AssistantMessage) -> serde_json::Value {
83    /// Internally tagged exactly like `AgentMessage`, so serde emits the same
84    /// `"kind"` discriminator first and then the message fields.
85    #[derive(serde::Serialize)]
86    #[serde(tag = "kind", rename_all = "snake_case")]
87    enum Tagged<'a> {
88        Assistant(&'a AssistantMessage),
89    }
90    serde_json::to_value(Tagged::Assistant(message)).unwrap_or(serde_json::Value::Null)
91}
92
93impl From<UserMessage> for AgentMessage {
94    fn from(m: UserMessage) -> Self {
95        AgentMessage::User(m)
96    }
97}
98impl From<AssistantMessage> for AgentMessage {
99    fn from(m: AssistantMessage) -> Self {
100        AgentMessage::Assistant(Box::new(m))
101    }
102}
103impl From<ToolResultMessage> for AgentMessage {
104    fn from(m: ToolResultMessage) -> Self {
105        AgentMessage::ToolResult(Box::new(m))
106    }
107}
108impl From<Message> for AgentMessage {
109    fn from(m: Message) -> Self {
110        match m {
111            Message::User(u) => AgentMessage::User(u),
112            Message::Assistant(a) => AgentMessage::Assistant(a),
113            Message::ToolResult(t) => AgentMessage::ToolResult(t),
114        }
115    }
116}
117
118/// Typed view of `AgentMessage::role`.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum AgentMessageRole {
121    User,
122    Assistant,
123    ToolResult,
124    Custom(String),
125}
126
127impl AgentMessageRole {
128    pub fn as_str(&self) -> &str {
129        match self {
130            AgentMessageRole::User => "user",
131            AgentMessageRole::Assistant => "assistant",
132            AgentMessageRole::ToolResult => "toolResult",
133            AgentMessageRole::Custom(s) => s.as_str(),
134        }
135    }
136}
137
138/// A custom app message. `role` is the app-defined tag (e.g. `"bashExecution"`,
139/// `"branchSummary"`); `content` is provider-content the renderer emits;
140/// `data` carries the role's structured payload; `timestamp` is ms-since-epoch.
141///
142/// `pi-harness::messages` provides typed constructors per known role;
143/// `pi-agent` stays role-agnostic and only owns this shell.
144///
145/// `PartialEq` is derived so `pi-harness` entry equality (and tests) can compare
146/// persisted custom messages by value. `serde_json::Value` + `Content` both
147/// implement `PartialEq`, so the derive is sound.
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct CustomMessage {
151    pub role: String,
152    pub content: Vec<Content>,
153    pub data: serde_json::Value,
154    pub timestamp: i64,
155}
156
157impl CustomMessage {
158    pub fn new(
159        role: impl Into<String>,
160        content: Vec<Content>,
161        data: serde_json::Value,
162        timestamp: i64,
163    ) -> Self {
164        Self {
165            role: role.into(),
166            content,
167            data,
168            timestamp,
169        }
170    }
171}
172
173#[cfg(test)]
174mod assistant_json_tests {
175    use super::*;
176    use rpi_ai::types::{Api, AssistantMessage};
177
178    #[test]
179    fn assistant_json_matches_the_agent_message_shape() {
180        // The whole point of `assistant_json` is that consumers can keep
181        // serializing a `MessageUpdate` partial the way they always have, so it
182        // must be indistinguishable from the real `AgentMessage` encoding.
183        let mut assistant =
184            AssistantMessage::empty(Api::OpenaiCompletions, "gateway", "gpt-test", 7);
185        assistant.error_message = Some("boom".into());
186        let as_agent = AgentMessage::Assistant(Box::new(assistant.clone()));
187        assert_eq!(
188            assistant_json(&assistant),
189            serde_json::to_value(&as_agent).expect("AgentMessage serializes"),
190        );
191    }
192}