1use serde::{Deserialize, Serialize};
2
3#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
4#[serde(tag = "type", rename_all = "snake_case")]
5pub enum ContentBlock {
6 Text {
7 text: String,
8 },
9 Thinking {
10 text: String,
11 },
12 ToolCall {
13 id: String,
14 name: String,
15 arguments: serde_json::Value,
16 },
17}
18
19#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq)]
20#[serde(rename_all = "snake_case")]
21pub struct Usage {
22 pub input_tokens: u64,
23 pub output_tokens: u64,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub cache_read_tokens: Option<u64>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub cache_write_tokens: Option<u64>,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum StopReason {
33 Stop,
34 Length,
35 ToolUse,
36 Aborted,
37 Error,
38 MaxTurns,
42}
43
44#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
45#[serde(tag = "role", rename_all = "snake_case")]
46pub enum AgentMessage {
47 User {
48 content: Vec<ContentBlock>,
49 },
50 Assistant {
51 content: Vec<ContentBlock>,
52 stop_reason: StopReason,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 usage: Option<Usage>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 model: Option<String>,
57 },
58 ToolResult {
59 tool_call_id: String,
60 name: String,
61 output: String,
62 is_error: bool,
63 },
64}
65
66impl AgentMessage {
67 pub fn user_text(s: impl Into<String>) -> AgentMessage {
68 AgentMessage::User {
69 content: vec![ContentBlock::Text { text: s.into() }],
70 }
71 }
72
73 pub fn summary(text: impl Into<String>) -> AgentMessage {
76 AgentMessage::User {
77 content: vec![ContentBlock::Text {
78 text: format!(
79 "Summary of the earlier conversation (auto-generated by compaction):\n\n{}",
80 text.into()
81 ),
82 }],
83 }
84 }
85
86 pub fn as_text(&self) -> String {
87 let content = match self {
88 AgentMessage::User { content } | AgentMessage::Assistant { content, .. } => content,
89 AgentMessage::ToolResult { .. } => return String::new(),
90 };
91 content
92 .iter()
93 .filter_map(|b| match b {
94 ContentBlock::Text { text } => Some(text.as_str()),
95 _ => None,
96 })
97 .collect::<Vec<_>>()
98 .join("")
99 }
100
101 pub fn tool_calls(&self) -> Vec<&ContentBlock> {
102 match self {
103 AgentMessage::Assistant { content, .. } => content
104 .iter()
105 .filter(|b| matches!(b, ContentBlock::ToolCall { .. }))
106 .collect(),
107 _ => Vec::new(),
108 }
109 }
110}
111
112pub fn last_assistant_text(messages: &[AgentMessage]) -> Option<String> {
117 let message = messages
118 .iter()
119 .rev()
120 .find(|m| matches!(m, AgentMessage::Assistant { .. }))?;
121 let AgentMessage::Assistant { content, .. } = message else {
122 return None;
123 };
124 let text = content
125 .iter()
126 .filter_map(|b| match b {
127 ContentBlock::Text { text } => Some(text.as_str()),
128 _ => None,
129 })
130 .collect::<Vec<_>>()
131 .join("\n");
132 (!text.trim().is_empty()).then_some(text)
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use serde_json::json;
139
140 #[test]
141 fn content_block_roundtrip_and_tags() {
142 let blocks = vec![
143 ContentBlock::Text { text: "hi".into() },
144 ContentBlock::Thinking { text: "hmm".into() },
145 ContentBlock::ToolCall {
146 id: "t1".into(),
147 name: "run".into(),
148 arguments: json!({"x": 1}),
149 },
150 ];
151 for b in blocks {
152 let v = serde_json::to_value(&b).unwrap();
153 let back: ContentBlock = serde_json::from_value(v.clone()).unwrap();
154 assert_eq!(back, b);
155 let expected = match &b {
156 ContentBlock::Text { .. } => "text",
157 ContentBlock::Thinking { .. } => "thinking",
158 ContentBlock::ToolCall { .. } => "tool_call",
159 };
160 assert_eq!(v["type"], expected);
161 }
162 }
163
164 #[test]
165 fn usage_roundtrip_and_omits_none() {
166 let u = Usage::default();
167 let v = serde_json::to_value(u).unwrap();
168 assert_eq!(v, json!({"input_tokens": 0, "output_tokens": 0}));
169 let back: Usage = serde_json::from_value(v).unwrap();
170 assert_eq!(back, u);
171
172 let u2 = Usage {
173 input_tokens: 10,
174 output_tokens: 20,
175 cache_read_tokens: Some(5),
176 cache_write_tokens: Some(7),
177 };
178 let v2 = serde_json::to_value(u2).unwrap();
179 assert_eq!(
180 v2,
181 json!({
182 "input_tokens": 10,
183 "output_tokens": 20,
184 "cache_read_tokens": 5,
185 "cache_write_tokens": 7
186 })
187 );
188 assert_eq!(serde_json::from_value::<Usage>(v2).unwrap(), u2);
189 }
190
191 #[test]
192 fn stop_reason_roundtrip_snake_case() {
193 for (r, s) in [
194 (StopReason::Stop, "stop"),
195 (StopReason::Length, "length"),
196 (StopReason::ToolUse, "tool_use"),
197 (StopReason::Aborted, "aborted"),
198 (StopReason::Error, "error"),
199 (StopReason::MaxTurns, "max_turns"),
200 ] {
201 let v = serde_json::to_value(r).unwrap();
202 assert_eq!(v, json!(s));
203 assert_eq!(serde_json::from_value::<StopReason>(v).unwrap(), r);
204 }
205 }
206
207 #[test]
208 fn agent_message_roundtrip_and_role_tags() {
209 let msgs = vec![
210 AgentMessage::User {
211 content: vec![ContentBlock::Text { text: "q".into() }],
212 },
213 AgentMessage::Assistant {
214 content: vec![ContentBlock::Text { text: "a".into() }],
215 stop_reason: StopReason::Stop,
216 usage: Some(Usage::default()),
217 model: Some("m".into()),
218 },
219 AgentMessage::ToolResult {
220 tool_call_id: "t1".into(),
221 name: "run".into(),
222 output: "out".into(),
223 is_error: false,
224 },
225 ];
226 for m in msgs {
227 let v = serde_json::to_value(&m).unwrap();
228 let back: AgentMessage = serde_json::from_value(v.clone()).unwrap();
229 assert_eq!(back, m);
230 let expected = match &m {
231 AgentMessage::User { .. } => "user",
232 AgentMessage::Assistant { .. } => "assistant",
233 AgentMessage::ToolResult { .. } => "tool_result",
234 };
235 assert_eq!(v["role"], expected);
236 }
237 }
238
239 #[test]
240 fn assistant_optional_fields_default_and_skip() {
241 let v = json!({
242 "role": "assistant",
243 "content": [{"type": "text", "text": "a"}],
244 "stop_reason": "stop"
245 });
246 let m: AgentMessage = serde_json::from_value(v).unwrap();
247 match &m {
248 AgentMessage::Assistant {
249 content,
250 usage,
251 model,
252 ..
253 } => {
254 assert_eq!(content.len(), 1);
255 assert_eq!(*usage, None);
256 assert_eq!(*model, None);
257 }
258 _ => panic!("wrong variant"),
259 }
260 let out = serde_json::to_value(&m).unwrap();
261 assert!(out.get("usage").is_none());
262 assert!(out.get("model").is_none());
263 }
264
265 #[test]
266 fn user_text_and_as_text() {
267 let m = AgentMessage::user_text("hello");
268 assert_eq!(
269 m,
270 AgentMessage::User {
271 content: vec![ContentBlock::Text {
272 text: "hello".into()
273 }]
274 }
275 );
276 assert_eq!(m.as_text(), "hello");
277
278 let m2 = AgentMessage::Assistant {
279 content: vec![
280 ContentBlock::Text { text: "a".into() },
281 ContentBlock::Thinking { text: "t".into() },
282 ContentBlock::Text { text: "b".into() },
283 ],
284 stop_reason: StopReason::Stop,
285 usage: None,
286 model: None,
287 };
288 assert_eq!(m2.as_text(), "ab");
289 }
290
291 #[test]
292 fn tool_calls_returns_assistant_tool_call_blocks() {
293 let m = AgentMessage::Assistant {
294 content: vec![
295 ContentBlock::Text { text: "a".into() },
296 ContentBlock::ToolCall {
297 id: "t1".into(),
298 name: "run".into(),
299 arguments: json!({}),
300 },
301 ContentBlock::ToolCall {
302 id: "t2".into(),
303 name: "ls".into(),
304 arguments: json!({}),
305 },
306 ],
307 stop_reason: StopReason::ToolUse,
308 usage: None,
309 model: None,
310 };
311 assert_eq!(m.tool_calls().len(), 2);
312 assert!(matches!(m.tool_calls()[0], ContentBlock::ToolCall { .. }));
313 assert!(
314 m.tool_calls().iter().all(
315 |b| matches!(b, ContentBlock::ToolCall { id, .. } if id == "t1" || id == "t2")
316 )
317 );
318
319 let u = AgentMessage::user_text("x");
320 assert!(u.tool_calls().is_empty());
321 }
322}