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