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