Skip to main content

supercode_harness/schema/
mod.rs

1//! Typed schemas for the on-disk session formats.
2//!
3//! The loaders in [`crate::session`] used to pluck fields out of untyped
4//! `serde_json::Value`s, which meant anything we didn't explicitly look for was
5//! silently lost — and *invisible*. These modules give each format a typed
6//! representation instead, with two deliberate escape hatches that turn "what
7//! are we missing?" into a mechanical question:
8//!
9//! - every discriminated enum has a `#[serde(other)]` `Unknown` variant, so an
10//!   unmodeled record/block/payload **type** deserializes into a named bucket
11//!   instead of erroring or vanishing; and
12//! - every record struct carries a flattened `extra: ExtraFields` map, so an
13//!   unmodeled **field** is captured rather than dropped.
14//!
15//! The [`crate::audit`] module walks a corpus, deserializes into these types,
16//! and reports every `Unknown` and every non-empty `extra` — an evidence-based
17//! map of exactly where our coverage ends.
18
19pub mod claude_code;
20pub mod codex;
21
22use std::collections::BTreeMap;
23
24use serde::Deserialize;
25
26/// Catch-all for object fields a struct does not explicitly model.
27///
28/// `#[serde(flatten)]` this into any record struct: fields the struct names are
29/// consumed normally, and everything else lands here where the audit can see
30/// it. An empty map means we model the record fully.
31pub type ExtraFields = BTreeMap<String, serde_json::Value>;
32
33/// A content block that may carry text, used by both formats' message bodies.
34/// Unknown block types are preserved by tag in [`ContentBlock::Unknown`].
35#[derive(Debug, Clone, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum ContentBlock {
38    /// Plain text.
39    Text {
40        /// The text.
41        #[serde(default)]
42        text: String,
43    },
44    /// Anthropic reasoning block (not replayable across providers).
45    Thinking {
46        /// The (possibly empty) thinking text.
47        #[serde(default)]
48        thinking: String,
49    },
50    /// Anthropic redacted reasoning.
51    RedactedThinking {
52        /// Opaque payload.
53        #[serde(default)]
54        data: Option<String>,
55    },
56    /// An assistant tool call (Anthropic shape).
57    ToolUse {
58        /// Provider call id.
59        id: String,
60        /// Tool name.
61        name: String,
62        /// Arguments object.
63        #[serde(default)]
64        input: serde_json::Value,
65    },
66    /// A tool result (Anthropic shape).
67    ToolResult {
68        /// The id of the tool_use this answers.
69        #[serde(default)]
70        tool_use_id: Option<String>,
71        /// String or array-of-blocks content.
72        #[serde(default)]
73        content: serde_json::Value,
74    },
75    /// An image block (multimodal).
76    Image {
77        /// The image source descriptor.
78        #[serde(default)]
79        source: serde_json::Value,
80    },
81    /// Codex input text block.
82    InputText {
83        /// The text.
84        #[serde(default)]
85        text: String,
86    },
87    /// Codex output text block.
88    OutputText {
89        /// The text.
90        #[serde(default)]
91        text: String,
92    },
93    /// Codex input image block.
94    InputImage {
95        /// Opaque image reference.
96        #[serde(flatten)]
97        extra: ExtraFields,
98    },
99    /// PARITY-11 (provenance P011): a Claude provider-routing note —
100    /// real shape `{"type":"fallback","from":{"model":..},"to":{"model":..}}`,
101    /// a mid-generation model swap (e.g. an overloaded model falling back to
102    /// another). `session.rs`'s loader folds it into a short bracketed text
103    /// marker rather than dropping it.
104    Fallback {
105        /// Anything (`from`/`to` model descriptors).
106        #[serde(flatten)]
107        extra: ExtraFields,
108    },
109    /// Any block type we do not model yet. The tag is recovered via
110    /// `ContentBlock::unknown_tag` from the captured fields.
111    #[serde(other)]
112    Unknown,
113}
114
115impl ContentBlock {
116    /// Returns the static discriminant name for a modeled block, or `None` for
117    /// [`ContentBlock::Unknown`].
118    pub fn tag(&self) -> Option<&'static str> {
119        Some(match self {
120            ContentBlock::Text { .. } => "text",
121            ContentBlock::Thinking { .. } => "thinking",
122            ContentBlock::RedactedThinking { .. } => "redacted_thinking",
123            ContentBlock::ToolUse { .. } => "tool_use",
124            ContentBlock::ToolResult { .. } => "tool_result",
125            ContentBlock::Image { .. } => "image",
126            ContentBlock::InputText { .. } => "input_text",
127            ContentBlock::OutputText { .. } => "output_text",
128            ContentBlock::InputImage { .. } => "input_image",
129            ContentBlock::Fallback { .. } => "fallback",
130            ContentBlock::Unknown => return None,
131        })
132    }
133
134    /// Best-effort flattened text from a block (text-bearing variants only).
135    pub fn as_text(&self) -> Option<&str> {
136        match self {
137            ContentBlock::Text { text }
138            | ContentBlock::InputText { text }
139            | ContentBlock::OutputText { text } => Some(text),
140            _ => None,
141        }
142    }
143}
144
145/// Recover the `type` discriminant of an arbitrary JSON content block, even one
146/// that deserialized as [`ContentBlock::Unknown`] — used by the audit to name
147/// the unknown.
148pub fn raw_block_tag(v: &serde_json::Value) -> Option<String> {
149    v.get("type")
150        .and_then(serde_json::Value::as_str)
151        .map(str::to_string)
152}