Skip to main content

pi/
model.rs

1//! Message types, content blocks, and streaming events.
2//!
3//! These types are the shared “wire format” used across the project:
4//! - Providers stream [`StreamEvent`] values that incrementally build an assistant reply.
5//! - Sessions persist [`Message`] values as JSON (see [`crate::session`]).
6//! - Tools return [`ContentBlock`] output that can be rendered in the TUI and replayed to providers.
7
8use std::sync::Arc;
9
10use serde::{Deserialize, Serialize};
11
12// ============================================================================
13// Message Types
14// ============================================================================
15
16/// A message in a conversation.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(tag = "role", rename_all = "camelCase")]
19pub enum Message {
20    /// Message authored by the user.
21    User(UserMessage),
22    /// Message authored by the assistant/model.
23    ///
24    /// Wrapped in [`Arc`] for cheap cloning during streaming – the streaming
25    /// hot-path emits many events per token and [`Arc::make_mut`] gives O(1)
26    /// copy-on-write when the refcount is 1.
27    Assistant(Arc<AssistantMessage>),
28    /// Tool result produced by the host after executing a tool call.
29    ///
30    /// Wrapped in [`Arc`] for cheap cloning – tool results often contain large
31    /// file contents from the `read` tool and are cloned multiple times during
32    /// event dispatch and session persistence.
33    ToolResult(Arc<ToolResultMessage>),
34    /// Host/extension-defined message type.
35    Custom(CustomMessage),
36}
37
38/// A user message.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct UserMessage {
42    pub content: UserContent,
43    pub timestamp: i64,
44}
45
46/// User message content - either plain text or blocks.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(untagged)]
49pub enum UserContent {
50    /// Plain text content (common for interactive input).
51    Text(String),
52    /// Structured content blocks (e.g. text + images).
53    Blocks(Vec<ContentBlock>),
54}
55
56/// An assistant message.
57#[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/// A tool result message.
72#[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/// A custom message injected by the host or extensions.
85#[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    /// Convenience constructor: wraps an [`AssistantMessage`] in [`Arc`].
99    pub fn assistant(msg: AssistantMessage) -> Self {
100        Self::Assistant(Arc::new(msg))
101    }
102
103    /// Convenience constructor: wraps a [`ToolResultMessage`] in [`Arc`].
104    pub fn tool_result(msg: ToolResultMessage) -> Self {
105        Self::ToolResult(Arc::new(msg))
106    }
107}
108
109// ============================================================================
110// Stop Reasons
111// ============================================================================
112
113/// Why a response ended.
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub enum StopReason {
117    #[default]
118    /// The provider signaled a normal stop (end of message).
119    Stop,
120    /// The provider hit a token limit.
121    Length,
122    /// The provider requested tool execution.
123    ToolUse,
124    /// The stream terminated due to an error.
125    Error,
126    /// The request was aborted locally.
127    Aborted,
128}
129
130// ============================================================================
131// Content Blocks
132// ============================================================================
133
134/// A content block in a message.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[serde(tag = "type", rename_all = "camelCase")]
137pub enum ContentBlock {
138    /// Plain text content.
139    Text(TextContent),
140    /// Provider “thinking” / reasoning (if enabled).
141    Thinking(ThinkingContent),
142    /// An inline image (base64 + MIME type).
143    Image(ImageContent),
144    /// A request to call a tool with JSON arguments.
145    ToolCall(ToolCall),
146}
147
148/// Text content block.
149#[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/// Thinking/reasoning content block.
167#[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/// Image content block.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[serde(rename_all = "camelCase")]
178pub struct ImageContent {
179    pub data: String, // Base64 encoded
180    pub mime_type: String,
181}
182
183/// Tool call content block.
184#[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// ============================================================================
195// Usage Tracking
196// ============================================================================
197
198/// Token usage and cost tracking.
199#[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/// Cost breakdown in dollars.
211#[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// ============================================================================
222// Streaming Events
223// ============================================================================
224
225/// Streaming event from a provider.
226///
227/// Provider implementations emit this enum while decoding SSE/HTTP streams.
228#[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// ============================================================================
281// Assistant Message Events (Streaming)
282// ============================================================================
283
284/// Streaming event emitted for assistant message updates.
285#[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// ============================================================================
364// Thinking Level
365// ============================================================================
366
367/// Extended thinking level.
368#[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    /// Get the default token budget for this level.
398    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, // High reasonable limit
406        }
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    // ── Helper ─────────────────────────────────────────────────────────
432
433    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    // ── Message enum serialization ─────────────────────────────────────
679
680    #[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    // ── UserContent untagged deserialization ────────────────────────────
791
792    #[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    // ── StopReason ─────────────────────────────────────────────────────
817
818    #[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    // ── ContentBlock ───────────────────────────────────────────────────
852
853    #[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    // ── TextContent::new ───────────────────────────────────────────────
933
934    #[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    // ── Usage and Cost ─────────────────────────────────────────────────
948
949    #[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    // ── ThinkingLevel ──────────────────────────────────────────────────
977
978    #[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    // ── AssistantMessage optional fields ────────────────────────────────
1095
1096    #[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    // ── ToolCall optional fields ───────────────────────────────────────
1113
1114    #[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    // ── AssistantMessageEvent ──────────────────────────────────────────
1127
1128    #[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    // ── ToolResultMessage optional details ──────────────────────────────
1277
1278    #[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    // ── CustomMessage display default ──────────────────────────────────
1309
1310    #[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    // ── Proptest serde invariants ───────────────────────────────────────
1322
1323    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        // Use scalar_json_value_strategy for arguments to keep proptest
1457        // strategy tree shallow enough for the default thread stack.
1458        (
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            // Some representational forms are semantically equivalent for Option<Value>
1656            // fields (e.g., `details: null` vs omitted), so assert canonical stability
1657            // after one deserialize/serialize cycle.
1658            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}