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}