Skip to main content

meerkat_contracts/wire/
session.rs

1//! Wire session types.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6
7use meerkat_core::{
8    AssistantBlock, BlobId, ContentBlock, ContentInput, ImageData, Message, ProviderMeta,
9    SessionHistoryPage, SessionId, SessionInfo, SessionSummary, StopReason, SystemNoticeKind,
10    VideoData,
11};
12use std::convert::TryFrom;
13
14/// Canonical session info for wire protocol.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17pub struct WireSessionInfo {
18    #[cfg_attr(feature = "schema", schemars(with = "String"))]
19    pub session_id: SessionId,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub session_ref: Option<String>,
22    pub created_at: u64,
23    pub updated_at: u64,
24    pub message_count: usize,
25    pub is_active: bool,
26    pub model: String,
27    pub provider: String,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub last_assistant_text: Option<String>,
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub labels: BTreeMap<String, String>,
32}
33
34impl From<SessionInfo> for WireSessionInfo {
35    fn from(info: SessionInfo) -> Self {
36        Self {
37            session_id: info.session_id,
38            session_ref: None,
39            created_at: info
40                .created_at
41                .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
42                .map(|d| d.as_secs())
43                .unwrap_or(0),
44            updated_at: info
45                .updated_at
46                .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
47                .map(|d| d.as_secs())
48                .unwrap_or(0),
49            message_count: info.message_count,
50            is_active: info.is_active,
51            model: info.model,
52            provider: info.provider.as_str().to_string(),
53            last_assistant_text: info.last_assistant_text,
54            labels: info.labels,
55        }
56    }
57}
58
59/// Canonical session summary for wire protocol.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62pub struct WireSessionSummary {
63    #[cfg_attr(feature = "schema", schemars(with = "String"))]
64    pub session_id: SessionId,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub session_ref: Option<String>,
67    pub created_at: u64,
68    pub updated_at: u64,
69    pub message_count: usize,
70    pub total_tokens: u64,
71    pub is_active: bool,
72    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
73    pub labels: BTreeMap<String, String>,
74}
75
76impl From<SessionSummary> for WireSessionSummary {
77    fn from(summary: SessionSummary) -> Self {
78        Self {
79            session_id: summary.session_id,
80            session_ref: None,
81            created_at: summary
82                .created_at
83                .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
84                .map(|d| d.as_secs())
85                .unwrap_or(0),
86            updated_at: summary
87                .updated_at
88                .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
89                .map(|d| d.as_secs())
90                .unwrap_or(0),
91            message_count: summary.message_count,
92            total_tokens: summary.total_tokens,
93            is_active: summary.is_active,
94            labels: summary.labels,
95        }
96    }
97}
98
99/// Provider continuity metadata for transcript blocks.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
102#[serde(tag = "provider", rename_all = "snake_case")]
103pub enum WireProviderMeta {
104    Anthropic {
105        signature: String,
106    },
107    AnthropicRedacted {
108        data: String,
109    },
110    AnthropicCompaction {
111        content: String,
112    },
113    Gemini {
114        #[serde(rename = "thoughtSignature")]
115        thought_signature: String,
116    },
117    OpenAi {
118        id: String,
119        #[serde(skip_serializing_if = "Option::is_none")]
120        encrypted_content: Option<String>,
121    },
122    Unknown,
123}
124
125impl From<ProviderMeta> for WireProviderMeta {
126    fn from(value: ProviderMeta) -> Self {
127        match value {
128            ProviderMeta::Anthropic { signature } => Self::Anthropic { signature },
129            ProviderMeta::AnthropicRedacted { data } => Self::AnthropicRedacted { data },
130            ProviderMeta::AnthropicCompaction { content } => Self::AnthropicCompaction { content },
131            ProviderMeta::Gemini { thought_signature } => Self::Gemini { thought_signature },
132            ProviderMeta::OpenAi {
133                id,
134                encrypted_content,
135            } => Self::OpenAi {
136                id,
137                encrypted_content,
138            },
139            _ => Self::Unknown,
140        }
141    }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146#[serde(tag = "source", rename_all = "snake_case")]
147pub enum WireImageData {
148    Inline {
149        data: String,
150    },
151    Blob {
152        #[cfg_attr(feature = "schema", schemars(with = "String"))]
153        blob_id: BlobId,
154    },
155}
156
157impl From<String> for WireImageData {
158    fn from(data: String) -> Self {
159        Self::Inline { data }
160    }
161}
162
163impl From<&str> for WireImageData {
164    fn from(data: &str) -> Self {
165        Self::Inline {
166            data: data.to_string(),
167        }
168    }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
173#[serde(tag = "source", rename_all = "snake_case")]
174pub enum WireVideoData {
175    Inline { data: String },
176}
177
178impl From<String> for WireVideoData {
179    fn from(data: String) -> Self {
180        Self::Inline { data }
181    }
182}
183
184impl From<&str> for WireVideoData {
185    fn from(data: &str) -> Self {
186        Self::Inline {
187            data: data.to_string(),
188        }
189    }
190}
191
192/// Wire-safe content block (no `source_path` — internal only).
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195#[serde(tag = "type", rename_all = "snake_case")]
196pub enum WireContentBlock {
197    Text {
198        text: String,
199    },
200    Image {
201        media_type: String,
202        #[serde(flatten)]
203        data: WireImageData,
204    },
205    Video {
206        media_type: String,
207        duration_ms: u64,
208        #[serde(flatten)]
209        data: WireVideoData,
210    },
211    /// Forward-compatibility for unknown block types.
212    #[serde(other)]
213    Unknown,
214}
215
216impl From<ContentBlock> for WireContentBlock {
217    fn from(block: ContentBlock) -> Self {
218        match block {
219            ContentBlock::Text { text } => WireContentBlock::Text { text },
220            ContentBlock::Image { media_type, data } => WireContentBlock::Image {
221                media_type,
222                data: match data {
223                    ImageData::Inline { data } => WireImageData::Inline { data },
224                    ImageData::Blob { blob_id } => WireImageData::Blob { blob_id },
225                },
226            },
227            ContentBlock::Video {
228                media_type,
229                duration_ms,
230                data,
231            } => WireContentBlock::Video {
232                media_type,
233                duration_ms,
234                data: match data {
235                    VideoData::Inline { data } => WireVideoData::Inline { data },
236                },
237            },
238            _ => WireContentBlock::Unknown,
239        }
240    }
241}
242
243impl TryFrom<WireContentBlock> for ContentBlock {
244    type Error = &'static str;
245
246    fn try_from(block: WireContentBlock) -> Result<Self, Self::Error> {
247        match block {
248            WireContentBlock::Text { text } => Ok(ContentBlock::Text { text }),
249            WireContentBlock::Image { media_type, data } => Ok(ContentBlock::Image {
250                media_type,
251                data: match data {
252                    WireImageData::Inline { data } => ImageData::Inline { data },
253                    WireImageData::Blob { blob_id } => ImageData::Blob { blob_id },
254                },
255            }),
256            WireContentBlock::Video {
257                media_type,
258                duration_ms,
259                data,
260            } => Ok(ContentBlock::Video {
261                media_type,
262                duration_ms,
263                data: match data {
264                    WireVideoData::Inline { data } => VideoData::Inline { data },
265                },
266            }),
267            WireContentBlock::Unknown => Err("unknown content block type"),
268        }
269    }
270}
271
272/// Wire-safe content input (mirrors `ContentInput`).
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
274#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
275#[serde(untagged)]
276pub enum WireContentInput {
277    Text(String),
278    Blocks(Vec<WireContentBlock>),
279}
280
281impl From<ContentInput> for WireContentInput {
282    fn from(input: ContentInput) -> Self {
283        match input {
284            ContentInput::Text(s) => WireContentInput::Text(s),
285            ContentInput::Blocks(blocks) => {
286                WireContentInput::Blocks(blocks.into_iter().map(Into::into).collect())
287            }
288        }
289    }
290}
291
292impl TryFrom<WireContentInput> for ContentInput {
293    type Error = &'static str;
294
295    fn try_from(input: WireContentInput) -> Result<Self, Self::Error> {
296        match input {
297            WireContentInput::Text(text) => Ok(ContentInput::Text(text)),
298            WireContentInput::Blocks(blocks) => Ok(ContentInput::Blocks(
299                blocks
300                    .into_iter()
301                    .map(ContentBlock::try_from)
302                    .collect::<Result<Vec<_>, _>>()?,
303            )),
304        }
305    }
306}
307
308/// Wire-safe tool result content that handles both legacy string and array formats.
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
310#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
311#[serde(untagged)]
312pub enum WireToolResultContent {
313    Text(String),
314    Blocks(Vec<WireContentBlock>),
315}
316
317/// Transcript block inside a block-assistant message.
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
319#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
320#[serde(tag = "block_type", content = "data", rename_all = "snake_case")]
321pub enum WireAssistantBlock {
322    Text {
323        text: String,
324        #[serde(skip_serializing_if = "Option::is_none")]
325        meta: Option<WireProviderMeta>,
326    },
327    Reasoning {
328        #[serde(default)]
329        text: String,
330        #[serde(skip_serializing_if = "Option::is_none")]
331        meta: Option<WireProviderMeta>,
332    },
333    ToolUse {
334        id: String,
335        name: String,
336        args: Value,
337        #[serde(skip_serializing_if = "Option::is_none")]
338        meta: Option<WireProviderMeta>,
339    },
340    Unknown,
341}
342
343impl From<AssistantBlock> for WireAssistantBlock {
344    fn from(value: AssistantBlock) -> Self {
345        match value {
346            AssistantBlock::Text { text, meta } => Self::Text {
347                text,
348                meta: meta.map(|m| (*m).into()),
349            },
350            AssistantBlock::Reasoning { text, meta } => Self::Reasoning {
351                text,
352                meta: meta.map(|m| (*m).into()),
353            },
354            AssistantBlock::ToolUse {
355                id,
356                name,
357                args,
358                meta,
359            } => Self::ToolUse {
360                id,
361                name,
362                args: serde_json::from_str(args.get()).unwrap_or(Value::Null),
363                meta: meta.map(|m| (*m).into()),
364            },
365            _ => Self::Unknown,
366        }
367    }
368}
369
370/// Canonical stop reason for transcript messages.
371#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
372#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
373#[serde(rename_all = "snake_case")]
374pub enum WireStopReason {
375    EndTurn,
376    ToolUse,
377    MaxTokens,
378    StopSequence,
379    ContentFilter,
380    Cancelled,
381}
382
383impl From<StopReason> for WireStopReason {
384    fn from(value: StopReason) -> Self {
385        match value {
386            StopReason::EndTurn => Self::EndTurn,
387            StopReason::ToolUse => Self::ToolUse,
388            StopReason::MaxTokens => Self::MaxTokens,
389            StopReason::StopSequence => Self::StopSequence,
390            StopReason::ContentFilter => Self::ContentFilter,
391            StopReason::Cancelled => Self::Cancelled,
392        }
393    }
394}
395
396/// Legacy assistant tool call payload.
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
399pub struct WireToolCall {
400    pub id: String,
401    pub name: String,
402    pub args: Value,
403}
404
405/// Tool result payload in a transcript.
406#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
407#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
408pub struct WireToolResult {
409    pub tool_use_id: String,
410    pub content: WireToolResultContent,
411    #[serde(default)]
412    pub is_error: bool,
413}
414
415/// Canonical transcript message for public wire surfaces.
416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
417#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
418#[serde(tag = "role", rename_all = "snake_case")]
419pub enum WireSessionMessage {
420    System {
421        content: String,
422    },
423    SystemNotice {
424        kind: SystemNoticeKind,
425        body: String,
426    },
427    User {
428        content: WireContentInput,
429    },
430    Assistant {
431        content: String,
432        #[serde(default, skip_serializing_if = "Vec::is_empty")]
433        tool_calls: Vec<WireToolCall>,
434        stop_reason: WireStopReason,
435    },
436    #[serde(rename = "block_assistant")]
437    BlockAssistant {
438        blocks: Vec<WireAssistantBlock>,
439        stop_reason: WireStopReason,
440    },
441    #[serde(rename = "tool_results")]
442    ToolResults {
443        results: Vec<WireToolResult>,
444    },
445}
446
447impl From<Message> for WireSessionMessage {
448    fn from(value: Message) -> Self {
449        match value {
450            Message::System(message) => Self::System {
451                content: message.content,
452            },
453            Message::SystemNotice(message) => Self::SystemNotice {
454                kind: message.kind,
455                body: message.body,
456            },
457            Message::User(message) => {
458                let content = if message.content.len() == 1
459                    && matches!(&message.content[0], ContentBlock::Text { .. })
460                {
461                    WireContentInput::Text(message.text_content())
462                } else {
463                    WireContentInput::Blocks(message.content.into_iter().map(Into::into).collect())
464                };
465                Self::User { content }
466            }
467            Message::Assistant(message) => Self::Assistant {
468                content: message.content,
469                tool_calls: message
470                    .tool_calls
471                    .into_iter()
472                    .map(|tool_call| WireToolCall {
473                        id: tool_call.id,
474                        name: tool_call.name,
475                        args: tool_call.args,
476                    })
477                    .collect(),
478                stop_reason: message.stop_reason.into(),
479            },
480            Message::BlockAssistant(message) => Self::BlockAssistant {
481                blocks: message.blocks.into_iter().map(Into::into).collect(),
482                stop_reason: message.stop_reason.into(),
483            },
484            Message::ToolResults { results } => Self::ToolResults {
485                results: results
486                    .into_iter()
487                    .map(|result| {
488                        let content = if result.content.len() == 1
489                            && matches!(&result.content[0], ContentBlock::Text { .. })
490                        {
491                            WireToolResultContent::Text(result.text_content())
492                        } else {
493                            WireToolResultContent::Blocks(
494                                result.content.into_iter().map(Into::into).collect(),
495                            )
496                        };
497                        WireToolResult {
498                            tool_use_id: result.tool_use_id,
499                            content,
500                            is_error: result.is_error,
501                        }
502                    })
503                    .collect(),
504            },
505        }
506    }
507}
508
509/// Full session history in canonical wire format.
510#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
512pub struct WireSessionHistory {
513    #[cfg_attr(feature = "schema", schemars(with = "String"))]
514    pub session_id: SessionId,
515    #[serde(skip_serializing_if = "Option::is_none")]
516    pub session_ref: Option<String>,
517    pub message_count: usize,
518    pub offset: usize,
519    #[serde(skip_serializing_if = "Option::is_none")]
520    pub limit: Option<usize>,
521    pub has_more: bool,
522    pub messages: Vec<WireSessionMessage>,
523}
524
525impl From<SessionHistoryPage> for WireSessionHistory {
526    fn from(page: SessionHistoryPage) -> Self {
527        Self {
528            session_id: page.session_id,
529            session_ref: None,
530            message_count: page.message_count,
531            offset: page.offset,
532            limit: page.limit,
533            has_more: page.has_more,
534            messages: page.messages.into_iter().map(Into::into).collect(),
535        }
536    }
537}
538
539#[cfg(test)]
540#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
541mod tests {
542    use super::*;
543    use meerkat_core::time_compat::SystemTime;
544    use meerkat_core::{
545        AssistantMessage, BlockAssistantMessage, SystemMessage, ToolCall, UserMessage,
546    };
547
548    #[test]
549    fn test_wire_session_summary_labels_roundtrip() {
550        let mut labels = BTreeMap::new();
551        labels.insert("env".to_string(), "prod".to_string());
552        labels.insert("team".to_string(), "infra".to_string());
553
554        let wire = WireSessionSummary {
555            session_id: SessionId::new(),
556            session_ref: None,
557            created_at: 1000,
558            updated_at: 2000,
559            message_count: 5,
560            total_tokens: 100,
561            is_active: true,
562            labels: labels.clone(),
563        };
564        let json = serde_json::to_string(&wire).unwrap();
565        let parsed: WireSessionSummary = serde_json::from_str(&json).unwrap();
566        assert_eq!(parsed.labels, labels);
567    }
568
569    #[test]
570    fn test_wire_session_summary_empty_labels_omitted() {
571        let wire = WireSessionSummary {
572            session_id: SessionId::new(),
573            session_ref: None,
574            created_at: 1000,
575            updated_at: 2000,
576            message_count: 0,
577            total_tokens: 0,
578            is_active: false,
579            labels: BTreeMap::new(),
580        };
581        let json = serde_json::to_string(&wire).unwrap();
582        assert!(
583            !json.contains("\"labels\""),
584            "empty labels should be omitted from JSON"
585        );
586    }
587
588    #[test]
589    fn test_wire_session_info_labels_roundtrip() {
590        let mut labels = BTreeMap::new();
591        labels.insert("role".to_string(), "orchestrator".to_string());
592
593        let wire = WireSessionInfo {
594            session_id: SessionId::new(),
595            session_ref: None,
596            created_at: 1000,
597            updated_at: 2000,
598            message_count: 3,
599            is_active: true,
600            model: "claude-sonnet-4-5".to_string(),
601            provider: "anthropic".to_string(),
602            last_assistant_text: None,
603            labels: labels.clone(),
604        };
605        let json = serde_json::to_string(&wire).unwrap();
606        let parsed: WireSessionInfo = serde_json::from_str(&json).unwrap();
607        assert_eq!(parsed.labels, labels);
608    }
609
610    #[test]
611    fn test_wire_session_info_from_session_info_maps_labels() {
612        let mut labels = BTreeMap::new();
613        labels.insert("env".to_string(), "staging".to_string());
614
615        let info = SessionInfo {
616            session_id: SessionId::new(),
617            created_at: SystemTime::now(),
618            updated_at: SystemTime::now(),
619            message_count: 2,
620            is_active: false,
621            model: "claude-sonnet-4-5".to_string(),
622            provider: meerkat_core::Provider::Anthropic,
623            last_assistant_text: Some("hello".to_string()),
624            labels: labels.clone(),
625        };
626        let wire: WireSessionInfo = info.into();
627        assert_eq!(wire.labels, labels);
628    }
629
630    #[test]
631    fn test_wire_session_summary_from_session_summary_maps_labels() {
632        let mut labels = BTreeMap::new();
633        labels.insert("project".to_string(), "meerkat".to_string());
634
635        let summary = SessionSummary {
636            session_id: SessionId::new(),
637            created_at: SystemTime::now(),
638            updated_at: SystemTime::now(),
639            message_count: 10,
640            total_tokens: 500,
641            is_active: true,
642            labels: labels.clone(),
643        };
644        let wire: WireSessionSummary = summary.into();
645        assert_eq!(wire.labels, labels);
646    }
647
648    #[test]
649    fn test_wire_session_info_backward_compat_no_labels() {
650        let json = r#"{
651            "session_id": "019405c8-1234-7000-8000-000000000001",
652            "created_at": 1000,
653            "updated_at": 2000,
654            "message_count": 0,
655            "is_active": false,
656            "model": "claude-sonnet-4-5",
657            "provider": "anthropic"
658        }"#;
659        let parsed: WireSessionInfo = serde_json::from_str(json).unwrap();
660        assert!(parsed.labels.is_empty());
661    }
662
663    #[test]
664    fn test_wire_session_summary_backward_compat_no_labels() {
665        let json = r#"{
666            "session_id": "019405c8-1234-7000-8000-000000000001",
667            "created_at": 1000,
668            "updated_at": 2000,
669            "message_count": 0,
670            "total_tokens": 0,
671            "is_active": false
672        }"#;
673        let parsed: WireSessionSummary = serde_json::from_str(json).unwrap();
674        assert!(parsed.labels.is_empty());
675    }
676
677    #[test]
678    fn test_wire_session_history_roundtrip_mixed_messages() {
679        let history = WireSessionHistory {
680            session_id: SessionId::new(),
681            session_ref: Some("session://example".to_string()),
682            message_count: 5,
683            offset: 0,
684            limit: Some(5),
685            has_more: false,
686            messages: vec![
687                WireSessionMessage::System {
688                    content: "You are helpful".to_string(),
689                },
690                WireSessionMessage::User {
691                    content: WireContentInput::Text("hello".to_string()),
692                },
693                WireSessionMessage::Assistant {
694                    content: "hi".to_string(),
695                    tool_calls: vec![WireToolCall {
696                        id: "tool-1".to_string(),
697                        name: "search".to_string(),
698                        args: serde_json::json!({"q":"rust"}),
699                    }],
700                    stop_reason: WireStopReason::ToolUse,
701                },
702                WireSessionMessage::BlockAssistant {
703                    blocks: vec![
704                        WireAssistantBlock::Reasoning {
705                            text: "thinking".to_string(),
706                            meta: None,
707                        },
708                        WireAssistantBlock::Text {
709                            text: "done".to_string(),
710                            meta: None,
711                        },
712                    ],
713                    stop_reason: WireStopReason::EndTurn,
714                },
715                WireSessionMessage::ToolResults {
716                    results: vec![WireToolResult {
717                        tool_use_id: "tool-1".to_string(),
718                        content: WireToolResultContent::Text("ok".to_string()),
719                        is_error: false,
720                    }],
721                },
722            ],
723        };
724        let json = serde_json::to_string(&history).unwrap();
725        let parsed: WireSessionHistory = serde_json::from_str(&json).unwrap();
726        assert_eq!(parsed, history);
727    }
728
729    #[test]
730    fn test_wire_session_history_from_page_maps_messages() {
731        let page = SessionHistoryPage {
732            session_id: SessionId::new(),
733            message_count: 3,
734            offset: 0,
735            limit: None,
736            has_more: false,
737            messages: vec![
738                Message::System(SystemMessage {
739                    content: "sys".to_string(),
740                }),
741                Message::SystemNotice(meerkat_core::SystemNoticeMessage::new(
742                    meerkat_core::SystemNoticeKind::BackgroundJob,
743                    "still running",
744                )),
745                Message::Assistant(AssistantMessage {
746                    content: "hello".to_string(),
747                    tool_calls: vec![ToolCall::new(
748                        "call-1".to_string(),
749                        "search".to_string(),
750                        serde_json::json!({"q":"meerkat"}),
751                    )],
752                    stop_reason: StopReason::ToolUse,
753                    usage: meerkat_core::Usage::default(),
754                }),
755            ],
756        };
757        let wire: WireSessionHistory = page.into();
758        assert_eq!(wire.messages.len(), 3);
759        assert!(matches!(
760            wire.messages[0],
761            WireSessionMessage::System { .. }
762        ));
763        assert!(matches!(
764            wire.messages[1],
765            WireSessionMessage::SystemNotice { .. }
766        ));
767        assert!(matches!(
768            wire.messages[2],
769            WireSessionMessage::Assistant { .. }
770        ));
771    }
772
773    #[test]
774    fn test_wire_session_history_from_page_maps_block_assistant_and_tool_results() {
775        let page = SessionHistoryPage {
776            session_id: SessionId::new(),
777            message_count: 2,
778            offset: 0,
779            limit: Some(2),
780            has_more: false,
781            messages: vec![
782                Message::BlockAssistant(BlockAssistantMessage {
783                    blocks: vec![AssistantBlock::Text {
784                        text: "hi".to_string(),
785                        meta: None,
786                    }],
787                    stop_reason: StopReason::EndTurn,
788                }),
789                Message::ToolResults {
790                    results: vec![meerkat_core::ToolResult::new(
791                        "tool-2".to_string(),
792                        "done".to_string(),
793                        false,
794                    )],
795                },
796            ],
797        };
798        let wire: WireSessionHistory = page.into();
799        assert!(matches!(
800            wire.messages[0],
801            WireSessionMessage::BlockAssistant { .. }
802        ));
803        assert!(matches!(
804            wire.messages[1],
805            WireSessionMessage::ToolResults { .. }
806        ));
807    }
808
809    #[test]
810    fn test_wire_content_block_text_roundtrip() {
811        let block = WireContentBlock::Text {
812            text: "hello".to_string(),
813        };
814        let json = serde_json::to_string(&block).unwrap();
815        let parsed: WireContentBlock = serde_json::from_str(&json).unwrap();
816        assert_eq!(parsed, block);
817    }
818
819    #[test]
820    fn test_wire_content_block_image_roundtrip() {
821        let block = WireContentBlock::Image {
822            media_type: "image/png".to_string(),
823            data: "iVBOR...".into(),
824        };
825        let json = serde_json::to_string(&block).unwrap();
826        let parsed: WireContentBlock = serde_json::from_str(&json).unwrap();
827        assert_eq!(parsed, block);
828    }
829
830    #[test]
831    fn test_wire_content_block_video_roundtrip() {
832        let block = WireContentBlock::Video {
833            media_type: "video/mp4".to_string(),
834            duration_ms: 12_000,
835            data: "AAAA".into(),
836        };
837        let json = serde_json::to_string(&block).unwrap();
838        let parsed: WireContentBlock = serde_json::from_str(&json).unwrap();
839        assert_eq!(parsed, block);
840    }
841
842    #[test]
843    fn test_wire_content_block_unknown_forward_compat() {
844        let json = r#"{"type":"hologram","url":"https://example.com/v.mp4"}"#;
845        let parsed: WireContentBlock = serde_json::from_str(json).unwrap();
846        assert_eq!(parsed, WireContentBlock::Unknown);
847    }
848
849    #[test]
850    fn test_wire_content_block_from_core_strips_source_path() {
851        let core_block = ContentBlock::Image {
852            media_type: "image/jpeg".to_string(),
853            data: "base64data".into(),
854        };
855        let wire: WireContentBlock = core_block.into();
856        assert_eq!(
857            wire,
858            WireContentBlock::Image {
859                media_type: "image/jpeg".to_string(),
860                data: "base64data".into(),
861            }
862        );
863    }
864
865    #[test]
866    fn test_wire_content_block_from_core_video_roundtrip() {
867        let core_block = ContentBlock::Video {
868            media_type: "video/mp4".to_string(),
869            duration_ms: 12_000,
870            data: VideoData::Inline {
871                data: "base64video".to_string(),
872            },
873        };
874        let wire: WireContentBlock = core_block.clone().into();
875        assert_eq!(
876            wire,
877            WireContentBlock::Video {
878                media_type: "video/mp4".to_string(),
879                duration_ms: 12_000,
880                data: "base64video".into(),
881            }
882        );
883        let restored = ContentBlock::try_from(wire).unwrap();
884        assert_eq!(restored, core_block);
885    }
886
887    #[test]
888    fn test_wire_content_input_text_roundtrip() {
889        let input = WireContentInput::Text("hello world".to_string());
890        let json = serde_json::to_string(&input).unwrap();
891        assert_eq!(json, r#""hello world""#);
892        let parsed: WireContentInput = serde_json::from_str(&json).unwrap();
893        assert_eq!(parsed, input);
894    }
895
896    #[test]
897    fn test_wire_content_input_blocks_roundtrip() {
898        let input = WireContentInput::Blocks(vec![
899            WireContentBlock::Text {
900                text: "look at this".to_string(),
901            },
902            WireContentBlock::Image {
903                media_type: "image/png".to_string(),
904                data: "abc123".into(),
905            },
906        ]);
907        let json = serde_json::to_string(&input).unwrap();
908        let parsed: WireContentInput = serde_json::from_str(&json).unwrap();
909        assert_eq!(parsed, input);
910    }
911
912    #[test]
913    fn test_wire_tool_result_content_text_roundtrip() {
914        let content = WireToolResultContent::Text("result text".to_string());
915        let json = serde_json::to_string(&content).unwrap();
916        assert_eq!(json, r#""result text""#);
917        let parsed: WireToolResultContent = serde_json::from_str(&json).unwrap();
918        assert_eq!(parsed, content);
919    }
920
921    #[test]
922    fn test_wire_tool_result_content_blocks_roundtrip() {
923        let content = WireToolResultContent::Blocks(vec![
924            WireContentBlock::Text {
925                text: "output".to_string(),
926            },
927            WireContentBlock::Image {
928                media_type: "image/png".to_string(),
929                data: "data".into(),
930            },
931        ]);
932        let json = serde_json::to_string(&content).unwrap();
933        let parsed: WireToolResultContent = serde_json::from_str(&json).unwrap();
934        assert_eq!(parsed, content);
935    }
936
937    #[test]
938    fn test_wire_tool_result_backward_compat_string() {
939        let json = r#"{"tool_use_id":"t1","content":"hello","is_error":false}"#;
940        let parsed: WireToolResult = serde_json::from_str(json).unwrap();
941        assert_eq!(
942            parsed.content,
943            WireToolResultContent::Text("hello".to_string())
944        );
945    }
946
947    #[test]
948    fn test_wire_user_message_text_backward_compat() {
949        let json = r#"{"role":"user","content":"hello"}"#;
950        let parsed: WireSessionMessage = serde_json::from_str(json).unwrap();
951        match parsed {
952            WireSessionMessage::User { content } => {
953                assert_eq!(content, WireContentInput::Text("hello".to_string()));
954            }
955            _ => panic!("expected User message"),
956        }
957    }
958
959    #[test]
960    fn test_wire_user_message_blocks() {
961        let json = r#"{"role":"user","content":[{"type":"text","text":"look"},{"type":"image","media_type":"image/png","source":"inline","data":"abc"}]}"#;
962        let parsed: WireSessionMessage = serde_json::from_str(json).unwrap();
963        match parsed {
964            WireSessionMessage::User { content } => {
965                assert_eq!(
966                    content,
967                    WireContentInput::Blocks(vec![
968                        WireContentBlock::Text {
969                            text: "look".to_string()
970                        },
971                        WireContentBlock::Image {
972                            media_type: "image/png".to_string(),
973                            data: "abc".into()
974                        },
975                    ])
976                );
977            }
978            _ => panic!("expected User message"),
979        }
980    }
981
982    #[test]
983    fn test_wire_user_message_from_multimodal_core() {
984        let page = SessionHistoryPage {
985            session_id: SessionId::new(),
986            message_count: 1,
987            offset: 0,
988            limit: None,
989            has_more: false,
990            messages: vec![Message::User(UserMessage::with_blocks(vec![
991                ContentBlock::Text {
992                    text: "describe this".to_string(),
993                },
994                ContentBlock::Image {
995                    media_type: "image/png".to_string(),
996                    data: "base64data".into(),
997                },
998            ]))],
999        };
1000        let wire: WireSessionHistory = page.into();
1001        match &wire.messages[0] {
1002            WireSessionMessage::User { content } => {
1003                assert_eq!(
1004                    *content,
1005                    WireContentInput::Blocks(vec![
1006                        WireContentBlock::Text {
1007                            text: "describe this".to_string()
1008                        },
1009                        WireContentBlock::Image {
1010                            media_type: "image/png".to_string(),
1011                            data: "base64data".into()
1012                        },
1013                    ])
1014                );
1015            }
1016            _ => panic!("expected User message"),
1017        }
1018    }
1019
1020    #[test]
1021    fn test_wire_tool_result_from_multimodal_core() {
1022        let page = SessionHistoryPage {
1023            session_id: SessionId::new(),
1024            message_count: 1,
1025            offset: 0,
1026            limit: None,
1027            has_more: false,
1028            messages: vec![Message::ToolResults {
1029                results: vec![meerkat_core::ToolResult::with_blocks(
1030                    "tool-1".to_string(),
1031                    vec![
1032                        ContentBlock::Text {
1033                            text: "screenshot:".to_string(),
1034                        },
1035                        ContentBlock::Image {
1036                            media_type: "image/png".to_string(),
1037                            data: "imgdata".into(),
1038                        },
1039                    ],
1040                    false,
1041                )],
1042            }],
1043        };
1044        let wire: WireSessionHistory = page.into();
1045        match &wire.messages[0] {
1046            WireSessionMessage::ToolResults { results } => {
1047                assert_eq!(
1048                    results[0].content,
1049                    WireToolResultContent::Blocks(vec![
1050                        WireContentBlock::Text {
1051                            text: "screenshot:".to_string()
1052                        },
1053                        WireContentBlock::Image {
1054                            media_type: "image/png".to_string(),
1055                            data: "imgdata".into()
1056                        },
1057                    ])
1058                );
1059            }
1060            _ => panic!("expected ToolResults message"),
1061        }
1062    }
1063}