Skip to main content

meerkat_contracts/wire/
session.rs

1//! Wire session types.
2
3use 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/// Request payload for `session/stream_open`.
40#[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/// Response payload for `session/stream_open`.
48#[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/// Request payload for `session/stream_close`.
57#[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/// Response payload for `session/stream_close`.
65#[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/// Request payload for `session/fork_at`.
74#[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/// Request payload for `session/fork_replace`.
85///
86/// `replacement` rides as the typed [`WireTranscriptReplacement`] mirror so
87/// the emitted JSON schema is the closed replacement enum rather than a bare
88/// `serde_json::Value`. The consuming surface lowers it into the core
89/// [`TranscriptReplacement`] via [`WireTranscriptReplacement::into_core`].
90#[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/// Public transcript message accepted by same-session rewrite APIs.
102#[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        /// Typed transcript role for the user channel. Hosts may declare
123        /// `conversational` (the omitted default) or `injected_context` so a
124        /// rewrite preserves the typed indexing exemption instead of
125        /// laundering injected context back to conversation;
126        /// `compaction_summary` is runtime-mintable only and is rejected
127        /// fail-closed by [`TranscriptRewriteMessage::into_core`].
128        #[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/// Request payload for `session/rewrite_transcript`.
153#[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/// Request payload for `session/restore_transcript_revision`.
170#[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/// Request payload for `session/transcript_revision`.
186#[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/// Request payload for `session/transcript_revisions`.
199#[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/// Canonical content-addressed transcript revision identity (e.g. a
211/// `sha256:...` digest). A newtype over the wire string so revision identity is
212/// never confused with the `"current"` head sentinel.
213#[derive(Debug, Clone, PartialEq, Eq, Hash)]
214pub struct RevisionId(pub String);
215
216impl RevisionId {
217    /// The literal revision string.
218    pub fn as_str(&self) -> &str {
219        &self.0
220    }
221
222    /// Consume into the owned revision string.
223    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
234/// Wire sentinel that selects the session head revision.
235const REVISION_SELECTOR_CURRENT: &str = "current";
236
237/// Typed selector for a transcript revision lookup. The wire carries a bare
238/// string where `"current"` is an undocumented sentinel meaning "resolve to the
239/// session head". Parsing that string into this enum at the consumer boundary
240/// replaces the `revision == "current"` string oracle with a closed type.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum RevisionSelector {
243    /// Resolve to the current session head revision.
244    Current,
245    /// Use this literal content-addressed revision.
246    Specific(RevisionId),
247}
248
249impl RevisionSelector {
250    /// Parse the wire revision string into a typed selector. The `"current"`
251    /// sentinel selects the head; anything else is a specific revision id.
252    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/// Canonical session info for wire protocol.
263#[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/// Canonical session summary for wire protocol.
311#[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/// Provider continuity metadata for transcript blocks.
351#[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/// Wire projection of `meerkat_core::ServerToolKind`.
411///
412/// Mirrors the typed semantic owner of a provider-executed tool. The core enum
413/// is `#[non_exhaustive]`; a future semantic kind that lacks an explicit arm in
414/// the forward `From` surfaces as `Unknown { debug }` rather than silently
415/// collapsing into a plausible-but-wrong kind. SDK consumers route on the
416/// `kind` discriminator and treat `unknown` as unrecognized.
417///
418/// **When a new core variant is added, add an explicit arm in the forward
419/// `From` impl above the wildcard — `Unknown` is the floor, not the
420/// destination.**
421#[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            // Core enum is `#[non_exhaustive]`. Surface unknown semantic kinds
438            // explicitly rather than coercing to a plausible-but-wrong kind.
439            // **When a new core variant is added, add an explicit arm above.**
440            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/// Wire-safe content block (no `source_path` — internal only).
512#[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 JSON tool output, materialized as inline JSON for SDK
531    /// consumers (the core side keeps it as opaque `Box<RawValue>`).
532    Structured {
533        data: serde_json::Value,
534    },
535    /// Forward-compatibility for unknown block types.
536    #[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                // Materialize the opaque `Box<RawValue>` into a wire JSON value.
565                // The payload is valid JSON by construction; preserve the raw
566                // bytes losslessly on the unreachable parse-failure path rather
567                // than fabricating a success-shaped null.
568                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/// Wire-safe content input (mirrors `ContentInput`).
611#[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/// Discriminated (externally tagged) prompt-input wire shape.
647///
648/// Unlike [`WireContentInput`] (untagged, for transcripts that already know
649/// their shape), this is the ingress contract for content-bearing surface
650/// exports: `{"text": "..."}` or `{"blocks": [...]}`. The tag is the
651/// discriminator — a plain-text prompt whose body happens to be valid
652/// block-array JSON can never be misread as structured blocks (K19).
653#[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/// Non-error outcome of a user interrupt request.
678#[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    /// A live in-flight turn was cancelled.
683    Interrupted,
684    /// The session was staged with no live run — the interrupt is a typed
685    /// no-op terminal, not a fabricated live cancellation.
686    StagedNoop,
687}
688
689/// Shared interrupt result wire contract (REST and RPC).
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct InterruptResult {
693    pub session_id: String,
694    /// `true` only when a live turn was actually cancelled.
695    pub interrupted: bool,
696    pub result: WireInterruptOutcome,
697}
698
699impl InterruptResult {
700    /// Project the typed interrupt outcome into the wire result.
701    #[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/// Wire-safe tool result content that handles both legacy string and array formats.
712#[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/// Transcript block inside a block-assistant message.
721///
722/// Not `PartialEq`: `ToolUse.args` is `Box<RawValue>` for pass-through
723/// fidelity (core invariant — opaque from provider to dispatcher), and
724/// `RawValue` does not derive equality. Equivalence checks should
725/// round-trip through serialization and compare the serialized bytes.
726#[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    /// Spoken-transcript output (provider audio output → text). Distinct
736    /// from `Text` so callers can render or filter by lane provenance.
737    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        /// Wire-projected tool-call args. Carries the opaque JSON payload
753        /// as `Box<RawValue>` to mirror the core `AssistantBlock::ToolUse`
754        /// invariant ("tool-call args pass through un-parsed from provider
755        /// to dispatcher") — see `CLAUDE.md` Rust design principles §3.
756        /// Allow-listed by dogma-blind-spots §7.
757        #[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/// Wire projection of `meerkat_core::TranscriptSource`. Lane provenance
788/// for spoken-transcript blocks.
789///
790/// R7-4 (P3 dogma): the previous shape used a wildcard arm in
791/// `From<TranscriptSource>` that fell through to `Spoken`, silently
792/// misattributing future core variants as user-spoken transcript. The
793/// fix mirrors the live-wire pattern from R5-3 / R6-5 — future core
794/// variants surface as `Unknown { debug }` (a fail-loud sentinel), and
795/// the reverse direction is `TryFrom` returning
796/// `WireConversionError::TranscriptSource { debug }` for the `Unknown`
797/// case rather than fabricating a typed `Spoken`.
798#[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    /// R7-4 (P3 dogma): explicit fail-loud variant for unknown core
805    /// variants. The core [`TranscriptSource`] enum is `#[non_exhaustive]`;
806    /// when a future variant lands without an explicit arm in the wire
807    /// `From` impl, the conversion surfaces as `Unknown { debug }` rather
808    /// than silently coercing into `Spoken` — a "plausible lie" that would
809    /// mark a non-spoken provenance as user-voice on the wire. SDK
810    /// consumers route on the `kind: "unknown"` discriminator and treat
811    /// it as unrecognized — never as `Spoken`.
812    ///
813    /// **When a new core variant is added, add an explicit arm in the
814    /// forward `From` impl above this variant — `Unknown` is the floor,
815    /// not the destination.**
816    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            // Core enum is `#[non_exhaustive]`. R7-4 (P3 dogma): surface
826            // unknown variants explicitly via `Unknown { debug }` rather
827            // than silently coercing to `Spoken`. **When a new core
828            // variant is added, add an explicit arm above this comment.**
829            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                // Pass-through: no re-parse, no silent `Value::Null`
874                // downgrade on malformed bytes. Core invariant — tool-call
875                // args are opaque from provider to dispatcher and this
876                // wire type preserves that invariant.
877                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
913/// CC1 (FIX-A): inverse of `From<ProviderMeta> for WireProviderMeta`.
914///
915/// Returns `None` for `WireProviderMeta::Unknown` (the wire-side sink for
916/// future provider variants we don't yet recognize). Caller should drop
917/// the meta in that case rather than fabricate a typed shape. Returns
918/// `Some(...)` for every typed variant — the conversion is lossless for
919/// the four typed cases.
920fn 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
950/// CC1 (FIX-A) / R7-5 (P3 dogma): inverse of
951/// `From<AssistantBlock> for WireAssistantBlock`.
952///
953/// Mirrors the forward direction arm-for-arm. `WireAssistantBlock::Unknown`
954/// is the wire-side sink for future core variants we don't yet recognize;
955/// the inverse cannot fabricate a typed `AssistantBlock` from it.
956///
957/// R7-4 (P3 dogma) promoted this from `From` to `TryFrom` so the inner
958/// `WireTranscriptSource::Unknown` can propagate via `?` instead of
959/// silently fabricating a `Spoken` provenance.
960///
961/// R7-5 (P3 dogma) replaced the previous fabrication of an empty
962/// `AssistantBlock::Text { "" }` for the `Unknown` arm with a typed
963/// [`WireConversionError::AssistantBlock`] error. SDK consumers and
964/// upstream callers now see a typed conversion failure rather than a
965/// zero-length text block silently injected into the canonical
966/// transcript.
967///
968/// Pre-1.0 dogma: when a new `AssistantBlock` variant lands, add an
969/// explicit arm both here and in the forward direction.
970impl 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                // Pass-through: opaque from provider to dispatcher; mirrors
997                // the forward direction's preservation of `Box<RawValue>`.
998                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/// Canonical stop reason for transcript messages.
1039#[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/// Tool result payload in a transcript.
1079#[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                // Rewrite ingress may only declare host-mintable roles.
1131                // `CompactionSummary` is the runtime-authority marker the
1132                // transcript-continuity save-guard trusts — admitting it here
1133                // would let hosts forge a compaction boundary. Fail closed.
1134                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/// Typed wire mirror of [`meerkat_core::TranscriptReplacement`].
1206///
1207/// R-#87 (P3 dogma): `ForkSessionReplaceParams.replacement` previously carried
1208/// the core `TranscriptReplacement` directly with a
1209/// `#[schemars(with = "serde_json::Value")]` override, so the emitted JSON
1210/// schema collapsed to a bare `Value` (artifacts/schemas/params.json) and the
1211/// SDK codegen saw an untyped bag. The core enum cannot derive `JsonSchema`
1212/// directly: it embeds `Message` and `AssistantBlock`, neither of which has a
1213/// `JsonSchema` impl in `meerkat-core` (and `Message::BlockAssistant` carries
1214/// `Box<RawValue>` tool-call args).
1215///
1216/// This wire mirror is schema-emittable because it is built entirely from
1217/// types that already carry `JsonSchema` derives in this module:
1218/// [`TranscriptRewriteMessage`] (the public message shape, with its own
1219/// `into_core`), [`WireContentBlock`], and [`WireAssistantBlock`]. Conversion
1220/// into the core enum is fallible — every inner conversion already returns a
1221/// typed [`WireConversionError`](crate::wire::error::WireConversionError), so
1222/// malformed payloads surface a typed parse fault at the boundary rather than
1223/// being smuggled through as an opaque `Value`.
1224///
1225/// The serde tag/rename mirror the core enum (`#[serde(tag = "type",
1226/// rename_all = "snake_case")]`) so the wire bytes are unchanged: the only
1227/// observable delta is that the schema is now the closed enum.
1228#[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    /// Replace the addressed message with a full canonical message.
1233    Message { message: TranscriptRewriteMessage },
1234    /// Replace one user-message content block.
1235    UserContentBlock {
1236        block_index: usize,
1237        block: WireContentBlock,
1238    },
1239    /// Replace one block in a block-assistant message.
1240    AssistantBlock {
1241        block_index: usize,
1242        block: WireAssistantBlock,
1243    },
1244    /// Replace one content block inside one tool-result payload.
1245    ToolResultContentBlock {
1246        result_index: usize,
1247        block_index: usize,
1248        block: WireContentBlock,
1249    },
1250}
1251
1252impl WireTranscriptReplacement {
1253    /// Lower the typed wire replacement into the core
1254    /// [`TranscriptReplacement`].
1255    ///
1256    /// Each inner conversion is fallible and surfaces a typed
1257    /// [`WireConversionError`](crate::wire::error::WireConversionError):
1258    /// `Message` delegates to [`TranscriptRewriteMessage::into_core`];
1259    /// `ContentBlock`/`AssistantBlock` conversions wrap their `&'static str` /
1260    /// typed failures into [`WireConversionError::TranscriptMessage`] so the
1261    /// surface sees one closed error type.
1262    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/// Canonical transcript message for public wire surfaces.
1303///
1304/// Not `PartialEq`: the `BlockAssistant.blocks` variant carries
1305/// `WireAssistantBlock`s which hold opaque tool-call args as
1306/// `Box<RawValue>`. See `WireAssistantBlock` doc.
1307#[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        /// Typed transcript role for the user channel — projection of the
1326        /// core owner (`UserMessage.transcript_role`). Omitted for ordinary
1327        /// `conversational` messages; `compaction_summary` and
1328        /// `injected_context` surface so hosts (e.g. transcript curators)
1329        /// read the typed fact instead of classifying rendered content.
1330        #[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/// Full session history in canonical wire format.
1425///
1426/// Not `PartialEq`: `messages` carries `WireSessionMessage` which
1427/// transitively holds opaque tool-call args; compare via serialization
1428/// round-trip rather than field equality.
1429#[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/// Full transcript revision body in canonical wire format.
1459#[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/// One transcript rewrite commit in canonical wire format.
1493#[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    /// Display projection of the typed rewrite reason.
1501    pub reason: String,
1502    /// Commit timestamp as seconds since the Unix epoch.
1503    pub committed_at: u64,
1504}
1505
1506/// Ordered (oldest-first) transcript revision commit list in canonical wire
1507/// format.
1508#[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        // The wire field is now the typed `WireTranscriptReplacement` mirror
1573        // (so the schema is the closed enum, not a bare Value).
1574        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        // Lowering into the core enum is typed and fallible; the user
1582        // message round-trips into a core `Message::User`.
1583        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(&params).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        // The text-shaped `role: "assistant"` rewrite message was folded into
1649        // `block_assistant`; the legacy wire form must fail closed at parse.
1650        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        // Hosts (e.g. transcript curators) may preserve the typed
1716        // injected-context role through a rewrite instead of laundering it
1717        // back to `conversational` and re-poisoning the memory index.
1718        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        // Absent `transcript_role` stays the conversational default — the
1756        // pre-existing rewrite wire shape is unchanged.
1757        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        // `compaction_summary` is the runtime-authority marker trusted by the
1773        // transcript-continuity save-guard; rewrite ingress must never mint
1774        // it. Fail-closed typed rejection, not silent role coercion.
1775        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        // Egress projection of the typed core owner: `injected_context` and
1793        // `compaction_summary` surface on the wire, `conversational` omits
1794        // the field entirely.
1795        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        // Round-trip: the projected role deserializes back to the typed
1816        // wire variant field.
1817        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                        // T3-extension (FIX-A): pin spoken-transcript
2138                        // round-trip alongside Text + Reasoning.
2139                        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        // `WireSessionHistory` is not `PartialEq` post-C-2 because
2163        // tool-call args ride as `Box<RawValue>`. Round-trip via JSON
2164        // string equivalence is the canonical wire comparison.
2165        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    /// CC1 (FIX-A): assert `From<AssistantBlock> for WireAssistantBlock`
2599    /// is symmetric with `From<WireAssistantBlock> for AssistantBlock`
2600    /// for every typed variant. Round-trip core → wire → core must
2601    /// preserve `Text`, `Reasoning`, `Transcript` (with `meta`),
2602    /// `ToolUse` (opaque args), `ServerToolContent`, and `Image`.
2603    #[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            // `AssistantBlock` is `PartialEq` (custom impl handles
2667            // `ToolUse.args` via byte-equivalence on the underlying JSON).
2668            assert_eq!(case, back, "round trip lost data for {snapshot}");
2669        }
2670    }
2671
2672    /// R7-4 (P3 dogma): the wire enum must carry an explicit `Unknown`
2673    /// variant so future core variants surface as fail-loud sentinels
2674    /// rather than silently coercing into `Spoken`. This test pins the
2675    /// shape: `Unknown { debug }` exists, round-trips through JSON with
2676    /// the `kind: "unknown"` discriminator, and is *not* equal to
2677    /// `Spoken` after a round-trip.
2678    #[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    /// R7-4 (P3 dogma): the reverse direction `Wire -> core` returns a
2692    /// typed error for `Unknown`, never silently fabricating a typed
2693    /// `Spoken` value.
2694    #[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    /// R7-4 (P3 dogma): the typed `Unknown` variant must not poison
2708    /// known-variant round-trips.
2709    #[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    /// R7-5 (P3 dogma): the reverse `WireAssistantBlock::Unknown -> core`
2718    /// path must surface a typed [`WireConversionError::AssistantBlock`]
2719    /// rather than fabricating an empty `AssistantBlock::Text { "" }`.
2720    /// Closes the silent zero-length-text injection regression that the
2721    /// previous `From` impl exhibited for unknown future wire variants.
2722    #[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}