1use serde::{Deserialize, Serialize};
4
5fn serialize_raw_json_box<S>(
6 value: &serde_json::value::RawValue,
7 serializer: S,
8) -> Result<S::Ok, S::Error>
9where
10 S: serde::Serializer,
11{
12 let value: serde_json::Value =
13 serde_json::from_str(value.get()).map_err(serde::ser::Error::custom)?;
14 value.serialize(serializer)
15}
16
17fn deserialize_raw_json_box<'de, D>(
18 deserializer: D,
19) -> Result<Box<serde_json::value::RawValue>, D::Error>
20where
21 D: serde::Deserializer<'de>,
22{
23 let value = serde_json::Value::deserialize(deserializer)?;
24 serde_json::value::RawValue::from_string(value.to_string()).map_err(serde::de::Error::custom)
25}
26use std::collections::BTreeMap;
27
28use meerkat_core::{
29 AssistantBlock, BlobId, BlockAssistantMessage, ContentBlock, ContentInput, ImageData, Message,
30 ProviderMeta, ServerToolKind, SessionHistoryPage, SessionId, SessionInfo, SessionSummary,
31 SessionTranscriptRevisionList, SessionTranscriptRevisionPage, StopReason, SystemMessage,
32 SystemNoticeKind, SystemNoticeMessage, ToolResult, TranscriptEditRunningBehavior,
33 TranscriptReplacement, TranscriptRewriteReason, TranscriptRewriteSelection, TranscriptSource,
34 UserMessage, VideoData,
35};
36use meerkat_core::{InteractionId, RunId};
37use std::convert::TryFrom;
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42#[serde(deny_unknown_fields)]
43pub struct SessionStreamOpenParams {
44 pub session_id: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50pub struct SessionStreamOpenResult {
51 pub stream_id: String,
52 pub session_id: String,
53 pub opened: bool,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[serde(deny_unknown_fields)]
60pub struct SessionStreamCloseParams {
61 pub stream_id: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
67pub struct SessionStreamCloseResult {
68 pub stream_id: String,
69 pub closed: bool,
70 pub already_closed: bool,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76#[serde(deny_unknown_fields)]
77pub struct ForkSessionAtParams {
78 pub session_id: String,
79 pub message_index: usize,
80 #[serde(default)]
81 pub running_behavior: TranscriptEditRunningBehavior,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
91#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[serde(deny_unknown_fields)]
93pub struct ForkSessionReplaceParams {
94 pub session_id: String,
95 pub message_index: usize,
96 pub replacement: WireTranscriptReplacement,
97 #[serde(default)]
98 pub running_behavior: TranscriptEditRunningBehavior,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
104#[serde(tag = "role", rename_all = "snake_case")]
105pub enum TranscriptRewriteMessage {
106 System {
107 content: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 created_at: Option<String>,
110 },
111 SystemNotice {
112 kind: SystemNoticeKind,
113 #[serde(default, alias = "content", skip_serializing_if = "Option::is_none")]
114 body: Option<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 blocks: Vec<meerkat_core::SystemNoticeBlock>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 created_at: Option<String>,
119 },
120 User {
121 content: WireContentInput,
122 #[serde(
129 default,
130 skip_serializing_if = "meerkat_core::types::TranscriptUserRole::is_conversational"
131 )]
132 transcript_role: meerkat_core::types::TranscriptUserRole,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 created_at: Option<String>,
135 },
136 #[serde(rename = "block_assistant")]
137 BlockAssistant {
138 blocks: Vec<WireAssistantBlock>,
139 #[serde(default)]
140 stop_reason: WireStopReason,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 created_at: Option<String>,
143 },
144 #[serde(rename = "tool_results")]
145 ToolResults {
146 results: Vec<WireToolResult>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 created_at: Option<String>,
149 },
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[serde(deny_unknown_fields)]
156pub struct RewriteSessionTranscriptParams {
157 pub session_id: String,
158 pub selection: TranscriptRewriteSelection,
159 pub replacement: Vec<TranscriptRewriteMessage>,
160 pub reason: TranscriptRewriteReason,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub actor: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub expected_parent_revision: Option<String>,
165 #[serde(default)]
166 pub running_behavior: TranscriptEditRunningBehavior,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172#[serde(deny_unknown_fields)]
173pub struct RestoreSessionTranscriptRevisionParams {
174 pub session_id: String,
175 pub revision: String,
176 pub reason: TranscriptRewriteReason,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub actor: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub expected_parent_revision: Option<String>,
181 #[serde(default)]
182 pub running_behavior: TranscriptEditRunningBehavior,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
188#[serde(deny_unknown_fields)]
189pub struct ReadSessionTranscriptRevisionParams {
190 pub session_id: String,
191 pub revision: String,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub offset: Option<usize>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub limit: Option<usize>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201#[serde(deny_unknown_fields)]
202pub struct ListSessionTranscriptRevisionsParams {
203 pub session_id: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub offset: Option<usize>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub limit: Option<usize>,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Hash)]
214pub struct RevisionId(pub String);
215
216impl RevisionId {
217 pub fn as_str(&self) -> &str {
219 &self.0
220 }
221
222 pub fn into_string(self) -> String {
224 self.0
225 }
226}
227
228impl std::fmt::Display for RevisionId {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 f.write_str(&self.0)
231 }
232}
233
234const REVISION_SELECTOR_CURRENT: &str = "current";
236
237#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum RevisionSelector {
243 Current,
245 Specific(RevisionId),
247}
248
249impl RevisionSelector {
250 pub fn parse(revision: impl Into<String>) -> Self {
253 let revision = revision.into();
254 if revision == REVISION_SELECTOR_CURRENT {
255 Self::Current
256 } else {
257 Self::Specific(RevisionId(revision))
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
265pub struct WireSessionInfo {
266 #[cfg_attr(feature = "schema", schemars(with = "String"))]
267 pub session_id: SessionId,
268 #[serde(skip_serializing_if = "Option::is_none")]
269 pub session_ref: Option<String>,
270 pub created_at: u64,
271 pub updated_at: u64,
272 pub message_count: usize,
273 pub is_active: bool,
274 pub model: String,
275 pub provider: String,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub last_assistant_text: Option<String>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub resolved_capabilities: Option<crate::wire::WireResolvedModelCapabilities>,
280 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
281 pub labels: BTreeMap<String, String>,
282}
283
284impl From<SessionInfo> for WireSessionInfo {
285 fn from(info: SessionInfo) -> Self {
286 Self {
287 session_id: info.session_id,
288 session_ref: None,
289 created_at: info
290 .created_at
291 .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
292 .map(|d| d.as_secs())
293 .unwrap_or(0),
294 updated_at: info
295 .updated_at
296 .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
297 .map(|d| d.as_secs())
298 .unwrap_or(0),
299 message_count: info.message_count,
300 is_active: info.is_active,
301 model: info.model,
302 provider: info.provider.as_str().to_string(),
303 last_assistant_text: info.last_assistant_text,
304 resolved_capabilities: None,
305 labels: info.labels,
306 }
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub struct WireSessionSummary {
314 #[cfg_attr(feature = "schema", schemars(with = "String"))]
315 pub session_id: SessionId,
316 #[serde(skip_serializing_if = "Option::is_none")]
317 pub session_ref: Option<String>,
318 pub created_at: u64,
319 pub updated_at: u64,
320 pub message_count: usize,
321 pub total_tokens: u64,
322 pub is_active: bool,
323 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
324 pub labels: BTreeMap<String, String>,
325}
326
327impl From<SessionSummary> for WireSessionSummary {
328 fn from(summary: SessionSummary) -> Self {
329 Self {
330 session_id: summary.session_id,
331 session_ref: None,
332 created_at: summary
333 .created_at
334 .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
335 .map(|d| d.as_secs())
336 .unwrap_or(0),
337 updated_at: summary
338 .updated_at
339 .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
340 .map(|d| d.as_secs())
341 .unwrap_or(0),
342 message_count: summary.message_count,
343 total_tokens: summary.total_tokens,
344 is_active: summary.is_active,
345 labels: summary.labels,
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
352#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
353#[serde(tag = "provider", rename_all = "snake_case")]
354pub enum WireProviderMeta {
355 Anthropic {
356 signature: String,
357 },
358 AnthropicRedacted {
359 data: String,
360 },
361 AnthropicCompaction {
362 content: String,
363 },
364 Gemini {
365 #[serde(rename = "thoughtSignature")]
366 thought_signature: String,
367 },
368 OpenAi {
369 id: String,
370 #[serde(default)]
371 #[serde(skip_serializing_if = "Option::is_none")]
372 encrypted_content: Option<String>,
373 #[serde(default)]
374 #[serde(skip_serializing_if = "Option::is_none")]
375 phase: Option<String>,
376 #[serde(default)]
377 #[serde(skip_serializing_if = "Option::is_none")]
378 response_id: Option<String>,
379 },
380 OpenAiResponse {
381 response_id: String,
382 },
383 Unknown,
384}
385
386impl From<ProviderMeta> for WireProviderMeta {
387 fn from(value: ProviderMeta) -> Self {
388 match value {
389 ProviderMeta::Anthropic { signature } => Self::Anthropic { signature },
390 ProviderMeta::AnthropicRedacted { data } => Self::AnthropicRedacted { data },
391 ProviderMeta::AnthropicCompaction { content } => Self::AnthropicCompaction { content },
392 ProviderMeta::Gemini { thought_signature } => Self::Gemini { thought_signature },
393 ProviderMeta::OpenAi {
394 id,
395 encrypted_content,
396 phase,
397 response_id,
398 } => Self::OpenAi {
399 id,
400 encrypted_content,
401 phase,
402 response_id,
403 },
404 ProviderMeta::OpenAiResponse { response_id } => Self::OpenAiResponse { response_id },
405 _ => Self::Unknown,
406 }
407 }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
422#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
423#[serde(tag = "kind", rename_all = "snake_case")]
424pub enum WireServerToolKind {
425 WebSearch,
426 GoogleSearch,
427 ProviderNative { name: String },
428 Unknown { debug: String },
429}
430
431impl From<ServerToolKind> for WireServerToolKind {
432 fn from(value: ServerToolKind) -> Self {
433 match value {
434 ServerToolKind::WebSearch => Self::WebSearch,
435 ServerToolKind::GoogleSearch => Self::GoogleSearch,
436 ServerToolKind::ProviderNative { name } => Self::ProviderNative { name },
437 other => Self::Unknown {
441 debug: format!("{other:?}"),
442 },
443 }
444 }
445}
446
447impl TryFrom<WireServerToolKind> for ServerToolKind {
448 type Error = crate::wire::error::WireConversionError;
449
450 fn try_from(value: WireServerToolKind) -> Result<Self, Self::Error> {
451 match value {
452 WireServerToolKind::WebSearch => Ok(Self::WebSearch),
453 WireServerToolKind::GoogleSearch => Ok(Self::GoogleSearch),
454 WireServerToolKind::ProviderNative { name } => Ok(Self::ProviderNative { name }),
455 WireServerToolKind::Unknown { debug } => {
456 Err(crate::wire::error::WireConversionError::AssistantBlock { debug })
457 }
458 }
459 }
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
464#[serde(tag = "source", rename_all = "snake_case")]
465pub enum WireImageData {
466 Inline {
467 data: String,
468 },
469 Blob {
470 #[cfg_attr(feature = "schema", schemars(with = "String"))]
471 blob_id: BlobId,
472 },
473}
474
475impl From<String> for WireImageData {
476 fn from(data: String) -> Self {
477 Self::Inline { data }
478 }
479}
480
481impl From<&str> for WireImageData {
482 fn from(data: &str) -> Self {
483 Self::Inline {
484 data: data.to_string(),
485 }
486 }
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
490#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491#[serde(tag = "source", rename_all = "snake_case")]
492pub enum WireVideoData {
493 Inline { data: String },
494 Uri { uri: String },
495}
496
497impl From<String> for WireVideoData {
498 fn from(data: String) -> Self {
499 Self::Inline { data }
500 }
501}
502
503impl From<&str> for WireVideoData {
504 fn from(data: &str) -> Self {
505 Self::Inline {
506 data: data.to_string(),
507 }
508 }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
513#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
514#[serde(tag = "type", rename_all = "snake_case")]
515pub enum WireContentBlock {
516 Text {
517 text: String,
518 },
519 Image {
520 media_type: String,
521 #[serde(flatten)]
522 data: WireImageData,
523 },
524 Video {
525 media_type: String,
526 duration_ms: u64,
527 #[serde(flatten)]
528 data: WireVideoData,
529 },
530 Structured {
533 data: serde_json::Value,
534 },
535 #[serde(other)]
537 Unknown,
538}
539
540impl From<ContentBlock> for WireContentBlock {
541 fn from(block: ContentBlock) -> Self {
542 match block {
543 ContentBlock::Text { text } => WireContentBlock::Text { text },
544 ContentBlock::Image { media_type, data } => WireContentBlock::Image {
545 media_type,
546 data: match data {
547 ImageData::Inline { data } => WireImageData::Inline { data },
548 ImageData::Blob { blob_id } => WireImageData::Blob { blob_id },
549 },
550 },
551 ContentBlock::Video {
552 media_type,
553 duration_ms,
554 data,
555 } => WireContentBlock::Video {
556 media_type,
557 duration_ms,
558 data: match data {
559 VideoData::Inline { data } => WireVideoData::Inline { data },
560 VideoData::Uri { uri } => WireVideoData::Uri { uri },
561 },
562 },
563 ContentBlock::Structured { data } => WireContentBlock::Structured {
564 data: serde_json::from_str(data.get())
569 .unwrap_or_else(|_| serde_json::Value::String(data.get().to_owned())),
570 },
571 _ => WireContentBlock::Unknown,
572 }
573 }
574}
575
576impl TryFrom<WireContentBlock> for ContentBlock {
577 type Error = &'static str;
578
579 fn try_from(block: WireContentBlock) -> Result<Self, Self::Error> {
580 match block {
581 WireContentBlock::Text { text } => Ok(ContentBlock::Text { text }),
582 WireContentBlock::Image { media_type, data } => Ok(ContentBlock::Image {
583 media_type,
584 data: match data {
585 WireImageData::Inline { data } => ImageData::Inline { data },
586 WireImageData::Blob { blob_id } => ImageData::Blob { blob_id },
587 },
588 }),
589 WireContentBlock::Video {
590 media_type,
591 duration_ms,
592 data,
593 } => Ok(ContentBlock::Video {
594 media_type,
595 duration_ms,
596 data: match data {
597 WireVideoData::Inline { data } => VideoData::Inline { data },
598 WireVideoData::Uri { uri } => VideoData::Uri { uri },
599 },
600 }),
601 WireContentBlock::Structured { data } => Ok(ContentBlock::Structured {
602 data: serde_json::value::to_raw_value(&data)
603 .map_err(|_| "structured content block is not serializable JSON")?,
604 }),
605 WireContentBlock::Unknown => Err("unknown content block type"),
606 }
607 }
608}
609
610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
612#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
613#[serde(untagged)]
614pub enum WireContentInput {
615 Text(String),
616 Blocks(Vec<WireContentBlock>),
617}
618
619impl From<ContentInput> for WireContentInput {
620 fn from(input: ContentInput) -> Self {
621 match input {
622 ContentInput::Text(s) => WireContentInput::Text(s),
623 ContentInput::Blocks(blocks) => {
624 WireContentInput::Blocks(blocks.into_iter().map(Into::into).collect())
625 }
626 }
627 }
628}
629
630impl TryFrom<WireContentInput> for ContentInput {
631 type Error = &'static str;
632
633 fn try_from(input: WireContentInput) -> Result<Self, Self::Error> {
634 match input {
635 WireContentInput::Text(text) => Ok(ContentInput::Text(text)),
636 WireContentInput::Blocks(blocks) => Ok(ContentInput::Blocks(
637 blocks
638 .into_iter()
639 .map(ContentBlock::try_from)
640 .collect::<Result<Vec<_>, _>>()?,
641 )),
642 }
643 }
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
654#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
655#[serde(rename_all = "snake_case")]
656pub enum WirePromptInput {
657 Text(String),
658 Blocks(Vec<WireContentBlock>),
659}
660
661impl TryFrom<WirePromptInput> for ContentInput {
662 type Error = &'static str;
663
664 fn try_from(input: WirePromptInput) -> Result<Self, Self::Error> {
665 match input {
666 WirePromptInput::Text(text) => Ok(ContentInput::Text(text)),
667 WirePromptInput::Blocks(blocks) => Ok(ContentInput::Blocks(
668 blocks
669 .into_iter()
670 .map(ContentBlock::try_from)
671 .collect::<Result<Vec<_>, _>>()?,
672 )),
673 }
674 }
675}
676
677#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
679#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
680#[serde(rename_all = "snake_case")]
681pub enum WireInterruptOutcome {
682 Interrupted,
684 StagedNoop,
687}
688
689#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct InterruptResult {
693 pub session_id: String,
694 pub interrupted: bool,
696 pub result: WireInterruptOutcome,
697}
698
699impl InterruptResult {
700 #[must_use]
702 pub fn from_outcome(session_id: String, outcome: WireInterruptOutcome) -> Self {
703 Self {
704 session_id,
705 interrupted: matches!(outcome, WireInterruptOutcome::Interrupted),
706 result: outcome,
707 }
708 }
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
713#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
714#[serde(untagged)]
715pub enum WireToolResultContent {
716 Text(String),
717 Blocks(Vec<WireContentBlock>),
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize)]
727#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
728#[serde(tag = "block_type", content = "data", rename_all = "snake_case")]
729pub enum WireAssistantBlock {
730 Text {
731 text: String,
732 #[serde(skip_serializing_if = "Option::is_none")]
733 meta: Option<WireProviderMeta>,
734 },
735 Transcript {
738 text: String,
739 source: WireTranscriptSource,
740 #[serde(skip_serializing_if = "Option::is_none")]
741 meta: Option<WireProviderMeta>,
742 },
743 Reasoning {
744 #[serde(default)]
745 text: String,
746 #[serde(skip_serializing_if = "Option::is_none")]
747 meta: Option<WireProviderMeta>,
748 },
749 ToolUse {
750 id: String,
751 name: String,
752 #[serde(
758 serialize_with = "serialize_raw_json_box",
759 deserialize_with = "deserialize_raw_json_box"
760 )]
761 #[cfg_attr(feature = "schema", schemars(with = "serde_json::Value"))]
762 args: Box<serde_json::value::RawValue>,
763 #[serde(skip_serializing_if = "Option::is_none")]
764 meta: Option<WireProviderMeta>,
765 },
766 ServerToolContent {
767 #[serde(skip_serializing_if = "Option::is_none")]
768 id: Option<String>,
769 kind: WireServerToolKind,
770 content: serde_json::Value,
771 #[serde(skip_serializing_if = "Option::is_none")]
772 meta: Option<WireProviderMeta>,
773 },
774 Image {
775 image_id: meerkat_core::AssistantImageId,
776 blob_ref: meerkat_core::BlobRef,
777 media_type: meerkat_core::MediaType,
778 width: u32,
779 height: u32,
780 revised_prompt: meerkat_core::RevisedPromptDisposition,
781 meta: meerkat_core::ProviderImageMetadata,
782 },
783 #[serde(other)]
784 Unknown,
785}
786
787#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
799#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
800#[serde(tag = "kind", rename_all = "snake_case")]
801#[non_exhaustive]
802pub enum WireTranscriptSource {
803 Spoken,
804 Unknown {
817 debug: String,
818 },
819}
820
821impl From<TranscriptSource> for WireTranscriptSource {
822 fn from(value: TranscriptSource) -> Self {
823 match value {
824 TranscriptSource::Spoken => Self::Spoken,
825 other => Self::Unknown {
830 debug: format!("{other:?}"),
831 },
832 }
833 }
834}
835
836impl TryFrom<WireTranscriptSource> for TranscriptSource {
837 type Error = crate::wire::error::WireConversionError;
838
839 fn try_from(value: WireTranscriptSource) -> Result<Self, Self::Error> {
840 match value {
841 WireTranscriptSource::Spoken => Ok(Self::Spoken),
842 WireTranscriptSource::Unknown { debug } => {
843 Err(crate::wire::error::WireConversionError::TranscriptSource { debug })
844 }
845 }
846 }
847}
848
849impl From<AssistantBlock> for WireAssistantBlock {
850 fn from(value: AssistantBlock) -> Self {
851 match value {
852 AssistantBlock::Text { text, meta } => Self::Text {
853 text,
854 meta: meta.map(|m| (*m).into()),
855 },
856 AssistantBlock::Transcript { text, source, meta } => Self::Transcript {
857 text,
858 source: source.into(),
859 meta: meta.map(|m| (*m).into()),
860 },
861 AssistantBlock::Reasoning { text, meta } => Self::Reasoning {
862 text,
863 meta: meta.map(|m| (*m).into()),
864 },
865 AssistantBlock::ToolUse {
866 id,
867 name,
868 args,
869 meta,
870 } => Self::ToolUse {
871 id,
872 name,
873 args,
878 meta: meta.map(|m| (*m).into()),
879 },
880 AssistantBlock::ServerToolContent {
881 id,
882 kind,
883 content,
884 meta,
885 } => Self::ServerToolContent {
886 id,
887 kind: kind.into(),
888 content,
889 meta: meta.map(|m| (*m).into()),
890 },
891 AssistantBlock::Image {
892 image_id,
893 blob_ref,
894 media_type,
895 width,
896 height,
897 revised_prompt,
898 meta,
899 } => Self::Image {
900 image_id,
901 blob_ref,
902 media_type,
903 width,
904 height,
905 revised_prompt,
906 meta,
907 },
908 _ => Self::Unknown,
909 }
910 }
911}
912
913fn wire_provider_meta_to_core(value: WireProviderMeta) -> Option<ProviderMeta> {
921 match value {
922 WireProviderMeta::Anthropic { signature } => Some(ProviderMeta::Anthropic { signature }),
923 WireProviderMeta::AnthropicRedacted { data } => {
924 Some(ProviderMeta::AnthropicRedacted { data })
925 }
926 WireProviderMeta::AnthropicCompaction { content } => {
927 Some(ProviderMeta::AnthropicCompaction { content })
928 }
929 WireProviderMeta::Gemini { thought_signature } => {
930 Some(ProviderMeta::Gemini { thought_signature })
931 }
932 WireProviderMeta::OpenAi {
933 id,
934 encrypted_content,
935 phase,
936 response_id,
937 } => Some(ProviderMeta::OpenAi {
938 id,
939 encrypted_content,
940 phase,
941 response_id,
942 }),
943 WireProviderMeta::OpenAiResponse { response_id } => {
944 Some(ProviderMeta::OpenAiResponse { response_id })
945 }
946 WireProviderMeta::Unknown => None,
947 }
948}
949
950impl TryFrom<WireAssistantBlock> for AssistantBlock {
971 type Error = crate::wire::error::WireConversionError;
972
973 fn try_from(value: WireAssistantBlock) -> Result<Self, Self::Error> {
974 Ok(match value {
975 WireAssistantBlock::Text { text, meta } => Self::Text {
976 text,
977 meta: meta.and_then(wire_provider_meta_to_core).map(Box::new),
978 },
979 WireAssistantBlock::Transcript { text, source, meta } => Self::Transcript {
980 text,
981 source: TranscriptSource::try_from(source)?,
982 meta: meta.and_then(wire_provider_meta_to_core).map(Box::new),
983 },
984 WireAssistantBlock::Reasoning { text, meta } => Self::Reasoning {
985 text,
986 meta: meta.and_then(wire_provider_meta_to_core).map(Box::new),
987 },
988 WireAssistantBlock::ToolUse {
989 id,
990 name,
991 args,
992 meta,
993 } => Self::ToolUse {
994 id,
995 name,
996 args,
999 meta: meta.and_then(wire_provider_meta_to_core).map(Box::new),
1000 },
1001 WireAssistantBlock::ServerToolContent {
1002 id,
1003 kind,
1004 content,
1005 meta,
1006 } => Self::ServerToolContent {
1007 id,
1008 kind: ServerToolKind::try_from(kind)?,
1009 content,
1010 meta: meta.and_then(wire_provider_meta_to_core).map(Box::new),
1011 },
1012 WireAssistantBlock::Image {
1013 image_id,
1014 blob_ref,
1015 media_type,
1016 width,
1017 height,
1018 revised_prompt,
1019 meta,
1020 } => Self::Image {
1021 image_id,
1022 blob_ref,
1023 media_type,
1024 width,
1025 height,
1026 revised_prompt,
1027 meta,
1028 },
1029 WireAssistantBlock::Unknown => {
1030 return Err(crate::wire::error::WireConversionError::AssistantBlock {
1031 debug: "WireAssistantBlock::Unknown".to_string(),
1032 });
1033 }
1034 })
1035 }
1036}
1037
1038#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1040#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1041#[serde(rename_all = "snake_case")]
1042pub enum WireStopReason {
1043 #[default]
1044 EndTurn,
1045 ToolUse,
1046 MaxTokens,
1047 StopSequence,
1048 ContentFilter,
1049 Cancelled,
1050}
1051
1052impl From<StopReason> for WireStopReason {
1053 fn from(value: StopReason) -> Self {
1054 match value {
1055 StopReason::EndTurn => Self::EndTurn,
1056 StopReason::ToolUse => Self::ToolUse,
1057 StopReason::MaxTokens => Self::MaxTokens,
1058 StopReason::StopSequence => Self::StopSequence,
1059 StopReason::ContentFilter => Self::ContentFilter,
1060 StopReason::Cancelled => Self::Cancelled,
1061 }
1062 }
1063}
1064
1065impl From<WireStopReason> for StopReason {
1066 fn from(value: WireStopReason) -> Self {
1067 match value {
1068 WireStopReason::EndTurn => Self::EndTurn,
1069 WireStopReason::ToolUse => Self::ToolUse,
1070 WireStopReason::MaxTokens => Self::MaxTokens,
1071 WireStopReason::StopSequence => Self::StopSequence,
1072 WireStopReason::ContentFilter => Self::ContentFilter,
1073 WireStopReason::Cancelled => Self::Cancelled,
1074 }
1075 }
1076}
1077
1078#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1080#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1081pub struct WireToolResult {
1082 pub tool_use_id: String,
1083 pub content: WireToolResultContent,
1084 #[serde(default)]
1085 pub is_error: bool,
1086}
1087
1088fn transcript_message_timestamp(
1089 created_at: Option<String>,
1090) -> Result<meerkat_core::types::MessageTimestamp, crate::wire::error::WireConversionError> {
1091 match created_at {
1092 Some(value) => chrono::DateTime::parse_from_rfc3339(&value)
1093 .map(|timestamp| timestamp.with_timezone(&chrono::Utc))
1094 .map_err(
1095 |err| crate::wire::error::WireConversionError::TranscriptMessage {
1096 debug: format!("invalid created_at {value:?}: {err}"),
1097 },
1098 ),
1099 None => Ok(meerkat_core::types::message_timestamp_now()),
1100 }
1101}
1102
1103impl TranscriptRewriteMessage {
1104 pub fn into_core(self) -> Result<Message, crate::wire::error::WireConversionError> {
1105 match self {
1106 Self::System {
1107 content,
1108 created_at,
1109 } => Ok(Message::System(SystemMessage {
1110 content,
1111 mutation_kind: meerkat_core::types::SystemPromptMutationKind::default(),
1112 created_at: transcript_message_timestamp(created_at)?,
1113 })),
1114 Self::SystemNotice {
1115 kind,
1116 body,
1117 blocks,
1118 created_at,
1119 } => Ok(Message::SystemNotice(SystemNoticeMessage {
1120 kind,
1121 body,
1122 blocks,
1123 created_at: transcript_message_timestamp(created_at)?,
1124 })),
1125 Self::User {
1126 content,
1127 transcript_role,
1128 created_at,
1129 } => {
1130 if transcript_role.is_compaction_summary() {
1135 return Err(crate::wire::error::WireConversionError::TranscriptRole {
1136 debug: "compaction_summary".to_string(),
1137 });
1138 }
1139 let content = ContentInput::try_from(content)
1140 .map_err(
1141 |err| crate::wire::error::WireConversionError::TranscriptMessage {
1142 debug: err.to_string(),
1143 },
1144 )?
1145 .into_blocks();
1146 Ok(Message::User(UserMessage {
1147 content,
1148 render_metadata: None,
1149 identity: meerkat_core::types::TranscriptMessageIdentity::default(),
1150 transcript_role,
1151 created_at: transcript_message_timestamp(created_at)?,
1152 }))
1153 }
1154 Self::BlockAssistant {
1155 blocks,
1156 stop_reason,
1157 created_at,
1158 } => {
1159 let blocks = blocks
1160 .into_iter()
1161 .map(AssistantBlock::try_from)
1162 .collect::<Result<Vec<_>, _>>()?;
1163 Ok(Message::BlockAssistant(BlockAssistantMessage {
1164 blocks,
1165 stop_reason: stop_reason.into(),
1166 identity: meerkat_core::types::TranscriptMessageIdentity::default(),
1167 created_at: transcript_message_timestamp(created_at)?,
1168 }))
1169 }
1170 Self::ToolResults {
1171 results,
1172 created_at,
1173 } => {
1174 let results = results
1175 .into_iter()
1176 .map(|result| {
1177 let content = match result.content {
1178 WireToolResultContent::Text(text) => ContentBlock::text_vec(text),
1179 WireToolResultContent::Blocks(blocks) => blocks
1180 .into_iter()
1181 .map(ContentBlock::try_from)
1182 .collect::<Result<Vec<_>, _>>()
1183 .map_err(|err| {
1184 crate::wire::error::WireConversionError::TranscriptMessage {
1185 debug: err.to_string(),
1186 }
1187 })?,
1188 };
1189 Ok(ToolResult {
1190 tool_use_id: result.tool_use_id,
1191 content,
1192 is_error: result.is_error,
1193 })
1194 })
1195 .collect::<Result<Vec<_>, crate::wire::error::WireConversionError>>()?;
1196 Ok(Message::ToolResults {
1197 results,
1198 created_at: transcript_message_timestamp(created_at)?,
1199 })
1200 }
1201 }
1202 }
1203}
1204
1205#[derive(Debug, Clone, Serialize, Deserialize)]
1229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1230#[serde(tag = "type", rename_all = "snake_case")]
1231pub enum WireTranscriptReplacement {
1232 Message { message: TranscriptRewriteMessage },
1234 UserContentBlock {
1236 block_index: usize,
1237 block: WireContentBlock,
1238 },
1239 AssistantBlock {
1241 block_index: usize,
1242 block: WireAssistantBlock,
1243 },
1244 ToolResultContentBlock {
1246 result_index: usize,
1247 block_index: usize,
1248 block: WireContentBlock,
1249 },
1250}
1251
1252impl WireTranscriptReplacement {
1253 pub fn into_core(
1263 self,
1264 ) -> Result<TranscriptReplacement, crate::wire::error::WireConversionError> {
1265 match self {
1266 Self::Message { message } => Ok(TranscriptReplacement::Message {
1267 message: message.into_core()?,
1268 }),
1269 Self::UserContentBlock { block_index, block } => {
1270 Ok(TranscriptReplacement::UserContentBlock {
1271 block_index,
1272 block: ContentBlock::try_from(block).map_err(|err| {
1273 crate::wire::error::WireConversionError::TranscriptMessage {
1274 debug: format!("invalid user content block: {err}"),
1275 }
1276 })?,
1277 })
1278 }
1279 Self::AssistantBlock { block_index, block } => {
1280 Ok(TranscriptReplacement::AssistantBlock {
1281 block_index,
1282 block: AssistantBlock::try_from(block)?,
1283 })
1284 }
1285 Self::ToolResultContentBlock {
1286 result_index,
1287 block_index,
1288 block,
1289 } => Ok(TranscriptReplacement::ToolResultContentBlock {
1290 result_index,
1291 block_index,
1292 block: ContentBlock::try_from(block).map_err(|err| {
1293 crate::wire::error::WireConversionError::TranscriptMessage {
1294 debug: format!("invalid tool-result content block: {err}"),
1295 }
1296 })?,
1297 }),
1298 }
1299 }
1300}
1301
1302#[derive(Debug, Clone, Serialize, Deserialize)]
1308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1309#[serde(tag = "role", rename_all = "snake_case")]
1310pub enum WireSessionMessage {
1311 System {
1312 content: String,
1313 created_at: String,
1314 },
1315 SystemNotice {
1316 kind: SystemNoticeKind,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1318 body: Option<String>,
1319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1320 blocks: Vec<meerkat_core::SystemNoticeBlock>,
1321 created_at: String,
1322 },
1323 User {
1324 content: WireContentInput,
1325 #[serde(
1331 default,
1332 skip_serializing_if = "meerkat_core::types::TranscriptUserRole::is_conversational"
1333 )]
1334 transcript_role: meerkat_core::types::TranscriptUserRole,
1335 #[serde(default, skip_serializing_if = "Option::is_none")]
1336 interaction_id: Option<InteractionId>,
1337 #[serde(default, skip_serializing_if = "Option::is_none")]
1338 run_id: Option<RunId>,
1339 created_at: String,
1340 },
1341 #[serde(rename = "block_assistant")]
1342 BlockAssistant {
1343 blocks: Vec<WireAssistantBlock>,
1344 stop_reason: WireStopReason,
1345 #[serde(default, skip_serializing_if = "Option::is_none")]
1346 interaction_id: Option<InteractionId>,
1347 #[serde(default, skip_serializing_if = "Option::is_none")]
1348 run_id: Option<RunId>,
1349 created_at: String,
1350 },
1351 #[serde(rename = "tool_results")]
1352 ToolResults {
1353 results: Vec<WireToolResult>,
1354 created_at: String,
1355 },
1356}
1357
1358impl From<Message> for WireSessionMessage {
1359 fn from(value: Message) -> Self {
1360 match value {
1361 Message::System(message) => Self::System {
1362 content: message.content,
1363 created_at: message.created_at.to_rfc3339(),
1364 },
1365 Message::SystemNotice(message) => Self::SystemNotice {
1366 kind: message.kind,
1367 body: message.body,
1368 blocks: message.blocks,
1369 created_at: message.created_at.to_rfc3339(),
1370 },
1371 Message::User(message) => {
1372 let created_at = message.created_at.to_rfc3339();
1373 let content = if message.content.len() == 1
1374 && matches!(&message.content[0], ContentBlock::Text { .. })
1375 {
1376 WireContentInput::Text(message.text_content())
1377 } else {
1378 WireContentInput::Blocks(message.content.into_iter().map(Into::into).collect())
1379 };
1380 Self::User {
1381 content,
1382 transcript_role: message.transcript_role,
1383 interaction_id: message.identity.interaction_id,
1384 run_id: message.identity.run_id,
1385 created_at,
1386 }
1387 }
1388 Message::BlockAssistant(message) => Self::BlockAssistant {
1389 blocks: message.blocks.into_iter().map(Into::into).collect(),
1390 stop_reason: message.stop_reason.into(),
1391 interaction_id: message.identity.interaction_id,
1392 run_id: message.identity.run_id,
1393 created_at: message.created_at.to_rfc3339(),
1394 },
1395 Message::ToolResults {
1396 results,
1397 created_at,
1398 } => Self::ToolResults {
1399 results: results
1400 .into_iter()
1401 .map(|result| {
1402 let content = if result.content.len() == 1
1403 && matches!(&result.content[0], ContentBlock::Text { .. })
1404 {
1405 WireToolResultContent::Text(result.text_content())
1406 } else {
1407 WireToolResultContent::Blocks(
1408 result.content.into_iter().map(Into::into).collect(),
1409 )
1410 };
1411 WireToolResult {
1412 tool_use_id: result.tool_use_id,
1413 content,
1414 is_error: result.is_error,
1415 }
1416 })
1417 .collect(),
1418 created_at: created_at.to_rfc3339(),
1419 },
1420 }
1421 }
1422}
1423
1424#[derive(Debug, Clone, Serialize, Deserialize)]
1430#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1431pub struct WireSessionHistory {
1432 #[cfg_attr(feature = "schema", schemars(with = "String"))]
1433 pub session_id: SessionId,
1434 #[serde(skip_serializing_if = "Option::is_none")]
1435 pub session_ref: Option<String>,
1436 pub message_count: usize,
1437 pub offset: usize,
1438 #[serde(skip_serializing_if = "Option::is_none")]
1439 pub limit: Option<usize>,
1440 pub has_more: bool,
1441 pub messages: Vec<WireSessionMessage>,
1442}
1443
1444impl From<SessionHistoryPage> for WireSessionHistory {
1445 fn from(page: SessionHistoryPage) -> Self {
1446 Self {
1447 session_id: page.session_id,
1448 session_ref: None,
1449 message_count: page.message_count,
1450 offset: page.offset,
1451 limit: page.limit,
1452 has_more: page.has_more,
1453 messages: page.messages.into_iter().map(Into::into).collect(),
1454 }
1455 }
1456}
1457
1458#[derive(Debug, Clone, Serialize, Deserialize)]
1460#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1461pub struct WireSessionTranscriptRevision {
1462 #[cfg_attr(feature = "schema", schemars(with = "String"))]
1463 pub session_id: SessionId,
1464 #[serde(skip_serializing_if = "Option::is_none")]
1465 pub session_ref: Option<String>,
1466 pub revision: String,
1467 pub head_revision: String,
1468 pub message_count: usize,
1469 pub offset: usize,
1470 #[serde(skip_serializing_if = "Option::is_none")]
1471 pub limit: Option<usize>,
1472 pub has_more: bool,
1473 pub messages: Vec<WireSessionMessage>,
1474}
1475
1476impl From<SessionTranscriptRevisionPage> for WireSessionTranscriptRevision {
1477 fn from(page: SessionTranscriptRevisionPage) -> Self {
1478 Self {
1479 session_id: page.session_id,
1480 session_ref: None,
1481 revision: page.revision,
1482 head_revision: page.head_revision,
1483 message_count: page.message_count,
1484 offset: page.offset,
1485 limit: page.limit,
1486 has_more: page.has_more,
1487 messages: page.messages.into_iter().map(Into::into).collect(),
1488 }
1489 }
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1494#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1495pub struct WireSessionTranscriptRevisionEntry {
1496 pub revision: String,
1497 pub parent_revision: String,
1498 #[serde(default, skip_serializing_if = "Option::is_none")]
1499 pub actor: Option<String>,
1500 pub reason: String,
1502 pub committed_at: u64,
1504}
1505
1506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1509#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1510pub struct WireSessionTranscriptRevisionList {
1511 pub head_revision: String,
1512 pub entries: Vec<WireSessionTranscriptRevisionEntry>,
1513}
1514
1515impl From<SessionTranscriptRevisionList> for WireSessionTranscriptRevisionList {
1516 fn from(list: SessionTranscriptRevisionList) -> Self {
1517 Self {
1518 head_revision: list.head_revision,
1519 entries: list
1520 .entries
1521 .into_iter()
1522 .map(|entry| WireSessionTranscriptRevisionEntry {
1523 revision: entry.revision,
1524 parent_revision: entry.parent_revision,
1525 actor: entry.actor,
1526 reason: entry.reason,
1527 committed_at: entry
1528 .committed_at
1529 .duration_since(meerkat_core::time_compat::UNIX_EPOCH)
1530 .map(|d| d.as_secs())
1531 .unwrap_or(0),
1532 })
1533 .collect(),
1534 }
1535 }
1536}
1537
1538#[cfg(test)]
1539#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1540mod tests {
1541 use super::*;
1542 use meerkat_core::time_compat::SystemTime;
1543 use meerkat_core::{BlockAssistantMessage, Message, SystemMessage, UserMessage};
1544
1545 #[test]
1546 fn test_fork_session_params_roundtrip_typed_replacement() {
1547 let fork_at: ForkSessionAtParams = serde_json::from_value(serde_json::json!({
1548 "session_id": "session_123",
1549 "message_index": 2,
1550 "running_behavior": "reject"
1551 }))
1552 .unwrap();
1553 assert_eq!(fork_at.session_id, "session_123");
1554 assert_eq!(fork_at.message_index, 2);
1555 assert_eq!(
1556 fork_at.running_behavior,
1557 TranscriptEditRunningBehavior::Reject
1558 );
1559
1560 let fork_replace: ForkSessionReplaceParams = serde_json::from_value(serde_json::json!({
1561 "session_id": "session_123",
1562 "message_index": 2,
1563 "replacement": {
1564 "type": "message",
1565 "message": {
1566 "role": "user",
1567 "content": "edited follow up"
1568 }
1569 }
1570 }))
1571 .unwrap();
1572 assert!(matches!(
1575 &fork_replace.replacement,
1576 WireTranscriptReplacement::Message {
1577 message: TranscriptRewriteMessage::User { content, .. },
1578 } if matches!(content, WireContentInput::Text(text) if text == "edited follow up")
1579 ));
1580
1581 let core = fork_replace
1584 .replacement
1585 .clone()
1586 .into_core()
1587 .expect("typed wire replacement lowers into core");
1588 assert!(matches!(
1589 core,
1590 TranscriptReplacement::Message {
1591 message: Message::User(user),
1592 } if user.text_content() == "edited follow up"
1593 ));
1594
1595 let json = serde_json::to_value(&fork_replace).unwrap();
1596 assert_eq!(json["replacement"]["type"], "message");
1597 assert_eq!(json["running_behavior"], "reject");
1598 }
1599
1600 #[test]
1601 fn test_rewrite_session_transcript_params_roundtrip() {
1602 let params: RewriteSessionTranscriptParams = serde_json::from_value(serde_json::json!({
1603 "session_id": "session_123",
1604 "selection": {
1605 "type": "message_range",
1606 "start": 1,
1607 "end": 4
1608 },
1609 "replacement": [
1610 {
1611 "role": "block_assistant",
1612 "blocks": [
1613 {
1614 "block_type": "text",
1615 "data": {
1616 "text": "compacted trace"
1617 }
1618 }
1619 ],
1620 "stop_reason": "end_turn"
1621 }
1622 ],
1623 "reason": {
1624 "kind": "compaction",
1625 "note": "replace N-3 trace"
1626 },
1627 "actor": "test",
1628 "expected_parent_revision": "sha256:parent",
1629 "running_behavior": "reject"
1630 }))
1631 .unwrap();
1632 assert_eq!(params.session_id, "session_123");
1633 assert!(matches!(
1634 params.selection,
1635 TranscriptRewriteSelection::MessageRange { start: 1, end: 4 }
1636 ));
1637 assert_eq!(params.replacement.len(), 1);
1638 assert_eq!(params.reason.kind, "compaction");
1639
1640 let json = serde_json::to_value(¶ms).unwrap();
1641 assert_eq!(json["selection"]["type"], "message_range");
1642 assert_eq!(json["replacement"][0]["role"], "block_assistant");
1643 assert_eq!(json["running_behavior"], "reject");
1644 }
1645
1646 #[test]
1647 fn test_rewrite_session_transcript_rejects_legacy_assistant_role() {
1648 let err = serde_json::from_value::<RewriteSessionTranscriptParams>(serde_json::json!({
1651 "session_id": "session_123",
1652 "selection": {
1653 "type": "message_range",
1654 "start": 1,
1655 "end": 2
1656 },
1657 "replacement": [
1658 {
1659 "role": "assistant",
1660 "content": "Compacted assistant trace"
1661 }
1662 ],
1663 "reason": {
1664 "kind": "compaction"
1665 }
1666 }))
1667 .expect_err("legacy assistant rewrite role must be rejected");
1668 assert!(
1669 err.to_string().contains("assistant"),
1670 "rejection should name the unknown role: {err}"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_rewrite_session_transcript_accepts_public_system_notice() {
1676 let params: RewriteSessionTranscriptParams = serde_json::from_value(serde_json::json!({
1677 "session_id": "session_123",
1678 "selection": {
1679 "type": "message_range",
1680 "start": 0,
1681 "end": 1
1682 },
1683 "replacement": [
1684 {
1685 "role": "system_notice",
1686 "kind": "background_job",
1687 "body": "still running"
1688 }
1689 ],
1690 "reason": {
1691 "kind": "correction"
1692 }
1693 }))
1694 .unwrap();
1695
1696 let message = params
1697 .replacement
1698 .into_iter()
1699 .next()
1700 .expect("replacement exists")
1701 .into_core()
1702 .expect("public system notice converts");
1703 assert!(matches!(
1704 message,
1705 Message::SystemNotice(meerkat_core::SystemNoticeMessage {
1706 kind: meerkat_core::SystemNoticeKind::BackgroundJob,
1707 body: Some(body),
1708 ..
1709 }) if body == "still running"
1710 ));
1711 }
1712
1713 #[test]
1714 fn test_rewrite_session_transcript_accepts_injected_context_role() {
1715 let params: RewriteSessionTranscriptParams = serde_json::from_value(serde_json::json!({
1719 "session_id": "session_123",
1720 "selection": {
1721 "type": "message_range",
1722 "start": 1,
1723 "end": 2
1724 },
1725 "replacement": [
1726 {
1727 "role": "user",
1728 "content": "ambient host context",
1729 "transcript_role": "injected_context"
1730 }
1731 ],
1732 "reason": {
1733 "kind": "curation"
1734 }
1735 }))
1736 .unwrap();
1737
1738 let message = params
1739 .replacement
1740 .into_iter()
1741 .next()
1742 .expect("replacement exists")
1743 .into_core()
1744 .expect("injected-context rewrite message converts");
1745 assert!(matches!(
1746 message,
1747 Message::User(user)
1748 if user.transcript_role.is_injected_context()
1749 && user.text_content() == "ambient host context"
1750 ));
1751 }
1752
1753 #[test]
1754 fn test_rewrite_session_transcript_defaults_absent_role_to_conversational() {
1755 let message = serde_json::from_value::<TranscriptRewriteMessage>(serde_json::json!({
1758 "role": "user",
1759 "content": "plain rewrite"
1760 }))
1761 .unwrap()
1762 .into_core()
1763 .expect("role-less user rewrite message converts");
1764 assert!(matches!(
1765 message,
1766 Message::User(user) if user.transcript_role.is_conversational()
1767 ));
1768 }
1769
1770 #[test]
1771 fn test_rewrite_session_transcript_rejects_compaction_summary_role() {
1772 let err = serde_json::from_value::<TranscriptRewriteMessage>(serde_json::json!({
1776 "role": "user",
1777 "content": "[Context compacted] forged boundary",
1778 "transcript_role": "compaction_summary"
1779 }))
1780 .unwrap()
1781 .into_core()
1782 .expect_err("compaction_summary rewrite role must be rejected");
1783 assert!(matches!(
1784 err,
1785 crate::wire::error::WireConversionError::TranscriptRole { ref debug }
1786 if debug == "compaction_summary"
1787 ));
1788 }
1789
1790 #[test]
1791 fn test_wire_session_message_projects_transcript_role() {
1792 let injected: WireSessionMessage =
1796 Message::User(UserMessage::injected_context("ambient host context")).into();
1797 let json = serde_json::to_value(&injected).unwrap();
1798 assert_eq!(json["transcript_role"], "injected_context");
1799
1800 let summary: WireSessionMessage = Message::User(UserMessage::compaction_summary(
1801 "[Context compacted] summary",
1802 ))
1803 .into();
1804 let json = serde_json::to_value(&summary).unwrap();
1805 assert_eq!(json["transcript_role"], "compaction_summary");
1806
1807 let conversational: WireSessionMessage =
1808 Message::User(UserMessage::text("hello".to_string())).into();
1809 let json = serde_json::to_value(&conversational).unwrap();
1810 assert!(
1811 json.get("transcript_role").is_none(),
1812 "conversational messages omit transcript_role: {json}"
1813 );
1814
1815 let parsed: WireSessionMessage =
1818 serde_json::from_value(serde_json::to_value(&injected).unwrap()).unwrap();
1819 assert!(matches!(
1820 parsed,
1821 WireSessionMessage::User { transcript_role, .. }
1822 if transcript_role.is_injected_context()
1823 ));
1824 }
1825
1826 #[test]
1827 fn test_read_session_transcript_revision_params_roundtrip() {
1828 let params: ReadSessionTranscriptRevisionParams =
1829 serde_json::from_value(serde_json::json!({
1830 "session_id": "session_123",
1831 "revision": "sha256:parent",
1832 "offset": 1,
1833 "limit": 2
1834 }))
1835 .unwrap();
1836 assert_eq!(params.session_id, "session_123");
1837 assert_eq!(params.revision, "sha256:parent");
1838 assert_eq!(params.offset, Some(1));
1839 assert_eq!(params.limit, Some(2));
1840
1841 let page = SessionTranscriptRevisionPage::from_messages(
1842 SessionId::new(),
1843 "sha256:parent".to_string(),
1844 "sha256:head".to_string(),
1845 &[Message::User(UserMessage::with_blocks(vec![
1846 ContentBlock::Text {
1847 text: "recover me".to_string(),
1848 },
1849 ]))],
1850 0,
1851 None,
1852 );
1853 let wire: WireSessionTranscriptRevision = page.into();
1854 let encoded = serde_json::to_value(wire).unwrap();
1855 assert!(encoded["session_id"].as_str().is_some());
1856 assert_eq!(encoded["revision"], "sha256:parent");
1857 assert_eq!(encoded["head_revision"], "sha256:head");
1858 assert_eq!(encoded["messages"][0]["role"], "user");
1859 }
1860
1861 #[test]
1862 fn test_list_session_transcript_revisions_params_roundtrip() {
1863 let params: ListSessionTranscriptRevisionsParams =
1864 serde_json::from_value(serde_json::json!({
1865 "session_id": "session_123",
1866 "offset": 1,
1867 "limit": 2
1868 }))
1869 .unwrap();
1870 assert_eq!(params.session_id, "session_123");
1871 assert_eq!(params.offset, Some(1));
1872 assert_eq!(params.limit, Some(2));
1873
1874 let defaulted: ListSessionTranscriptRevisionsParams =
1875 serde_json::from_value(serde_json::json!({
1876 "session_id": "session_123"
1877 }))
1878 .unwrap();
1879 assert_eq!(defaulted.offset, None);
1880 assert_eq!(defaulted.limit, None);
1881
1882 let committed_at =
1883 meerkat_core::time_compat::UNIX_EPOCH + std::time::Duration::from_secs(1234);
1884 let list = SessionTranscriptRevisionList {
1885 entries: vec![meerkat_core::SessionTranscriptRevisionListEntry {
1886 revision: "sha256:child".to_string(),
1887 parent_revision: "sha256:parent".to_string(),
1888 actor: None,
1889 reason: "compaction".to_string(),
1890 committed_at,
1891 }],
1892 head_revision: "sha256:child".to_string(),
1893 };
1894 let wire: WireSessionTranscriptRevisionList = list.into();
1895 let encoded = serde_json::to_value(wire).unwrap();
1896 assert_eq!(encoded["head_revision"], "sha256:child");
1897 assert_eq!(encoded["entries"][0]["revision"], "sha256:child");
1898 assert_eq!(encoded["entries"][0]["parent_revision"], "sha256:parent");
1899 assert_eq!(encoded["entries"][0]["reason"], "compaction");
1900 assert_eq!(encoded["entries"][0]["committed_at"], 1234);
1901 assert!(
1902 encoded["entries"][0].get("actor").is_none(),
1903 "absent actor must be omitted from the wire encoding"
1904 );
1905 }
1906
1907 #[test]
1908 fn test_restore_session_transcript_revision_params_roundtrip() {
1909 let params: RestoreSessionTranscriptRevisionParams =
1910 serde_json::from_value(serde_json::json!({
1911 "session_id": "session_123",
1912 "revision": "sha256:parent",
1913 "reason": {
1914 "kind": "restore",
1915 "note": "recover prior truth"
1916 },
1917 "actor": "test",
1918 "expected_parent_revision": "sha256:current",
1919 "running_behavior": "reject"
1920 }))
1921 .unwrap();
1922 assert_eq!(params.session_id, "session_123");
1923 assert_eq!(params.revision, "sha256:parent");
1924 assert_eq!(params.reason.kind, "restore");
1925 assert_eq!(params.actor.as_deref(), Some("test"));
1926 assert_eq!(
1927 params.expected_parent_revision.as_deref(),
1928 Some("sha256:current")
1929 );
1930 assert_eq!(
1931 params.running_behavior,
1932 TranscriptEditRunningBehavior::Reject
1933 );
1934 }
1935
1936 #[test]
1937 fn test_wire_session_summary_labels_roundtrip() {
1938 let mut labels = BTreeMap::new();
1939 labels.insert("env".to_string(), "prod".to_string());
1940 labels.insert("team".to_string(), "infra".to_string());
1941
1942 let wire = WireSessionSummary {
1943 session_id: SessionId::new(),
1944 session_ref: None,
1945 created_at: 1000,
1946 updated_at: 2000,
1947 message_count: 5,
1948 total_tokens: 100,
1949 is_active: true,
1950 labels: labels.clone(),
1951 };
1952 let json = serde_json::to_string(&wire).unwrap();
1953 let parsed: WireSessionSummary = serde_json::from_str(&json).unwrap();
1954 assert_eq!(parsed.labels, labels);
1955 }
1956
1957 #[test]
1958 fn test_wire_session_summary_empty_labels_omitted() {
1959 let wire = WireSessionSummary {
1960 session_id: SessionId::new(),
1961 session_ref: None,
1962 created_at: 1000,
1963 updated_at: 2000,
1964 message_count: 0,
1965 total_tokens: 0,
1966 is_active: false,
1967 labels: BTreeMap::new(),
1968 };
1969 let json = serde_json::to_string(&wire).unwrap();
1970 assert!(
1971 !json.contains("\"labels\""),
1972 "empty labels should be omitted from JSON"
1973 );
1974 }
1975
1976 #[test]
1977 fn test_wire_session_info_labels_roundtrip() {
1978 let mut labels = BTreeMap::new();
1979 labels.insert("role".to_string(), "orchestrator".to_string());
1980
1981 let wire = WireSessionInfo {
1982 session_id: SessionId::new(),
1983 session_ref: None,
1984 created_at: 1000,
1985 updated_at: 2000,
1986 message_count: 3,
1987 is_active: true,
1988 model: "claude-sonnet-4-5".to_string(),
1989 provider: "anthropic".to_string(),
1990 last_assistant_text: None,
1991 resolved_capabilities: None,
1992 labels: labels.clone(),
1993 };
1994 let json = serde_json::to_string(&wire).unwrap();
1995 let parsed: WireSessionInfo = serde_json::from_str(&json).unwrap();
1996 assert_eq!(parsed.labels, labels);
1997 }
1998
1999 #[test]
2000 fn test_wire_session_info_resolved_capabilities_roundtrip() {
2001 let capabilities = crate::wire::WireResolvedModelCapabilities {
2002 vision: true,
2003 image_input: true,
2004 image_tool_results: true,
2005 inline_video: false,
2006 realtime: true,
2007 web_search: true,
2008 image_generation: true,
2009 };
2010 let wire = WireSessionInfo {
2011 session_id: SessionId::new(),
2012 session_ref: None,
2013 created_at: 1000,
2014 updated_at: 2000,
2015 message_count: 3,
2016 is_active: true,
2017 model: "gpt-realtime-2".to_string(),
2018 provider: "openai".to_string(),
2019 last_assistant_text: None,
2020 labels: BTreeMap::new(),
2021 resolved_capabilities: Some(capabilities.clone()),
2022 };
2023
2024 let json = serde_json::to_string(&wire).unwrap();
2025 assert!(json.contains("\"resolved_capabilities\""));
2026 let parsed: WireSessionInfo = serde_json::from_str(&json).unwrap();
2027 assert_eq!(parsed.resolved_capabilities, Some(capabilities));
2028 }
2029
2030 #[test]
2031 fn test_wire_session_info_from_session_info_maps_labels() {
2032 let mut labels = BTreeMap::new();
2033 labels.insert("env".to_string(), "staging".to_string());
2034
2035 let info = SessionInfo {
2036 session_id: SessionId::new(),
2037 created_at: SystemTime::now(),
2038 updated_at: SystemTime::now(),
2039 message_count: 2,
2040 is_active: false,
2041 model: "claude-sonnet-4-5".to_string(),
2042 provider: meerkat_core::Provider::Anthropic,
2043 last_assistant_text: Some("hello".to_string()),
2044 labels: labels.clone(),
2045 };
2046 let wire: WireSessionInfo = info.into();
2047 assert_eq!(wire.labels, labels);
2048 }
2049
2050 #[test]
2051 fn test_wire_session_summary_from_session_summary_maps_labels() {
2052 let mut labels = BTreeMap::new();
2053 labels.insert("project".to_string(), "meerkat".to_string());
2054
2055 let summary = SessionSummary {
2056 session_id: SessionId::new(),
2057 created_at: SystemTime::now(),
2058 updated_at: SystemTime::now(),
2059 message_count: 10,
2060 total_tokens: 500,
2061 is_active: true,
2062 labels: labels.clone(),
2063 };
2064 let wire: WireSessionSummary = summary.into();
2065 assert_eq!(wire.labels, labels);
2066 }
2067
2068 #[test]
2069 fn test_wire_session_info_backward_compat_no_labels() {
2070 let json = r#"{
2071 "session_id": "019405c8-1234-7000-8000-000000000001",
2072 "created_at": 1000,
2073 "updated_at": 2000,
2074 "message_count": 0,
2075 "is_active": false,
2076 "model": "claude-sonnet-4-5",
2077 "provider": "anthropic"
2078 }"#;
2079 let parsed: WireSessionInfo = serde_json::from_str(json).unwrap();
2080 assert!(parsed.labels.is_empty());
2081 }
2082
2083 #[test]
2084 fn test_wire_session_summary_backward_compat_no_labels() {
2085 let json = r#"{
2086 "session_id": "019405c8-1234-7000-8000-000000000001",
2087 "created_at": 1000,
2088 "updated_at": 2000,
2089 "message_count": 0,
2090 "total_tokens": 0,
2091 "is_active": false
2092 }"#;
2093 let parsed: WireSessionSummary = serde_json::from_str(json).unwrap();
2094 assert!(parsed.labels.is_empty());
2095 }
2096
2097 #[test]
2098 fn test_wire_session_history_roundtrip_mixed_messages() {
2099 let history = WireSessionHistory {
2100 session_id: SessionId::new(),
2101 session_ref: Some("session://example".to_string()),
2102 message_count: 4,
2103 offset: 0,
2104 limit: Some(4),
2105 has_more: false,
2106 messages: vec![
2107 WireSessionMessage::System {
2108 content: "You are helpful".to_string(),
2109 created_at: "2026-04-27T00:00:00Z".to_string(),
2110 },
2111 WireSessionMessage::User {
2112 content: WireContentInput::Text("hello".to_string()),
2113 transcript_role: meerkat_core::types::TranscriptUserRole::Conversational,
2114 interaction_id: None,
2115 run_id: None,
2116 created_at: "2026-04-27T00:00:01Z".to_string(),
2117 },
2118 WireSessionMessage::BlockAssistant {
2119 blocks: vec![
2120 WireAssistantBlock::Reasoning {
2121 text: "thinking".to_string(),
2122 meta: None,
2123 },
2124 WireAssistantBlock::ToolUse {
2125 id: "tool-1".to_string(),
2126 name: "search".to_string(),
2127 args: serde_json::value::RawValue::from_string(
2128 r#"{"q":"rust"}"#.to_string(),
2129 )
2130 .expect("fixture args literal is valid JSON"),
2131 meta: None,
2132 },
2133 WireAssistantBlock::Text {
2134 text: "done".to_string(),
2135 meta: None,
2136 },
2137 WireAssistantBlock::Transcript {
2140 text: "spoken hi".to_string(),
2141 source: WireTranscriptSource::Spoken,
2142 meta: None,
2143 },
2144 ],
2145 stop_reason: WireStopReason::EndTurn,
2146 interaction_id: None,
2147 run_id: None,
2148 created_at: "2026-04-27T00:00:03Z".to_string(),
2149 },
2150 WireSessionMessage::ToolResults {
2151 results: vec![WireToolResult {
2152 tool_use_id: "tool-1".to_string(),
2153 content: WireToolResultContent::Text("ok".to_string()),
2154 is_error: false,
2155 }],
2156 created_at: "2026-04-27T00:00:04Z".to_string(),
2157 },
2158 ],
2159 };
2160 let json = serde_json::to_string(&history).unwrap();
2161 let parsed: WireSessionHistory = serde_json::from_str(&json).unwrap();
2162 let reserialized = serde_json::to_string(&parsed).unwrap();
2166 assert_eq!(reserialized, json);
2167 }
2168
2169 #[test]
2170 fn test_wire_session_history_from_page_maps_messages() {
2171 let page = SessionHistoryPage {
2172 session_id: SessionId::new(),
2173 message_count: 3,
2174 offset: 0,
2175 limit: None,
2176 has_more: false,
2177 messages: vec![
2178 Message::System(SystemMessage::new("sys")),
2179 Message::SystemNotice(meerkat_core::SystemNoticeMessage::new(
2180 meerkat_core::SystemNoticeKind::BackgroundJob,
2181 "still running",
2182 )),
2183 Message::BlockAssistant(BlockAssistantMessage::new(
2184 vec![
2185 AssistantBlock::Text {
2186 text: "hello".to_string(),
2187 meta: None,
2188 },
2189 AssistantBlock::ToolUse {
2190 id: "call-1".to_string(),
2191 name: "search".to_string(),
2192 args: serde_json::value::RawValue::from_string(
2193 r#"{"q":"meerkat"}"#.to_string(),
2194 )
2195 .expect("fixture args literal is valid JSON"),
2196 meta: None,
2197 },
2198 ],
2199 StopReason::ToolUse,
2200 )),
2201 ],
2202 };
2203 let wire: WireSessionHistory = page.into();
2204 assert_eq!(wire.messages.len(), 3);
2205 assert!(matches!(
2206 wire.messages[0],
2207 WireSessionMessage::System { .. }
2208 ));
2209 assert!(matches!(
2210 wire.messages[1],
2211 WireSessionMessage::SystemNotice { .. }
2212 ));
2213 assert!(matches!(
2214 wire.messages[2],
2215 WireSessionMessage::BlockAssistant { .. }
2216 ));
2217 }
2218
2219 #[test]
2220 fn test_wire_session_history_exposes_transcript_identity() {
2221 let interaction_id = InteractionId(uuid::Uuid::from_u128(7));
2222 let run_id = RunId::from_uuid(uuid::Uuid::from_u128(8));
2223 let mut user = UserMessage::text("hello");
2224 user.identity = meerkat_core::types::TranscriptMessageIdentity {
2225 interaction_id: Some(interaction_id),
2226 run_id: None,
2227 };
2228 let mut assistant = BlockAssistantMessage::new(
2229 vec![AssistantBlock::Text {
2230 text: "Still here.".to_string(),
2231 meta: None,
2232 }],
2233 StopReason::EndTurn,
2234 );
2235 assistant.identity = meerkat_core::types::TranscriptMessageIdentity {
2236 interaction_id: Some(interaction_id),
2237 run_id: Some(run_id.clone()),
2238 };
2239 let page = SessionHistoryPage {
2240 session_id: SessionId::new(),
2241 message_count: 2,
2242 offset: 0,
2243 limit: None,
2244 has_more: false,
2245 messages: vec![Message::User(user), Message::BlockAssistant(assistant)],
2246 };
2247
2248 let wire: WireSessionHistory = page.into();
2249
2250 match &wire.messages[0] {
2251 WireSessionMessage::User {
2252 interaction_id: Some(actual),
2253 run_id: None,
2254 ..
2255 } => assert_eq!(*actual, interaction_id),
2256 other => panic!("expected user identity, got {other:?}"),
2257 }
2258 match &wire.messages[1] {
2259 WireSessionMessage::BlockAssistant {
2260 interaction_id: Some(actual_interaction),
2261 run_id: Some(actual_run),
2262 ..
2263 } => {
2264 assert_eq!(*actual_interaction, interaction_id);
2265 assert_eq!(actual_run, &run_id);
2266 }
2267 other => panic!("expected assistant identity, got {other:?}"),
2268 }
2269 }
2270
2271 #[test]
2272 fn test_wire_session_history_from_page_maps_block_assistant_and_tool_results() {
2273 let page = SessionHistoryPage {
2274 session_id: SessionId::new(),
2275 message_count: 2,
2276 offset: 0,
2277 limit: Some(2),
2278 has_more: false,
2279 messages: vec![
2280 Message::BlockAssistant(BlockAssistantMessage::new(
2281 vec![AssistantBlock::Text {
2282 text: "hi".to_string(),
2283 meta: None,
2284 }],
2285 StopReason::EndTurn,
2286 )),
2287 Message::tool_results(vec![meerkat_core::ToolResult::new(
2288 "tool-2".to_string(),
2289 "done".to_string(),
2290 false,
2291 )]),
2292 ],
2293 };
2294 let wire: WireSessionHistory = page.into();
2295 assert!(matches!(
2296 wire.messages[0],
2297 WireSessionMessage::BlockAssistant { .. }
2298 ));
2299 assert!(matches!(
2300 wire.messages[1],
2301 WireSessionMessage::ToolResults { .. }
2302 ));
2303 }
2304
2305 #[test]
2306 fn test_wire_content_block_text_roundtrip() {
2307 let block = WireContentBlock::Text {
2308 text: "hello".to_string(),
2309 };
2310 let json = serde_json::to_string(&block).unwrap();
2311 let parsed: WireContentBlock = serde_json::from_str(&json).unwrap();
2312 assert_eq!(parsed, block);
2313 }
2314
2315 #[test]
2316 fn test_wire_content_block_image_roundtrip() {
2317 let block = WireContentBlock::Image {
2318 media_type: "image/png".to_string(),
2319 data: "iVBOR...".into(),
2320 };
2321 let json = serde_json::to_string(&block).unwrap();
2322 let parsed: WireContentBlock = serde_json::from_str(&json).unwrap();
2323 assert_eq!(parsed, block);
2324 }
2325
2326 #[test]
2327 fn test_wire_content_block_video_roundtrip() {
2328 let block = WireContentBlock::Video {
2329 media_type: "video/mp4".to_string(),
2330 duration_ms: 12_000,
2331 data: "AAAA".into(),
2332 };
2333 let json = serde_json::to_string(&block).unwrap();
2334 let parsed: WireContentBlock = serde_json::from_str(&json).unwrap();
2335 assert_eq!(parsed, block);
2336 }
2337
2338 #[test]
2339 fn test_wire_content_block_video_uri_roundtrip() {
2340 let block = WireContentBlock::Video {
2341 media_type: "video/mp4".to_string(),
2342 duration_ms: 12_000,
2343 data: WireVideoData::Uri {
2344 uri: "https://example.com/timeline.mp4".to_string(),
2345 },
2346 };
2347 let json = serde_json::to_string(&block).unwrap();
2348 let parsed: WireContentBlock = serde_json::from_str(&json).unwrap();
2349 assert_eq!(parsed, block);
2350 }
2351
2352 #[test]
2353 fn test_wire_content_block_unknown_forward_compat() {
2354 let json = r#"{"type":"hologram","url":"https://example.com/v.mp4"}"#;
2355 let parsed: WireContentBlock = serde_json::from_str(json).unwrap();
2356 assert_eq!(parsed, WireContentBlock::Unknown);
2357 }
2358
2359 #[test]
2360 fn test_wire_content_block_from_core_strips_source_path() {
2361 let core_block = ContentBlock::Image {
2362 media_type: "image/jpeg".to_string(),
2363 data: "base64data".into(),
2364 };
2365 let wire: WireContentBlock = core_block.into();
2366 assert_eq!(
2367 wire,
2368 WireContentBlock::Image {
2369 media_type: "image/jpeg".to_string(),
2370 data: "base64data".into(),
2371 }
2372 );
2373 }
2374
2375 #[test]
2376 fn test_wire_content_block_from_core_video_roundtrip() {
2377 let core_block = ContentBlock::Video {
2378 media_type: "video/mp4".to_string(),
2379 duration_ms: 12_000,
2380 data: VideoData::Inline {
2381 data: "base64video".to_string(),
2382 },
2383 };
2384 let wire: WireContentBlock = core_block.clone().into();
2385 assert_eq!(
2386 wire,
2387 WireContentBlock::Video {
2388 media_type: "video/mp4".to_string(),
2389 duration_ms: 12_000,
2390 data: "base64video".into(),
2391 }
2392 );
2393 let restored = ContentBlock::try_from(wire).unwrap();
2394 assert_eq!(restored, core_block);
2395 }
2396
2397 #[test]
2398 fn test_wire_content_block_from_core_video_uri_roundtrip() {
2399 let core_block = ContentBlock::Video {
2400 media_type: "video/mp4".to_string(),
2401 duration_ms: 12_000,
2402 data: VideoData::Uri {
2403 uri: "gs://bucket/object.mp4".to_string(),
2404 },
2405 };
2406 let wire: WireContentBlock = core_block.clone().into();
2407 assert_eq!(
2408 wire,
2409 WireContentBlock::Video {
2410 media_type: "video/mp4".to_string(),
2411 duration_ms: 12_000,
2412 data: WireVideoData::Uri {
2413 uri: "gs://bucket/object.mp4".to_string(),
2414 },
2415 }
2416 );
2417 let restored = ContentBlock::try_from(wire).unwrap();
2418 assert_eq!(restored, core_block);
2419 }
2420
2421 #[test]
2422 fn test_wire_content_input_text_roundtrip() {
2423 let input = WireContentInput::Text("hello world".to_string());
2424 let json = serde_json::to_string(&input).unwrap();
2425 assert_eq!(json, r#""hello world""#);
2426 let parsed: WireContentInput = serde_json::from_str(&json).unwrap();
2427 assert_eq!(parsed, input);
2428 }
2429
2430 #[test]
2431 fn test_wire_content_input_blocks_roundtrip() {
2432 let input = WireContentInput::Blocks(vec![
2433 WireContentBlock::Text {
2434 text: "look at this".to_string(),
2435 },
2436 WireContentBlock::Image {
2437 media_type: "image/png".to_string(),
2438 data: "abc123".into(),
2439 },
2440 ]);
2441 let json = serde_json::to_string(&input).unwrap();
2442 let parsed: WireContentInput = serde_json::from_str(&json).unwrap();
2443 assert_eq!(parsed, input);
2444 }
2445
2446 #[test]
2447 fn test_wire_tool_result_content_text_roundtrip() {
2448 let content = WireToolResultContent::Text("result text".to_string());
2449 let json = serde_json::to_string(&content).unwrap();
2450 assert_eq!(json, r#""result text""#);
2451 let parsed: WireToolResultContent = serde_json::from_str(&json).unwrap();
2452 assert_eq!(parsed, content);
2453 }
2454
2455 #[test]
2456 fn test_wire_tool_result_content_blocks_roundtrip() {
2457 let content = WireToolResultContent::Blocks(vec![
2458 WireContentBlock::Text {
2459 text: "output".to_string(),
2460 },
2461 WireContentBlock::Image {
2462 media_type: "image/png".to_string(),
2463 data: "data".into(),
2464 },
2465 ]);
2466 let json = serde_json::to_string(&content).unwrap();
2467 let parsed: WireToolResultContent = serde_json::from_str(&json).unwrap();
2468 assert_eq!(parsed, content);
2469 }
2470
2471 #[test]
2472 fn test_wire_tool_result_backward_compat_string() {
2473 let json = r#"{"tool_use_id":"t1","content":"hello","is_error":false}"#;
2474 let parsed: WireToolResult = serde_json::from_str(json).unwrap();
2475 assert_eq!(
2476 parsed.content,
2477 WireToolResultContent::Text("hello".to_string())
2478 );
2479 }
2480
2481 #[test]
2482 fn test_wire_user_message_text_backward_compat() {
2483 let json = r#"{"role":"user","content":"hello","created_at":"2026-04-27T00:00:00Z"}"#;
2484 let parsed: WireSessionMessage = serde_json::from_str(json).unwrap();
2485 match parsed {
2486 WireSessionMessage::User { content, .. } => {
2487 assert_eq!(content, WireContentInput::Text("hello".to_string()));
2488 }
2489 _ => panic!("expected User message"),
2490 }
2491 }
2492
2493 #[test]
2494 fn test_wire_user_message_blocks() {
2495 let json = r#"{"role":"user","content":[{"type":"text","text":"look"},{"type":"image","media_type":"image/png","source":"inline","data":"abc"}],"created_at":"2026-04-27T00:00:00Z"}"#;
2496 let parsed: WireSessionMessage = serde_json::from_str(json).unwrap();
2497 match parsed {
2498 WireSessionMessage::User { content, .. } => {
2499 assert_eq!(
2500 content,
2501 WireContentInput::Blocks(vec![
2502 WireContentBlock::Text {
2503 text: "look".to_string()
2504 },
2505 WireContentBlock::Image {
2506 media_type: "image/png".to_string(),
2507 data: "abc".into()
2508 },
2509 ])
2510 );
2511 }
2512 _ => panic!("expected User message"),
2513 }
2514 }
2515
2516 #[test]
2517 fn test_wire_user_message_from_multimodal_core() {
2518 let page = SessionHistoryPage {
2519 session_id: SessionId::new(),
2520 message_count: 1,
2521 offset: 0,
2522 limit: None,
2523 has_more: false,
2524 messages: vec![Message::User(UserMessage::with_blocks(vec![
2525 ContentBlock::Text {
2526 text: "describe this".to_string(),
2527 },
2528 ContentBlock::Image {
2529 media_type: "image/png".to_string(),
2530 data: "base64data".into(),
2531 },
2532 ]))],
2533 };
2534 let wire: WireSessionHistory = page.into();
2535 match &wire.messages[0] {
2536 WireSessionMessage::User { content, .. } => {
2537 assert_eq!(
2538 *content,
2539 WireContentInput::Blocks(vec![
2540 WireContentBlock::Text {
2541 text: "describe this".to_string()
2542 },
2543 WireContentBlock::Image {
2544 media_type: "image/png".to_string(),
2545 data: "base64data".into()
2546 },
2547 ])
2548 );
2549 }
2550 _ => panic!("expected User message"),
2551 }
2552 }
2553
2554 #[test]
2555 fn test_wire_tool_result_from_multimodal_core() {
2556 let page = SessionHistoryPage {
2557 session_id: SessionId::new(),
2558 message_count: 1,
2559 offset: 0,
2560 limit: None,
2561 has_more: false,
2562 messages: vec![Message::tool_results(vec![
2563 meerkat_core::ToolResult::with_blocks(
2564 "tool-1".to_string(),
2565 vec![
2566 ContentBlock::Text {
2567 text: "screenshot:".to_string(),
2568 },
2569 ContentBlock::Image {
2570 media_type: "image/png".to_string(),
2571 data: "imgdata".into(),
2572 },
2573 ],
2574 false,
2575 ),
2576 ])],
2577 };
2578 let wire: WireSessionHistory = page.into();
2579 match &wire.messages[0] {
2580 WireSessionMessage::ToolResults { results, .. } => {
2581 assert_eq!(
2582 results[0].content,
2583 WireToolResultContent::Blocks(vec![
2584 WireContentBlock::Text {
2585 text: "screenshot:".to_string()
2586 },
2587 WireContentBlock::Image {
2588 media_type: "image/png".to_string(),
2589 data: "imgdata".into()
2590 },
2591 ])
2592 );
2593 }
2594 _ => panic!("expected ToolResults message"),
2595 }
2596 }
2597
2598 #[test]
2604 fn test_assistant_block_core_wire_core_round_trip_symmetric() {
2605 use meerkat_core::{
2606 AssistantImageId, BlobId, BlobRef, MediaType, ProviderImageMetadata,
2607 RevisedPromptDisposition,
2608 };
2609 use uuid::Uuid;
2610
2611 let cases: Vec<AssistantBlock> = vec![
2612 AssistantBlock::Text {
2613 text: "display lane".to_string(),
2614 meta: Some(Box::new(ProviderMeta::Anthropic {
2615 signature: "sig-1".to_string(),
2616 })),
2617 },
2618 AssistantBlock::Reasoning {
2619 text: "thinking".to_string(),
2620 meta: Some(Box::new(ProviderMeta::OpenAi {
2621 id: "rs_1".to_string(),
2622 encrypted_content: Some("enc".to_string()),
2623 phase: Some("draft".to_string()),
2624 response_id: None,
2625 })),
2626 },
2627 AssistantBlock::Transcript {
2628 text: "spoken".to_string(),
2629 source: TranscriptSource::Spoken,
2630 meta: Some(Box::new(ProviderMeta::Gemini {
2631 thought_signature: "ts-1".to_string(),
2632 })),
2633 },
2634 AssistantBlock::ToolUse {
2635 id: "call-1".to_string(),
2636 name: "search".to_string(),
2637 args: serde_json::value::RawValue::from_string(r#"{"q":"rust"}"#.to_string())
2638 .expect("fixture args literal is valid JSON"),
2639 meta: None,
2640 },
2641 AssistantBlock::ServerToolContent {
2642 id: Some("st-1".to_string()),
2643 kind: ServerToolKind::WebSearch,
2644 content: serde_json::json!({"hits": 3}),
2645 meta: None,
2646 },
2647 AssistantBlock::Image {
2648 image_id: AssistantImageId::new(Uuid::nil()),
2649 blob_ref: BlobRef {
2650 blob_id: BlobId::new("blob-fixture"),
2651 media_type: "image/png".to_string(),
2652 },
2653 media_type: MediaType::new("image/png"),
2654 width: 64,
2655 height: 64,
2656 revised_prompt: RevisedPromptDisposition::NotRequested,
2657 meta: ProviderImageMetadata::NotEmitted,
2658 },
2659 ];
2660
2661 for case in cases {
2662 let snapshot = format!("{case:?}");
2663 let wire: WireAssistantBlock = case.clone().into();
2664 let back: AssistantBlock =
2665 AssistantBlock::try_from(wire).expect("typed wire variants round-trip");
2666 assert_eq!(case, back, "round trip lost data for {snapshot}");
2669 }
2670 }
2671
2672 #[test]
2679 fn unknown_transcript_source_does_not_become_spoken() {
2680 let wire = WireTranscriptSource::Unknown {
2681 debug: "future_lane".to_string(),
2682 };
2683 let json = serde_json::to_value(&wire).unwrap();
2684 assert_eq!(json["kind"], "unknown");
2685 let parsed: WireTranscriptSource = serde_json::from_value(json).unwrap();
2686 assert!(matches!(parsed, WireTranscriptSource::Unknown { ref debug }
2687 if debug == "future_lane"));
2688 assert!(!matches!(parsed, WireTranscriptSource::Spoken));
2689 }
2690
2691 #[test]
2695 fn wire_to_core_transcript_source_unknown_returns_typed_error() {
2696 let wire = WireTranscriptSource::Unknown {
2697 debug: "FutureSpokenSource".to_string(),
2698 };
2699 let err = TranscriptSource::try_from(wire).unwrap_err();
2700 assert!(matches!(
2701 err,
2702 crate::wire::error::WireConversionError::TranscriptSource { ref debug }
2703 if debug == "FutureSpokenSource"
2704 ));
2705 }
2706
2707 #[test]
2710 fn known_transcript_sources_round_trip() {
2711 let wire: WireTranscriptSource = TranscriptSource::Spoken.into();
2712 assert!(matches!(wire, WireTranscriptSource::Spoken));
2713 let back = TranscriptSource::try_from(wire).unwrap();
2714 assert!(matches!(back, TranscriptSource::Spoken));
2715 }
2716
2717 #[test]
2723 fn wire_to_core_assistant_block_unknown_returns_typed_error() {
2724 let wire = WireAssistantBlock::Unknown;
2725 let err = AssistantBlock::try_from(wire).unwrap_err();
2726 assert!(
2727 matches!(
2728 err,
2729 crate::wire::error::WireConversionError::AssistantBlock { ref debug }
2730 if debug == "WireAssistantBlock::Unknown"
2731 ),
2732 "expected WireConversionError::AssistantBlock, got {err:?}"
2733 );
2734 }
2735}