1use serde::{Deserialize, Serialize};
8
9use crate::llm::ToolCall;
10
11#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub reasoning_content: Option<String>,
39 #[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 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 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 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 assert!(is_false(&false));
166 }
167
168 #[test]
169 fn is_false_returns_false_for_true_value() {
170 assert!(!is_false(&true));
172 }
173
174 #[test]
175 fn constructor_roles_are_correct() {
176 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 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 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 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 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}