1use std::sync::Arc;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "role", rename_all = "camelCase")]
19pub enum Message {
20 User(UserMessage),
22 Assistant(Arc<AssistantMessage>),
28 ToolResult(Arc<ToolResultMessage>),
34 Custom(CustomMessage),
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct UserMessage {
42 pub content: UserContent,
43 pub timestamp: i64,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(untagged)]
49pub enum UserContent {
50 Text(String),
52 Blocks(Vec<ContentBlock>),
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct AssistantMessage {
60 pub content: Vec<ContentBlock>,
61 pub api: String,
62 pub provider: String,
63 pub model: String,
64 pub usage: Usage,
65 pub stop_reason: StopReason,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub error_message: Option<String>,
68 pub timestamp: i64,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ToolResultMessage {
75 pub tool_call_id: String,
76 pub tool_name: String,
77 pub content: Vec<ContentBlock>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub details: Option<serde_json::Value>,
80 pub is_error: bool,
81 pub timestamp: i64,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct CustomMessage {
88 pub content: String,
89 pub custom_type: String,
90 #[serde(default)]
91 pub display: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub details: Option<serde_json::Value>,
94 pub timestamp: i64,
95}
96
97impl Message {
98 pub fn assistant(msg: AssistantMessage) -> Self {
100 Self::Assistant(Arc::new(msg))
101 }
102
103 pub fn tool_result(msg: ToolResultMessage) -> Self {
105 Self::ToolResult(Arc::new(msg))
106 }
107}
108
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub enum StopReason {
117 #[default]
118 Stop,
120 Length,
122 ToolUse,
124 Error,
126 Aborted,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "type", rename_all = "camelCase")]
137pub enum ContentBlock {
138 Text(TextContent),
140 Thinking(ThinkingContent),
142 #[serde(rename = "redacted_thinking")]
148 RedactedThinking(RedactedThinkingContent),
149 Image(ImageContent),
151 ToolCall(ToolCall),
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct TextContent {
159 pub text: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
161 pub text_signature: Option<String>,
162}
163
164impl TextContent {
165 pub fn new(text: impl Into<String>) -> Self {
166 Self {
167 text: text.into(),
168 text_signature: None,
169 }
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct ThinkingContent {
177 pub thinking: String,
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub thinking_signature: Option<String>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct ImageContent {
186 pub data: String, pub mime_type: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196pub struct RedactedThinkingContent {
197 pub data: String,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct ToolCall {
204 pub id: String,
205 pub name: String,
206 pub arguments: serde_json::Value,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub thought_signature: Option<String>,
209}
210
211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct Usage {
219 pub input: u64,
220 pub output: u64,
221 pub cache_read: u64,
222 pub cache_write: u64,
223 pub total_tokens: u64,
224 pub cost: Cost,
225}
226
227#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229#[serde(rename_all = "camelCase")]
230pub struct Cost {
231 pub input: f64,
232 pub output: f64,
233 pub cache_read: f64,
234 pub cache_write: f64,
235 pub total: f64,
236}
237
238#[derive(Debug, Clone)]
246pub enum StreamEvent {
247 Start {
248 partial: AssistantMessage,
249 },
250
251 TextStart {
252 content_index: usize,
253 },
254 TextDelta {
255 content_index: usize,
256 delta: String,
257 },
258 TextEnd {
259 content_index: usize,
260 content: String,
261 },
262
263 ThinkingStart {
264 content_index: usize,
265 },
266 ThinkingDelta {
267 content_index: usize,
268 delta: String,
269 },
270 ThinkingEnd {
271 content_index: usize,
272 content: String,
273 },
274
275 ToolCallStart {
282 content_index: usize,
283 id: String,
284 name: String,
285 },
286 ToolCallDelta {
287 content_index: usize,
288 delta: String,
289 },
290 ToolCallEnd {
291 content_index: usize,
292 tool_call: ToolCall,
293 },
294
295 Done {
296 reason: StopReason,
297 message: AssistantMessage,
298 },
299 Error {
300 reason: StopReason,
301 error: AssistantMessage,
302 },
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
311#[serde(tag = "type")]
312pub enum AssistantMessageEvent {
313 #[serde(rename = "start")]
314 Start { partial: Arc<AssistantMessage> },
315 #[serde(rename = "text_start")]
316 TextStart {
317 #[serde(rename = "contentIndex")]
318 content_index: usize,
319 partial: Arc<AssistantMessage>,
320 },
321 #[serde(rename = "text_delta")]
322 TextDelta {
323 #[serde(rename = "contentIndex")]
324 content_index: usize,
325 delta: String,
326 partial: Arc<AssistantMessage>,
327 },
328 #[serde(rename = "text_end")]
329 TextEnd {
330 #[serde(rename = "contentIndex")]
331 content_index: usize,
332 content: String,
333 partial: Arc<AssistantMessage>,
334 },
335 #[serde(rename = "thinking_start")]
336 ThinkingStart {
337 #[serde(rename = "contentIndex")]
338 content_index: usize,
339 partial: Arc<AssistantMessage>,
340 },
341 #[serde(rename = "thinking_delta")]
342 ThinkingDelta {
343 #[serde(rename = "contentIndex")]
344 content_index: usize,
345 delta: String,
346 partial: Arc<AssistantMessage>,
347 },
348 #[serde(rename = "thinking_end")]
349 ThinkingEnd {
350 #[serde(rename = "contentIndex")]
351 content_index: usize,
352 content: String,
353 partial: Arc<AssistantMessage>,
354 },
355 #[serde(rename = "toolcall_start")]
356 ToolCallStart {
357 #[serde(rename = "contentIndex")]
358 content_index: usize,
359 partial: Arc<AssistantMessage>,
360 },
361 #[serde(rename = "toolcall_delta")]
362 ToolCallDelta {
363 #[serde(rename = "contentIndex")]
364 content_index: usize,
365 delta: String,
366 partial: Arc<AssistantMessage>,
367 },
368 #[serde(rename = "toolcall_end")]
369 ToolCallEnd {
370 #[serde(rename = "contentIndex")]
371 content_index: usize,
372 #[serde(rename = "toolCall")]
373 tool_call: ToolCall,
374 partial: Arc<AssistantMessage>,
375 },
376 #[serde(rename = "done")]
377 Done {
378 reason: StopReason,
379 message: Arc<AssistantMessage>,
380 },
381 #[serde(rename = "error")]
382 Error {
383 reason: StopReason,
384 error: Arc<AssistantMessage>,
385 },
386}
387
388#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
394#[serde(rename_all = "lowercase")]
395pub enum ThinkingLevel {
396 #[default]
397 Off,
398 Minimal,
399 Low,
400 Medium,
401 High,
402 XHigh,
403 Max,
404}
405
406impl std::str::FromStr for ThinkingLevel {
407 type Err = String;
408
409 fn from_str(s: &str) -> Result<Self, Self::Err> {
410 match s.trim().to_lowercase().as_str() {
411 "off" | "none" | "0" => Ok(Self::Off),
412 "minimal" | "min" => Ok(Self::Minimal),
413 "low" | "1" => Ok(Self::Low),
414 "medium" | "med" | "2" => Ok(Self::Medium),
415 "high" | "3" => Ok(Self::High),
416 "xhigh" | "4" => Ok(Self::XHigh),
417 "max" | "5" => Ok(Self::Max),
418 _ => Err(format!("Invalid thinking level: {s}")),
419 }
420 }
421}
422
423impl ThinkingLevel {
424 pub const fn default_budget(self) -> u32 {
426 match self {
427 Self::Off => 0,
428 Self::Minimal => 1024,
429 Self::Low => 2048,
430 Self::Medium => 8192,
431 Self::High => 16384,
432 Self::XHigh => 32768, Self::Max => 65536, }
435 }
436}
437
438impl std::fmt::Display for ThinkingLevel {
439 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440 let s = match self {
441 Self::Off => "off",
442 Self::Minimal => "minimal",
443 Self::Low => "low",
444 Self::Medium => "medium",
445 Self::High => "high",
446 Self::XHigh => "xhigh",
447 Self::Max => "max",
448 };
449 write!(f, "{s}")
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use proptest::prelude::*;
457 use serde_json::json;
458 use std::collections::BTreeSet;
459
460 fn sample_usage() -> Usage {
463 Usage {
464 input: 100,
465 output: 50,
466 cache_read: 10,
467 cache_write: 5,
468 total_tokens: 165,
469 cost: Cost {
470 input: 0.001,
471 output: 0.002,
472 cache_read: 0.0001,
473 cache_write: 0.0002,
474 total: 0.0033,
475 },
476 }
477 }
478
479 fn sample_assistant_message() -> AssistantMessage {
480 AssistantMessage {
481 content: vec![ContentBlock::Text(TextContent::new("Hello"))],
482 api: "anthropic".to_string(),
483 provider: "anthropic".to_string(),
484 model: "claude-sonnet-4".to_string(),
485 usage: sample_usage(),
486 stop_reason: StopReason::Stop,
487 error_message: None,
488 timestamp: 1_700_000_000,
489 }
490 }
491
492 #[derive(Debug, Default)]
493 struct EventTransitionState {
494 seen_start: bool,
495 finished: bool,
496 open_text_indices: BTreeSet<usize>,
497 open_thinking_indices: BTreeSet<usize>,
498 open_tool_indices: BTreeSet<usize>,
499 }
500
501 fn event_transition_diag(
502 fixture_id: &str,
503 step: usize,
504 event_type: &str,
505 state: &EventTransitionState,
506 detail: &str,
507 ) -> String {
508 json!({
509 "fixture_id": fixture_id,
510 "seed": "deterministic-static",
511 "env": {
512 "os": std::env::consts::OS,
513 "arch": std::env::consts::ARCH,
514 },
515 "step": step,
516 "event_type": event_type,
517 "state_snapshot": {
518 "seen_start": state.seen_start,
519 "finished": state.finished,
520 "open_text_indices": state.open_text_indices.iter().copied().collect::<Vec<_>>(),
521 "open_thinking_indices": state.open_thinking_indices.iter().copied().collect::<Vec<_>>(),
522 "open_tool_indices": state.open_tool_indices.iter().copied().collect::<Vec<_>>(),
523 },
524 "detail": detail,
525 })
526 .to_string()
527 }
528
529 #[allow(clippy::too_many_lines)]
530 fn validate_event_transitions(
531 fixture_id: &str,
532 events: &[AssistantMessageEvent],
533 ) -> Result<(), String> {
534 let mut state = EventTransitionState::default();
535
536 for (step, event) in events.iter().enumerate() {
537 match event {
538 AssistantMessageEvent::Start { .. } => {
539 if state.seen_start || state.finished {
540 return Err(event_transition_diag(
541 fixture_id,
542 step,
543 "start",
544 &state,
545 "start must appear exactly once before done/error",
546 ));
547 }
548 state.seen_start = true;
549 }
550 AssistantMessageEvent::TextStart { content_index, .. } => {
551 if !state.seen_start || state.finished {
552 return Err(event_transition_diag(
553 fixture_id,
554 step,
555 "text_start",
556 &state,
557 "text_start before start or after done/error",
558 ));
559 }
560 if !state.open_text_indices.insert(*content_index) {
561 return Err(event_transition_diag(
562 fixture_id,
563 step,
564 "text_start",
565 &state,
566 "duplicate text_start for same content index",
567 ));
568 }
569 }
570 AssistantMessageEvent::TextDelta { content_index, .. } => {
571 if !state.open_text_indices.contains(content_index) {
572 return Err(event_transition_diag(
573 fixture_id,
574 step,
575 "text_delta",
576 &state,
577 "text_delta without matching text_start",
578 ));
579 }
580 }
581 AssistantMessageEvent::TextEnd { content_index, .. } => {
582 if !state.open_text_indices.remove(content_index) {
583 return Err(event_transition_diag(
584 fixture_id,
585 step,
586 "text_end",
587 &state,
588 "text_end without matching text_start",
589 ));
590 }
591 }
592 AssistantMessageEvent::ThinkingStart { content_index, .. } => {
593 if !state.open_thinking_indices.insert(*content_index) {
594 return Err(event_transition_diag(
595 fixture_id,
596 step,
597 "thinking_start",
598 &state,
599 "duplicate thinking_start for same content index",
600 ));
601 }
602 }
603 AssistantMessageEvent::ThinkingDelta { content_index, .. } => {
604 if !state.open_thinking_indices.contains(content_index) {
605 return Err(event_transition_diag(
606 fixture_id,
607 step,
608 "thinking_delta",
609 &state,
610 "thinking_delta without matching thinking_start",
611 ));
612 }
613 }
614 AssistantMessageEvent::ThinkingEnd { content_index, .. } => {
615 if !state.open_thinking_indices.remove(content_index) {
616 return Err(event_transition_diag(
617 fixture_id,
618 step,
619 "thinking_end",
620 &state,
621 "thinking_end without matching thinking_start",
622 ));
623 }
624 }
625 AssistantMessageEvent::ToolCallStart { content_index, .. } => {
626 if !state.open_tool_indices.insert(*content_index) {
627 return Err(event_transition_diag(
628 fixture_id,
629 step,
630 "toolcall_start",
631 &state,
632 "duplicate toolcall_start for same content index",
633 ));
634 }
635 }
636 AssistantMessageEvent::ToolCallDelta { content_index, .. } => {
637 if !state.open_tool_indices.contains(content_index) {
638 return Err(event_transition_diag(
639 fixture_id,
640 step,
641 "toolcall_delta",
642 &state,
643 "toolcall_delta without matching toolcall_start",
644 ));
645 }
646 }
647 AssistantMessageEvent::ToolCallEnd { content_index, .. } => {
648 if !state.open_tool_indices.remove(content_index) {
649 return Err(event_transition_diag(
650 fixture_id,
651 step,
652 "toolcall_end",
653 &state,
654 "toolcall_end without matching toolcall_start",
655 ));
656 }
657 }
658 AssistantMessageEvent::Done { .. } | AssistantMessageEvent::Error { .. } => {
659 if !state.seen_start {
660 return Err(event_transition_diag(
661 fixture_id,
662 step,
663 "terminal",
664 &state,
665 "done/error before start",
666 ));
667 }
668 if state.finished {
669 return Err(event_transition_diag(
670 fixture_id,
671 step,
672 "terminal",
673 &state,
674 "multiple terminal events",
675 ));
676 }
677 if !state.open_text_indices.is_empty()
678 || !state.open_thinking_indices.is_empty()
679 || !state.open_tool_indices.is_empty()
680 {
681 return Err(event_transition_diag(
682 fixture_id,
683 step,
684 "terminal",
685 &state,
686 "done/error while content blocks still open",
687 ));
688 }
689 state.finished = true;
690 }
691 }
692 }
693
694 if !state.finished {
695 return Err(event_transition_diag(
696 fixture_id,
697 events.len(),
698 "end_of_stream",
699 &state,
700 "missing terminal done/error event",
701 ));
702 }
703
704 Ok(())
705 }
706
707 #[test]
710 fn message_user_text_roundtrip() {
711 let msg = Message::User(UserMessage {
712 content: UserContent::Text("hi".to_string()),
713 timestamp: 1_700_000_000,
714 });
715 let json = serde_json::to_string(&msg).expect("serialize");
716 let parsed: Message = serde_json::from_str(&json).expect("deserialize");
717 match parsed {
718 Message::User(u) => {
719 assert!(matches!(u.content, UserContent::Text(ref s) if s == "hi"));
720 assert_eq!(u.timestamp, 1_700_000_000);
721 }
722 _ => panic!(),
723 }
724 }
725
726 #[test]
727 fn message_user_blocks_roundtrip() {
728 let msg = Message::User(UserMessage {
729 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new("hello"))]),
730 timestamp: 42,
731 });
732 let json = serde_json::to_string(&msg).expect("serialize");
733 let parsed: Message = serde_json::from_str(&json).expect("deserialize");
734 match parsed {
735 Message::User(u) => match u.content {
736 UserContent::Blocks(blocks) => {
737 assert_eq!(blocks.len(), 1);
738 assert!(matches!(&blocks[0], ContentBlock::Text(t) if t.text == "hello"));
739 }
740 UserContent::Text(_) => panic!(),
741 },
742 _ => panic!(),
743 }
744 }
745
746 #[test]
747 fn message_assistant_roundtrip() {
748 let msg = Message::assistant(sample_assistant_message());
749 let json = serde_json::to_string(&msg).expect("serialize");
750 let parsed: Message = serde_json::from_str(&json).expect("deserialize");
751 match parsed {
752 Message::Assistant(a) => {
753 assert_eq!(a.model, "claude-sonnet-4");
754 assert_eq!(a.stop_reason, StopReason::Stop);
755 assert_eq!(a.usage.input, 100);
756 }
757 _ => panic!(),
758 }
759 }
760
761 #[test]
762 fn message_tool_result_roundtrip() {
763 let msg = Message::tool_result(ToolResultMessage {
764 tool_call_id: "call_1".to_string(),
765 tool_name: "read".to_string(),
766 content: vec![ContentBlock::Text(TextContent::new("file contents"))],
767 details: Some(json!({"path": "/tmp/test.txt"})),
768 is_error: false,
769 timestamp: 99,
770 });
771 let json = serde_json::to_string(&msg).expect("serialize");
772 let parsed: Message = serde_json::from_str(&json).expect("deserialize");
773 match parsed {
774 Message::ToolResult(tr) => {
775 assert_eq!(tr.tool_call_id, "call_1");
776 assert_eq!(tr.tool_name, "read");
777 assert!(!tr.is_error);
778 assert!(tr.details.is_some());
779 }
780 _ => panic!(),
781 }
782 }
783
784 #[test]
785 fn message_custom_roundtrip() {
786 let msg = Message::Custom(CustomMessage {
787 content: "custom data".to_string(),
788 custom_type: "extension_output".to_string(),
789 display: true,
790 details: None,
791 timestamp: 77,
792 });
793 let json = serde_json::to_string(&msg).expect("serialize");
794 let parsed: Message = serde_json::from_str(&json).expect("deserialize");
795 match parsed {
796 Message::Custom(c) => {
797 assert_eq!(c.custom_type, "extension_output");
798 assert!(c.display);
799 assert!(c.details.is_none());
800 }
801 _ => panic!(),
802 }
803 }
804
805 #[test]
806 fn message_role_tag_in_json() {
807 let user = Message::User(UserMessage {
808 content: UserContent::Text("x".to_string()),
809 timestamp: 0,
810 });
811 let v: serde_json::Value = serde_json::to_value(&user).expect("to_value");
812 assert_eq!(v["role"], "user");
813
814 let assistant = Message::assistant(sample_assistant_message());
815 let v: serde_json::Value = serde_json::to_value(&assistant).expect("to_value");
816 assert_eq!(v["role"], "assistant");
817 }
818
819 #[test]
822 fn user_content_text_from_string() {
823 let content: UserContent = serde_json::from_str("\"hello\"").expect("deserialize");
824 assert!(matches!(content, UserContent::Text(s) if s == "hello"));
825 }
826
827 #[test]
828 fn user_content_blocks_from_array() {
829 let json = json!([{"type": "text", "text": "hi"}]);
830 let content: UserContent = serde_json::from_value(json).expect("deserialize");
831 match content {
832 UserContent::Blocks(blocks) => {
833 assert_eq!(blocks.len(), 1);
834 }
835 UserContent::Text(_) => panic!(),
836 }
837 }
838
839 #[test]
840 fn user_content_empty_string() {
841 let content: UserContent = serde_json::from_str("\"\"").expect("deserialize");
842 assert!(matches!(content, UserContent::Text(s) if s.is_empty()));
843 }
844
845 #[test]
848 fn stop_reason_default_is_stop() {
849 assert_eq!(StopReason::default(), StopReason::Stop);
850 }
851
852 #[test]
853 fn stop_reason_serde_roundtrip() {
854 let reasons = [
855 StopReason::Stop,
856 StopReason::Length,
857 StopReason::ToolUse,
858 StopReason::Error,
859 StopReason::Aborted,
860 ];
861 for reason in &reasons {
862 let json = serde_json::to_string(reason).expect("serialize");
863 let parsed: StopReason = serde_json::from_str(&json).expect("deserialize");
864 assert_eq!(*reason, parsed);
865 }
866 }
867
868 #[test]
869 fn stop_reason_camel_case_serialization() {
870 assert_eq!(
871 serde_json::to_string(&StopReason::ToolUse).unwrap(),
872 "\"toolUse\""
873 );
874 assert_eq!(
875 serde_json::to_string(&StopReason::Stop).unwrap(),
876 "\"stop\""
877 );
878 }
879
880 #[test]
883 fn content_block_text_roundtrip() {
884 let block = ContentBlock::Text(TextContent {
885 text: "hello".to_string(),
886 text_signature: Some("sig123".to_string()),
887 });
888 let json = serde_json::to_string(&block).expect("serialize");
889 let parsed: ContentBlock = serde_json::from_str(&json).expect("deserialize");
890 match parsed {
891 ContentBlock::Text(t) => {
892 assert_eq!(t.text, "hello");
893 assert_eq!(t.text_signature.as_deref(), Some("sig123"));
894 }
895 _ => panic!(),
896 }
897 }
898
899 #[test]
900 fn content_block_thinking_roundtrip() {
901 let block = ContentBlock::Thinking(ThinkingContent {
902 thinking: "reasoning...".to_string(),
903 thinking_signature: None,
904 });
905 let json = serde_json::to_string(&block).expect("serialize");
906 let parsed: ContentBlock = serde_json::from_str(&json).expect("deserialize");
907 assert!(matches!(parsed, ContentBlock::Thinking(t) if t.thinking == "reasoning..."));
908 }
909
910 #[test]
915 fn content_block_redacted_thinking_wire_form_is_accepted() {
916 let wire = serde_json::json!({
917 "type": "redacted_thinking",
918 "data": "OPAQUE_BLOB",
919 });
920 let parsed: ContentBlock =
921 serde_json::from_value(wire).expect("redacted_thinking must deserialize");
922 let ContentBlock::RedactedThinking(rt) = &parsed else {
923 panic!("expected RedactedThinking, got {parsed:?}");
924 };
925 assert_eq!(rt.data, "OPAQUE_BLOB");
926
927 let reserialized = serde_json::to_value(&parsed).expect("re-serialize");
930 assert_eq!(reserialized["type"], "redacted_thinking");
931 assert_eq!(reserialized["data"], "OPAQUE_BLOB");
932 }
933
934 #[test]
937 fn content_block_redacted_thinking_in_mixed_assistant_content() {
938 let original = AssistantMessage {
939 content: vec![
940 ContentBlock::Text(TextContent::new("Before.")),
941 ContentBlock::RedactedThinking(RedactedThinkingContent {
942 data: "REDACTED".to_string(),
943 }),
944 ContentBlock::Text(TextContent::new("After.")),
945 ],
946 ..AssistantMessage::default()
947 };
948 let json = serde_json::to_value(&original).expect("serialize");
949 let parsed: AssistantMessage =
950 serde_json::from_value(json).expect("deserialize mixed-content message");
951 assert_eq!(parsed.content.len(), 3);
952 assert!(matches!(&parsed.content[0], ContentBlock::Text(t) if t.text == "Before."));
953 assert!(matches!(
954 &parsed.content[1],
955 ContentBlock::RedactedThinking(rt) if rt.data == "REDACTED"
956 ));
957 assert!(matches!(&parsed.content[2], ContentBlock::Text(t) if t.text == "After."));
958 }
959
960 #[test]
964 fn content_block_redacted_thinking_ignores_unknown_siblings() {
965 let wire = serde_json::json!({
966 "type": "redacted_thinking",
967 "data": "OPAQUE",
968 "futureFieldFromAnthropic": "ignore me",
969 });
970 let parsed: ContentBlock =
971 serde_json::from_value(wire).expect("unknown sibling fields must not break parsing");
972 assert!(matches!(parsed, ContentBlock::RedactedThinking(_)));
973 }
974
975 #[test]
976 fn content_block_image_roundtrip() {
977 let block = ContentBlock::Image(ImageContent {
978 data: "aGVsbG8=".to_string(),
979 mime_type: "image/png".to_string(),
980 });
981 let json = serde_json::to_string(&block).expect("serialize");
982 let parsed: ContentBlock = serde_json::from_str(&json).expect("deserialize");
983 match parsed {
984 ContentBlock::Image(img) => {
985 assert_eq!(img.data, "aGVsbG8=");
986 assert_eq!(img.mime_type, "image/png");
987 }
988 _ => panic!(),
989 }
990 }
991
992 #[test]
993 fn content_block_tool_call_roundtrip() {
994 let block = ContentBlock::ToolCall(ToolCall {
995 id: "tc_1".to_string(),
996 name: "read".to_string(),
997 arguments: json!({"path": "/tmp/test.txt"}),
998 thought_signature: None,
999 });
1000 let json = serde_json::to_string(&block).expect("serialize");
1001 let parsed: ContentBlock = serde_json::from_str(&json).expect("deserialize");
1002 match parsed {
1003 ContentBlock::ToolCall(tc) => {
1004 assert_eq!(tc.id, "tc_1");
1005 assert_eq!(tc.name, "read");
1006 assert_eq!(tc.arguments["path"], "/tmp/test.txt");
1007 }
1008 _ => panic!(),
1009 }
1010 }
1011
1012 #[test]
1013 fn content_block_type_tag_in_json() {
1014 let text = ContentBlock::Text(TextContent::new("x"));
1015 let v: serde_json::Value = serde_json::to_value(&text).expect("to_value");
1016 assert_eq!(v["type"], "text");
1017
1018 let thinking = ContentBlock::Thinking(ThinkingContent {
1019 thinking: "t".to_string(),
1020 thinking_signature: None,
1021 });
1022 let v: serde_json::Value = serde_json::to_value(&thinking).expect("to_value");
1023 assert_eq!(v["type"], "thinking");
1024 }
1025
1026 #[test]
1029 fn text_content_new_sets_none_signature() {
1030 let tc = TextContent::new("test");
1031 assert_eq!(tc.text, "test");
1032 assert!(tc.text_signature.is_none());
1033 }
1034
1035 #[test]
1036 fn text_content_new_accepts_string() {
1037 let tc = TextContent::new(String::from("owned"));
1038 assert_eq!(tc.text, "owned");
1039 }
1040
1041 #[test]
1044 fn usage_default_is_zero() {
1045 let u = Usage::default();
1046 assert_eq!(u.input, 0);
1047 assert_eq!(u.output, 0);
1048 assert_eq!(u.total_tokens, 0);
1049 assert!((u.cost.total - 0.0).abs() < f64::EPSILON);
1050 }
1051
1052 #[test]
1053 fn usage_serde_roundtrip() {
1054 let u = sample_usage();
1055 let json = serde_json::to_string(&u).expect("serialize");
1056 let parsed: Usage = serde_json::from_str(&json).expect("deserialize");
1057 assert_eq!(parsed.input, 100);
1058 assert_eq!(parsed.output, 50);
1059 assert!((parsed.cost.total - 0.0033).abs() < 1e-10);
1060 }
1061
1062 #[test]
1063 fn cost_default_is_zero() {
1064 let c = Cost::default();
1065 assert!((c.input - 0.0).abs() < f64::EPSILON);
1066 assert!((c.output - 0.0).abs() < f64::EPSILON);
1067 assert!((c.total - 0.0).abs() < f64::EPSILON);
1068 }
1069
1070 #[test]
1073 fn thinking_level_default_is_off() {
1074 assert_eq!(ThinkingLevel::default(), ThinkingLevel::Off);
1075 }
1076
1077 #[test]
1078 fn thinking_level_from_str_all_valid() {
1079 let cases = [
1080 ("off", ThinkingLevel::Off),
1081 ("none", ThinkingLevel::Off),
1082 ("0", ThinkingLevel::Off),
1083 ("minimal", ThinkingLevel::Minimal),
1084 ("min", ThinkingLevel::Minimal),
1085 ("low", ThinkingLevel::Low),
1086 ("1", ThinkingLevel::Low),
1087 ("medium", ThinkingLevel::Medium),
1088 ("med", ThinkingLevel::Medium),
1089 ("2", ThinkingLevel::Medium),
1090 ("high", ThinkingLevel::High),
1091 ("3", ThinkingLevel::High),
1092 ("xhigh", ThinkingLevel::XHigh),
1093 ("4", ThinkingLevel::XHigh),
1094 ("max", ThinkingLevel::Max),
1095 ("5", ThinkingLevel::Max),
1096 ];
1097 for (input, expected) in &cases {
1098 let parsed: ThinkingLevel = input.parse().expect(input);
1099 assert_eq!(parsed, *expected, "input: {input}");
1100 }
1101 }
1102
1103 #[test]
1104 fn thinking_level_from_str_case_insensitive() {
1105 let parsed: ThinkingLevel = "HIGH".parse().expect("HIGH");
1106 assert_eq!(parsed, ThinkingLevel::High);
1107 let parsed: ThinkingLevel = "Medium".parse().expect("Medium");
1108 assert_eq!(parsed, ThinkingLevel::Medium);
1109 }
1110
1111 #[test]
1112 fn thinking_level_from_str_trims_whitespace() {
1113 let parsed: ThinkingLevel = " off ".parse().expect("trimmed");
1114 assert_eq!(parsed, ThinkingLevel::Off);
1115 }
1116
1117 #[test]
1118 fn thinking_level_from_str_invalid() {
1119 let result: Result<ThinkingLevel, _> = "invalid".parse();
1120 assert!(result.is_err());
1121 assert!(result.unwrap_err().contains("Invalid thinking level"));
1122 }
1123
1124 #[test]
1125 fn thinking_level_display_roundtrip() {
1126 let levels = [
1127 ThinkingLevel::Off,
1128 ThinkingLevel::Minimal,
1129 ThinkingLevel::Low,
1130 ThinkingLevel::Medium,
1131 ThinkingLevel::High,
1132 ThinkingLevel::XHigh,
1133 ];
1134 for level in &levels {
1135 let displayed = level.to_string();
1136 let parsed: ThinkingLevel = displayed.parse().expect(&displayed);
1137 assert_eq!(*level, parsed);
1138 }
1139 }
1140
1141 #[test]
1142 fn thinking_level_default_budget_values() {
1143 assert_eq!(ThinkingLevel::Off.default_budget(), 0);
1144 assert_eq!(ThinkingLevel::Minimal.default_budget(), 1024);
1145 assert_eq!(ThinkingLevel::Low.default_budget(), 2048);
1146 assert_eq!(ThinkingLevel::Medium.default_budget(), 8192);
1147 assert_eq!(ThinkingLevel::High.default_budget(), 16384);
1148 assert_eq!(ThinkingLevel::XHigh.default_budget(), 32768);
1149 }
1150
1151 #[test]
1152 fn thinking_level_budgets_are_monotonically_increasing() {
1153 let levels = [
1154 ThinkingLevel::Off,
1155 ThinkingLevel::Minimal,
1156 ThinkingLevel::Low,
1157 ThinkingLevel::Medium,
1158 ThinkingLevel::High,
1159 ThinkingLevel::XHigh,
1160 ];
1161 for pair in levels.windows(2) {
1162 assert!(
1163 pair[0].default_budget() < pair[1].default_budget(),
1164 "{} budget ({}) should be less than {} budget ({})",
1165 pair[0],
1166 pair[0].default_budget(),
1167 pair[1],
1168 pair[1].default_budget()
1169 );
1170 }
1171 }
1172
1173 #[test]
1174 fn thinking_level_serde_roundtrip() {
1175 let levels = [
1176 ThinkingLevel::Off,
1177 ThinkingLevel::Minimal,
1178 ThinkingLevel::Low,
1179 ThinkingLevel::Medium,
1180 ThinkingLevel::High,
1181 ThinkingLevel::XHigh,
1182 ];
1183 for level in &levels {
1184 let json = serde_json::to_string(level).expect("serialize");
1185 let parsed: ThinkingLevel = serde_json::from_str(&json).expect("deserialize");
1186 assert_eq!(*level, parsed);
1187 }
1188 }
1189
1190 #[test]
1193 fn assistant_message_error_message_skipped_when_none() {
1194 let msg = sample_assistant_message();
1195 let json = serde_json::to_string(&msg).expect("serialize");
1196 assert!(!json.contains("errorMessage"), "None should be skipped");
1197 }
1198
1199 #[test]
1200 fn assistant_message_error_message_included_when_some() {
1201 let mut msg = sample_assistant_message();
1202 msg.error_message = Some("rate limit".to_string());
1203 let json = serde_json::to_string(&msg).expect("serialize");
1204 assert!(json.contains("errorMessage"));
1205 assert!(json.contains("rate limit"));
1206 }
1207
1208 #[test]
1211 fn tool_call_thought_signature_skipped_when_none() {
1212 let tc = ToolCall {
1213 id: "t1".to_string(),
1214 name: "read".to_string(),
1215 arguments: json!({}),
1216 thought_signature: None,
1217 };
1218 let json = serde_json::to_string(&tc).expect("serialize");
1219 assert!(!json.contains("thoughtSignature"));
1220 }
1221
1222 #[test]
1225 fn assistant_message_event_type_tags() {
1226 let events = vec![
1227 (
1228 AssistantMessageEvent::Start {
1229 partial: sample_assistant_message().into(),
1230 },
1231 "start",
1232 ),
1233 (
1234 AssistantMessageEvent::TextDelta {
1235 content_index: 0,
1236 delta: "hi".to_string(),
1237 partial: sample_assistant_message().into(),
1238 },
1239 "text_delta",
1240 ),
1241 (
1242 AssistantMessageEvent::Done {
1243 reason: StopReason::Stop,
1244 message: sample_assistant_message().into(),
1245 },
1246 "done",
1247 ),
1248 (
1249 AssistantMessageEvent::Error {
1250 reason: StopReason::Error,
1251 error: sample_assistant_message().into(),
1252 },
1253 "error",
1254 ),
1255 ];
1256 for (event, expected_type) in &events {
1257 let v: serde_json::Value = serde_json::to_value(event).expect("to_value");
1258 assert_eq!(
1259 v["type"].as_str(),
1260 Some(*expected_type),
1261 "expected type={expected_type}"
1262 );
1263 }
1264 }
1265
1266 #[test]
1267 fn assistant_message_event_roundtrip() {
1268 let event = AssistantMessageEvent::TextEnd {
1269 content_index: 2,
1270 content: "final text".to_string(),
1271 partial: sample_assistant_message().into(),
1272 };
1273 let json = serde_json::to_string(&event).expect("serialize");
1274 let parsed: AssistantMessageEvent = serde_json::from_str(&json).expect("deserialize");
1275 match parsed {
1276 AssistantMessageEvent::TextEnd {
1277 content_index,
1278 content,
1279 ..
1280 } => {
1281 assert_eq!(content_index, 2);
1282 assert_eq!(content, "final text");
1283 }
1284 _ => panic!(),
1285 }
1286 }
1287
1288 #[test]
1289 fn assistant_message_event_rejects_malformed_payload() {
1290 let malformed = json!({
1291 "type": "text_delta",
1292 "delta": "hi",
1293 "partial": sample_assistant_message()
1294 });
1295 let encoded = malformed.to_string();
1296 let err = serde_json::from_str::<AssistantMessageEvent>(&encoded)
1297 .expect_err("text_delta without contentIndex should fail");
1298 let diag = json!({
1299 "fixture_id": "model-assistant-event-malformed-payload",
1300 "seed": "deterministic-static",
1301 "expected": "serde error for missing contentIndex",
1302 "actual_error": err.to_string(),
1303 "payload": malformed,
1304 })
1305 .to_string();
1306 assert!(
1307 err.to_string().contains("contentIndex"),
1308 "missing contentIndex not reported: {diag}"
1309 );
1310 }
1311
1312 #[test]
1313 fn assistant_message_event_transitions_accept_valid_sequence() {
1314 let partial = sample_assistant_message();
1315 let message = sample_assistant_message();
1316 let events = vec![
1317 AssistantMessageEvent::Start {
1318 partial: partial.clone().into(),
1319 },
1320 AssistantMessageEvent::TextStart {
1321 content_index: 0,
1322 partial: partial.clone().into(),
1323 },
1324 AssistantMessageEvent::TextDelta {
1325 content_index: 0,
1326 delta: "he".to_string(),
1327 partial: partial.clone().into(),
1328 },
1329 AssistantMessageEvent::TextEnd {
1330 content_index: 0,
1331 content: "hello".to_string(),
1332 partial: partial.into(),
1333 },
1334 AssistantMessageEvent::Done {
1335 reason: StopReason::Stop,
1336 message: message.into(),
1337 },
1338 ];
1339
1340 validate_event_transitions("model-event-transition-valid", &events)
1341 .expect("valid sequence should pass");
1342 }
1343
1344 #[test]
1345 fn assistant_message_event_transitions_reject_out_of_order_delta() {
1346 let partial = sample_assistant_message();
1347 let message = sample_assistant_message();
1348 let events = vec![
1349 AssistantMessageEvent::Start {
1350 partial: partial.clone().into(),
1351 },
1352 AssistantMessageEvent::TextDelta {
1353 content_index: 0,
1354 delta: "hi".to_string(),
1355 partial: partial.into(),
1356 },
1357 AssistantMessageEvent::Done {
1358 reason: StopReason::Stop,
1359 message: message.into(),
1360 },
1361 ];
1362
1363 let err = validate_event_transitions("model-event-transition-out-of-order", &events)
1364 .expect_err("out-of-order text_delta should fail");
1365 assert!(
1366 err.contains("\"fixture_id\":\"model-event-transition-out-of-order\"")
1367 && err.contains("text_delta without matching text_start"),
1368 "unexpected diagnostic payload: {err}"
1369 );
1370 }
1371
1372 #[test]
1375 fn tool_result_details_skipped_when_none() {
1376 let tr = ToolResultMessage {
1377 tool_call_id: "c1".to_string(),
1378 tool_name: "bash".to_string(),
1379 content: vec![],
1380 details: None,
1381 is_error: false,
1382 timestamp: 0,
1383 };
1384 let json = serde_json::to_string(&tr).expect("serialize");
1385 assert!(!json.contains("details"));
1386 }
1387
1388 #[test]
1389 fn tool_result_is_error_roundtrip() {
1390 let tr = ToolResultMessage {
1391 tool_call_id: "c1".to_string(),
1392 tool_name: "bash".to_string(),
1393 content: vec![ContentBlock::Text(TextContent::new("error output"))],
1394 details: None,
1395 is_error: true,
1396 timestamp: 1,
1397 };
1398 let json = serde_json::to_string(&tr).expect("serialize");
1399 let parsed: ToolResultMessage = serde_json::from_str(&json).expect("deserialize");
1400 assert!(parsed.is_error);
1401 assert_eq!(parsed.tool_name, "bash");
1402 }
1403
1404 #[test]
1407 fn custom_message_display_defaults_to_false() {
1408 let json = json!({
1409 "content": "data",
1410 "customType": "ext",
1411 "timestamp": 0
1412 });
1413 let msg: CustomMessage = serde_json::from_value(json).expect("deserialize");
1414 assert!(!msg.display);
1415 }
1416
1417 fn arbitrary_small_string() -> impl Strategy<Value = String> {
1420 prop::collection::vec(any::<u8>(), 0..128)
1421 .prop_map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1422 }
1423
1424 fn interesting_text_strategy() -> impl Strategy<Value = String> {
1425 prop_oneof![
1426 arbitrary_small_string(),
1427 Just(String::new()),
1428 Just("[]".to_string()),
1429 Just("{}".to_string()),
1430 Just("cafe\u{0301}".to_string()),
1431 Just("emoji \u{1F600}".to_string()),
1432 ]
1433 }
1434
1435 fn scalar_json_value_strategy() -> impl Strategy<Value = serde_json::Value> {
1436 prop_oneof![
1437 Just(serde_json::Value::Null),
1438 any::<bool>().prop_map(serde_json::Value::Bool),
1439 any::<i64>().prop_map(|n| json!(n)),
1440 any::<u64>().prop_map(|n| json!(n)),
1441 interesting_text_strategy().prop_map(serde_json::Value::String),
1442 ]
1443 }
1444
1445 fn bounded_json_value_strategy() -> impl Strategy<Value = serde_json::Value> {
1446 prop_oneof![
1447 scalar_json_value_strategy(),
1448 prop::collection::vec(scalar_json_value_strategy(), 0..5)
1449 .prop_map(serde_json::Value::Array),
1450 prop::collection::btree_map(
1451 arbitrary_small_string(),
1452 scalar_json_value_strategy(),
1453 0..5
1454 )
1455 .prop_map(|map| {
1456 serde_json::Value::Object(
1457 map.into_iter()
1458 .collect::<serde_json::Map<String, serde_json::Value>>(),
1459 )
1460 }),
1461 ]
1462 }
1463
1464 fn stop_reason_strategy() -> impl Strategy<Value = StopReason> {
1465 prop_oneof![
1466 Just(StopReason::Stop),
1467 Just(StopReason::Length),
1468 Just(StopReason::ToolUse),
1469 Just(StopReason::Error),
1470 Just(StopReason::Aborted),
1471 ]
1472 }
1473
1474 fn usage_strategy() -> impl Strategy<Value = Usage> {
1475 (
1476 any::<u16>(),
1477 any::<u16>(),
1478 any::<u16>(),
1479 any::<u16>(),
1480 any::<u16>(),
1481 any::<u32>(),
1482 any::<u32>(),
1483 any::<u32>(),
1484 any::<u32>(),
1485 any::<u32>(),
1486 )
1487 .prop_map(
1488 |(
1489 input,
1490 output,
1491 cache_read,
1492 cache_write,
1493 total_tokens,
1494 cost_input,
1495 cost_output,
1496 cost_cache_read,
1497 cost_cache_write,
1498 cost_total,
1499 )| Usage {
1500 input: u64::from(input),
1501 output: u64::from(output),
1502 cache_read: u64::from(cache_read),
1503 cache_write: u64::from(cache_write),
1504 total_tokens: u64::from(total_tokens),
1505 cost: Cost {
1506 input: f64::from(cost_input) / 1_000_000.0,
1507 output: f64::from(cost_output) / 1_000_000.0,
1508 cache_read: f64::from(cost_cache_read) / 1_000_000.0,
1509 cache_write: f64::from(cost_cache_write) / 1_000_000.0,
1510 total: f64::from(cost_total) / 1_000_000.0,
1511 },
1512 },
1513 )
1514 }
1515
1516 fn text_content_strategy() -> impl Strategy<Value = TextContent> {
1517 (
1518 interesting_text_strategy(),
1519 prop::option::of(interesting_text_strategy()),
1520 )
1521 .prop_map(|(text, text_signature)| TextContent {
1522 text,
1523 text_signature,
1524 })
1525 }
1526
1527 fn thinking_content_strategy() -> impl Strategy<Value = ThinkingContent> {
1528 (
1529 interesting_text_strategy(),
1530 prop::option::of(interesting_text_strategy()),
1531 )
1532 .prop_map(|(thinking, thinking_signature)| ThinkingContent {
1533 thinking,
1534 thinking_signature,
1535 })
1536 }
1537
1538 fn image_content_strategy() -> impl Strategy<Value = ImageContent> {
1539 (
1540 interesting_text_strategy(),
1541 prop_oneof![
1542 Just("image/png".to_string()),
1543 Just("image/jpeg".to_string()),
1544 Just("image/webp".to_string()),
1545 interesting_text_strategy(),
1546 ],
1547 )
1548 .prop_map(|(data, mime_type)| ImageContent { data, mime_type })
1549 }
1550
1551 fn tool_call_strategy() -> impl Strategy<Value = ToolCall> {
1552 (
1555 interesting_text_strategy(),
1556 interesting_text_strategy(),
1557 scalar_json_value_strategy(),
1558 prop::option::of(interesting_text_strategy()),
1559 )
1560 .prop_map(|(id, name, arguments, thought_signature)| ToolCall {
1561 id,
1562 name,
1563 arguments,
1564 thought_signature,
1565 })
1566 }
1567
1568 fn content_block_strategy() -> impl Strategy<Value = ContentBlock> {
1569 prop_oneof![
1570 text_content_strategy().prop_map(ContentBlock::Text),
1571 thinking_content_strategy().prop_map(ContentBlock::Thinking),
1572 image_content_strategy().prop_map(ContentBlock::Image),
1573 tool_call_strategy().prop_map(ContentBlock::ToolCall),
1574 ]
1575 }
1576
1577 fn content_block_json_strategy() -> impl Strategy<Value = serde_json::Value> {
1578 content_block_strategy()
1579 .prop_map(|block| serde_json::to_value(block).expect("content block should serialize"))
1580 }
1581
1582 fn invalid_content_block_json_strategy() -> impl Strategy<Value = serde_json::Value> {
1583 prop_oneof![
1584 interesting_text_strategy().prop_map(|text| json!({ "text": text })),
1585 interesting_text_strategy().prop_map(|text| json!({ "type": "unknown", "text": text })),
1586 Just(json!({ "type": 42, "text": "bad-discriminator-type" })),
1587 Just(json!({ "type": "text" })),
1588 Just(json!({ "type": "image", "mimeType": "image/png" })),
1589 Just(json!({ "type": "toolCall", "id": "tool-only-id" })),
1590 ]
1591 }
1592
1593 fn user_content_strategy() -> impl Strategy<Value = UserContent> {
1594 prop_oneof![
1595 interesting_text_strategy().prop_map(UserContent::Text),
1596 prop::collection::vec(content_block_strategy(), 0..6).prop_map(UserContent::Blocks),
1597 ]
1598 }
1599
1600 fn assistant_message_strategy() -> impl Strategy<Value = AssistantMessage> {
1601 (
1602 prop::collection::vec(content_block_strategy(), 0..3),
1603 interesting_text_strategy(),
1604 interesting_text_strategy(),
1605 interesting_text_strategy(),
1606 usage_strategy(),
1607 stop_reason_strategy(),
1608 prop::option::of(interesting_text_strategy()),
1609 any::<i64>(),
1610 )
1611 .prop_map(
1612 |(content, api, provider, model, usage, stop_reason, error_message, timestamp)| {
1613 AssistantMessage {
1614 content,
1615 api,
1616 provider,
1617 model,
1618 usage,
1619 stop_reason,
1620 error_message,
1621 timestamp,
1622 }
1623 },
1624 )
1625 }
1626
1627 fn tool_result_message_strategy() -> impl Strategy<Value = ToolResultMessage> {
1628 (
1629 interesting_text_strategy(),
1630 interesting_text_strategy(),
1631 prop::collection::vec(content_block_strategy(), 0..3),
1632 prop::option::of(scalar_json_value_strategy()),
1633 any::<bool>(),
1634 any::<i64>(),
1635 )
1636 .prop_map(
1637 |(tool_call_id, tool_name, content, details, is_error, timestamp)| {
1638 ToolResultMessage {
1639 tool_call_id,
1640 tool_name,
1641 content,
1642 details,
1643 is_error,
1644 timestamp,
1645 }
1646 },
1647 )
1648 }
1649
1650 fn custom_message_strategy() -> impl Strategy<Value = CustomMessage> {
1651 (
1652 interesting_text_strategy(),
1653 interesting_text_strategy(),
1654 any::<bool>(),
1655 prop::option::of(scalar_json_value_strategy()),
1656 any::<i64>(),
1657 )
1658 .prop_map(|(content, custom_type, display, details, timestamp)| {
1659 CustomMessage {
1660 content,
1661 custom_type,
1662 display,
1663 details,
1664 timestamp,
1665 }
1666 })
1667 }
1668
1669 fn message_strategy() -> impl Strategy<Value = Message> {
1670 prop_oneof![
1671 (user_content_strategy(), any::<i64>())
1672 .prop_map(|(content, timestamp)| Message::User(UserMessage { content, timestamp })),
1673 assistant_message_strategy().prop_map(|m| Message::Assistant(Arc::new(m))),
1674 tool_result_message_strategy().prop_map(|m| Message::ToolResult(Arc::new(m))),
1675 custom_message_strategy().prop_map(Message::Custom),
1676 ]
1677 }
1678
1679 fn non_string_or_array_json_strategy() -> impl Strategy<Value = serde_json::Value> {
1680 prop_oneof![
1681 Just(serde_json::Value::Null),
1682 any::<bool>().prop_map(serde_json::Value::Bool),
1683 any::<i64>().prop_map(|n| json!(n)),
1684 prop::collection::btree_map(
1685 arbitrary_small_string(),
1686 scalar_json_value_strategy(),
1687 0..4
1688 )
1689 .prop_map(|map| {
1690 serde_json::Value::Object(
1691 map.into_iter()
1692 .collect::<serde_json::Map<String, serde_json::Value>>(),
1693 )
1694 }),
1695 ]
1696 }
1697
1698 proptest! {
1699 #![proptest_config(ProptestConfig { cases: 256, .. ProptestConfig::default() })]
1700
1701 #[test]
1702 fn proptest_user_content_untagged_text_vs_blocks(
1703 text in interesting_text_strategy(),
1704 blocks in prop::collection::vec(content_block_json_strategy(), 0..5),
1705 ) {
1706 let parsed_text: UserContent = serde_json::from_value(serde_json::Value::String(text.clone()))
1707 .expect("string must deserialize as UserContent::Text");
1708 prop_assert!(matches!(parsed_text, UserContent::Text(ref s) if s == &text));
1709
1710 let parsed_blocks: UserContent = serde_json::from_value(serde_json::Value::Array(blocks.clone()))
1711 .expect("array of content-block JSON must deserialize as UserContent::Blocks");
1712 match parsed_blocks {
1713 UserContent::Blocks(parsed) => prop_assert_eq!(parsed.len(), blocks.len()),
1714 UserContent::Text(_) => {
1715 prop_assert!(false, "array input must not deserialize as UserContent::Text");
1716 }
1717 }
1718 }
1719
1720 #[test]
1721 fn proptest_user_content_rejects_non_string_or_array(value in non_string_or_array_json_strategy()) {
1722 let result = serde_json::from_value::<UserContent>(value);
1723 prop_assert!(result.is_err());
1724 }
1725
1726 #[test]
1727 fn proptest_content_block_roundtrip(block in content_block_strategy()) {
1728 let serialized = serde_json::to_value(&block).expect("content block should serialize");
1729 let parsed: ContentBlock = serde_json::from_value(serialized.clone())
1730 .expect("serialized content block should deserialize");
1731 let reserialized = serde_json::to_value(parsed).expect("re-serialize should succeed");
1732 prop_assert_eq!(reserialized, serialized);
1733 }
1734
1735 #[test]
1736 fn proptest_content_block_invalid_discriminator_errors(payload in invalid_content_block_json_strategy()) {
1737 let result = serde_json::from_value::<ContentBlock>(payload);
1738 prop_assert!(result.is_err());
1739 }
1740
1741 #[test]
1742 fn proptest_message_roundtrip_and_unknown_fields(
1743 message in message_strategy(),
1744 extra_value in scalar_json_value_strategy(),
1745 ) {
1746 let serialized = serde_json::to_value(&message).expect("message should serialize");
1747 let parsed: Message = serde_json::from_value(serialized.clone())
1748 .expect("serialized message should deserialize");
1749 let reserialized = serde_json::to_value(parsed).expect("re-serialize should succeed");
1750
1751 let reparsed: Message = serde_json::from_value(reserialized.clone())
1755 .expect("re-serialized message should deserialize");
1756 let stabilized = serde_json::to_value(reparsed).expect("stabilized serialize");
1757 prop_assert_eq!(stabilized, reserialized);
1758
1759 let mut with_extra = serialized;
1760 if let serde_json::Value::Object(ref mut obj) = with_extra {
1761 obj.insert("extraFieldProptest".to_string(), extra_value);
1762 }
1763 let parsed_with_extra = serde_json::from_value::<Message>(with_extra);
1764 prop_assert!(parsed_with_extra.is_ok());
1765 }
1766 }
1767}