Skip to main content

supercode_interchange/session/
native.rs

1//! Supercode's own native/sidecar session container and its residue envelopes.
2
3use super::*;
4
5impl Session {
6    /// Serialize to the **supercode-native** lossless format: a header line
7    /// recording the original source, followed by every original JSONL line
8    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
9    /// schema and is necessarily lossy), this preserves *everything* — including
10    /// records with no canonical representation — so [`Self::from_native_str`]
11    /// reconstructs the session with full fidelity.
12    pub fn to_native_jsonl(&self) -> String {
13        let source = match self.meta.source {
14            SessionSource::ClaudeCode => "claude_code",
15            SessionSource::Codex => "codex",
16            SessionSource::Pi => "pi",
17            SessionSource::OpenCode => "opencode",
18            SessionSource::Grok => "grok",
19            SessionSource::Gemini => "gemini",
20            SessionSource::Goose => "goose",
21            SessionSource::OpenClaw => "openclaw",
22            SessionSource::Hermes => "hermes",
23            // P5-3 safety-hardening fix: a natively-spawned session must
24            // never be written to disk labeled as an imported CC session.
25            SessionSource::Native => "native",
26        };
27        let header = serde_json::json!({
28            "supercode_native": 1,
29            "source": source,
30            // IX-1: carries whether the ORIGINAL imported source text ended
31            // with a trailing newline — `from_native_str` needs this to
32            // reconstruct the exact source bytes (not just the `raw` line
33            // list) when re-parsing the body with the per-source loader.
34            "raw_trailing_newline": self.raw_trailing_newline,
35        })
36        .to_string();
37        let mut out =
38            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
39        out.push_str(&header);
40        out.push('\n');
41        for line in &self.raw {
42            out.push_str(line);
43            out.push('\n');
44        }
45        out
46    }
47
48    /// Serialize to the **supercode-native v2** format: the same imported-body
49    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
50    /// followed by every `Session.raw` line verbatim), plus one
51    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
52    /// produced after import, which have no backing `raw` line of their own.
53    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
54    /// serde), so nothing the live agent loop records is lost to disk.
55    ///
56    /// `appended` is caller-supplied rather than inferred from
57    /// `self.messages`: A1 doesn't track which of `self.messages` came from
58    /// import vs. the live loop — that bookkeeping belongs to the live writer
59    /// built on top of this (A2/A3).
60    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
61        self.to_native_jsonl_v2_with_timestamp(appended, None)
62    }
63
64    pub(crate) fn to_native_jsonl_v2_with_timestamp(
65        &self,
66        appended: &[ChatMessage],
67        fixed_timestamp: Option<&str>,
68    ) -> String {
69        let source = match self.meta.source {
70            SessionSource::ClaudeCode => "claude_code",
71            SessionSource::Codex => "codex",
72            SessionSource::Pi => "pi",
73            SessionSource::OpenCode => "opencode",
74            SessionSource::Grok => "grok",
75            SessionSource::Gemini => "gemini",
76            SessionSource::Goose => "goose",
77            SessionSource::OpenClaw => "openclaw",
78            SessionSource::Hermes => "hermes",
79            // P5-3 safety-hardening fix: a natively-spawned session must
80            // never be written to disk labeled as an imported CC session.
81            SessionSource::Native => "native",
82        };
83        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
84        // already parses CC sidechains + CX lineage on import"): a
85        // natively-spawned subagent's own `Session` carries its lineage on
86        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
87        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
88        // all, so a native-spawned child's lineage was lost the instant it
89        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
90        // (`skip_serializing_if`-equivalent via manual omission below) so a
91        // plain top-level session's header is byte-identical to before this
92        // change.
93        let mut header_obj = serde_json::json!({
94            "supercode_native": 2,
95            "source": source,
96            "session_id": self.meta.session_id,
97            "created": fixed_timestamp
98                .map(ToOwned::to_owned)
99                .unwrap_or_else(crate::sidecar::now_rfc3339),
100            // IX-1: see `to_native_jsonl`'s header field of the same name.
101            "raw_trailing_newline": self.raw_trailing_newline,
102        });
103        if let Some(obj) = header_obj.as_object_mut() {
104            if let Some(agent_id) = &self.meta.agent_id {
105                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
106            }
107            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
108                obj.insert(
109                    "parent_tool_use_id".to_string(),
110                    Value::String(parent_tool_use_id.clone()),
111                );
112            }
113            if !self.meta.lineage.is_empty() {
114                obj.insert(
115                    "lineage".to_string(),
116                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
117                );
118            }
119        }
120        let header = header_obj.to_string();
121        let mut out =
122            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
123        out.push_str(&header);
124        out.push('\n');
125        for line in &self.raw {
126            out.push_str(line);
127            out.push('\n');
128        }
129        for (turn_index, msg) in appended.iter().enumerate() {
130            let turn = match fixed_timestamp {
131                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
132                    msg,
133                    timestamp.to_string(),
134                    turn_index as u64,
135                ),
136                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
137                    msg,
138                    crate::sidecar::now_rfc3339(),
139                    turn_index as u64,
140                ),
141            };
142            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
143            out.push('\n');
144        }
145        out
146    }
147
148    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
149    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
150    /// exactly as before. A v2 file's appended `NativeTurn` records —
151    /// discriminated by the `supercode_turn` key, which never appears in a v1
152    /// body — are split out before the imported body is handed to the
153    /// per-source loader, then reattached in file order: to `messages` (via
154    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
155    /// so a v2 file round-trips byte-for-byte through
156    /// [`Self::to_native_jsonl_v2`] again.
157    pub fn from_native_str(jsonl: &str) -> Result<Session> {
158        // IX-1: the native WRAPPER's own lines are split verbatim (not via
159        // the blank-skipping `non_empty_lines`) so that any `raw` line it
160        // carries — which can itself be blank, CRLF-terminated, or
161        // whitespace-padded, now that raw-capture is strict-verbatim —
162        // survives being embedded in (and re-extracted from) this wrapper
163        // bit-for-bit. The wrapper we ourselves emit never has a blank line
164        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
165        // record per line), so this is a behavior-preserving switch for any
166        // native text this crate produced; it also makes a hand-fed/legacy
167        // native string tolerated exactly as `non_empty_lines` used to.
168        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
169        let mut lines = all_lines.into_iter();
170        let header = lines.next().unwrap_or("");
171        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
172        let source = hv.get("source").and_then(Value::as_str);
173        // IX-1: whether the ORIGINAL imported source (before it was wrapped
174        // in this native format) ended with a trailing newline — a property
175        // of the pre-wrap source, not of this wrapper (which always
176        // LF-terminates every line it writes, regardless). Missing on a
177        // native file written before IX-1 (or a hand-built header in an
178        // older test/sidecar) — default `true`, the historical
179        // always-newline-terminated assumption.
180        let raw_trailing_newline = hv
181            .get("raw_trailing_newline")
182            .and_then(Value::as_bool)
183            .unwrap_or(true);
184
185        // Split appended NativeTurn records (v2) out of the imported body. A
186        // v1 body never carries a `supercode_turn` key, so this is a no-op
187        // there — one code path serves both versions.
188        let mut body_lines: Vec<String> = Vec::new();
189        let mut turn_lines: Vec<&str> = Vec::new();
190        for line in lines {
191            let is_turn = serde_json::from_str::<Value>(line)
192                .ok()
193                .is_some_and(|v| v.get("supercode_turn").is_some());
194            if is_turn {
195                turn_lines.push(line);
196            } else {
197                body_lines.push(line.to_string());
198            }
199        }
200        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
201        // `body_lines.join("\n")` alone would silently gain a trailing
202        // newline the original source never had (or lose one it did have).
203        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
204
205        // The remaining lines are the original log; re-parse with the right loader.
206        let mut session = match source {
207            Some("codex") => Self::from_codex_str(&body)?,
208            Some("claude_code") => Self::from_claude_code_str(&body)?,
209            Some("pi") => Self::from_pi_str(&body)?,
210            Some("opencode") => Self::from_opencode_str(&body)?,
211            Some("grok") => Self::from_grok_str(&body)?,
212            Some("gemini") => Self::from_gemini_str(&body)?,
213            Some("goose") => Self::from_goose_str(&body)?,
214            Some("openclaw") => Self::from_openclaw_str(&body)?,
215            // P5-3 safety-hardening fix: a natively-spawned session's body
216            // is always empty (it never had any foreign-tool prefix to
217            // begin with — see `SessionSource::Native`'s doc comment), so
218            // any loader would parse it identically; `from_claude_code_str`
219            // is reused purely as a blank-skeleton builder (empty
220            // `raw`/`messages`), then its `meta.source` is corrected to
221            // `Native` — never left mislabeled as `ClaudeCode`.
222            Some("native") => {
223                let mut s = Self::from_claude_code_str(&body)?;
224                s.meta.source = SessionSource::Native;
225                s
226            }
227            // No/unknown header — auto-detect the body.
228            _ => match detect_source(&body) {
229                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
230                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
231                Some(SessionSource::OpenClaw) => Self::from_openclaw_str(&body)?,
232                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
233                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
234                Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
235                Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
236                _ => Self::from_claude_code_str(&body)?,
237            },
238        };
239
240        for line in turn_lines {
241            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
242                Ok(turn) => {
243                    session.raw.push(line.to_string());
244                    session.messages.push(turn.into_message());
245                }
246                Err(_) => {
247                    // A valid JSON object carrying the native-turn
248                    // discriminator belongs to this wrapper, not to the
249                    // imported body. If its required fields are malformed,
250                    // count it as parse loss so every fail-loud caller can
251                    // refuse continuation instead of silently dropping a
252                    // native history record. Keep the rejected source line
253                    // in `raw` as well: diagnostics must count it in their
254                    // denominator, and even corrupt input must not disappear
255                    // merely because it reached the parser.
256                    session.raw.push(line.to_string());
257                    session.parse_error_lines += 1;
258                }
259            }
260        }
261
262        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
263        // header block): recover a natively-spawned subagent's own lineage
264        // from the v2 header, when present. Overlays (rather than merges
265        // into) whatever the per-source body loader may have already set on
266        // `session.meta` — these three keys are ONLY ever written by
267        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
268        // header that carries them is authoritative for a file this crate
269        // produced.
270        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
271            session.meta.agent_id = Some(agent_id.to_string());
272        }
273        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
274            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
275        }
276        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
277            for (k, v) in lineage {
278                if let Some(s) = v.as_str() {
279                    session.meta.lineage.insert(k.clone(), s.to_string());
280                }
281            }
282        }
283
284        Ok(session)
285    }
286
287    /// The full-fidelity [`Session`] a sidecar denotes.
288    ///
289    /// The sidecar (native-v2 format, D1) is the imported body plus every
290    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
291    /// tolerant lower-level native parser, this persisted-store entry point
292    /// validates its framing header before loading anything: a missing,
293    /// malformed, or unsupported header must never become a zero-message
294    /// session that callers could continue as if it were complete.
295    pub fn from_sidecar_str(s: &str) -> Result<Session> {
296        let header = s.lines().next().ok_or_else(|| {
297            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
298        })?;
299        let value: Value = serde_json::from_str(header).map_err(|error| {
300            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
301        })?;
302        let version = value.get("supercode_native").and_then(Value::as_u64);
303        if !matches!(version, Some(1 | 2)) {
304            return Err(Error::InvalidSession(
305                "sidecar header must declare supported `supercode_native` version 1 or 2"
306                    .to_string(),
307            ));
308        }
309        let source = value.get("source").and_then(Value::as_str);
310        if !matches!(
311            source,
312            Some(
313                "native"
314                    | "claude_code"
315                    | "codex"
316                    | "gemini"
317                    | "goose"
318                    | "opencode"
319                    | "pi"
320                    | "grok"
321            )
322        ) {
323            return Err(Error::InvalidSession(
324                "sidecar header must declare a supported `source`".to_string(),
325            ));
326        }
327        Self::from_native_str(s)
328    }
329}
330
331pub(super) fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
332    if !line
333        .as_bytes()
334        .windows(6)
335        .any(|window| window == b"\"user\"")
336    {
337        return false;
338    }
339    let Ok(value) = serde_json::from_str::<Value>(line) else {
340        return false;
341    };
342    match source {
343        Some(SessionSource::Codex) => {
344            value.get("type").and_then(Value::as_str) == Some("response_item")
345                && value
346                    .get("payload")
347                    .and_then(|payload| payload.get("type"))
348                    .and_then(Value::as_str)
349                    == Some("message")
350                && value
351                    .get("payload")
352                    .and_then(|payload| payload.get("role"))
353                    .and_then(Value::as_str)
354                    == Some("user")
355        }
356        Some(SessionSource::ClaudeCode) => {
357            value.get("type").and_then(Value::as_str) == Some("user")
358                && value
359                    .get("message")
360                    .and_then(|message| message.get("content"))
361                    .is_some_and(|content| match content {
362                        Value::String(text) => !text.trim().is_empty(),
363                        Value::Array(parts) => parts.iter().any(|part| {
364                            part.get("type").and_then(Value::as_str) == Some("text")
365                                && part
366                                    .get("text")
367                                    .and_then(Value::as_str)
368                                    .is_some_and(|text| !text.trim().is_empty())
369                        }),
370                        _ => false,
371                    })
372        }
373        Some(SessionSource::Gemini) => {
374            value.get("type").and_then(Value::as_str) == Some("user")
375                && value.get("content").is_some_and(|content| match content {
376                    Value::String(text) => !text.trim().is_empty(),
377                    Value::Array(parts) => parts.iter().any(|part| {
378                        part.get("text")
379                            .and_then(Value::as_str)
380                            .is_some_and(|text| !text.trim().is_empty())
381                    }),
382                    _ => false,
383                })
384        }
385        _ => false,
386    }
387}
388
389pub(super) fn capture_native_residue(
390    meta: &mut SessionMeta,
391    source: &str,
392    record_index: usize,
393    raw_line: &str,
394    record: &Value,
395    kind: &str,
396) {
397    // A record that IS a restored envelope carrier never re-captures itself.
398    if record.get(SUPERCODE_NATIVE_RESIDUE_KEY).is_some()
399        || record.get(SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY).is_some()
400        || record.get(SUPERCODE_CODEX_PROVENANCE_KEY).is_some()
401    {
402        return;
403    }
404    // A transcript that RESTORED a foreign envelope holds residue in transit
405    // back to its own source; local capture never clobbers it (the local
406    // records still survive this format's own raw diagonal).
407    if meta
408        .native_residue_source
409        .as_deref()
410        .is_some_and(|existing| existing != source)
411    {
412        return;
413    }
414    meta.native_residue.push(serde_json::json!({
415        "record_index": record_index,
416        "kind": kind,
417        "raw": raw_line,
418    }));
419    meta.native_residue_source = Some(source.to_string());
420}
421
422/// PARITY-23: the GENERALIZED portable residue key (envelope v2). One key
423/// for every source format; `_supercode_codex_provenance` (v1) stays
424/// readable forever for artifacts written before the generalization.
425pub(super) const SUPERCODE_NATIVE_RESIDUE_KEY: &str = "_supercode_native_residue";
426
427/// PARITY-23 dev/05: the residue TOMBSTONE — a tiny always-embedded summary
428/// (`{version, source, kinds, records, records_sha256}`) that survives when
429/// a caller deliberately strips the heavy records key to shed weight. A
430/// summary without its records makes the deletion DETECTABLE: load succeeds
431/// and reports exactly which source-native metadata can no longer be
432/// restored, instead of the loss being silent.
433pub(super) const SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY: &str = "_supercode_native_residue_summary";
434
435pub(super) fn native_residue_summary(envelope: &Value) -> Value {
436    let records = envelope
437        .get("records")
438        .and_then(Value::as_array)
439        .cloned()
440        .unwrap_or_default();
441    let mut kinds: Vec<String> = records
442        .iter()
443        .filter_map(|entry| entry.get("kind").and_then(Value::as_str))
444        .map(str::to_string)
445        .collect();
446    kinds.sort();
447    kinds.dedup();
448    serde_json::json!({
449        "version": 2,
450        "source": envelope.get("source").cloned().unwrap_or(Value::Null),
451        "kinds": kinds,
452        "records": records.len(),
453        "records_sha256": envelope.get("records_sha256").cloned().unwrap_or(Value::Null),
454    })
455}
456
457/// Canonical-JSON digest over the residue records — the tamper/corruption
458/// binding (PARITY-23 dev/04). Uses the same canonical ordering rules as the
459/// audit's `canonicalJson` so the digest is stable across serializers.
460fn residue_records_sha256(records: &[Value]) -> String {
461    fn canonical(value: &Value, out: &mut String) {
462        match value {
463            Value::Array(items) => {
464                out.push('[');
465                for (index, item) in items.iter().enumerate() {
466                    if index > 0 {
467                        out.push(',');
468                    }
469                    canonical(item, out);
470                }
471                out.push(']');
472            }
473            Value::Object(map) => {
474                out.push('{');
475                let mut keys: Vec<&String> = map.keys().collect();
476                keys.sort();
477                for (index, key) in keys.iter().enumerate() {
478                    if index > 0 {
479                        out.push(',');
480                    }
481                    out.push_str(&serde_json::to_string(key).unwrap_or_default());
482                    out.push(':');
483                    canonical(&map[*key], out);
484                }
485                out.push('}');
486            }
487            other => out.push_str(&other.to_string()),
488        }
489    }
490    let mut text = String::from("[");
491    for (index, record) in records.iter().enumerate() {
492        if index > 0 {
493            text.push(',');
494        }
495        canonical(record, &mut text);
496    }
497    text.push(']');
498    let mut hasher = blake3::Hasher::new();
499    hasher.update(text.as_bytes());
500    hasher.finalize().to_hex().to_string()
501}
502
503/// Envelope v2 for the session's native residue. Today the only capture
504/// store is `meta.codex_provenance` (source `codex`); per-format inventories
505/// land incrementally per the PARITY-23 design doc.
506pub(super) fn native_residue_envelope(meta: &SessionMeta) -> Option<Value> {
507    if !meta.codex_provenance.is_empty() {
508        return Some(serde_json::json!({
509            "version": 2,
510            "source": "codex",
511            "records": &meta.codex_provenance,
512            "records_sha256": residue_records_sha256(&meta.codex_provenance),
513        }));
514    }
515    let source = meta.native_residue_source.as_deref()?;
516    (!meta.native_residue.is_empty()).then(|| {
517        serde_json::json!({
518            "version": 2,
519            "source": source,
520            "records": &meta.native_residue,
521            "records_sha256": residue_records_sha256(&meta.native_residue),
522        })
523    })
524}
525
526/// Restore a v2 residue envelope, digest-verified (PARITY-23 dev/04):
527/// version/source/digest problems FAIL CLOSED with a diagnostic naming what
528/// cannot be restored — never silent fabrication. v1 envelopes delegate to
529/// the original codex restore path unchanged.
530pub(super) fn restore_native_residue(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
531    match extension.get("version").and_then(Value::as_u64) {
532        Some(2) => {}
533        other => {
534            return Err(Error::InvalidSession(format!(
535                "invalid portable native residue: expected version 2, found {other:?} —                  source-native records cannot be restored from this envelope"
536            )));
537        }
538    }
539    let source = extension.get("source").and_then(Value::as_str);
540    if !matches!(source, Some("codex") | Some("claude_code") | Some("grok")) {
541        return Err(Error::InvalidSession(format!(
542            "invalid portable native residue: unsupported source {source:?} —              this build restores codex, claude_code and grok residue; the records are preserved raw but not replayed"
543        )));
544    }
545    let Some(records) = extension.get("records").and_then(Value::as_array) else {
546        return Err(Error::InvalidSession(
547            "invalid portable native residue: `records` must be an array".to_string(),
548        ));
549    };
550    let Some(claimed) = extension.get("records_sha256").and_then(Value::as_str) else {
551        return Err(Error::InvalidSession(
552            "invalid portable native residue: `records_sha256` digest is missing —              cannot verify the residue was not tampered with; refusing to restore"
553                .to_string(),
554        ));
555    };
556    let actual = residue_records_sha256(records);
557    if claimed != actual {
558        return Err(Error::InvalidSession(
559            "invalid portable native residue: records digest mismatch — the residue was              modified or corrupted after export; refusing to restore source-native records"
560                .to_string(),
561        ));
562    }
563    if let Some(source) = source.filter(|source| *source != "codex") {
564        // Digest verified: validate each record kind against the raw line
565        // (the same fail-loud rule the codex path applies).
566        let mut restored = Vec::with_capacity(records.len());
567        for entry in records {
568            let (Some(_), Some(kind), Some(raw)) = (
569                entry.get("record_index").and_then(Value::as_u64),
570                entry.get("kind").and_then(Value::as_str),
571                entry.get("raw").and_then(Value::as_str),
572            ) else {
573                return Err(Error::InvalidSession(
574                    "invalid portable native residue: each record needs record_index/kind/raw"
575                        .to_string(),
576                ));
577            };
578            let Ok(record) = serde_json::from_str::<Value>(raw) else {
579                return Err(Error::InvalidSession(
580                    "invalid portable native residue: raw is not valid JSON".to_string(),
581                ));
582            };
583            let matches = match source {
584                "claude_code" => claude_residue_kind(&record) == Some(kind),
585                "grok" => grok_residue_kind(&record).as_deref() == Some(kind),
586                _ => unreachable!("source whitelist checked above"),
587            };
588            if !matches {
589                return Err(Error::InvalidSession(format!(
590                    "invalid portable native residue: kind `{kind}` does not match raw record"
591                )));
592            }
593            restored.push(entry.clone());
594        }
595        if restored.is_empty() {
596            return Err(Error::InvalidSession(
597                "invalid portable native residue: `records` must not be empty".to_string(),
598            ));
599        }
600        meta.native_residue = restored;
601        meta.native_residue_source = Some(source.to_string());
602        return Ok(true);
603    }
604    // Digest verified: the inner record validation and meta rebuild are the
605    // v1 rules exactly.
606    restore_codex_provenance(&serde_json::json!({"version": 1, "records": records}), meta)
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
615        let base = Session::from_native_messages(Vec::new());
616        let mut native = base.to_native_jsonl_v2(&[]);
617        native.push_str("{\"supercode_turn\":1}\n");
618
619        let parsed = Session::from_native_str(&native).unwrap();
620        assert_eq!(parsed.parse_error_lines, 1);
621        assert!(parsed.messages.is_empty());
622        assert_eq!(
623            parsed.raw.last().map(String::as_str),
624            Some("{\"supercode_turn\":1}")
625        );
626    }
627
628    #[test]
629    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
630        let imported = Session::from_claude_code_str(
631            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
632        )
633        .unwrap();
634        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
635        native.push_str("{\"supercode_turn\":1}\n");
636
637        let parsed = Session::from_native_str(&native).unwrap();
638        let error = parsed
639            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
640            .unwrap_err();
641        assert!(error.to_string().contains("parse loss"), "{error}");
642    }
643
644    #[test]
645    fn sidecar_loader_requires_a_supported_native_header() {
646        for malformed in [
647            "",
648            "not-json\n",
649            "{}\n",
650            "{\"supercode_native\":2}\n",
651            "{\"supercode_native\":99,\"source\":\"native\"}\n",
652        ] {
653            let error = Session::from_sidecar_str(malformed).unwrap_err();
654            assert!(error.to_string().contains("sidecar header"), "{error}");
655        }
656    }
657}