Skip to main content

recursive/
message.rs

1//! Chat message primitive.
2//!
3//! A `Message` mirrors the wire format that most chat completion APIs use,
4//! but stays provider-agnostic. Providers translate to/from their own shape
5//! in their adapter.
6
7use serde::{Deserialize, Serialize};
8
9use crate::llm::ToolCall;
10
11/// Serde helper: skip serializing `is_compaction_summary` when false, so old
12/// JSONL transcripts on disk deserialize cleanly without the field.
13#[inline]
14fn is_false(b: &bool) -> bool {
15    !*b
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Role {
21    System,
22    User,
23    Assistant,
24    Tool,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct Message {
29    pub role: Role,
30    pub content: String,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub tool_calls: Vec<ToolCall>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub tool_call_id: Option<String>,
35    /// DeepSeek reasoning/thinking content. Must be echoed back to the API
36    /// when present, otherwise the API returns a 400 error.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub reasoning_content: Option<String>,
39    /// True when this message was inserted by `Compactor` as a summary of
40    /// older messages. Used by AgentKernel::run to detect intra-turn
41    /// compaction summaries without sniffing the rendered content text.
42    /// Defaults to false; old transcripts on disk serialize as false
43    /// (field omitted).
44    #[serde(default, skip_serializing_if = "is_false")]
45    pub is_compaction_summary: bool,
46}
47
48impl Message {
49    pub fn system(content: impl Into<String>) -> Self {
50        Self {
51            role: Role::System,
52            content: content.into(),
53            tool_calls: Vec::new(),
54            tool_call_id: None,
55            reasoning_content: None,
56            is_compaction_summary: false,
57        }
58    }
59
60    pub fn user(content: impl Into<String>) -> Self {
61        Self {
62            role: Role::User,
63            content: content.into(),
64            tool_calls: Vec::new(),
65            tool_call_id: None,
66            reasoning_content: None,
67            is_compaction_summary: false,
68        }
69    }
70
71    pub fn assistant(content: impl Into<String>) -> Self {
72        Self {
73            role: Role::Assistant,
74            content: content.into(),
75            tool_calls: Vec::new(),
76            tool_call_id: None,
77            reasoning_content: None,
78            is_compaction_summary: false,
79        }
80    }
81
82    pub fn assistant_with_tool_calls(
83        content: impl Into<String>,
84        tool_calls: Vec<ToolCall>,
85    ) -> Self {
86        Self {
87            role: Role::Assistant,
88            content: content.into(),
89            tool_calls,
90            tool_call_id: None,
91            reasoning_content: None,
92            is_compaction_summary: false,
93        }
94    }
95
96    pub fn tool_result(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
97        Self {
98            role: Role::Tool,
99            content: content.into(),
100            tool_calls: Vec::new(),
101            tool_call_id: Some(tool_call_id.into()),
102            reasoning_content: None,
103            is_compaction_summary: false,
104        }
105    }
106
107    /// Mark this message as a compaction summary.
108    ///
109    /// Used by `Compactor` so that `AgentKernel::run` can detect intra-turn
110    /// compaction summaries via a typed field instead of sniffing the
111    /// rendered content text.
112    pub fn with_compaction_summary(mut self) -> Self {
113        self.is_compaction_summary = true;
114        self
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn compaction_summary_bit_default_false() {
124        let m = Message::user("hi");
125        assert!(!m.is_compaction_summary);
126    }
127
128    #[test]
129    fn with_compaction_summary_sets_bit() {
130        let m = Message::system("...").with_compaction_summary();
131        assert!(m.is_compaction_summary);
132    }
133
134    #[test]
135    fn compaction_summary_bit_omitted_from_json_when_false() {
136        let m = Message::user("hi");
137        let json = serde_json::to_string(&m).unwrap();
138        assert!(!json.contains("is_compaction_summary"));
139    }
140
141    #[test]
142    fn compaction_summary_bit_included_in_json_when_true() {
143        // Kills: `replace is_false -> bool with true` (always skip).
144        // When `is_compaction_summary = true`, the field MUST appear in JSON
145        // so that `AgentKernel::run` can detect intra-turn summaries after
146        // deserialising a transcript from disk.
147        let m = Message::system("summary").with_compaction_summary();
148        let json = serde_json::to_string(&m).unwrap();
149        assert!(
150            json.contains("\"is_compaction_summary\":true"),
151            "is_compaction_summary must be serialised when true; got: {json}"
152        );
153
154        // Round-trip: deserialise back and verify the flag is preserved.
155        let restored: Message = serde_json::from_str(&json).unwrap();
156        assert!(
157            restored.is_compaction_summary,
158            "is_compaction_summary must survive a serde round-trip"
159        );
160    }
161
162    #[test]
163    fn is_false_returns_true_for_false_value() {
164        // kills `replace !*b with *b` mutation in is_false()
165        assert!(is_false(&false));
166    }
167
168    #[test]
169    fn is_false_returns_false_for_true_value() {
170        // complementary: is_false(true) must be false
171        assert!(!is_false(&true));
172    }
173
174    #[test]
175    fn constructor_roles_are_correct() {
176        // kills any mutation that swaps Role variants in constructors
177        assert_eq!(Message::system("").role, Role::System);
178        assert_eq!(Message::user("").role, Role::User);
179        assert_eq!(Message::assistant("").role, Role::Assistant);
180        assert_eq!(
181            Message::assistant_with_tool_calls("", Vec::new()).role,
182            Role::Assistant
183        );
184        assert_eq!(Message::tool_result("id", "").role, Role::Tool);
185    }
186
187    #[test]
188    fn tool_result_sets_tool_call_id() {
189        // kills `tool_call_id: None` mutation in tool_result()
190        let m = Message::tool_result("call-123", "output");
191        assert_eq!(m.tool_call_id.as_deref(), Some("call-123"));
192    }
193
194    #[test]
195    fn constructors_set_empty_tool_calls_by_default() {
196        // kills mutations that set tool_calls: vec![<something>] in constructors
197        assert!(Message::system("").tool_calls.is_empty());
198        assert!(Message::user("").tool_calls.is_empty());
199        assert!(Message::assistant("").tool_calls.is_empty());
200        assert!(Message::tool_result("", "").tool_calls.is_empty());
201    }
202
203    #[test]
204    fn assistant_with_tool_calls_stores_tool_calls() {
205        // kills `tool_calls: vec![]` mutation in assistant_with_tool_calls
206        use crate::llm::ToolCall;
207        let tc = ToolCall {
208            id: "tc-1".to_string(),
209            name: "Read".to_string(),
210            arguments: serde_json::json!({}),
211        };
212        let m = Message::assistant_with_tool_calls("calling", vec![tc]);
213        assert_eq!(m.tool_calls.len(), 1, "tool_calls must be stored");
214        assert_eq!(m.tool_calls[0].id, "tc-1");
215    }
216
217    #[test]
218    fn constructors_store_content() {
219        // kills `content: "".into()` or `content: Default::default()` mutations
220        assert_eq!(Message::system("sys-content").content, "sys-content");
221        assert_eq!(Message::user("user-content").content, "user-content");
222        assert_eq!(Message::assistant("asst-content").content, "asst-content");
223        assert_eq!(Message::tool_result("id", "tool-out").content, "tool-out");
224    }
225}