1use serde::{Deserialize, Serialize};
4
5use crate::tool::ToolProvenance;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9#[serde(rename_all = "snake_case")]
10pub enum SystemCacheType {
11 Ephemeral,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
17pub struct SystemCacheMarker {
18 pub offset: usize,
20 pub length: usize,
22 pub cache_type: SystemCacheType,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct ToolCall {
29 pub id: String,
31 pub name: String,
33 pub input: serde_json::Value,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct MessageToolResult {
40 pub tool_use_id: String,
42 pub content: String,
44 pub is_error: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum ReasoningBlock {
54 Thinking {
58 text: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 signature: Option<String>,
61 },
62 Redacted { data: String },
65 Plain { text: String },
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct AssistantReasoning {
73 pub provider: String,
75 pub model: String,
77 pub blocks: Vec<ReasoningBlock>,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq)]
100pub struct ContentDigest([u8; 32]);
101
102impl ContentDigest {
103 pub const fn from_raw(bytes: [u8; 32]) -> Self {
105 Self(bytes)
106 }
107
108 pub const fn as_bytes(&self) -> &[u8; 32] {
110 &self.0
111 }
112
113 pub fn to_hex(&self) -> String {
115 let mut out = String::with_capacity(64);
116 for byte in self.0 {
117 out.push_str(&format!("{byte:02x}"));
118 }
119 out
120 }
121
122 pub fn from_hex(s: &str) -> Result<Self, String> {
124 if s.len() != 64 {
125 return Err(format!(
126 "content_digest must be 64 hex chars, got {}",
127 s.len()
128 ));
129 }
130 let mut bytes = [0u8; 32];
131 for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
132 let hex = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
133 bytes[i] = u8::from_str_radix(hex, 16).map_err(|e| e.to_string())?;
134 }
135 Ok(Self(bytes))
136 }
137}
138
139impl std::fmt::Display for ContentDigest {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.write_str(&self.to_hex())
142 }
143}
144
145impl serde::Serialize for ContentDigest {
146 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
147 s.serialize_str(&self.to_hex())
148 }
149}
150
151impl<'de> serde::Deserialize<'de> for ContentDigest {
152 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
153 let s = String::deserialize(d)?;
154 Self::from_hex(&s).map_err(serde::de::Error::custom)
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
165#[serde(tag = "type", rename_all = "snake_case")]
166pub enum ContentPart {
167 Text {
168 text: String,
169 },
170 Image {
171 path: std::path::PathBuf,
172 mime: String,
173 byte_count: u64,
174 #[serde(default)]
179 content_digest: ContentDigest,
180 },
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[serde(tag = "role", rename_all = "snake_case")]
186pub enum Message {
187 System {
189 content: String,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 cache_markers: Vec<SystemCacheMarker>,
194 },
195 Context {
197 content: String,
199 },
200 User {
202 content: String,
204 },
205 Multimodal {
211 parts: Vec<ContentPart>,
214 },
215 Assistant {
217 content: String,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
221 tool_calls: Vec<ToolCall>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
226 reasoning: Option<AssistantReasoning>,
227 },
228 Tool {
230 result: MessageToolResult,
232 },
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
237#[serde(rename_all = "snake_case")]
238pub enum StopReason {
239 EndTurn,
241 ToolUse,
243 MaxTokens,
245}
246
247#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
249pub struct Usage {
250 pub input_tokens: u32,
252 pub output_tokens: u32,
254 #[serde(default)]
256 pub cache_read_tokens: u32,
257 #[serde(default)]
259 pub cache_write_tokens: u32,
260 #[serde(default)]
262 pub reasoning_tokens: u32,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267#[serde(tag = "type", rename_all = "snake_case")]
268#[non_exhaustive]
269pub enum AgentEvent {
270 TurnStart,
272 TextDelta {
274 delta: String,
276 },
277 ThinkingDelta {
282 delta: String,
284 },
285 ReasoningComplete {
288 blocks: Vec<ReasoningBlock>,
290 },
291 ToolCallStarted {
293 name: String,
295 },
296 ToolCall {
298 call: ToolCall,
300 provenance: ToolProvenance,
302 summary_fields: Vec<String>,
304 },
305 ToolResult {
307 result: MessageToolResult,
309 },
310 TurnEnd {
312 stop_reason: StopReason,
314 usage: Usage,
316 },
317 Error {
319 message: String,
321 },
322}
323
324#[cfg(test)]
325#[allow(warnings)]
326#[allow(warnings)]
327#[allow(warnings)]
328#[allow(warnings)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn message_roundtrip_user() {
334 let msg = Message::User {
335 content: "Hello, world!".into(),
336 };
337 let json = serde_json::to_string(&msg).expect("operation should succeed");
338 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
339 assert_eq!(msg, decoded);
340 }
341
342 #[test]
343 fn message_roundtrip_assistant() {
344 let msg = Message::Assistant {
345 content: "I can help with that.".into(),
346 tool_calls: vec![ToolCall {
347 id: "call_1".into(),
348 name: "read_file".into(),
349 input: serde_json::json!({"path": "src/main.rs"}),
350 }],
351 reasoning: None,
352 };
353 let json = serde_json::to_string(&msg).expect("operation should succeed");
354 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
355 assert_eq!(msg, decoded);
356 }
357
358 #[test]
359 fn message_roundtrip_tool() {
360 let msg = Message::Tool {
361 result: MessageToolResult {
362 tool_use_id: "call_1".into(),
363 content: "fn main() {}".into(),
364 is_error: false,
365 },
366 };
367 let json = serde_json::to_string(&msg).expect("operation should succeed");
368 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
369 assert_eq!(msg, decoded);
370 }
371
372 #[test]
373 fn event_roundtrip() {
374 let events = vec![
375 AgentEvent::TurnStart,
376 AgentEvent::TextDelta {
377 delta: "Hello".into(),
378 },
379 AgentEvent::ToolCall {
380 call: ToolCall {
381 id: "c1".into(),
382 name: "bash".into(),
383 input: serde_json::json!({"command": "ls"}),
384 },
385 provenance: ToolProvenance::Native,
386 summary_fields: vec![],
387 },
388 AgentEvent::ToolResult {
389 result: MessageToolResult {
390 tool_use_id: "c1".into(),
391 content: "file.rs".into(),
392 is_error: false,
393 },
394 },
395 AgentEvent::TurnEnd {
396 stop_reason: StopReason::EndTurn,
397 usage: Usage {
398 input_tokens: 100,
399 output_tokens: 50,
400 cache_read_tokens: 80,
401 cache_write_tokens: 20,
402 reasoning_tokens: 0,
403 },
404 },
405 AgentEvent::Error {
406 message: "something failed".into(),
407 },
408 ];
409 for event in events {
410 let json = serde_json::to_string(&event).expect("operation should succeed");
411 let decoded: AgentEvent =
412 serde_json::from_str(&json).expect("operation should succeed");
413 assert_eq!(event, decoded);
414 }
415 }
416
417 #[test]
418 fn extract_tool_calls_preserves_id_from_json_tool_block() {
419 let text = r#"I'll run that for you.
420```json-tool
421{"id":"call_abc123","args":{"command":"ls"},"name":"bash"}
422```
423Done."#;
424 let calls = extract_tool_calls_from_text(text);
425 assert_eq!(calls.len(), 1);
426 assert_eq!(calls[0].id, "call_abc123");
427 assert_eq!(calls[0].name, "bash");
428 assert_eq!(calls[0].input, serde_json::json!({"command": "ls"}));
429 }
430
431 #[test]
432 fn extract_tool_calls_falls_back_to_synthetic_id_when_missing() {
433 let text = r#"```json-tool
434{"args":{"command":"ls"},"name":"bash"}
435```"#;
436 let calls = extract_tool_calls_from_text(text);
437 assert_eq!(calls.len(), 1);
438 assert_eq!(calls[0].id, "tc_0");
439 assert_eq!(calls[0].name, "bash");
440 }
441
442 #[test]
443 fn extract_tool_calls_falls_back_when_id_is_empty() {
444 let text = r#"```json-tool
445{"id":"","args":{"command":"ls"},"name":"bash"}
446```"#;
447 let calls = extract_tool_calls_from_text(text);
448 assert_eq!(calls.len(), 1);
449 assert_eq!(calls[0].id, "tc_0");
450 }
451
452 #[test]
453 fn content_part_text_roundtrip() {
454 let part = ContentPart::Text {
455 text: "Hello, image!".into(),
456 };
457 let json = serde_json::to_string(&part).expect("operation should succeed");
458 let decoded: ContentPart = serde_json::from_str(&json).expect("operation should succeed");
459 assert_eq!(part, decoded);
460 }
461
462 #[test]
463 fn content_part_image_roundtrip() {
464 let part = ContentPart::Image {
465 path: "/tmp/test.png".into(),
466 mime: "image/png".into(),
467 byte_count: 12345,
468 content_digest: ContentDigest::from_raw([7u8; 32]),
469 };
470 let json = serde_json::to_string(&part).expect("operation should succeed");
471 let decoded: ContentPart = serde_json::from_str(&json).expect("operation should succeed");
472 assert_eq!(part, decoded);
473 }
474
475 #[test]
476 fn message_multimodal_roundtrip() {
477 let msg = Message::Multimodal {
478 parts: vec![
479 ContentPart::Text {
480 text: "What is in this image?".into(),
481 },
482 ContentPart::Image {
483 path: "/tmp/screenshot.png".into(),
484 mime: "image/png".into(),
485 byte_count: 67890,
486 content_digest: ContentDigest::from_raw([9u8; 32]),
487 },
488 ],
489 };
490 let json = serde_json::to_string(&msg).expect("operation should succeed");
491 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
492 assert_eq!(msg, decoded);
493 }
494
495 #[test]
496 fn message_user_still_works_after_multimodal_addition() {
497 let msg = Message::User {
498 content: "text only".into(),
499 };
500 let json = serde_json::to_string(&msg).expect("operation should succeed");
501 let decoded: Message = serde_json::from_str(&json).expect("operation should succeed");
502 assert_eq!(msg, decoded);
503 }
504}
505
506pub fn extract_tool_calls_from_text(text: &str) -> Vec<ToolCall> {
507 let mut calls = Vec::new();
508 let mut remaining = text;
509
510 while let Some(start) = remaining.find("```json-tool") {
511 let inner_start = start + "```json-tool".len();
512 let inner = remaining[inner_start..].trim_start();
513 let end = inner.find("```").unwrap_or(inner.len());
514 let content = inner[..end].trim();
515
516 if let Ok(obj) = serde_json::from_str::<serde_json::Value>(content)
517 && let (Some(name), Some(args)) = (obj["name"].as_str(), Some(obj["args"].clone()))
518 {
519 let id = obj["id"]
520 .as_str()
521 .filter(|s| !s.is_empty())
522 .map(String::from)
523 .unwrap_or_else(|| format!("tc_{}", calls.len()));
524 calls.push(ToolCall {
525 id,
526 name: name.to_string(),
527 input: args,
528 });
529 }
530
531 remaining = &inner[end..];
532 if end + 3 < remaining.len() {
533 remaining = &remaining[3..];
534 } else {
535 break;
536 }
537 }
538
539 calls
540}
541
542pub fn strip_tool_syntax(text: &str) -> String {
543 let mut result = text.to_string();
544 while let Some(start) = result.find("```json-tool") {
545 let inner_start = start + "```json-tool".len();
546 let inner = &result[inner_start..];
547 let end = inner_start + inner.find("```").unwrap_or(inner.len()) + 3;
548 result.replace_range(start..end, "");
549 }
550 result.trim().to_string()
551}
552
553pub fn project_displayable_reasoning(ar: &AssistantReasoning) -> Option<String> {
554 let mut parts = Vec::new();
555 for block in &ar.blocks {
556 match block {
557 ReasoningBlock::Thinking { text, .. } if !text.is_empty() => parts.push(text.clone()),
558 ReasoningBlock::Plain { text } if !text.is_empty() => parts.push(text.clone()),
559 _ => {}
560 }
561 }
562 if parts.is_empty() {
563 None
564 } else {
565 Some(parts.join("\n"))
566 }
567}