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