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}