1use 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#[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#[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#[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#[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 #[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#[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#[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#[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#[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#[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#[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#[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#[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}