Skip to main content

codeoid_protocol/
message.rs

1//! Session messages — the unit of observable work. Every prompt, tool call,
2//! tool result, and assistant reply flows as one of these.
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::BTreeMap;
7
8use crate::tool::ToolInfo;
9
10/// Who produced a message. Every message carries one.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum IdentityType {
14    Human,
15    Agent,
16    Subagent,
17    System,
18}
19
20/// Identity of the entity that produced a message.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct MessageIdentity {
24    /// ZeroID WIMSE URI.
25    pub sub: String,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub name: Option<String>,
28    #[serde(rename = "type")]
29    pub kind: IdentityType,
30}
31
32/// Role discriminator on each [`SessionMessage`]. Simple frontends render
33/// based on role alone.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum MessageRole {
37    User,
38    Assistant,
39    Thinking,
40    ToolCall,
41    ToolResult,
42    System,
43    Info,
44}
45
46/// Rich structured content. Discriminated on `kind` — unknown kinds
47/// deserialize into [`ContentPart::Unknown`] and are rendered as raw JSON.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(tag = "kind", rename_all = "snake_case")]
50pub enum ContentPart {
51    Text {
52        text: String,
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        markdown: Option<bool>,
55    },
56    Code {
57        code: String,
58        #[serde(default, skip_serializing_if = "Option::is_none")]
59        language: Option<String>,
60        #[serde(default, skip_serializing_if = "Option::is_none", rename = "filePath")]
61        file_path: Option<String>,
62    },
63    FileRef {
64        path: String,
65        #[serde(default, skip_serializing_if = "Option::is_none")]
66        lines: Option<[u32; 2]>,
67        #[serde(default, skip_serializing_if = "Option::is_none")]
68        change: Option<FileChange>,
69    },
70    Diff {
71        path: String,
72        added: u32,
73        removed: u32,
74        #[serde(
75            default,
76            skip_serializing_if = "Option::is_none",
77            rename = "originalPath"
78        )]
79        original_path: Option<String>,
80    },
81    Tree {
82        label: String,
83        children: Vec<TreeNode>,
84    },
85    Button {
86        label: String,
87        action: String,
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        data: Option<BTreeMap<String, Value>>,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        style: Option<ButtonStyle>,
92    },
93    Progress {
94        message: String,
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        percent: Option<u8>,
97        #[serde(default, skip_serializing_if = "Option::is_none", rename = "elapsedMs")]
98        elapsed_ms: Option<u64>,
99    },
100    Image {
101        url: String,
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        alt: Option<String>,
104    },
105    Anchor {
106        uri: String,
107        title: String,
108    },
109    Table {
110        headers: Vec<String>,
111        rows: Vec<Vec<String>>,
112    },
113    /// Forward-compat sink: new daemon-side part kinds land here with their
114    /// raw JSON preserved. Frontend logs + falls back to rendering the parent
115    /// message's `content` string.
116    #[serde(other, deserialize_with = "deserialize_unknown")]
117    Unknown,
118}
119
120fn deserialize_unknown<'de, D>(deserializer: D) -> Result<(), D::Error>
121where
122    D: serde::Deserializer<'de>,
123{
124    // `#[serde(other)]` needs a unit variant; this just satisfies the trait.
125    serde::de::IgnoredAny::deserialize(deserializer).map(|_| ())
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct FileChange {
130    pub added: u32,
131    pub removed: u32,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct TreeNode {
137    pub label: String,
138    #[serde(rename = "type")]
139    pub kind: TreeNodeType,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub path: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub children: Option<Vec<TreeNode>>,
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum TreeNodeType {
149    File,
150    Directory,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum ButtonStyle {
156    Primary,
157    Secondary,
158    Danger,
159}
160
161/// Complete session message.
162///
163/// Mirrors `SessionMessage` in the TS protocol. Self-contained, auditable,
164/// and always carries identity.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct SessionMessage {
168    pub session_id: String,
169    pub message_id: String,
170    pub role: MessageRole,
171    /// Plain text — always present, used by simple frontends.
172    pub content: String,
173    /// Rich parts — optional, used by capable frontends.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub parts: Option<Vec<ContentPart>>,
176    pub identity: MessageIdentity,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub tool: Option<ToolInfo>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub metadata: Option<BTreeMap<String, Value>>,
181    pub timestamp: String,
182}
183
184/// Incremental update applied to an existing [`SessionMessage`] by `message_id`.
185///
186/// * `content_append` — concatenate to `content`.
187/// * `parts_append` / `parts_update` — extend or replace entries in `parts`.
188/// * `tool_state_update` — **replace** `tool.state` wholesale; do NOT merge fields.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(rename_all = "camelCase")]
191pub struct SessionMessageDelta {
192    pub session_id: String,
193    pub message_id: String,
194
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub content_append: Option<String>,
197
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub parts_append: Option<Vec<ContentPart>>,
200
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub parts_update: Option<Vec<PartsUpdate>>,
203
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub tool_state_update: Option<crate::tool::ToolState>,
206
207    pub timestamp: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct PartsUpdate {
212    pub index: u32,
213    pub part: ContentPart,
214}