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