Skip to main content

supercode/
session.rs

1//! Natively load — and continue — real Claude Code and Codex sessions.
2//!
3//! Both tools persist their conversations as JSONL on disk:
4//!
5//! - **Claude Code**: `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,
6//!   one line per event in the Anthropic message format, linked by
7//!   `uuid`/`parentUuid`.
8//! - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, where each line
9//!   is a `{timestamp, type, payload}` envelope and the `response_item` lines
10//!   form the canonical conversation.
11//!
12//! [`Session::load`] auto-detects the format and normalizes either one into a
13//! provider-neutral [`Vec<ChatMessage>`] that can be handed straight back to a
14//! model (via OpenRouter or any OpenAI-compatible endpoint) to continue.
15//!
16//! Provider-internal artifacts that don't replay across vendors — Anthropic
17//! `thinking` blocks, Codex `reasoning` items — are dropped during
18//! normalization.
19//!
20//! # Where this sits in supercode's priorities
21//!
22//! This module is the home of **feature 1 (translate between session formats)**
23//! and half of **feature 2 (emulate-to-continue)** — the load/emit surface for
24//! each harness ([`SessionFormat`], `from_*_str` loaders, `to_*_jsonl`
25//! emitters). See [`AGENTS.md`](../../../AGENTS.md) for the three ranked
26//! feature-priorities and the glue-tool positioning; the top priority is
27//! **feature 3 (continue losslessly *with massive token reduction*)**, which
28//! this fidelity work exists to make trustworthy. `opencode` + `pi` loaders
29//! are built against the frozen `docs/interop/opencode-pi-spec.md` contract
30//! — OpenCode additionally reads its native SQLite store (`opencode*.db`,
31//! PARITY-3/PARITY-16) via `rusqlite`, reconstructing the same envelope form
32//! [`Session::from_opencode_str`] already parses for the JSON-tree surfaces.
33
34use std::collections::{BTreeMap, HashMap, HashSet};
35use std::path::{Path, PathBuf};
36
37use rusqlite::Connection;
38use serde_json::Value;
39
40use crate::error::{Error, Result};
41use crate::fidelity::Fidelity;
42use crate::message::{ChatMessage, FunctionCall, Role, ToolCall};
43
44/// Which tool produced a session log.
45///
46/// This is **read-provenance**: a fact recovered when a log is loaded (stored
47/// in [`SessionMeta::source`], filled in by auto-detection in
48/// `detect_source`), describing which tool originally wrote the file on
49/// disk. It answers "where did this session come from?" — e.g. for
50/// `inspect`/`convert` display in the CLI.
51///
52/// It is deliberately distinct from [`SessionFormat`], even though the two
53/// enums' variant lists currently coincide: [`SessionFormat`] selects a
54/// serialization codec (what to parse/export *as*), while `SessionSource`
55/// records history (what wrote the file). The pair is intentionally kept
56/// separate rather than merged — a session loaded from one tool's log can
57/// still be exported in the other tool's format, and the two concepts could
58/// diverge further (e.g. a format that is readable but not attributable, or
59/// multiple versioned formats sharing one source).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum SessionSource {
62    /// A `~/.claude/projects/.../<id>.jsonl` transcript.
63    ClaudeCode,
64    /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
65    Codex,
66    /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
67    /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
68    /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
69    /// 1:1 per the frozen interop spec (§0).
70    OpenCode,
71    /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
72    /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
73    /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
74    /// round-trip property.
75    Pi,
76    /// A Grok session transcript stored as
77    /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
78    Grok,
79    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
80    /// cosmetic"): a session that was never imported from ANY foreign
81    /// tool's log at all — authored directly by supercode's own agent loop,
82    /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
83    /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
84    /// natively-spawned `spawn_subagent` children) uses this — before this
85    /// variant existed, that call site built its blank `Session` via
86    /// `Session::from_claude_code_str("")` purely as an "empty parser to
87    /// get a blank skeleton" trick, which left `meta.source ==
88    /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
89    /// was ever involved, mislabeling a native supercode spawn as an
90    /// imported CC session on disk (and in any `inspect`/`convert` reading
91    /// it back). Never produced by auto-detection (`detect_source`) or any
92    /// `from_<tool>_str` loader — only by code that explicitly constructs
93    /// a `SessionMeta` with this source, so no existing imported-session
94    /// path can ever observe this variant appearing where it didn't before.
95    Native,
96}
97
98/// An on-disk session format supercode can both read and write.
99///
100/// Like an image editor that opens and exports several file formats, supercode
101/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
102/// supported format on the edges.
103///
104/// This is a **write-target** / codec selector: a caller's request, passed to
105/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
106/// choosing which on-disk dialect to parse or emit. It answers "what format
107/// should I read/write?" — as opposed to [`SessionSource`], which records the
108/// provenance fact of what actually produced a loaded file. The two enums are
109/// intentionally kept separate (provenance fact vs. serialization choice) and
110/// should not be unified, even though their variants currently match
111/// one-to-one.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum SessionFormat {
114    /// Claude Code transcript JSONL.
115    ClaudeCode,
116    /// Codex rollout JSONL.
117    Codex,
118    /// OpenCode export-document / envelope JSONL (wave B; see
119    /// [`SessionSource::OpenCode`]).
120    OpenCode,
121    /// Pi session JSONL (see [`SessionSource::Pi`]).
122    Pi,
123    /// Grok `chat_history.jsonl` transcript.
124    Grok,
125}
126
127impl SessionFormat {
128    /// The [`SessionSource`] a file of this format reports.
129    ///
130    /// This is the deliberate one-way bridge between the two concepts: a file
131    /// saved in this format will, when reloaded, report this provenance (see
132    /// `crates/core/tests/session_saving.rs`), making the relationship
133    /// discoverable from the method itself.
134    pub fn source(self) -> SessionSource {
135        match self {
136            SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
137            SessionFormat::Codex => SessionSource::Codex,
138            SessionFormat::OpenCode => SessionSource::OpenCode,
139            SessionFormat::Pi => SessionSource::Pi,
140            SessionFormat::Grok => SessionSource::Grok,
141        }
142    }
143}
144
145/// Metadata recovered from a session log.
146#[derive(Debug, Clone)]
147#[non_exhaustive]
148pub struct SessionMeta {
149    /// The tool that wrote the log.
150    pub source: SessionSource,
151    /// The session/rollout id.
152    pub session_id: Option<String>,
153    /// The model the session was running.
154    pub model: Option<String>,
155    /// The working directory the session ran in.
156    pub cwd: Option<PathBuf>,
157    /// The system / base-instructions prompt, when the log records it.
158    pub system_prompt: Option<String>,
159    /// Verbatim source-format header records (the Codex `session_meta` /
160    /// `turn_context` lines), preserved so re-export can replay the exact header
161    /// the original tool expects rather than guessing its required fields.
162    pub codex_headers: Vec<Value>,
163    /// Exact source lines for Codex execution/provenance records that affect
164    /// continuation semantics but must not be replayed as active events after
165    /// a foreign-format hop. Each entry records its original physical-line
166    /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
167    /// list in a namespaced extension; a later Codex export restores headers
168    /// from it while keeping compaction/rollback/review records non-operative,
169    /// avoiding a second rollback or compaction of the already-normalized view.
170    pub codex_provenance: Vec<Value>,
171    /// The OpenCode analogue of [`Self::codex_headers`]
172    /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
173    /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
174    /// absent), plus any captured `session_diff`/`todo` side-records — each
175    /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
176    /// shape `raw` uses, so a consumer can tell which storage key a header
177    /// record belongs to. These replay only via the direct-write fallback
178    /// (`Session::to_opencode_direct_write`); `opencode import` has no
179    /// ingestion path for `session_diff`/`todo` (S5).
180    pub opencode_headers: Vec<Value>,
181    /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
182    /// stem). `None` for top-level sessions.
183    pub agent_id: Option<String>,
184    /// For a subagent session: the `tool_use_id` of the parent `Task` call that
185    /// spawned it, recovered from the parent transcript's tool result. Best
186    /// effort — `None` if the link could not be established.
187    pub parent_tool_use_id: Option<String>,
188    /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
189    /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
190    /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
191    /// `depth`). Empty for a plain top-level session. Used by
192    /// [`Session::reconstruct_tree`] to nest children under their parents.
193    pub lineage: std::collections::BTreeMap<String, String>,
194}
195
196impl SessionMeta {
197    fn new(source: SessionSource) -> Self {
198        SessionMeta {
199            source,
200            session_id: None,
201            model: None,
202            cwd: None,
203            system_prompt: None,
204            codex_headers: Vec::new(),
205            codex_provenance: Vec::new(),
206            opencode_headers: Vec::new(),
207            agent_id: None,
208            parent_tool_use_id: None,
209            lineage: std::collections::BTreeMap::new(),
210        }
211    }
212}
213
214/// A normalized, replayable conversation loaded from a tool's session log.
215#[derive(Debug, Clone)]
216pub struct Session {
217    /// Recovered metadata.
218    pub meta: SessionMeta,
219    /// The conversation, normalized to the OpenAI chat-completions shape.
220    pub messages: Vec<ChatMessage>,
221    /// Subagent (Task) sub-conversations. Claude Code stores these as separate
222    /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
223    /// discovers and attaches them here (each is a full [`Session`] whose
224    /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
225    pub subagents: Vec<Session>,
226    /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
227    /// captured via [`split_lines_verbatim`], not the blank-skipping/trimming
228    /// [`non_empty_lines`] parse view, so a blank line, a CRLF (`\r\n`)
229    /// terminator, or trailing whitespace on a line all survive bit-for-bit
230    /// rather than being dropped/normalized away. Normalization into
231    /// `messages` is still lossy by design (it targets the OpenAI replay
232    /// shape), but these raw lines retain *everything* — including records
233    /// with no canonical representation (e.g. Claude `file-history-snapshot`)
234    /// — so a round-trip through the supercode-native format
235    /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
236    /// formats (Claude Code/Codex/Pi), for ANY input (see
237    /// [`Self::raw_trailing_newline`] for the one piece of information a line
238    /// list alone can't carry).
239    pub raw: Vec<String>,
240    /// Whether the source text `raw` was captured from ended with a trailing
241    /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
242    /// trailing newline from one that doesn't (both split into the same
243    /// lines) — this flag carries that fact out-of-band so
244    /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
245    /// original source bytes exactly, including the presence/absence of a
246    /// final newline. `true` for a `Session` whose `raw` isn't captured
247    /// verbatim from real source text (e.g. OpenCode's re-synthesized
248    /// export-document `raw`, or a `Session` assembled programmatically) —
249    /// matching the historical always-terminated-by-newline behavior for
250    /// those cases.
251    pub raw_trailing_newline: bool,
252    /// How many of `messages` (and, symmetrically, of `raw` — see below) came
253    /// from parsing the imported log, as opposed to being appended after
254    /// import. Set once, at the end of [`Self::from_claude_code_str`] /
255    /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
256    /// before [`Self::from_native_str`]'s subsequent loop reattaches any
257    /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
258    /// That loop pushes exactly one `raw` line and one message per appended
259    /// turn, so the two lists grow in lockstep from here on: the raw-prefix
260    /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
261    /// as `raw.len() - (messages.len() - imported_message_count)`, without a
262    /// second counter. `None` only when a `Session` is constructed some other
263    /// way than through those two loaders — splicing then has no boundary to
264    /// honor and treats every message as imported (equivalent to
265    /// `Some(messages.len())`).
266    pub imported_message_count: Option<usize>,
267    /// Whether `raw` was captured strict-verbatim from real source text
268    /// (`true`) or re-synthesized by this crate (`false`) — the fact
269    /// [`Self::raw_verbatim`]'s callers need to know before claiming a
270    /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
271    /// `true` for every line-oriented loader (`from_claude_code_str`,
272    /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
273    /// surface (`from_opencode_str`'s per-line loop) — each of those splits
274    /// `raw` directly out of the source text via `split_lines_verbatim`, so
275    /// replaying it reproduces the original bytes exactly. `false` for
276    /// OpenCode's EXPORT-DOCUMENT read surface
277    /// (`Session::from_opencode_export_doc`): a pretty-printed
278    /// `{info, messages:[...]}` document has no per-line envelope structure
279    /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
280    /// record — faithful in value, but not the original document's bytes.
281    /// A `Session` assembled programmatically (not through a `from_*_str`
282    /// loader) also defaults to `false` — no real source text was captured
283    /// at all.
284    pub raw_is_verbatim: bool,
285    /// PARITY-15: how many non-empty lines of the source text FAILED to
286    /// deserialize at all (a genuinely malformed/truncated JSON line — not
287    /// a well-formed-but-unmodeled record type, which is a normal,
288    /// intentional "skip", tracked separately by `crate::audit`). Every
289    /// line-oriented loader tolerates a stray corrupt line rather than
290    /// hard-failing the whole load (a single bad line must not make an
291    /// otherwise-healthy multi-thousand-line session unloadable) — but that
292    /// tolerance used to be completely invisible: `Session::load` returned
293    /// `Ok` either way, with no signal that anything was skipped. This
294    /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
295    /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
296    /// and for a `Session` assembled programmatically.
297    pub parse_error_lines: usize,
298    /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
299    /// failing — the same "say exactly what was given up" residue list
300    /// `harness.v1.sessions.export` already reports for artifacts.
301    ///
302    /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
303    /// transcript it cannot reconstruct exactly, which is what keeps
304    /// continuation/transfer/export guarantees intact. A non-empty list means
305    /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
306    /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
307    pub load_residue: Vec<String>,
308}
309
310impl Session {
311    /// The fidelity this reconstruction actually achieved.
312    ///
313    /// Same rule the export path applies to an artifact: named residue means
314    /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
315    /// [`Fidelity::ByteLossless`] and a re-synthesized one is
316    /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
317    /// session's: the whole reconstruction is only as faithful as its least
318    /// faithful part, and each child still reports its own residue where it
319    /// was measured.
320    pub fn load_fidelity(&self) -> Fidelity {
321        let own = if !self.load_residue.is_empty() {
322            Fidelity::Semantic
323        } else if self.raw_is_verbatim {
324            Fidelity::ByteLossless
325        } else {
326            Fidelity::ValueLossless
327        };
328        if own != Fidelity::Semantic
329            && self
330                .subagents
331                .iter()
332                .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
333        {
334            return Fidelity::Semantic;
335        }
336        own
337    }
338
339    /// Assemble a session from supercode's own flat store transcript (one
340    /// [`ChatMessage`] per JSONL line). These files are the native working
341    /// format written by [`crate::store::SessionStore`], not a foreign
342    /// harness log, so routing them through format auto-detection would
343    /// misclassify them as an empty Claude Code session.
344    pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
345        Session {
346            meta: SessionMeta::new(SessionSource::Native),
347            messages,
348            subagents: Vec::new(),
349            raw: Vec::new(),
350            raw_trailing_newline: true,
351            imported_message_count: None,
352            raw_is_verbatim: false,
353            parse_error_lines: 0,
354            load_residue: Vec::new(),
355        }
356    }
357
358    /// Load a session, auto-detecting whether it's a Claude Code or Codex log
359    /// — or, when `path` looks like a SQLite database, a real OpenCode
360    /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
361    /// UTF-8 text read, so a binary `.db` file is routed to
362    /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
363    /// did not contain valid UTF-8" error (the confirmed footgun these items
364    /// close — see [`looks_like_sqlite`] / [`read_utf8_or_diagnose`]).
365    ///
366    /// A DIRECTORY is also accepted directly: `path` is probed with
367    /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
368    /// checks below (both of which assume a file and would otherwise surface
369    /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
370    /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
371    /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
372    /// `audit --format opencode` already does. A resolved `Sqlite` surface
373    /// loads exactly like pointing `load` at that `opencode*.db` file
374    /// directly (most-recently-updated top-level session). The legacy
375    /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
376    /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
377    /// that case returns a clear error naming the `.db` file / `audit` as the
378    /// way in, rather than silently doing nothing or crashing.
379    pub fn load(path: impl AsRef<Path>) -> Result<Session> {
380        Self::load_with_fidelity(path, Fidelity::ByteLossless)
381    }
382
383    /// Load a session at a declared [`Fidelity`].
384    ///
385    /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
386    /// record graph cannot be reconstructed exactly (the everyday case for a
387    /// Claude Code session that has been compacted or resumed across files,
388    /// where a live record's `parentUuid` names a record that was pruned)
389    /// still loads, stitched best-effort in transcript order, and names what
390    /// it gave up in [`Session::load_residue`]. Every stricter level keeps
391    /// the historical behavior — refuse loudly — because a continuation,
392    /// transfer or export built on a guessed graph is exactly the loss
393    /// supercode exists to prevent. Callers that go on to RESUME a session
394    /// must therefore use [`Session::load`].
395    pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
396        let path = path.as_ref();
397        if path.is_dir() {
398            return match detect_opencode_storage_surface(path) {
399                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
400                    Self::from_opencode_sqlite(&db_path, None)
401                }
402                Some((
403                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
404                    _,
405                )) => Err(crate::Error::Other(format!(
406                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
407                         supercode does not load directly — point `inspect`/`convert`/`resume` \
408                         at the store's `opencode*.db` SQLite file if this install has one, or \
409                         use `audit --format opencode {}` instead",
410                    path.display(),
411                    path.display()
412                ))),
413                None => Err(crate::Error::Other(format!(
414                    "{} is a directory, but no session file or OpenCode store was found in it \
415                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
416                     tree)",
417                    path.display()
418                ))),
419            };
420        }
421        if looks_like_sqlite(path) {
422            return Self::from_opencode_sqlite(path, None);
423        }
424        let text = read_utf8_or_diagnose(path)?;
425        match detect_source(&text) {
426            Some(SessionSource::Codex) => Self::from_codex_str(&text),
427            Some(SessionSource::Pi) => Self::from_pi_str(&text),
428            Some(SessionSource::Grok) => {
429                let mut session = Self::from_grok_str(&text)?;
430                session.capture_grok_path_metadata(path);
431                Ok(session)
432            }
433            // IX-3: a detected OpenCode session must route to its own
434            // loader, not the Claude Code fallback below
435            // (`docs/interop/build-followups.md`).
436            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
437            _ => {
438                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
439                session.attach_claude_subagents(path, &text, fidelity)?;
440                Ok(session)
441            }
442        }
443    }
444
445    /// Load a Claude Code transcript from a file, attaching any subagent
446    /// (`Task`) sub-conversations stored alongside it.
447    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
448        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
449    }
450
451    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
452    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
453    pub fn from_claude_code_with_fidelity(
454        path: impl AsRef<Path>,
455        fidelity: Fidelity,
456    ) -> Result<Session> {
457        let text = std::fs::read_to_string(path.as_ref())?;
458        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
459        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
460        Ok(session)
461    }
462
463    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
464    /// Claude Code transcript at `main_path`, linking each back to the parent
465    /// `Task` tool call via the agent id embedded in the parent's tool result.
466    fn attach_claude_subagents(
467        &mut self,
468        main_path: &Path,
469        main_text: &str,
470        fidelity: Fidelity,
471    ) -> Result<()> {
472        let Some(dir) = subagents_dir_for(main_path) else {
473            return Ok(());
474        };
475        let entries = std::fs::read_dir(&dir).map_err(|error| {
476            crate::Error::Other(format!(
477                "failed to enumerate Claude subagents at {}: {error}",
478                dir.display()
479            ))
480        })?;
481        let mut files = Vec::new();
482        for entry in entries {
483            let entry = entry.map_err(|error| {
484                crate::Error::Other(format!(
485                    "failed to enumerate Claude subagents at {}: {error}",
486                    dir.display()
487                ))
488            })?;
489            let path = entry.path();
490            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
491                files.push(path);
492            }
493        }
494        files.sort();
495
496        // Phase 1 — collect each subagent + its recovered agent id, without
497        // touching the main transcript yet.
498        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
499        for file in files {
500            let text = read_utf8_or_diagnose(&file).map_err(|error| {
501                crate::Error::Other(format!(
502                    "failed to read Claude subagent {}: {error}",
503                    file.display()
504                ))
505            })?;
506            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
507                Ok(sub) => sub,
508                // A read-only VIEW keeps the main conversation rather than
509                // losing the whole session to one unreconstructable child;
510                // the skip is named, not silent. Every stricter fidelity
511                // still propagates the child's failure.
512                Err(error) if fidelity.tolerates_residue() => {
513                    self.load_residue.push(format!(
514                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
515                        file.display()
516                    ));
517                    continue;
518                }
519                Err(error) => {
520                    return Err(crate::Error::Other(format!(
521                        "failed to reconstruct Claude subagent {}: {error}",
522                        file.display()
523                    )))
524                }
525            };
526            // agentId: prefer the file's own record, fall back to the filename stem.
527            let agent_id = first_agent_id(&text).or_else(|| {
528                file.file_stem()
529                    .and_then(|s| s.to_str())
530                    .map(|s| s.trim_start_matches("agent-").to_string())
531            });
532            collected.push((sub, agent_id));
533        }
534
535        // Phase 2 — single pass over the main transcript to index every
536        // requested agent id at once, then assign each subagent's parent by
537        // an O(1) lookup.
538        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
539        let index = parent_tool_use_index(main_text, &agent_ids);
540
541        for (mut sub, agent_id) in collected {
542            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
543            sub.meta.agent_id = agent_id;
544            self.subagents.push(sub);
545        }
546        Ok(())
547    }
548
549    /// Load a Codex rollout from a file.
550    pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
551        Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
552    }
553
554    /// Parse a Claude Code transcript from an in-memory JSONL string.
555    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
556        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
557    }
558
559    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
560    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
561    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
562        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
563        let mut messages = Vec::new();
564        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
565        // whitespace all preserved) — separate from the blank-skipping
566        // `non_empty_lines` walk just below, which still parses records only
567        // (a blank line is not a JSON record and must not become one).
568        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
569        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
570        // PARITY-15: a malformed/truncated line is still tolerated (a
571        // single bad line must not make an otherwise-healthy multi-
572        // thousand-line session unloadable) — but it's no longer INVISIBLE.
573        let mut parse_error_lines = 0usize;
574        let mut index = ClaudeReplayIndex::default();
575
576        // Claude transcripts are append-only trees, not linear chat logs.
577        // Build a lightweight graph index first so normalization sees the
578        // same single active, post-compaction branch Claude Code would
579        // resume. `raw` above deliberately remains the complete source.
580        for (line_index, line) in raw_lines.iter().enumerate() {
581            if line.trim().is_empty() {
582                continue;
583            }
584            let v: Value = match serde_json::from_str(line) {
585                Ok(v) => v,
586                Err(_) => {
587                    parse_error_lines += 1; // tolerate stray/corrupt lines
588                    continue;
589                }
590            };
591            capture_claude_meta(&v, &mut meta, line)?;
592            index.observe(line_index, &v)?;
593        }
594
595        let ClaudeReplaySelection {
596            lines: replay_lines,
597            residue: load_residue,
598        } = index.select_lines(fidelity)?;
599        let mut pending_assistant: Option<Value> = None;
600
601        for line_index in replay_lines {
602            let line = raw_lines[line_index];
603            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
604
605            if v.get("type").and_then(Value::as_str) == Some("assistant") {
606                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
607                    flush_claude_assistant(&mut pending_assistant, &mut messages);
608                    continue;
609                }
610                if let Some(pending) = pending_assistant.as_mut() {
611                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
612                        claude_assistant_message_id(&v) == Some(message_id)
613                    }) {
614                        merge_claude_assistant_chunk(pending, &v);
615                        continue;
616                    }
617                    flush_claude_assistant(&mut pending_assistant, &mut messages);
618                }
619                pending_assistant = Some(v);
620                continue;
621            }
622
623            flush_claude_assistant(&mut pending_assistant, &mut messages);
624
625            // WAVE-2 item 1: every Claude Code record carries a real
626            // top-level `timestamp` (ISO-8601) — provenance stamping below
627            // attaches it to every canonical `ChatMessage` this line
628            // produces, together with the record UUID and assistant model.
629            // `entry(...).or_insert_with` preserves any more-precise value a
630            // role-specific loader already supplied.
631            let before = messages.len();
632            match v.get("type").and_then(Value::as_str) {
633                Some("user") => push_claude_user(&v, &mut messages),
634                Some("assistant") => push_claude_assistant(&v, &mut messages),
635                Some("attachment") => push_claude_attachment(&v, &mut messages),
636                Some("system") => push_claude_system(&v, &mut messages),
637                _ => {} // mode, queue-operation, ... — skip
638            }
639            // UUID/model provenance remains meaningful even for legacy
640            // records that predate Claude Code's timestamp field.
641            capture_claude_record_provenance(&v, &mut messages[before..]);
642            restore_single_grok_message(&v, &mut messages[before..]);
643        }
644        flush_claude_assistant(&mut pending_assistant, &mut messages);
645
646        reorder_tool_results_after_calls(&mut messages);
647        ensure_tool_results_paired(&mut messages);
648        let imported_message_count = Some(messages.len());
649        Ok(Session {
650            meta,
651            messages,
652            subagents: Vec::new(),
653            raw,
654            raw_trailing_newline,
655            imported_message_count,
656            // Claude Code is line-oriented: `raw` is split directly out of
657            // the source text (strict-verbatim, IX-1).
658            raw_is_verbatim: true,
659            parse_error_lines,
660            load_residue,
661        })
662    }
663
664    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
665    ///
666    /// Codex stores subagents as separate rollout files linked to their parent
667    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
668    /// collection of sessions, this nests each child into its parent's
669    /// [`Session::subagents`] and returns only the roots. Children whose parent
670    /// isn't in the set are returned as roots themselves (best effort).
671    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
672        use std::collections::HashMap;
673        // Index each session's position by its session_id.
674        let mut idx: HashMap<String, usize> = HashMap::new();
675        for (i, s) in sessions.iter().enumerate() {
676            if let Some(id) = &s.meta.session_id {
677                idx.insert(id.clone(), i);
678            }
679        }
680        // Determine each session's parent (by index), if present in the set.
681        let parent_of: Vec<Option<usize>> = sessions
682            .iter()
683            .map(|s| {
684                s.meta
685                    .lineage
686                    .get("parent_thread_id")
687                    .and_then(|p| idx.get(p).copied())
688            })
689            .collect();
690
691        // Move children into parents, deepest-first so chains nest correctly.
692        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
693        let mut order: Vec<usize> = (0..slots.len()).collect();
694        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
695        for i in order {
696            if let Some(p) = parent_of[i] {
697                if p != i {
698                    if let Some(child) = slots[i].take() {
699                        if let Some(parent) = slots[p].as_mut() {
700                            parent.subagents.push(child);
701                        } else {
702                            slots[i] = Some(child); // parent already moved; keep as root
703                        }
704                    }
705                }
706            }
707        }
708        slots.into_iter().flatten().collect()
709    }
710
711    /// Parse a session of a known format from an in-memory JSONL string.
712    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
713        match format {
714            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
715            SessionFormat::Codex => Self::from_codex_str(jsonl),
716            SessionFormat::Pi => Self::from_pi_str(jsonl),
717            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
718            SessionFormat::Grok => Self::from_grok_str(jsonl),
719        }
720    }
721
722    /// Serialize this session to JSONL in the given format.
723    ///
724    /// The conversation is synthesized from the canonical messages, so this
725    /// works for sessions loaded from *either* tool as well as ones supercode
726    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
727    /// "export": format-specific framing that has no slot in the target may be
728    /// dropped, but the user/assistant/tool conversation is preserved.
729    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
730        match format {
731            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
732            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
733            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
734            SessionFormat::OpenCode => self.to_opencode_jsonl(),
735            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
736        }
737    }
738
739    /// Export back to `format`, replaying the imported `raw` prefix
740    /// **verbatim** — original uuids/ids, real timestamps, and
741    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
742    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
743    /// is the session's own origin (`format.source() == self.meta.source`,
744    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
745    /// Only messages appended *after* import (tracked by
746    /// [`Self::imported_message_count`]) are synthesized, chained onto the
747    /// last original record found in the raw prefix.
748    ///
749    /// `session_id` of `Some(new)` rewrites the session id on every emitted
750    /// line, raw and synthesized alike (`sessionId` for Claude Code,
751    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
752    ///
753    /// Cross-format export (no verbatim prefix exists in the target dialect,
754    /// by definition) and a session with no `raw` lines both fall back
755    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
756    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
757    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
758    /// cross-format stays at the documented semantic tier.
759    pub fn to_jsonl_spliced(
760        &self,
761        format: SessionFormat,
762        session_id: Option<&str>,
763    ) -> Result<String> {
764        if self.parse_error_lines > 0
765            || self
766                .subagents
767                .iter()
768                .any(|subagent| subagent.parse_error_lines > 0)
769        {
770            return Err(Error::InvalidSession(
771                "refusing spliced export because the loaded session contains parse loss"
772                    .to_string(),
773            ));
774        }
775        if self.raw.is_empty() || format.source() != self.meta.source {
776            if let Some(session_id) = session_id {
777                let mut rewritten = self.clone();
778                rewritten.meta.session_id = Some(session_id.to_string());
779                return rewritten.to_jsonl(format);
780            }
781            return self.to_jsonl(format);
782        }
783        match format {
784            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
785            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
786            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
787            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
788            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
789        }
790    }
791
792    /// Write this session to `path` in the given format.
793    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
794        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
795        Ok(())
796    }
797
798    /// Reconstruct the exact source bytes this `Session` was loaded from,
799    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
800    /// inverse of the strict-verbatim capture those two fields record — see
801    /// [`join_lines_verbatim`]).
802    ///
803    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
804    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
805    /// original text, so this reproduces the original file byte-for-byte —
806    /// the P008/P009 diagonal-convert fix (`convert <file> --to
807    /// <same-format>` is byte-identical to `<file>`) is built on exactly
808    /// this. The one documented exception is an OpenCode **export-document**
809    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
810    /// is RE-SYNTHESIZED as one envelope line per record (see
811    /// [`Self::from_opencode_export_doc`]'s doc comment), so this returns a
812    /// verbatim reproduction of THAT captured representation rather than the
813    /// original pretty-printed document — a known, narrow residue, not a
814    /// silent loss (the same records are all still present).
815    pub fn raw_verbatim(&self) -> String {
816        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
817    }
818
819    /// Serialize to the **supercode-native** lossless format: a header line
820    /// recording the original source, followed by every original JSONL line
821    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
822    /// schema and is necessarily lossy), this preserves *everything* — including
823    /// records with no canonical representation — so [`Self::from_native_str`]
824    /// reconstructs the session with full fidelity.
825    pub fn to_native_jsonl(&self) -> String {
826        let source = match self.meta.source {
827            SessionSource::ClaudeCode => "claude_code",
828            SessionSource::Codex => "codex",
829            SessionSource::Pi => "pi",
830            SessionSource::OpenCode => "opencode",
831            SessionSource::Grok => "grok",
832            // P5-3 safety-hardening fix: a natively-spawned session must
833            // never be written to disk labeled as an imported CC session.
834            SessionSource::Native => "native",
835        };
836        let header = serde_json::json!({
837            "supercode_native": 1,
838            "source": source,
839            // IX-1: carries whether the ORIGINAL imported source text ended
840            // with a trailing newline — `from_native_str` needs this to
841            // reconstruct the exact source bytes (not just the `raw` line
842            // list) when re-parsing the body with the per-source loader.
843            "raw_trailing_newline": self.raw_trailing_newline,
844        })
845        .to_string();
846        let mut out =
847            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
848        out.push_str(&header);
849        out.push('\n');
850        for line in &self.raw {
851            out.push_str(line);
852            out.push('\n');
853        }
854        out
855    }
856
857    /// Serialize to the **supercode-native v2** format: the same imported-body
858    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
859    /// followed by every `Session.raw` line verbatim), plus one
860    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
861    /// produced after import, which have no backing `raw` line of their own.
862    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
863    /// serde), so nothing the live agent loop records is lost to disk.
864    ///
865    /// `appended` is caller-supplied rather than inferred from
866    /// `self.messages`: A1 doesn't track which of `self.messages` came from
867    /// import vs. the live loop — that bookkeeping belongs to the live writer
868    /// built on top of this (A2/A3).
869    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
870        self.to_native_jsonl_v2_with_timestamp(appended, None)
871    }
872
873    pub(crate) fn to_native_jsonl_v2_with_timestamp(
874        &self,
875        appended: &[ChatMessage],
876        fixed_timestamp: Option<&str>,
877    ) -> String {
878        let source = match self.meta.source {
879            SessionSource::ClaudeCode => "claude_code",
880            SessionSource::Codex => "codex",
881            SessionSource::Pi => "pi",
882            SessionSource::OpenCode => "opencode",
883            SessionSource::Grok => "grok",
884            // P5-3 safety-hardening fix: a natively-spawned session must
885            // never be written to disk labeled as an imported CC session.
886            SessionSource::Native => "native",
887        };
888        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
889        // already parses CC sidechains + CX lineage on import"): a
890        // natively-spawned subagent's own `Session` carries its lineage on
891        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
892        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
893        // all, so a native-spawned child's lineage was lost the instant it
894        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
895        // (`skip_serializing_if`-equivalent via manual omission below) so a
896        // plain top-level session's header is byte-identical to before this
897        // change.
898        let mut header_obj = serde_json::json!({
899            "supercode_native": 2,
900            "source": source,
901            "session_id": self.meta.session_id,
902            "created": fixed_timestamp
903                .map(ToOwned::to_owned)
904                .unwrap_or_else(crate::sidecar::now_rfc3339),
905            // IX-1: see `to_native_jsonl`'s header field of the same name.
906            "raw_trailing_newline": self.raw_trailing_newline,
907        });
908        if let Some(obj) = header_obj.as_object_mut() {
909            if let Some(agent_id) = &self.meta.agent_id {
910                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
911            }
912            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
913                obj.insert(
914                    "parent_tool_use_id".to_string(),
915                    Value::String(parent_tool_use_id.clone()),
916                );
917            }
918            if !self.meta.lineage.is_empty() {
919                obj.insert(
920                    "lineage".to_string(),
921                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
922                );
923            }
924        }
925        let header = header_obj.to_string();
926        let mut out =
927            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
928        out.push_str(&header);
929        out.push('\n');
930        for line in &self.raw {
931            out.push_str(line);
932            out.push('\n');
933        }
934        for (turn_index, msg) in appended.iter().enumerate() {
935            let turn = match fixed_timestamp {
936                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
937                    msg,
938                    timestamp.to_string(),
939                    turn_index as u64,
940                ),
941                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
942                    msg,
943                    crate::sidecar::now_rfc3339(),
944                    turn_index as u64,
945                ),
946            };
947            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
948            out.push('\n');
949        }
950        out
951    }
952
953    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
954    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
955    /// exactly as before. A v2 file's appended `NativeTurn` records —
956    /// discriminated by the `supercode_turn` key, which never appears in a v1
957    /// body — are split out before the imported body is handed to the
958    /// per-source loader, then reattached in file order: to `messages` (via
959    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
960    /// so a v2 file round-trips byte-for-byte through
961    /// [`Self::to_native_jsonl_v2`] again.
962    pub fn from_native_str(jsonl: &str) -> Result<Session> {
963        // IX-1: the native WRAPPER's own lines are split verbatim (not via
964        // the blank-skipping `non_empty_lines`) so that any `raw` line it
965        // carries — which can itself be blank, CRLF-terminated, or
966        // whitespace-padded, now that raw-capture is strict-verbatim —
967        // survives being embedded in (and re-extracted from) this wrapper
968        // bit-for-bit. The wrapper we ourselves emit never has a blank line
969        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
970        // record per line), so this is a behavior-preserving switch for any
971        // native text this crate produced; it also makes a hand-fed/legacy
972        // native string tolerated exactly as `non_empty_lines` used to.
973        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
974        let mut lines = all_lines.into_iter();
975        let header = lines.next().unwrap_or("");
976        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
977        let source = hv.get("source").and_then(Value::as_str);
978        // IX-1: whether the ORIGINAL imported source (before it was wrapped
979        // in this native format) ended with a trailing newline — a property
980        // of the pre-wrap source, not of this wrapper (which always
981        // LF-terminates every line it writes, regardless). Missing on a
982        // native file written before IX-1 (or a hand-built header in an
983        // older test/sidecar) — default `true`, the historical
984        // always-newline-terminated assumption.
985        let raw_trailing_newline = hv
986            .get("raw_trailing_newline")
987            .and_then(Value::as_bool)
988            .unwrap_or(true);
989
990        // Split appended NativeTurn records (v2) out of the imported body. A
991        // v1 body never carries a `supercode_turn` key, so this is a no-op
992        // there — one code path serves both versions.
993        let mut body_lines: Vec<String> = Vec::new();
994        let mut turn_lines: Vec<&str> = Vec::new();
995        for line in lines {
996            let is_turn = serde_json::from_str::<Value>(line)
997                .ok()
998                .is_some_and(|v| v.get("supercode_turn").is_some());
999            if is_turn {
1000                turn_lines.push(line);
1001            } else {
1002                body_lines.push(line.to_string());
1003            }
1004        }
1005        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1006        // `body_lines.join("\n")` alone would silently gain a trailing
1007        // newline the original source never had (or lose one it did have).
1008        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1009
1010        // The remaining lines are the original log; re-parse with the right loader.
1011        let mut session = match source {
1012            Some("codex") => Self::from_codex_str(&body)?,
1013            Some("claude_code") => Self::from_claude_code_str(&body)?,
1014            Some("pi") => Self::from_pi_str(&body)?,
1015            Some("opencode") => Self::from_opencode_str(&body)?,
1016            Some("grok") => Self::from_grok_str(&body)?,
1017            // P5-3 safety-hardening fix: a natively-spawned session's body
1018            // is always empty (it never had any foreign-tool prefix to
1019            // begin with — see `SessionSource::Native`'s doc comment), so
1020            // any loader would parse it identically; `from_claude_code_str`
1021            // is reused purely as a blank-skeleton builder (empty
1022            // `raw`/`messages`), then its `meta.source` is corrected to
1023            // `Native` — never left mislabeled as `ClaudeCode`.
1024            Some("native") => {
1025                let mut s = Self::from_claude_code_str(&body)?;
1026                s.meta.source = SessionSource::Native;
1027                s
1028            }
1029            // No/unknown header — auto-detect the body.
1030            _ => match detect_source(&body) {
1031                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1032                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1033                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1034                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1035                _ => Self::from_claude_code_str(&body)?,
1036            },
1037        };
1038
1039        for line in turn_lines {
1040            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1041                Ok(turn) => {
1042                    session.raw.push(line.to_string());
1043                    session.messages.push(turn.into_message());
1044                }
1045                Err(_) => {
1046                    // A valid JSON object carrying the native-turn
1047                    // discriminator belongs to this wrapper, not to the
1048                    // imported body. If its required fields are malformed,
1049                    // count it as parse loss so every fail-loud caller can
1050                    // refuse continuation instead of silently dropping a
1051                    // native history record. Keep the rejected source line
1052                    // in `raw` as well: diagnostics must count it in their
1053                    // denominator, and even corrupt input must not disappear
1054                    // merely because it reached the parser.
1055                    session.raw.push(line.to_string());
1056                    session.parse_error_lines += 1;
1057                }
1058            }
1059        }
1060
1061        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1062        // header block): recover a natively-spawned subagent's own lineage
1063        // from the v2 header, when present. Overlays (rather than merges
1064        // into) whatever the per-source body loader may have already set on
1065        // `session.meta` — these three keys are ONLY ever written by
1066        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1067        // header that carries them is authoritative for a file this crate
1068        // produced.
1069        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1070            session.meta.agent_id = Some(agent_id.to_string());
1071        }
1072        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1073            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1074        }
1075        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1076            for (k, v) in lineage {
1077                if let Some(s) = v.as_str() {
1078                    session.meta.lineage.insert(k.clone(), s.to_string());
1079                }
1080            }
1081        }
1082
1083        Ok(session)
1084    }
1085
1086    /// The full-fidelity [`Session`] a sidecar denotes.
1087    ///
1088    /// The sidecar (native-v2 format, D1) is the imported body plus every
1089    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1090    /// tolerant lower-level native parser, this persisted-store entry point
1091    /// validates its framing header before loading anything: a missing,
1092    /// malformed, or unsupported header must never become a zero-message
1093    /// session that callers could continue as if it were complete.
1094    pub fn from_sidecar_str(s: &str) -> Result<Session> {
1095        let header = s.lines().next().ok_or_else(|| {
1096            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1097        })?;
1098        let value: Value = serde_json::from_str(header).map_err(|error| {
1099            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1100        })?;
1101        let version = value.get("supercode_native").and_then(Value::as_u64);
1102        if !matches!(version, Some(1 | 2)) {
1103            return Err(Error::InvalidSession(
1104                "sidecar header must declare supported `supercode_native` version 1 or 2"
1105                    .to_string(),
1106            ));
1107        }
1108        let source = value.get("source").and_then(Value::as_str);
1109        if !matches!(
1110            source,
1111            Some("native" | "claude_code" | "codex" | "opencode" | "pi" | "grok")
1112        ) {
1113            return Err(Error::InvalidSession(
1114                "sidecar header must declare a supported `source`".to_string(),
1115            ));
1116        }
1117        Self::from_native_str(s)
1118    }
1119
1120    /// Parse a Codex rollout from an in-memory JSONL string.
1121    pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1122        let mut meta = SessionMeta::new(SessionSource::Codex);
1123        let mut messages = Vec::new();
1124
1125        // First pass: collect the text of every assistant message that exists as
1126        // a canonical `response_item`. In normal sessions the streamed
1127        // `event_msg/agent_message` events duplicate these and are safely
1128        // skipped; in collab/multi-agent sessions the assistant narration lives
1129        // ONLY as `agent_message` events, so we recover the ones with no
1130        // response_item counterpart (deduping by exact text).
1131        let assistant_texts = collect_codex_assistant_texts(jsonl);
1132        // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1133        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1134        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1135        let mut pending_reasoning = String::new();
1136        let mut pending_reasoning_content = String::new();
1137        let mut pending_reasoning_encrypted = false;
1138        // PARITY-15: see `from_claude_code_str`'s identical counter.
1139        let mut parse_error_lines = 0usize;
1140        let mut restored_embedded_codex_provenance = false;
1141
1142        for (record_index, raw_line) in raw_lines.iter().enumerate() {
1143            let line = raw_line.trim();
1144            if line.is_empty() {
1145                continue;
1146            }
1147            let v: Value = match serde_json::from_str(line) {
1148                Ok(v) => v,
1149                Err(_) => {
1150                    parse_error_lines += 1;
1151                    continue;
1152                }
1153            };
1154            let payload = v.get("payload").unwrap_or(&Value::Null);
1155            if !restored_embedded_codex_provenance
1156                && v.get("type").and_then(Value::as_str) == Some("session_meta")
1157                && payload
1158                    .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1159                    .map(|extension| restore_codex_provenance(extension, &mut meta))
1160                    .transpose()?
1161                    .unwrap_or(false)
1162            {
1163                restored_embedded_codex_provenance = true;
1164            }
1165            if !restored_embedded_codex_provenance {
1166                capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1167            }
1168            // WAVE-2 item 1: every Codex record carries a real top-level
1169            // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1170            // line produces via `stamp_new_codex_messages` below, at each
1171            // arm that pushes messages.
1172            let line_ts = v.get("timestamp").and_then(Value::as_str);
1173
1174            match v.get("type").and_then(Value::as_str) {
1175                Some("session_meta") => {
1176                    capture_codex_session_meta(payload, &mut meta);
1177                    if !restored_embedded_codex_provenance {
1178                        meta.codex_headers.push(v.clone());
1179                    }
1180                }
1181                Some("turn_context") => {
1182                    if meta.model.is_none() {
1183                        meta.model = payload
1184                            .get("model")
1185                            .and_then(Value::as_str)
1186                            .map(str::to_string);
1187                    }
1188                    if !restored_embedded_codex_provenance {
1189                        meta.codex_headers.push(v.clone());
1190                    }
1191                }
1192                Some("response_item")
1193                    if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1194                {
1195                    // Retain reasoning (P3): summary text if any, the raw
1196                    // `content` chain-of-thought text if any (N2 — this used
1197                    // to be dropped despite `Coverage::Retained` claiming the
1198                    // whole item survived; see `crate::audit`'s doc comment),
1199                    // plus a flag for the opaque encrypted_content a
1200                    // same-model continuation can replay. Stashed onto the
1201                    // next assistant message below.
1202                    let summary = extract_text_content(payload.get("summary"));
1203                    if !summary.trim().is_empty() {
1204                        push_str_field(&mut pending_reasoning, &summary);
1205                    }
1206                    // N2: `content` is `null` on the vast majority of real
1207                    // turns (raw reasoning text is only ever populated for
1208                    // certain reasoning-transcript configurations) — guard
1209                    // on non-null BEFORE calling `extract_text_content`,
1210                    // since `Some(&Value::Null)` would otherwise fall into
1211                    // its `Some(other) => other.to_string()` arm and
1212                    // stringify to the literal text `"null"`.
1213                    if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1214                        let text = extract_text_content(Some(raw_content));
1215                        if !text.trim().is_empty() {
1216                            push_str_field(&mut pending_reasoning_content, &text);
1217                        }
1218                    }
1219                    // N1: `serde_json` returns `Some(&Value::Null)` for a
1220                    // present-but-null `encrypted_content` key — which is
1221                    // what EVERY real rollout's reasoning item carries
1222                    // (upstream always serializes the field, never
1223                    // `skip_serializing_if`, `codex-rs/protocol/src/
1224                    // models.rs:970-983`). The old `.is_some()` check
1225                    // false-flagged every single reasoning item as
1226                    // "encrypted" on real data; only a genuinely non-null
1227                    // value means the model actually returned an opaque
1228                    // blob that a same-model continuation could replay.
1229                    if payload
1230                        .get("encrypted_content")
1231                        .is_some_and(|v| !v.is_null())
1232                    {
1233                        pending_reasoning_encrypted = true;
1234                    }
1235                }
1236                Some("response_item") => {
1237                    let before = messages.len();
1238                    push_codex_item(payload, &mut messages);
1239                    // Attach any pending reasoning to a newly produced assistant turn.
1240                    if messages.len() > before
1241                        && (!pending_reasoning.is_empty()
1242                            || !pending_reasoning_content.is_empty()
1243                            || pending_reasoning_encrypted)
1244                    {
1245                        let is_assistant = messages
1246                            .last()
1247                            .map(|m| m.role == Role::Assistant)
1248                            .unwrap_or(false);
1249                        if is_assistant {
1250                            let last = messages.last_mut().expect("checked above");
1251                            if !pending_reasoning.is_empty() {
1252                                last.metadata.insert(
1253                                    "reasoning".to_string(),
1254                                    std::mem::take(&mut pending_reasoning),
1255                                );
1256                            }
1257                            if !pending_reasoning_content.is_empty() {
1258                                last.metadata.insert(
1259                                    "reasoning_content".to_string(),
1260                                    std::mem::take(&mut pending_reasoning_content),
1261                                );
1262                            }
1263                            if pending_reasoning_encrypted {
1264                                last.metadata
1265                                    .insert("reasoning_encrypted".to_string(), "true".to_string());
1266                                pending_reasoning_encrypted = false;
1267                            }
1268                        } else {
1269                            // N3: the item that just landed is NOT the
1270                            // assistant turn the pending reasoning was for
1271                            // (e.g. an aborted turn's reasoning directly
1272                            // followed by a user message) — the old code
1273                            // unconditionally cleared the pending state
1274                            // here, silently discarding it. Flush it as its
1275                            // own message instead, inserted just before the
1276                            // interrupting item so replay order stays
1277                            // chronological, keeping `Coverage::Retained`
1278                            // honest for this shape too.
1279                            let orphan = orphaned_reasoning_message(
1280                                &mut pending_reasoning,
1281                                &mut pending_reasoning_content,
1282                                &mut pending_reasoning_encrypted,
1283                            );
1284                            messages.insert(before, orphan);
1285                        }
1286                    }
1287                    stamp_new_codex_messages(&mut messages, before, line_ts);
1288                    restore_single_grok_message(payload, &mut messages[before..]);
1289                }
1290                // A compaction record replaces all prior turns with its
1291                // summarized `replacement_history` — exactly how Codex itself
1292                // resumes a compacted session.
1293                Some("compacted") => {
1294                    messages.clear();
1295                    if let Some(Value::Array(history)) = payload.get("replacement_history") {
1296                        for item in history {
1297                            push_codex_item(item, &mut messages);
1298                        }
1299                    }
1300                    // `replacement_history` items carry no per-item
1301                    // timestamp of their own (observed corpora) — the
1302                    // `compacted` record's own timestamp (when it happened)
1303                    // is the best-effort real source for every message it
1304                    // synthesizes, so it stamps the whole rebuilt vec (index
1305                    // 0, since `clear()` reset it above).
1306                    stamp_new_codex_messages(&mut messages, 0, line_ts);
1307                    // IX-6 fix: replaying `replacement_history` through
1308                    // `push_codex_item` can leave the LAST replayed message
1309                    // marked `__codex_open_turn` (if it's an assistant
1310                    // `message`, per the combined-turn merge below). That
1311                    // marker must not survive past the compaction boundary —
1312                    // a live `function_call` arriving after this record is a
1313                    // NEW turn, not a continuation of the compaction
1314                    // summary's synthetic turn, so it must not merge into it.
1315                    if let Some(last) = messages.last_mut() {
1316                        last.metadata.remove("__codex_open_turn");
1317                    }
1318                }
1319                Some("event_msg")
1320                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1321                {
1322                    let before = messages.len();
1323                    let text = agent_message_text(payload);
1324                    if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1325                        push_assistant(&mut messages, text, Vec::new());
1326                        if let Some(last) = messages.last_mut() {
1327                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1328                                last.metadata.insert("phase".to_string(), phase.to_string());
1329                            }
1330                        }
1331                    }
1332                    stamp_new_codex_messages(&mut messages, before, line_ts);
1333                }
1334                // The user rolled back (undid) the last N turns — replay must
1335                // drop them so the reloaded conversation matches what the user
1336                // actually kept.
1337                Some("event_msg")
1338                    if payload.get("type").and_then(Value::as_str)
1339                        == Some("thread_rolled_back") =>
1340                {
1341                    let n = payload
1342                        .get("num_turns")
1343                        .and_then(Value::as_u64)
1344                        .unwrap_or(1);
1345                    for _ in 0..n {
1346                        remove_last_turn(&mut messages);
1347                    }
1348                }
1349                // The natural-language goal assigned to this thread (sometimes
1350                // the only place the objective text is recorded).
1351                Some("event_msg")
1352                    if payload.get("type").and_then(Value::as_str)
1353                        == Some("thread_goal_updated") =>
1354                {
1355                    let before = messages.len();
1356                    let goal = payload.get("goal");
1357                    if let Some(obj) = goal
1358                        .and_then(|g| g.get("objective"))
1359                        .and_then(Value::as_str)
1360                    {
1361                        if !obj.trim().is_empty() {
1362                            messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1363                            // D4: `goal.objective` alone used to be the ONLY
1364                            // captured field, but the audit labeled this
1365                            // `Retained` as if the whole record survived.
1366                            // `goal.status`/`goal.tokenBudget` (real
1367                            // `ThreadGoal` wire fields, camelCase) are
1368                            // captured too so that label is honest — see
1369                            // `crate::audit::event_msg_coverage`'s doc
1370                            // comment.
1371                            if let Some(last) = messages.last_mut() {
1372                                if let Some(status) =
1373                                    goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1374                                {
1375                                    last.metadata
1376                                        .insert("goal_status".to_string(), status.to_string());
1377                                }
1378                                if let Some(budget) = goal
1379                                    .and_then(|g| g.get("tokenBudget"))
1380                                    .and_then(Value::as_i64)
1381                                {
1382                                    last.metadata.insert(
1383                                        "goal_token_budget".to_string(),
1384                                        budget.to_string(),
1385                                    );
1386                                }
1387                            }
1388                        }
1389                    }
1390                    stamp_new_codex_messages(&mut messages, before, line_ts);
1391                }
1392                // Code-review output — unique assistant-generated content with no
1393                // `message` counterpart.
1394                Some("event_msg")
1395                    if payload.get("type").and_then(Value::as_str)
1396                        == Some("exited_review_mode") =>
1397                {
1398                    let before = messages.len();
1399                    if let Some(review) = payload.get("review_output") {
1400                        let text = review
1401                            .get("overall_explanation")
1402                            .and_then(Value::as_str)
1403                            .map(str::to_string)
1404                            .unwrap_or_else(|| review.to_string());
1405                        push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1406                        // D4: `overall_explanation` alone used to be the ONLY
1407                        // captured field, but the audit labeled this
1408                        // `Retained` as if `review_output.findings` survived
1409                        // too. Capture `findings` verbatim (as JSON, onto
1410                        // metadata) so that label is honest — this is the
1411                        // only place review-mode findings (title/body/
1412                        // confidence_score/priority/code_location) live.
1413                        if let Some(findings) = review.get("findings") {
1414                            if findings.as_array().is_some_and(|a| !a.is_empty()) {
1415                                if let Some(last) = messages.last_mut() {
1416                                    if let Ok(s) = serde_json::to_string(findings) {
1417                                        last.metadata.insert("review_findings".to_string(), s);
1418                                    }
1419                                }
1420                            }
1421                        }
1422                        // N4: `overall_correctness`/`overall_confidence_score`
1423                        // are the review's actual verdict — distinct from the
1424                        // findings list and the explanation prose already
1425                        // captured above — and were neither captured nor
1426                        // disclosed as residue while the audit doc stayed
1427                        // silent about them. Capture both onto the same
1428                        // message's metadata, same pattern as `findings`.
1429                        if let Some(last) = messages.last_mut() {
1430                            if let Some(correctness) =
1431                                review.get("overall_correctness").and_then(Value::as_str)
1432                            {
1433                                last.metadata.insert(
1434                                    "review_overall_correctness".to_string(),
1435                                    correctness.to_string(),
1436                                );
1437                            }
1438                            if let Some(score) = review
1439                                .get("overall_confidence_score")
1440                                .and_then(Value::as_f64)
1441                            {
1442                                last.metadata.insert(
1443                                    "review_overall_confidence_score".to_string(),
1444                                    score.to_string(),
1445                                );
1446                            }
1447                        }
1448                    }
1449                    stamp_new_codex_messages(&mut messages, before, line_ts);
1450                }
1451                _ => {} // other event_msg, token_count, ... — UI events, skip
1452            }
1453        }
1454
1455        // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1456        // shape a real rollout can leave behind (the process was
1457        // interrupted mid-turn, after the model reasoned but before it
1458        // replied — end of file, or a rollback/compaction boundary that
1459        // clears the pending state some other way) — the old code silently
1460        // dropped it here (nothing ever consumed the pending buffers once
1461        // the loop ended). Flush it as its own trailing message instead, so
1462        // `Coverage::Retained` holds for this shape too. Superset of the
1463        // independently-discovered PARITY-11 fix: also folds in
1464        // `pending_reasoning_content` (the raw chain-of-thought, distinct
1465        // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1466        // message` helper, which the interrupted-by-a-user-message shape
1467        // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1468        // relies on — a trailing-EOF-only flush here would miss that case.
1469        if !pending_reasoning.is_empty()
1470            || !pending_reasoning_content.is_empty()
1471            || pending_reasoning_encrypted
1472        {
1473            let orphan = orphaned_reasoning_message(
1474                &mut pending_reasoning,
1475                &mut pending_reasoning_content,
1476                &mut pending_reasoning_encrypted,
1477            );
1478            messages.push(orphan);
1479        }
1480
1481        ensure_tool_results_paired(&mut messages);
1482        // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1483        // combined-turn merge above — strip it so it never leaks out as
1484        // visible `ChatMessage` metadata.
1485        for m in &mut messages {
1486            m.metadata.remove("__codex_open_turn");
1487            if m.metadata
1488                .remove("__grok_remove_synthetic_turn_id")
1489                .is_some()
1490            {
1491                m.metadata.remove("turn_id");
1492            }
1493        }
1494        let imported_message_count = Some(messages.len());
1495        Ok(Session {
1496            meta,
1497            messages,
1498            subagents: Vec::new(),
1499            raw,
1500            raw_trailing_newline,
1501            imported_message_count,
1502            // Codex is line-oriented: `raw` is split directly out of the
1503            // source text (strict-verbatim, IX-1).
1504            raw_is_verbatim: true,
1505            parse_error_lines,
1506            load_residue: Vec::new(),
1507        })
1508    }
1509
1510    /// Load a Pi session from a file.
1511    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1512        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1513    }
1514
1515    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1516    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1517    ///
1518    /// Line 1 is the `session` header; every other line is one `SessionEntry`
1519    /// in a tree keyed by `id`/`parentId` — file order is append order, not
1520    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1521    /// exactly like Claude Code/Codex). `messages` is the **active path
1522    /// only**: pi's own leaf rule is "the last entry in file order"
1523    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1524    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1525    /// state records (`thinking_level_change`/`model_change`/`custom`/
1526    /// `session_info`) are never visited by that walk — they survive in
1527    /// `raw` only, pi's defining residue (§1.1).
1528    ///
1529    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1530    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1531    /// `custom`) produces no canonical message — raw-only survival, never a
1532    /// panic — and [`crate::audit::Corpus::Pi`] is what turns that into a
1533    /// visible coverage failure rather than a silent drop.
1534    ///
1535    /// Same fail-loud discipline applies to `ImageContent` blocks
1536    /// (`user`/`toolResult`/`custom*` content, see [`pi_image_shape`]): the
1537    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1538    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1539    /// cites the containing union) — a follow-up TR tracks confirming it
1540    /// against a real corpus. Until then, an image block that doesn't match
1541    /// that shape never gets silently synthesized as an empty/corrupt
1542    /// `image_url` part; the containing message survives in `raw` only and
1543    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1544    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1545        let mut meta = SessionMeta::new(SessionSource::Pi);
1546        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1547        // blank-skipping PARSE walk (`lines_v`) below, which must keep
1548        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1549        // records (a blank line is never a record, on either view).
1550        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1551        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1552        let non_empty_line_count = non_empty_lines(jsonl).count();
1553        let lines_v: Vec<Value> = non_empty_lines(jsonl)
1554            .filter_map(|l| serde_json::from_str(l).ok())
1555            .collect();
1556        // PARITY-15: every line that failed to even deserialize as JSON at
1557        // all (never mind whether it then parsed as a recognized
1558        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1559        // counter.
1560        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1561
1562        if let Some(header) = lines_v.first() {
1563            capture_pi_header(header, &mut meta)?;
1564        }
1565
1566        // Every non-header entry that parses as an object carrying an `id`.
1567        // (A line that fails to parse, or a header re-parsed as an entry,
1568        // simply never enters `by_id` — it survives in `raw` only, exactly
1569        // like a malformed/non-conversational line in the other loaders.)
1570        struct PiEntry {
1571            id: String,
1572            parent_id: Option<String>,
1573            value: Value,
1574        }
1575        let mut entries: Vec<PiEntry> = Vec::new();
1576        let mut by_id: HashMap<String, usize> = HashMap::new();
1577        for v in lines_v.iter().skip(1) {
1578            let Some(id) = v.get("id").and_then(Value::as_str) else {
1579                continue;
1580            };
1581            let parent_id = v
1582                .get("parentId")
1583                .and_then(Value::as_str)
1584                .map(str::to_string);
1585            by_id.insert(id.to_string(), entries.len());
1586            entries.push(PiEntry {
1587                id: id.to_string(),
1588                parent_id,
1589                value: v.clone(),
1590            });
1591        }
1592
1593        if entries.is_empty() {
1594            return Ok(Session {
1595                meta,
1596                messages: Vec::new(),
1597                subagents: Vec::new(),
1598                raw,
1599                raw_trailing_newline,
1600                imported_message_count: Some(0),
1601                // Pi is line-oriented: `raw` is split directly out of the
1602                // source text (strict-verbatim, IX-1), even for this
1603                // no-entries early return.
1604                raw_is_verbatim: true,
1605                parse_error_lines,
1606                load_residue: Vec::new(),
1607            });
1608        }
1609
1610        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1611        // necessarily a `message` entry — a trailing `label`/`session_info`
1612        // still anchors the walk correctly since the walk just follows
1613        // `parentId` regardless of the leaf's own type.
1614        let leaf_idx = entries.len() - 1;
1615        let mut chain_rev: Vec<usize> = Vec::new();
1616        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1617        let mut guard = 0usize;
1618        while let Some(id) = cur {
1619            let Some(&idx) = by_id.get(&id) else { break };
1620            chain_rev.push(idx);
1621            cur = entries[idx].parent_id.clone();
1622            guard += 1;
1623            if guard > entries.len() + 1 {
1624                break; // cycle guard — malformed parentId chain
1625            }
1626        }
1627        chain_rev.reverse();
1628        let active = chain_rev; // indices into `entries`, root..leaf order
1629
1630        let pos_in_active: HashMap<&str, usize> = active
1631            .iter()
1632            .enumerate()
1633            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1634            .collect();
1635
1636        // First pass: compaction discipline (§2.1 S3) — every message from an
1637        // entry before the LATEST `firstKeptEntryId` on the active path is
1638        // excluded from replay (`compacted_out`), mirroring pi's own
1639        // `buildContextEntries` slice (`sm:414-450`).
1640        let mut kept_from_pos = 0usize;
1641        for &idx in &active {
1642            let e = &entries[idx];
1643            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1644                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1645                    if let Some(&p) = pos_in_active.get(fk) {
1646                        kept_from_pos = kept_from_pos.max(p);
1647                    }
1648                }
1649            }
1650        }
1651
1652        let mut messages = Vec::new();
1653        let mut current_model: Option<String> = None;
1654        for (pos, &idx) in active.iter().enumerate() {
1655            let e = &entries[idx];
1656            let v = &e.value;
1657            let entry_ts = v
1658                .get("timestamp")
1659                .and_then(Value::as_str)
1660                .map(str::to_string);
1661            let before = messages.len();
1662            match v.get("type").and_then(Value::as_str) {
1663                Some("message") => {
1664                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1665                    match msg_v.get("role").and_then(Value::as_str) {
1666                        Some("user") => push_pi_user(&msg_v, &mut messages),
1667                        Some("assistant") => {
1668                            push_pi_assistant(&msg_v, &mut messages);
1669                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1670                                current_model = Some(m.to_string());
1671                            }
1672                        }
1673                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1674                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1675                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1676                        // OPEN UNION (S6): any other role — raw-only survival.
1677                        _ => {}
1678                    }
1679                }
1680                Some("custom_message") => push_pi_custom_common(v, &mut messages),
1681                Some("compaction") => push_pi_compaction(v, &mut messages),
1682                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1683                Some("model_change") => {
1684                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1685                        current_model = Some(m.to_string());
1686                    }
1687                }
1688                Some("session_info") => {
1689                    if let Some(name) = v.get("name").and_then(Value::as_str) {
1690                        if !name.is_empty() {
1691                            meta.lineage
1692                                .insert("session_name".to_string(), name.to_string());
1693                        }
1694                    }
1695                }
1696                // thinking_level_change, custom (entry-level state), label —
1697                // no clean home, raw-only (§2.3).
1698                _ => {}
1699            }
1700            let is_summary = matches!(
1701                v.get("type").and_then(Value::as_str),
1702                Some("compaction") | Some("branch_summary")
1703            );
1704            for m in &mut messages[before..] {
1705                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1706                if let Some(p) = &e.parent_id {
1707                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
1708                }
1709                if let Some(ts) = &entry_ts {
1710                    m.metadata
1711                        .entry("timestamp".to_string())
1712                        .or_insert_with(|| ts.clone());
1713                }
1714                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1715                // is pi's authoritative, always-monotonic-in-file-order
1716                // wall-clock (mandatory on every entry) and wins whenever
1717                // present. The nested `message.timestamp` (unix-ms) is only
1718                // reached here — via `entry(...).or_insert_with`, so it
1719                // never overwrites the entry-level value — in the rare case
1720                // an entry lacks its own `timestamp`. This intentionally
1721                // does NOT prefer the msg-level field even though it LOOKS
1722                // more precise: unlike the entry-level timestamp, it is not
1723                // guaranteed monotonic with this loader's root->leaf
1724                // linearization (e.g. a rewound-branch entry can carry an
1725                // earlier msg-level clock reading than its file-order
1726                // neighbors), and OpenCode's own loader re-sorts messages by
1727                // this canonical timestamp — a non-monotonic source would
1728                // silently scramble replay order on a pi->opencode hop.
1729                if let Some(ms) = v
1730                    .get("message")
1731                    .and_then(|mm| mm.get("timestamp"))
1732                    .and_then(Value::as_u64)
1733                {
1734                    m.metadata
1735                        .entry("timestamp".to_string())
1736                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
1737                }
1738                // A compaction/branch-summary message IS the retained marker
1739                // — never mark it excluded, regardless of its own position.
1740                if !is_summary && pos < kept_from_pos {
1741                    m.metadata
1742                        .insert("compacted_out".to_string(), "true".to_string());
1743                }
1744            }
1745            restore_single_grok_message(v, &mut messages[before..]);
1746            for message in &mut messages[before..] {
1747                restore_tool_outcome_extension(v, message);
1748            }
1749        }
1750
1751        meta.model = current_model;
1752        ensure_tool_results_paired(&mut messages);
1753        let imported_message_count = Some(messages.len());
1754        Ok(Session {
1755            meta,
1756            messages,
1757            subagents: Vec::new(),
1758            raw,
1759            raw_trailing_newline,
1760            imported_message_count,
1761            // Pi is line-oriented: `raw` is split directly out of the
1762            // source text (strict-verbatim, IX-1).
1763            raw_is_verbatim: true,
1764            parse_error_lines,
1765            load_residue: Vec::new(),
1766        })
1767    }
1768
1769    /// Load Grok's resumable `chat_history.jsonl` transcript.
1770    ///
1771    /// The surrounding session directory carries the session id, workspace,
1772    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
1773    /// itself while this path-aware entry point overlays that directory
1774    /// metadata.
1775    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
1776        let path = path.as_ref();
1777        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
1778        session.capture_grok_path_metadata(path);
1779        Ok(session)
1780    }
1781
1782    /// Parse Grok's line-oriented `chat_history.jsonl` format.
1783    ///
1784    /// Conversational records are `user`, `assistant`, and `tool_result`.
1785    /// `system` is the regenerated base prompt and is retained in
1786    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
1787    /// state remain byte-exact in [`Session::raw`] but are intentionally not
1788    /// replayed as chat turns.
1789    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
1790        let mut meta = SessionMeta::new(SessionSource::Grok);
1791        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1792        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
1793        let mut messages = Vec::new();
1794        let mut parse_error_lines = 0usize;
1795        let mut tool_names: HashMap<String, String> = HashMap::new();
1796
1797        for line in non_empty_lines(jsonl) {
1798            let value: Value = match serde_json::from_str(line) {
1799                Ok(value) => value,
1800                Err(_) => {
1801                    parse_error_lines += 1;
1802                    continue;
1803                }
1804            };
1805            restore_codex_provenance_from_top_level(&value, &mut meta)?;
1806            match value.get("type").and_then(Value::as_str) {
1807                Some("system") => {
1808                    if meta.system_prompt.is_none() {
1809                        meta.system_prompt = value
1810                            .get("content")
1811                            .and_then(Value::as_str)
1812                            .map(str::to_string);
1813                    }
1814                }
1815                Some("user") => {
1816                    let content = extract_text_content(value.get("content"));
1817                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
1818                        == Some("supercode_system_event")
1819                    {
1820                        Role::System
1821                    } else {
1822                        Role::User
1823                    };
1824                    let content = if role == Role::User {
1825                        match grok_human_user_text(&content) {
1826                            Some(content) => content,
1827                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
1828                                String::new()
1829                            }
1830                            None => continue,
1831                        }
1832                    } else {
1833                        content
1834                    };
1835                    let mut message = ChatMessage {
1836                        role,
1837                        content: Some(content),
1838                        content_parts: None,
1839                        tool_calls: None,
1840                        tool_call_id: None,
1841                        name: None,
1842                        metadata: Default::default(),
1843                    };
1844                    capture_grok_scalar_metadata(
1845                        &value,
1846                        &mut message,
1847                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
1848                    );
1849                    restore_grok_message_extension(&value, &mut message);
1850                    messages.push(message);
1851                }
1852                Some("assistant") => {
1853                    let calls: Vec<ToolCall> = value
1854                        .get("tool_calls")
1855                        .and_then(Value::as_array)
1856                        .into_iter()
1857                        .flatten()
1858                        .filter_map(|call| {
1859                            let id = call.get("id")?.as_str()?.to_string();
1860                            let name = call.get("name")?.as_str()?.to_string();
1861                            let arguments = call
1862                                .get("arguments")
1863                                .map(value_to_arg_string)
1864                                .unwrap_or_else(|| "{}".to_string());
1865                            tool_names.insert(id.clone(), name.clone());
1866                            Some(function_call(&id, &name, arguments))
1867                        })
1868                        .collect();
1869                    let content = value
1870                        .get("content")
1871                        .and_then(Value::as_str)
1872                        .filter(|content| !content.is_empty())
1873                        .map(str::to_string);
1874                    let mut message = ChatMessage {
1875                        role: Role::Assistant,
1876                        content,
1877                        content_parts: None,
1878                        tool_calls: (!calls.is_empty()).then_some(calls),
1879                        tool_call_id: None,
1880                        name: None,
1881                        metadata: Default::default(),
1882                    };
1883                    capture_grok_scalar_metadata(
1884                        &value,
1885                        &mut message,
1886                        &["model_id", "model_fingerprint", "reasoning_effort"],
1887                    );
1888                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
1889                        meta.model = Some(model.to_string());
1890                    }
1891                    restore_grok_message_extension(&value, &mut message);
1892                    messages.push(message);
1893                }
1894                Some("tool_result") => {
1895                    let id = value
1896                        .get("tool_call_id")
1897                        .and_then(Value::as_str)
1898                        .unwrap_or_default();
1899                    let content = value
1900                        .get("content")
1901                        .map(|value| match value {
1902                            Value::String(text) => text.clone(),
1903                            other => extract_text_content(Some(other)),
1904                        })
1905                        .unwrap_or_default();
1906                    let mut message = tool_message(id, content);
1907                    message.name = tool_names.get(id).cloned();
1908                    restore_grok_message_extension(&value, &mut message);
1909                    messages.push(message);
1910                }
1911                // `reasoning` contains encrypted chain-of-thought and
1912                // `backend_tool_call` is execution bookkeeping. Both survive
1913                // verbatim in raw without being replayed to another model.
1914                _ => {}
1915            }
1916        }
1917
1918        ensure_tool_results_paired(&mut messages);
1919        let imported_message_count = Some(messages.len());
1920        Ok(Session {
1921            meta,
1922            messages,
1923            subagents: Vec::new(),
1924            raw,
1925            raw_trailing_newline,
1926            imported_message_count,
1927            raw_is_verbatim: true,
1928            parse_error_lines,
1929            load_residue: Vec::new(),
1930        })
1931    }
1932
1933    fn capture_grok_path_metadata(&mut self, transcript: &Path) {
1934        let Some(session_dir) = transcript.parent() else {
1935            return;
1936        };
1937        self.meta.session_id = session_dir
1938            .file_name()
1939            .and_then(|name| name.to_str())
1940            .map(str::to_string);
1941        self.meta.cwd = session_dir
1942            .parent()
1943            .and_then(Path::file_name)
1944            .and_then(|name| name.to_str())
1945            .and_then(percent_decode_path)
1946            .map(PathBuf::from);
1947
1948        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
1949            return;
1950        };
1951        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
1952            return;
1953        };
1954        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
1955            self.meta.model = Some(model.to_string());
1956        }
1957        for (source, target) in [
1958            ("generated_title", "session_name"),
1959            ("created_at", "created_at"),
1960            ("updated_at", "updated_at"),
1961            ("chat_format_version", "grok_chat_format_version"),
1962        ] {
1963            if let Some(value) = summary.get(source) {
1964                self.meta.lineage.insert(
1965                    target.to_string(),
1966                    value
1967                        .as_str()
1968                        .map(str::to_string)
1969                        .unwrap_or_else(|| value.to_string()),
1970                );
1971            }
1972        }
1973    }
1974
1975    /// Load an OpenCode session from a file — either read surface, see
1976    /// [`Self::from_opencode_str`].
1977    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
1978        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
1979    }
1980
1981    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
1982    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
1983    /// most-recently-updated top-level session, see
1984    /// [`opencode_sqlite_primary_session_id`]) and reconstructs the SAME
1985    /// envelope form [`Self::from_opencode_str`] already parses for the
1986    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
1987    /// discipline, S1 tool-output masking, …) is shared code, not
1988    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
1989    /// for the envelope-construction rules this follows (all-columns rule,
1990    /// raw `revert` column carried verbatim).
1991    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
1992        let conn = opencode_sqlite_open(db_path)?;
1993        let id = match session_id {
1994            Some(id) => id.to_string(),
1995            None => opencode_sqlite_primary_session_id(&conn)?,
1996        };
1997        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
1998        let mut text = lines.join("\n");
1999        text.push('\n');
2000        let mut session = Self::from_opencode_str(&text)?;
2001        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2002        // not the original source bytes (a binary `.db` file has no
2003        // "verbatim" line-oriented form to begin with). `from_opencode_str`
2004        // defaults `raw_is_verbatim` to `true` because for its OTHER two
2005        // callers (an actual envelope-form file's own text, an actual
2006        // export-document's text) that really is the source. It is NEVER
2007        // true for this diagonal — mirrors the export-document fix just
2008        // above for the same reason (`from_opencode_export_doc`, `false`).
2009        // `convert opencode.db --to opencode` must not claim byte-identical.
2010        session.raw_is_verbatim = false;
2011        Ok(session)
2012    }
2013
2014    /// Parse an OpenCode session from either of its two frozen **read
2015    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2016    /// `opencode-fields.md`):
2017    ///
2018    /// - the **envelope form**: each line is
2019    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
2020    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
2021    ///   generations;
2022    /// - the **export-document form**: a single pretty-printed JSON document
2023    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2024    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
2025    ///   what [`Self::to_opencode_jsonl`] emits.
2026    ///
2027    /// Both forms are parsed into the same `(session_info, side_records,
2028    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2029    /// [`opencode_session_from_records`] — so the same underlying records
2030    /// produce identical `messages` regardless of which surface carried
2031    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2032    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2033    /// exercises): previously this function parsed the envelope form only
2034    /// and silently returned an empty-but-`Ok` `Session` for an export
2035    /// document — the confirmed footgun this now closes.
2036    ///
2037    /// Record classification (envelope form) is driven by the envelope
2038    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2039    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2040    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2041    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2042    /// every column, `data` and non-`data` alike — e.g. the `session` row's
2043    /// `revert` column under the V2 `Revert.State` schema, whose extra
2044    /// `files` field the CLI's own row→V1 reconstruction drops; the
2045    /// envelope's `raw` capture keeps that raw column value regardless of
2046    /// what this loader's canonicalization understands).
2047    ///
2048    /// Mapping to canonical `messages` (§2.1, shared by both forms via
2049    /// [`push_opencode_user`]/[`push_opencode_assistant`]): `User`/`Assistant`
2050    /// text parts → `content`; a `User` `file` part whose `mime` is an image
2051    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2052    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2053    /// `ToolCall`, and the SAME part's `state.completed.output` /
2054    /// `state.error.error` → a paired `Tool` message split by `callID`
2055    /// (opencode keeps call+result on one record; this loader splits it
2056    /// into the two OpenAI-shape messages the other loaders already
2057    /// produce).
2058    ///
2059    /// **S1 (`time.compacted`):** when a `tool` part's
2060    /// `state.completed.time.compacted` is set, the emitted `Tool`
2061    /// message's `content` is the placeholder
2062    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2063    /// own `toModelMessage` replays — while the REAL output survives in
2064    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2065    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2066    /// it is reversible, never actually lost.
2067    ///
2068    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2069    /// every message strictly before that message id
2070    /// `metadata["compacted_out"]="true"` (honored uniformly by
2071    /// [`is_replay_excluded`]) — except a `summary:true` `Assistant`
2072    /// message, which opencode itself hoists in FRONT of the retained tail
2073    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2074    /// regardless of its position, mirroring pi's identical exemption for
2075    /// its own compaction/branch-summary entries.
2076    ///
2077    /// **Unknown part `type` or unknown `tool.state.status`:** never
2078    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2079    /// `message.role` (S6-style fail-loud). [`crate::audit::Corpus::OpenCode`]
2080    /// is what turns that into a visible coverage failure rather than a
2081    /// silent drop.
2082    ///
2083    /// **Export-document `raw`:** an export document is a single
2084    /// pretty-printed JSON value with no per-line envelope structure of its
2085    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2086    /// envelope line per `session`/`message`/`part` record found in the
2087    /// document, in the exact `{"key":[...],"value":...}` shape the native
2088    /// envelope form uses — so every native/T1-value-tier path
2089    /// (`to_native_jsonl`, [`Self::opencode_records_from_raw`], the
2090    /// splice/direct-write writers) stays consistent regardless of which
2091    /// read surface produced this `Session`.
2092    ///
2093    /// **Malformed input:** input that reaches this function non-empty but
2094    /// yields zero session/message/part records under EITHER form returns a
2095    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2096    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2097    /// input must not silently succeed with an empty session). A
2098    /// legitimately-empty session — a real `session` record with zero
2099    /// messages, or a valid export document with an empty `messages` array
2100    /// — is not an error.
2101    pub fn from_opencode_str(text: &str) -> Result<Session> {
2102        let trimmed = text.trim();
2103
2104        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2105        // own precedence: try the whole-text parse before the per-line
2106        // envelope loop below, since a pretty-printed multi-line document
2107        // has no individually-valid-JSON lines for that loop to match.
2108        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2109            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2110            {
2111                return Self::from_opencode_export_doc(&doc);
2112            }
2113        }
2114
2115        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2116        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2117        // blank-skipping PARSE walk just below, which keeps skipping
2118        // blank/whitespace-only lines when it looks for envelope records.
2119        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2120        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2121        let mut session_info: Option<Value> = None;
2122        let mut side_records: Vec<Value> = Vec::new();
2123        let mut msgs: Vec<OcMsg> = Vec::new();
2124        let mut msg_index: HashMap<String, usize> = HashMap::new();
2125        // PARITY-15: see `from_claude_code_str`'s identical counter — only
2126        // a genuinely malformed line (fails to deserialize as JSON at all),
2127        // not a well-formed envelope this loader simply doesn't recognize.
2128        let mut parse_error_lines = 0usize;
2129
2130        for line in non_empty_lines(text) {
2131            let Ok(env) = serde_json::from_str::<Value>(line) else {
2132                parse_error_lines += 1;
2133                continue; // malformed line — raw-only, exactly like the other loaders
2134            };
2135            let Some(key) = env.get("key").and_then(Value::as_array) else {
2136                continue; // not an envelope record — raw-only
2137            };
2138            let value = env.get("value").cloned().unwrap_or(Value::Null);
2139            match key.first().and_then(Value::as_str) {
2140                Some("session") => session_info = Some(value),
2141                Some("message") => {
2142                    let Some(id) = value.get("id").and_then(Value::as_str) else {
2143                        continue;
2144                    };
2145                    let time_created = value
2146                        .get("time")
2147                        .and_then(|t| t.get("created"))
2148                        .and_then(Value::as_i64)
2149                        .unwrap_or(0);
2150                    msg_index.insert(id.to_string(), msgs.len());
2151                    msgs.push(OcMsg {
2152                        id: id.to_string(),
2153                        time_created,
2154                        value,
2155                        parts: Vec::new(),
2156                    });
2157                }
2158                Some("part") => {
2159                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2160                        if let Some(&idx) = msg_index.get(msg_id) {
2161                            msgs[idx].parts.push(value);
2162                        }
2163                        // A part whose message wasn't captured (out-of-order
2164                        // envelope) — still fully present in `raw`, just not
2165                        // attached to a canonical message.
2166                    }
2167                }
2168                Some("session_diff") | Some("todo") => {
2169                    side_records.push(serde_json::json!({"key": key, "value": value}));
2170                }
2171                _ => {} // unrecognized top-level key — raw-only
2172            }
2173        }
2174
2175        opencode_guard_against_silent_empty(
2176            !trimmed.is_empty(),
2177            &session_info,
2178            &msgs,
2179            &side_records,
2180        )?;
2181        opencode_session_from_records(
2182            session_info,
2183            side_records,
2184            msgs,
2185            raw,
2186            raw_trailing_newline,
2187            // Envelope form: `raw` is split directly out of the source text
2188            // (strict-verbatim, IX-1) — genuinely reproduces the original
2189            // bytes on replay.
2190            true,
2191            parse_error_lines,
2192        )
2193    }
2194
2195    /// The **export-document** read surface of [`Self::from_opencode_str`]
2196    /// — see that function's doc comment for the shared canonicalization
2197    /// and the `raw` re-synthesis this performs. `doc` is already known to
2198    /// have the `{info, messages:[...]}` shape (the caller checks this,
2199    /// matching `detect_source`'s own S9a check) before calling this.
2200    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2201        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2202        let messages_arr = doc
2203            .get("messages")
2204            .and_then(Value::as_array)
2205            .cloned()
2206            .unwrap_or_default();
2207
2208        let session_id = session_info
2209            .as_ref()
2210            .and_then(|si| si.get("id"))
2211            .and_then(Value::as_str)
2212            .unwrap_or("ses_unknown")
2213            .to_string();
2214        let project_id = session_info
2215            .as_ref()
2216            .and_then(|si| si.get("projectID"))
2217            .and_then(Value::as_str)
2218            .unwrap_or("global")
2219            .to_string();
2220
2221        // Re-synthesize one envelope line per record — see the doc comment
2222        // on `from_opencode_str` ("Export-document `raw`").
2223        let mut raw: Vec<String> = Vec::new();
2224        if let Some(si) = &session_info {
2225            raw.push(
2226                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2227                    .to_string(),
2228            );
2229        }
2230
2231        let mut msgs: Vec<OcMsg> = Vec::new();
2232        for entry in &messages_arr {
2233            let Some(info) = entry.get("info") else {
2234                continue; // malformed message entry — no clean home, raw-only
2235            };
2236            let Some(id) = info.get("id").and_then(Value::as_str) else {
2237                continue;
2238            };
2239            let time_created = info
2240                .get("time")
2241                .and_then(|t| t.get("created"))
2242                .and_then(Value::as_i64)
2243                .unwrap_or(0);
2244            let parts: Vec<Value> = entry
2245                .get("parts")
2246                .and_then(Value::as_array)
2247                .cloned()
2248                .unwrap_or_default();
2249
2250            raw.push(
2251                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2252            );
2253            for p in &parts {
2254                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
2255                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
2256            }
2257
2258            msgs.push(OcMsg {
2259                id: id.to_string(),
2260                time_created,
2261                value: info.clone(),
2262                parts,
2263            });
2264        }
2265
2266        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
2267        opencode_session_from_records(
2268            session_info,
2269            Vec::new(),
2270            msgs,
2271            raw,
2272            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
2273            // line re-derived per record, no real per-line source bytes to
2274            // measure) — matches the historical always-newline-terminated
2275            // behavior; see `Session::raw_trailing_newline`'s doc comment.
2276            true,
2277            // Export-document form: `raw` above is RE-SYNTHESIZED, one
2278            // envelope line derived per record — not the original document's
2279            // bytes (see this function's doc comment). `convert`'s
2280            // byte-identical claim must not fire on this diagonal.
2281            false,
2282            // PARITY-15: a pretty-printed export document is parsed WHOLE
2283            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
2284            // there's no per-line parse-loss concept here; a malformed
2285            // document fails that top-level parse and never reaches this
2286            // function at all.
2287            0,
2288        )
2289    }
2290
2291    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
2292    /// core.session(tree-addressable transcript)"): materialize this
2293    /// session's linear [`Self::messages`] into a native in-place
2294    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
2295    /// FIRST time it wants to run a tree operation (rewind/branch/label)
2296    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
2297    /// synthesized node (see
2298    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
2299    /// why a single timestamp is used: the source linear messages carry no
2300    /// per-turn timestamp of their own here).
2301    ///
2302    /// This does not mutate `self` or persist anything — see
2303    /// [`crate::store::SessionStore::save_tree`] for the sidecar write, and
2304    /// [`Self::apply_session_tree`] for the inverse bridge.
2305    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
2306        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
2307    }
2308
2309    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
2310    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
2311    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
2312    /// existing linear consumer — the agent loop, exporters — working
2313    /// unchanged after a tree operation runs). Nothing else on `self`
2314    /// (`meta`, `raw`, ...) is touched.
2315    ///
2316    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
2317    /// `Err` rather than applying anything — a structurally-corrupt tree
2318    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
2319    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
2320    /// `self` is left untouched on `Err` (the assignment only happens after
2321    /// the projection has already succeeded).
2322    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
2323        self.messages = tree.linear_projection()?;
2324        Ok(())
2325    }
2326}
2327
2328/// One opencode `message` record plus its `part` children, gathered from
2329/// EITHER read surface (envelope-form records or export-document
2330/// `{info, parts}` entries) before the shared per-record canonicalization
2331/// in [`opencode_session_from_records`].
2332struct OcMsg {
2333    id: String,
2334    time_created: i64,
2335    value: Value,
2336    parts: Vec<Value>,
2337}
2338
2339const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
2340const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
2341const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
2342
2343/// Guard against the confirmed footgun: input that reached
2344/// [`Session::from_opencode_str`] non-empty but produced no
2345/// session/message/part record under either read surface returns `Err`
2346/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
2347/// (a real session record with zero messages, or a valid empty `messages`
2348/// array) is not an error — only genuinely unparseable content is.
2349fn opencode_guard_against_silent_empty(
2350    non_empty_input: bool,
2351    session_info: &Option<Value>,
2352    msgs: &[OcMsg],
2353    side_records: &[Value],
2354) -> Result<()> {
2355    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
2356        || !msgs.is_empty()
2357        || !side_records.is_empty();
2358    if non_empty_input && !has_any_record {
2359        return Err(crate::Error::Other(
2360            "opencode input was recognized as an OpenCode source (envelope or \
2361             export-document form) but no session/message/part record could be parsed from \
2362             it — refusing to silently return an empty session"
2363                .to_string(),
2364        ));
2365    }
2366    Ok(())
2367}
2368
2369/// The shared per-record canonicalization for BOTH of
2370/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
2371/// export-document form): frozen ordering, `SessionMeta` capture, the
2372/// compaction boundary pass, and the `User`/`Assistant` → `messages`
2373/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
2374/// same underlying `(session_info, side_records, msgs)` regardless of which
2375/// surface produced them, this produces byte-for-byte identical `messages`
2376/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
2377fn opencode_session_from_records(
2378    session_info: Option<Value>,
2379    side_records: Vec<Value>,
2380    mut msgs: Vec<OcMsg>,
2381    raw: Vec<String>,
2382    raw_trailing_newline: bool,
2383    raw_is_verbatim: bool,
2384    parse_error_lines: usize,
2385) -> Result<Session> {
2386    let mut meta = SessionMeta::new(SessionSource::OpenCode);
2387
2388    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
2389    // each message id to its PRE-sort position — used only to resolve a
2390    // `tail_start_id` reference in the compaction-boundary pass further
2391    // down. In every real opencode session (either surface) records
2392    // already arrive/are listed in creation order, so pre- and post-sort
2393    // positions coincide; this mirrors the original envelope-only
2394    // implementation's behavior exactly (not a new invariant introduced by
2395    // sharing this code across both surfaces).
2396    let msg_index: HashMap<String, usize> = msgs
2397        .iter()
2398        .enumerate()
2399        .map(|(i, m)| (m.id.clone(), i))
2400        .collect();
2401
2402    // Frozen order (§1.2): messages by (time.created, id); each
2403    // message's parts by id.
2404    msgs.sort_by(|a, b| {
2405        a.time_created
2406            .cmp(&b.time_created)
2407            .then_with(|| a.id.cmp(&b.id))
2408    });
2409    for m in &mut msgs {
2410        m.parts.sort_by(|a, b| {
2411            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
2412            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
2413            ai.cmp(bi)
2414        });
2415    }
2416
2417    meta.opencode_headers
2418        .push(session_info.clone().unwrap_or(Value::Null));
2419    meta.opencode_headers.extend(side_records);
2420    if let Some(si) = &session_info {
2421        capture_opencode_session_info(si, &mut meta)?;
2422    }
2423
2424    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
2425    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
2426    // active path in opencode's own linear message list, so no branch
2427    // walk is needed the way pi's tree requires).
2428    let mut tail_start_pos: Option<usize> = None;
2429    for m in &msgs {
2430        for p in &m.parts {
2431            if p.get("type").and_then(Value::as_str) == Some("compaction") {
2432                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
2433                    if let Some(&tp) = msg_index.get(t) {
2434                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
2435                    }
2436                }
2437            }
2438        }
2439    }
2440
2441    let mut messages = Vec::new();
2442    let mut first_system_seen = false;
2443    for (pos, m) in msgs.iter().enumerate() {
2444        let before = messages.len();
2445        match m.value.get("role").and_then(Value::as_str) {
2446            // B4: a `User` message that's actually
2447            // `append_synthesized_opencode_messages`'s own re-materialized
2448            // Claude `system` record (one `synthetic: true` text part
2449            // carrying the supercode marker key — see
2450            // `opencode_claude_system_subtype`'s doc comment) restores
2451            // `Role::System`, not a genuine user turn.
2452            Some("user") => match opencode_claude_system_subtype(&m.parts) {
2453                Some(subtype) => {
2454                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
2455                }
2456                None => push_opencode_user(
2457                    &m.value,
2458                    &m.parts,
2459                    &mut messages,
2460                    &mut meta,
2461                    &mut first_system_seen,
2462                ),
2463            },
2464            Some("assistant") => {
2465                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
2466            }
2467            // Unrecognized/missing role — raw-only survival;
2468            // `audit::Corpus::OpenCode` scores this as Unmodeled.
2469            _ => {}
2470        }
2471        if let Some(original_position) = m
2472            .value
2473            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
2474            .and_then(Value::as_u64)
2475        {
2476            if let Some(message) = messages[before..]
2477                .iter_mut()
2478                .find(|message| message.role != Role::Tool)
2479            {
2480                message.metadata.insert(
2481                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
2482                    original_position.to_string(),
2483                );
2484            }
2485        }
2486        for msg in &mut messages[before..] {
2487            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
2488            if !is_summary {
2489                if let Some(tsp) = tail_start_pos {
2490                    if pos < tsp {
2491                        msg.metadata
2492                            .insert("compacted_out".to_string(), "true".to_string());
2493                    }
2494                }
2495            }
2496        }
2497    }
2498
2499    let marked_slots = messages
2500        .iter()
2501        .enumerate()
2502        .filter_map(|(index, message)| {
2503            message
2504                .metadata
2505                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
2506                .then_some(index)
2507        })
2508        .collect::<Vec<_>>();
2509    if !marked_slots.is_empty() {
2510        // A spliced OpenCode export can contain an unmarked native prefix
2511        // followed by a marked synthesized tail. Reorder only among the
2512        // marked slots so the tail never jumps in front of its raw prefix.
2513        let mut marked_messages = marked_slots
2514            .iter()
2515            .map(|index| messages[*index].clone())
2516            .collect::<Vec<_>>();
2517        marked_messages.sort_by_key(|message| {
2518            message
2519                .metadata
2520                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
2521                .and_then(|position| position.parse::<usize>().ok())
2522                .unwrap_or(usize::MAX)
2523        });
2524        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
2525            messages[slot] = message;
2526        }
2527        for message in &mut messages {
2528            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
2529        }
2530    }
2531    ensure_tool_results_paired(&mut messages);
2532    let imported_message_count = Some(messages.len());
2533    Ok(Session {
2534        meta,
2535        messages,
2536        subagents: Vec::new(),
2537        raw,
2538        raw_trailing_newline,
2539        imported_message_count,
2540        raw_is_verbatim,
2541        parse_error_lines,
2542        load_residue: Vec::new(),
2543    })
2544}
2545
2546/// Resolve each opencode subagent (`task`) child session's
2547/// `meta.parent_tool_use_id` from its parent's own `task` tool part
2548/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
2549/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
2550/// `opencode-fields.md` `task.ts:145,171-176`).
2551///
2552/// Nesting itself needs no opencode-specific pass:
2553/// [`capture_opencode_session_info`] already mirrors `SessionInfo.parentID`
2554/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
2555/// so the existing generic [`Session::reconstruct_tree`] nests these
2556/// sessions correctly on its own. Call this FIRST — it only reads
2557/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
2558/// the same `Vec` to `reconstruct_tree`.
2559pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
2560    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
2561    for i in 0..sessions.len() {
2562        let child_id = sessions[i].meta.session_id.clone();
2563        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
2564        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
2565            continue;
2566        };
2567        let Some(parent_idx) = ids
2568            .iter()
2569            .position(|id| id.as_deref() == Some(parent_id.as_str()))
2570        else {
2571            continue;
2572        };
2573        for m in &sessions[parent_idx].messages {
2574            for (k, v) in &m.metadata {
2575                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
2576                    if v == &child_id {
2577                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
2578                    }
2579                }
2580            }
2581        }
2582    }
2583}
2584
2585/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
2586///
2587/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
2588/// structure but are NOT guaranteed to be well-formed in raw file order: async
2589/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
2590/// line BEFORE the assistant `tool_use` line that owns it, even though the
2591/// parent/child tree itself is fine. The active-branch projection restores
2592/// parent-before-child order, but a result can still trail a later assistant
2593/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
2594/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
2595/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
2596///
2597/// This reorders `messages` so every OWNED `Role::Tool` result (its
2598/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
2599/// message anywhere in the list) sits immediately after the `Role::Assistant`
2600/// message that owns it, while leaving every other message's relative order
2601/// untouched. Orphan tool results — no matching call anywhere in the list —
2602/// are left in their ORIGINAL position, untouched; they are never moved. It
2603/// is a pure reorder: same message count, same multiset of messages, in/out.
2604///
2605/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
2606/// — each appears exactly once as a call and once as its result — so a
2607/// simple id -> owning-assistant map is sufficient; no special-casing is
2608/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
2609/// already pushes those inline with their own distinct ids.
2610///
2611/// Results whose matching call is missing entirely (no owner found) are left
2612/// in place untouched — `ensure_tool_results_paired` (which runs right after
2613/// this) is responsible for synthesizing a placeholder result for any call
2614/// that ends up unanswered; this pass never drops or fabricates anything.
2615fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
2616    // 0. First pass: which tool_call_ids are actually "owned" — emitted by
2617    //    some assistant message anywhere in the list — and the position of
2618    //    that owning assistant. Owned as `String` (not borrowed) so this map
2619    //    can outlive the later `messages.drain(..)`.
2620    let mut owner_positions: HashMap<String, usize> = HashMap::new();
2621    for (index, m) in messages.iter().enumerate() {
2622        if m.role == Role::Assistant {
2623            for c in m.tool_calls() {
2624                if !c.id.is_empty() {
2625                    owner_positions.entry(c.id.clone()).or_insert(index);
2626                }
2627            }
2628        }
2629    }
2630
2631    // Fast, cheap detection of "nothing to do": every owned result must be
2632    // in the contiguous tool-result block immediately following its owning
2633    // assistant. Checking only result-before-owner inversions is insufficient
2634    // after Claude's active-branch projection: that projection can put the
2635    // owner first while leaving its result behind a later assistant turn.
2636    // Mere orphans never set this flag. A canonical session returns with
2637    // `messages` byte-for-byte unchanged, mirroring
2638    // `ensure_tool_results_paired`'s own no-op guard.
2639    let mut contiguous_owner = None;
2640    let needs_reorder =
2641        messages
2642            .iter()
2643            .enumerate()
2644            .any(|(message_index, message)| match message.role {
2645                Role::Assistant => {
2646                    contiguous_owner = Some(message_index);
2647                    false
2648                }
2649                Role::Tool => match message
2650                    .tool_call_id
2651                    .as_deref()
2652                    .and_then(|id| owner_positions.get(id))
2653                    .copied()
2654                {
2655                    Some(owner) => Some(owner) != contiguous_owner,
2656                    None => {
2657                        // An orphan or unlinked tool message interrupts the
2658                        // owner's contiguous result block but never moves by
2659                        // itself.
2660                        contiguous_owner = None;
2661                        false
2662                    }
2663                },
2664                _ => {
2665                    contiguous_owner = None;
2666                    false
2667                }
2668            });
2669    if !needs_reorder {
2670        return;
2671    }
2672
2673    // 1. Second pass: route messages into the "spine" (everything that stays
2674    //    at its own position — non-tool messages AND orphan tool results)
2675    //    versus owned tool results (pulled out, to be reattached right after
2676    //    their owner). Record, for each spine index that's an assistant, the
2677    //    set of tool_call_ids it owns.
2678    let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
2679    let mut call_owner: HashMap<String, usize> = HashMap::new();
2680    // Buffer of (original_position, message) for every OWNED tool result,
2681    // built alongside the spine; a result can reference a call emitted later
2682    // in file order, so owner spine-index is resolved in a later step once
2683    // `call_owner` is complete.
2684    let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
2685
2686    let drained: Vec<ChatMessage> = std::mem::take(messages);
2687    for (orig_pos, msg) in drained.into_iter().enumerate() {
2688        if msg.role == Role::Tool {
2689            let is_owned = msg
2690                .tool_call_id
2691                .as_deref()
2692                .map(|id| !id.is_empty() && owner_positions.contains_key(id))
2693                .unwrap_or(false);
2694            if is_owned {
2695                owned_results.push((orig_pos, msg));
2696                continue;
2697            }
2698            // Orphan: no matching call anywhere. Treat exactly like a
2699            // non-tool message for placement — it joins the spine at its
2700            // current position and is never moved.
2701            spine.push(msg);
2702            continue;
2703        }
2704        if msg.role == Role::Assistant {
2705            let spine_idx = spine.len();
2706            for c in msg.tool_calls() {
2707                if !c.id.is_empty() {
2708                    call_owner.entry(c.id.clone()).or_insert(spine_idx);
2709                }
2710            }
2711        }
2712        spine.push(msg);
2713    }
2714
2715    // 2. Resolve each owned result's owner spine-index now that `call_owner`
2716    //    is complete, then bucket results by owner spine-index. Every result
2717    //    here was routed as "owned" because its id was found in `owned_ids`,
2718    //    which was built from the exact same `tool_calls()` scan that
2719    //    populates `call_owner` below, so the lookup is guaranteed to hit.
2720    let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
2721    for (orig_pos, msg) in owned_results.into_iter() {
2722        let id = msg
2723            .tool_call_id
2724            .as_deref()
2725            .filter(|id| !id.is_empty())
2726            .expect("routed as owned, so tool_call_id must be a non-empty owned id");
2727        let idx = *call_owner
2728            .get(id)
2729            .expect("owned id must have an owning assistant in call_owner");
2730        buckets.entry(idx).or_default().push((orig_pos, msg));
2731    }
2732    // Keep each bucket's results in their original relative file order.
2733    for v in buckets.values_mut() {
2734        v.sort_by_key(|(pos, _)| *pos);
2735    }
2736
2737    // 3. Rebuild: emit each spine message (which now includes orphans at
2738    //    their original position, untouched) in order; immediately after
2739    //    emitting an assistant message that owns one or more tool results,
2740    //    emit its owned results, in original relative order.
2741    let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
2742    for (idx, msg) in spine.into_iter().enumerate() {
2743        out.push(msg);
2744        if let Some(results) = buckets.remove(&idx) {
2745            for (_, r) in results {
2746                out.push(r);
2747            }
2748        }
2749    }
2750    *messages = out;
2751}
2752
2753/// Guarantee every assistant `tool_calls` entry is answered by a following tool
2754/// result. Interrupted/aborted turns leave a tool call with no result, which
2755/// many chat-completions endpoints reject when the conversation is replayed.
2756/// We insert a synthetic placeholder result immediately after the assistant
2757/// turn so the transcript stays valid for continuation. (Orphan results — a
2758/// tool message with no preceding call — do not occur in practice and are left
2759/// untouched.)
2760fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
2761    let answered: HashSet<String> = messages
2762        .iter()
2763        .filter(|m| m.role == Role::Tool)
2764        .filter_map(|m| m.tool_call_id.clone())
2765        .collect();
2766
2767    // Nothing missing? Leave the vector byte-for-byte unchanged.
2768    let any_missing = messages.iter().any(|m| {
2769        m.tool_calls()
2770            .iter()
2771            .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
2772    });
2773    if !any_missing {
2774        return;
2775    }
2776
2777    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
2778    for msg in messages.drain(..) {
2779        let synth: Vec<ChatMessage> = msg
2780            .tool_calls()
2781            .iter()
2782            .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
2783            .map(|c| {
2784                let mut m = ChatMessage::tool_result(
2785                    c.id.clone(),
2786                    c.function.name.clone(),
2787                    "[no tool result recorded — turn interrupted]".to_string(),
2788                );
2789                // TR-10: an interrupted call never executed to completion —
2790                // never a candidate for `ReductionKind::ToolInputElided`
2791                // (the "still-pending calls are never input-elided"
2792                // boundary).
2793                crate::reduce::mark_tool_error(&mut m);
2794                m
2795            })
2796            .collect();
2797        out.push(msg);
2798        out.extend(synth);
2799    }
2800    *messages = out;
2801}
2802
2803/// Whether `msg` is excluded from every replay/export path — the frozen
2804/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
2805/// a message marked `compacted_out` (pre-compaction history a source harness
2806/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
2807/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
2808/// format, not just the one that produced the marker — so a translated
2809/// compacted session replays the same sliced context the source harness
2810/// would, instead of double-including history plus its own summary.
2811fn is_replay_excluded(msg: &ChatMessage) -> bool {
2812    msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
2813        || msg
2814            .metadata
2815            .get("pi_exclude_from_context")
2816            .map(String::as_str)
2817            == Some("true")
2818}
2819
2820// ---- detection ------------------------------------------------------------
2821
2822fn detect_source(text: &str) -> Option<SessionSource> {
2823    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
2824    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
2825    // pretty-printed, MULTI-LINE JSON document, unlike every other format
2826    // this crate reads. It cannot be recognized by the per-line loop below
2827    // (no individual line of a pretty-printed document is itself valid
2828    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
2829    // attempt: a real JSONL file (many newline-separated objects) fails this
2830    // parse immediately (trailing-data error) and falls through unaffected.
2831    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
2832        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
2833            return Some(SessionSource::OpenCode);
2834        }
2835    }
2836    for line in non_empty_lines(text) {
2837        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
2838        // than abandoning detection — the loaders themselves skip bad lines, so
2839        // bailing here would silently misroute an otherwise-valid Codex file.
2840        let Ok(v) = serde_json::from_str::<Value>(line) else {
2841            continue;
2842        };
2843        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
2844        // one record per line — the synthesized raw-capture unit for the
2845        // JSON-tree/SQLite generations alike. No other format's lines carry
2846        // both a top-level `key` ARRAY and a `value` field, so this is
2847        // unambiguous against Codex/Pi/Claude Code.
2848        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
2849            return Some(SessionSource::OpenCode);
2850        }
2851        // Codex envelopes always carry a `payload`; Claude Code lines never do.
2852        if v.get("payload").is_some() {
2853            return Some(SessionSource::Codex);
2854        }
2855        // Grok's resumable `chat_history.jsonl` stores the role/type and
2856        // content directly on each record. Claude Code uses a nested
2857        // `message` envelope for the overlapping `user`/`assistant` tags.
2858        let tag = v.get("type").and_then(Value::as_str);
2859        if v.get("message").is_none()
2860            && v.get("uuid").is_none()
2861            && v.get("sessionId").is_none()
2862            && matches!(
2863                tag,
2864                Some(
2865                    "system"
2866                        | "user"
2867                        | "assistant"
2868                        | "tool_result"
2869                        | "reasoning"
2870                        | "backend_tool_call"
2871                )
2872            )
2873            && (v.get("content").is_some()
2874                || v.get("tool_calls").is_some()
2875                || v.get("tool_call_id").is_some()
2876                || v.get("encrypted_content").is_some()
2877                || v.get("kind").is_some())
2878        {
2879            return Some(SessionSource::Grok);
2880        }
2881        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
2882        // (the session id) with no `message`/`uuid` — Claude Code's own
2883        // `type`-bearing lines always carry one or the other, never a
2884        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
2885        // §1).
2886        if v.get("type").and_then(Value::as_str) == Some("session")
2887            && v.get("id").and_then(Value::as_str).is_some()
2888            && v.get("message").is_none()
2889            && v.get("uuid").is_none()
2890        {
2891            return Some(SessionSource::Pi);
2892        }
2893        if v.get("type").is_some() || v.get("message").is_some() {
2894            return Some(SessionSource::ClaudeCode);
2895        }
2896    }
2897    None
2898}
2899
2900/// Which on-disk OpenCode storage surface is present under a data root
2901/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
2902/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
2903/// generation A. This is a **filesystem classifier only** — it answers
2904/// "which generation is this?" for a corpus-discovery tool; it does not
2905/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
2906/// for the envelope form any of these three surfaces synthesizes into, and
2907/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
2908/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
2909/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
2910/// round-trips via the JSON store per upstream's own behavior even on a
2911/// SQLite install, so nothing is silently lost by not reading the legacy
2912/// trees directly).
2913#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2914pub enum OpenCodeStorageSurface {
2915    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
2916    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
2917    /// [`opencode_sqlite_corpus_envelope_text`].
2918    Sqlite,
2919    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
2920    /// marker file `storage/migration`.
2921    JsonTreeB,
2922    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
2923    JsonTreeA,
2924}
2925
2926/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
2927/// storage surface present, per the discovery rules frozen in
2928/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
2929/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
2930/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
2931/// tree generation-B marker (`storage/migration`); otherwise generation-A's
2932/// `project/` subtree. Returns `None` if nothing is found.
2933pub fn detect_opencode_storage_surface(
2934    data_root: &Path,
2935) -> Option<(OpenCodeStorageSurface, PathBuf)> {
2936    if let Ok(p) = std::env::var("OPENCODE_DB") {
2937        let pb = PathBuf::from(p);
2938        if pb.is_file() {
2939            return Some((OpenCodeStorageSurface::Sqlite, pb));
2940        }
2941    }
2942    if let Ok(entries) = std::fs::read_dir(data_root) {
2943        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
2944        // NOT deterministic — a store with both a default-channel
2945        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
2946        // are legal, e.g. after switching install channels) previously
2947        // returned "whichever the OS happened to list first", which could
2948        // differ between two `inspect`/`audit`/`convert` runs against the
2949        // exact same directory. Collect every `opencode*.db` candidate and
2950        // pick deterministically: the exact `opencode.db` name wins if
2951        // present (the default/most-common channel); otherwise the
2952        // lexicographically-smallest match, so repeated runs always agree.
2953        let mut candidates: Vec<PathBuf> = entries
2954            .flatten()
2955            .map(|entry| entry.path())
2956            .filter(|p| {
2957                p.file_name()
2958                    .and_then(|n| n.to_str())
2959                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
2960            })
2961            .collect();
2962        candidates.sort();
2963        if let Some(exact) = candidates
2964            .iter()
2965            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
2966        {
2967            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
2968        }
2969        if let Some(first) = candidates.into_iter().next() {
2970            return Some((OpenCodeStorageSurface::Sqlite, first));
2971        }
2972    }
2973    let storage = data_root.join("storage");
2974    if storage.join("migration").is_file() {
2975        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
2976    }
2977    let project_dir = data_root.join("project");
2978    if project_dir.is_dir() {
2979        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
2980    }
2981    None
2982}
2983
2984/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
2985/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
2986///
2987/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
2988/// `\r` survives as part of the returned line's own content; blank lines and
2989/// trailing-whitespace-only lines are kept verbatim rather than dropped or
2990/// trimmed. This is what makes `Session.raw` — populated from this at every
2991/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
2992/// just well-formed LF JSONL with no blank lines.
2993///
2994/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
2995/// distinguish a source that ended with a trailing newline from one that
2996/// didn't (both split into the same line list), so `ends_with_newline`
2997/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
2998/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
2999/// source has zero lines, not one blank line.
3000fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3001    if text.is_empty() {
3002        return (Vec::new(), false);
3003    }
3004    let ends_with_newline = text.ends_with('\n');
3005    let body = if ends_with_newline {
3006        &text[..text.len() - 1]
3007    } else {
3008        text
3009    };
3010    (body.split('\n').collect(), ends_with_newline)
3011}
3012
3013/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3014/// source bytes from its verbatim lines plus the trailing-newline flag.
3015fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3016    let mut out = lines.join("\n");
3017    if ends_with_newline {
3018        out.push('\n');
3019    }
3020    out
3021}
3022
3023// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3024//
3025// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3026// SQLite — no system library dependency) and reconstructs the SAME envelope
3027// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3028// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3029// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3030// `session.ts` `fromRow` (session table: columnar fields recombined into the
3031// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3032// carried as the RAW column value, not upstream's own `fromRow`
3033// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3034// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3035// `message`/`part` rows are simpler: their `data` column is already the V1
3036// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3037// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3038// `id`/`sessionID`(/`messageID`).
3039
3040/// First 16 bytes of every SQLite database file — the format's own magic,
3041/// independent of file extension.
3042const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3043
3044/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3045/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3046/// the SQLite magic, OR its extension is `.db` — the latter so a
3047/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3048/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3049/// A non-existent path is NOT considered SQLite here — the missing-file
3050/// diagnostic in that case comes from the normal load path (`with_context`
3051/// at the CLI call sites), which already names the path clearly.
3052pub fn looks_like_sqlite(path: &Path) -> bool {
3053    if !path.is_file() {
3054        return false;
3055    }
3056    if path.extension().and_then(|e| e.to_str()) == Some("db") {
3057        return true;
3058    }
3059    use std::io::Read;
3060    let Ok(mut f) = std::fs::File::open(path) else {
3061        return false;
3062    };
3063    let mut buf = [0u8; 16];
3064    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3065}
3066
3067/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3068/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3069/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3070/// accept there. Binary SQLite input never reaches this function: callers
3071/// check [`looks_like_sqlite`] first and route to
3072/// [`Session::from_opencode_sqlite`] instead.
3073fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3074    let bytes = std::fs::read(path)?;
3075    String::from_utf8(bytes).map_err(|_| {
3076        crate::Error::Other(format!(
3077            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3078             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3079             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3080            path.display()
3081        ))
3082    })
3083}
3084
3085fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3086    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3087}
3088
3089/// Open `db_path` read-only and confirm it carries the expected V1 schema
3090/// (a `session` table) — the shared entry point for every SQLite read below,
3091/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
3092/// path, not-a-database, and wrong/unsupported schema are each named
3093/// distinctly rather than surfacing later as "zero sessions" or a generic
3094/// parse failure.
3095fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
3096    if !db_path.is_file() {
3097        return Err(crate::Error::Other(format!(
3098            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
3099             (see `docs/interop/opencode-pi-spec.md` §1.2)",
3100            db_path.display()
3101        )));
3102    }
3103    let conn = Connection::open_with_flags(
3104        db_path,
3105        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3106    )
3107    .map_err(|e| {
3108        crate::Error::Other(format!(
3109            "{} does not look like a valid OpenCode SQLite database: {e}",
3110            db_path.display()
3111        ))
3112    })?;
3113    let has_session_table: i64 = conn
3114        .query_row(
3115            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
3116            [],
3117            |r| r.get(0),
3118        )
3119        .map_err(|e| {
3120            crate::Error::Other(format!(
3121                "failed to read the OpenCode SQLite schema at {}: {e}",
3122                db_path.display()
3123            ))
3124        })?;
3125    if has_session_table == 0 {
3126        return Err(crate::Error::Other(format!(
3127            "{} is a SQLite database but has no `session` table — not a recognized \
3128             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
3129            db_path.display()
3130        )));
3131    }
3132    Ok(conn)
3133}
3134
3135/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
3136/// …). D7: an unparseable non-empty column previously degraded to
3137/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
3138/// absent/NULL column, so a corrupt `data`/`metadata` value silently
3139/// vanished (e.g. a message whose `data` fails to parse loses its entire
3140/// canonical content with no trace). A `tracing::warn!` now surfaces the
3141/// column name and context (session/record id) whenever this happens, so
3142/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
3143/// (still the least-wrong placeholder for a broken column; changing it to a
3144/// sentinel would risk misleading every legitimate `.is_null()` check
3145/// elsewhere) but the frontend/log now knows it happened.
3146fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
3147    match s.as_deref() {
3148        None => Value::Null,
3149        Some(t) => match serde_json::from_str::<Value>(t) {
3150            Ok(v) => v,
3151            Err(e) => {
3152                tracing::warn!(
3153                    column = col,
3154                    context,
3155                    error = %e,
3156                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
3157                );
3158                Value::Null
3159            }
3160        },
3161    }
3162}
3163
3164/// Columns the `session` table has in a GIVEN store, read once per session
3165/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
3166/// `opencode` generation may lack columns the newest schema added, e.g.
3167/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
3168/// "Invalid column name" on an absent column, so callers must check
3169/// membership before reading a not-guaranteed column instead of reading it
3170/// unconditionally).
3171fn opencode_session_columns(
3172    conn: &Connection,
3173) -> rusqlite::Result<std::collections::HashSet<String>> {
3174    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
3175    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
3176    names.collect()
3177}
3178
3179/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
3180/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
3181/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
3182/// `revert` carries the raw column value verbatim rather than upstream's
3183/// field-selecting reconstruction (spec S9c: that reconstruction silently
3184/// drops the V2 `Revert.State` schema's extra `files` field).
3185///
3186/// D3: not every column this loader would like to read is guaranteed to
3187/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
3188/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
3189/// `agent`/`model` entirely. Those are read defensively (guarded by
3190/// [`opencode_session_columns`]); columns present in EVERY `opencode`
3191/// generation this loader has ever targeted are still read unconditionally.
3192fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
3193    let cols = opencode_session_columns(conn)
3194        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
3195    let has = |name: &str| cols.contains(name);
3196
3197    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
3198        let id: String = r.get("id")?;
3199        let project_id: String = r.get("project_id")?;
3200        let workspace_id: Option<String> = if has("workspace_id") {
3201            r.get("workspace_id")?
3202        } else {
3203            None
3204        };
3205        let parent_id: Option<String> = r.get("parent_id")?;
3206        let slug: String = r.get("slug")?;
3207        let directory: String = r.get("directory")?;
3208        let path: Option<String> = if has("path") { r.get("path")? } else { None };
3209        let title: String = r.get("title")?;
3210        let version: String = r.get("version")?;
3211        let share_url: Option<String> = r.get("share_url")?;
3212        let summary_additions: Option<i64> = r.get("summary_additions")?;
3213        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
3214        let summary_files: Option<i64> = r.get("summary_files")?;
3215        let summary_diffs: Option<String> = r.get("summary_diffs")?;
3216        let metadata: Option<String> = if has("metadata") {
3217            r.get("metadata")?
3218        } else {
3219            None
3220        };
3221        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
3222        let tokens_input: i64 = if has("tokens_input") {
3223            r.get("tokens_input")?
3224        } else {
3225            0
3226        };
3227        let tokens_output: i64 = if has("tokens_output") {
3228            r.get("tokens_output")?
3229        } else {
3230            0
3231        };
3232        let tokens_reasoning: i64 = if has("tokens_reasoning") {
3233            r.get("tokens_reasoning")?
3234        } else {
3235            0
3236        };
3237        let tokens_cache_read: i64 = if has("tokens_cache_read") {
3238            r.get("tokens_cache_read")?
3239        } else {
3240            0
3241        };
3242        let tokens_cache_write: i64 = if has("tokens_cache_write") {
3243            r.get("tokens_cache_write")?
3244        } else {
3245            0
3246        };
3247        let revert: Option<String> = r.get("revert")?;
3248        let permission: Option<String> = if has("permission") {
3249            r.get("permission")?
3250        } else {
3251            None
3252        };
3253        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
3254        let model: Option<String> = if has("model") { r.get("model")? } else { None };
3255        let time_created: i64 = r.get("time_created")?;
3256        let time_updated: i64 = r.get("time_updated")?;
3257        let time_compacting: Option<i64> = if has("time_compacting") {
3258            r.get("time_compacting")?
3259        } else {
3260            None
3261        };
3262        let time_archived: Option<i64> = if has("time_archived") {
3263            r.get("time_archived")?
3264        } else {
3265            None
3266        };
3267
3268        let summary =
3269            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
3270                .then(|| {
3271                    serde_json::json!({
3272                        "additions": summary_additions.unwrap_or(0),
3273                        "deletions": summary_deletions.unwrap_or(0),
3274                        "files": summary_files.unwrap_or(0),
3275                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
3276                    })
3277                });
3278        let share = share_url.map(|u| serde_json::json!({"url": u}));
3279
3280        Ok(serde_json::json!({
3281            "id": id,
3282            "slug": slug,
3283            "projectID": project_id,
3284            "workspaceID": workspace_id,
3285            "directory": directory,
3286            "path": path,
3287            "parentID": parent_id,
3288            "summary": summary,
3289            "cost": cost,
3290            "tokens": {
3291                "input": tokens_input,
3292                "output": tokens_output,
3293                "reasoning": tokens_reasoning,
3294                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
3295            },
3296            "share": share,
3297            "title": title,
3298            "agent": agent,
3299            "model": opencode_json_col(model, "model", session_id),
3300            "version": version,
3301            "metadata": opencode_json_col(metadata, "metadata", session_id),
3302            "time": {
3303                "created": time_created,
3304                "updated": time_updated,
3305                "compacting": time_compacting,
3306                "archived": time_archived,
3307            },
3308            "permission": opencode_json_col(permission, "permission", session_id),
3309            // S9c: raw column value, not a field-selecting reconstruction —
3310            // see this function's doc comment.
3311            "revert": opencode_json_col(revert, "revert", session_id),
3312        }))
3313    })
3314    .map_err(|e| match e {
3315        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
3316            "OpenCode session `{session_id}` not found in this SQLite store"
3317        )),
3318        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
3319    })
3320}
3321
3322/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
3323/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
3324/// re-inject them, matching what a JSON-tree file (or the export document)
3325/// carries at this same key. Also re-injects the row's own `time_created`/
3326/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
3327/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
3328/// in the envelope so `raw` is value-complete and re-writable without
3329/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
3330/// which is a different, in-schema field with different semantics).
3331fn opencode_row_message_value(
3332    id: &str,
3333    session_id: &str,
3334    data_json: &str,
3335    time_created: i64,
3336    time_updated: i64,
3337) -> Value {
3338    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
3339    if let Value::Object(map) = &mut v {
3340        map.insert("id".to_string(), Value::String(id.to_string()));
3341        map.insert(
3342            "sessionID".to_string(),
3343            Value::String(session_id.to_string()),
3344        );
3345        map.insert("time_created".to_string(), Value::from(time_created));
3346        map.insert("time_updated".to_string(), Value::from(time_updated));
3347    }
3348    v
3349}
3350
3351fn opencode_row_part_value(
3352    id: &str,
3353    session_id: &str,
3354    message_id: &str,
3355    data_json: &str,
3356    time_created: i64,
3357    time_updated: i64,
3358) -> Value {
3359    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
3360    if let Value::Object(map) = &mut v {
3361        map.insert("id".to_string(), Value::String(id.to_string()));
3362        map.insert(
3363            "sessionID".to_string(),
3364            Value::String(session_id.to_string()),
3365        );
3366        map.insert(
3367            "messageID".to_string(),
3368            Value::String(message_id.to_string()),
3369        );
3370        map.insert("time_created".to_string(), Value::from(time_created));
3371        map.insert("time_updated".to_string(), Value::from(time_updated));
3372    }
3373    v
3374}
3375
3376/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
3377/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
3378/// info first, then each message (by `time_created, id`) immediately
3379/// followed by its own parts (by `id`) — parts MUST directly follow their
3380/// owning message line, since `Session::from_opencode_str`'s envelope parser
3381/// attaches a `part` line to whichever message id is already in its index
3382/// and silently leaves an out-of-order part `raw`-only otherwise — then
3383/// `todo` side-records, then a `session_diff` side-record if the JSON
3384/// sidecar file for this session exists (order-independent).
3385///
3386/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
3387/// it "is still JSON-written even on SQLite installs" — verified against
3388/// `packages/opencode/src/session/revert.ts:76` /
3389/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
3390/// commit, which write it to `<data>/storage/session_diff/<session>.json`
3391/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
3392/// separate from the `session.revert` DB column this loader already
3393/// captures. Without this, revert diffs vanish from `raw` and audit
3394/// under-counts `session_diff` records for real reverted sessions.
3395fn opencode_sqlite_session_envelope_lines(
3396    conn: &Connection,
3397    db_path: &Path,
3398    session_id: &str,
3399) -> Result<Vec<String>> {
3400    let mut lines = Vec::new();
3401
3402    let session_info = opencode_row_session_info(conn, session_id)?;
3403    let project_id = session_info
3404        .get("projectID")
3405        .and_then(Value::as_str)
3406        .unwrap_or("global")
3407        .to_string();
3408    lines.push(
3409        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
3410            .to_string(),
3411    );
3412
3413    let mut msg_stmt = conn
3414        .prepare(
3415            "SELECT id, data, time_created, time_updated FROM message \
3416             WHERE session_id = ?1 ORDER BY time_created, id",
3417        )
3418        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
3419    let msg_rows = msg_stmt
3420        .query_map([session_id], |r| {
3421            let id: String = r.get("id")?;
3422            let data: String = r.get("data")?;
3423            let time_created: i64 = r.get("time_created")?;
3424            let time_updated: i64 = r.get("time_updated")?;
3425            Ok((id, data, time_created, time_updated))
3426        })
3427        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
3428
3429    let mut part_stmt = conn
3430        .prepare(
3431            "SELECT id, data, time_created, time_updated FROM part \
3432             WHERE message_id = ?1 ORDER BY id",
3433        )
3434        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
3435
3436    for row in msg_rows {
3437        let (msg_id, data, msg_time_created, msg_time_updated) =
3438            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
3439        let msg_value = opencode_row_message_value(
3440            &msg_id,
3441            session_id,
3442            &data,
3443            msg_time_created,
3444            msg_time_updated,
3445        );
3446        lines.push(
3447            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
3448                .to_string(),
3449        );
3450
3451        let part_rows = part_stmt
3452            .query_map([&msg_id], |r| {
3453                let id: String = r.get("id")?;
3454                let data: String = r.get("data")?;
3455                let time_created: i64 = r.get("time_created")?;
3456                let time_updated: i64 = r.get("time_updated")?;
3457                Ok((id, data, time_created, time_updated))
3458            })
3459            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
3460        for prow in part_rows {
3461            let (part_id, pdata, part_time_created, part_time_updated) =
3462                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
3463            let part_value = opencode_row_part_value(
3464                &part_id,
3465                session_id,
3466                &msg_id,
3467                &pdata,
3468                part_time_created,
3469                part_time_updated,
3470            );
3471            lines.push(
3472                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
3473                    .to_string(),
3474            );
3475        }
3476    }
3477
3478    let mut todo_stmt = conn
3479        .prepare(
3480            "SELECT content, status, priority, position, time_created, time_updated \
3481             FROM todo WHERE session_id = ?1 ORDER BY position",
3482        )
3483        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
3484    let todo_rows = todo_stmt
3485        .query_map([session_id], |r| {
3486            let content: String = r.get("content")?;
3487            let status: String = r.get("status")?;
3488            let priority: String = r.get("priority")?;
3489            let position: i64 = r.get("position")?;
3490            let time_created: i64 = r.get("time_created")?;
3491            let time_updated: i64 = r.get("time_updated")?;
3492            Ok(serde_json::json!({
3493                "sessionID": session_id,
3494                "content": content,
3495                "status": status,
3496                "priority": priority,
3497                "position": position,
3498                "time": {"created": time_created, "updated": time_updated},
3499            }))
3500        })
3501        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
3502    for trow in todo_rows {
3503        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
3504        let position = tv.get("position").cloned().unwrap_or(Value::Null);
3505        lines.push(
3506            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
3507        );
3508    }
3509
3510    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
3511        lines.push(
3512            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
3513                .to_string(),
3514        );
3515    }
3516
3517    Ok(lines)
3518}
3519
3520/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
3521/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
3522/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
3523/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
3524/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
3525/// case (most sessions never revert) and is not an error; an existing-but-
3526/// unparseable file surfaces a diagnostic (D7-style) rather than silently
3527/// vanishing.
3528fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
3529    let dir = db_path.parent()?;
3530    let sidecar = dir
3531        .join("storage")
3532        .join("session_diff")
3533        .join(format!("{session_id}.json"));
3534    let text = std::fs::read_to_string(&sidecar).ok()?;
3535    match serde_json::from_str::<Value>(&text) {
3536        Ok(v) => Some(v),
3537        Err(e) => {
3538            tracing::warn!(
3539                path = %sidecar.display(),
3540                error = %e,
3541                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
3542            );
3543            None
3544        }
3545    }
3546}
3547
3548/// Pick the "primary" session for a bare `.db` path with no explicit session
3549/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
3550/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
3551/// descending) — a subagent/task child session is never picked over an
3552/// available root session, mirroring `most_recent_session`'s "latest wins"
3553/// convention used elsewhere in this crate for supercode's own store.
3554fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
3555    conn.query_row(
3556        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
3557        [],
3558        |r| r.get::<_, String>(0),
3559    )
3560    .map_err(|e| match e {
3561        rusqlite::Error::QueryReturnedNoRows => {
3562            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
3563        }
3564        e => opencode_sql_err(e, "selecting the primary session"),
3565    })
3566}
3567
3568fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
3569    let mut stmt = conn
3570        .prepare("SELECT id FROM session ORDER BY time_created, id")
3571        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
3572    let rows = stmt
3573        .query_map([], |r| r.get::<_, String>(0))
3574        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
3575    let mut ids = Vec::new();
3576    for row in rows {
3577        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
3578        if limit.is_some_and(|n| ids.len() >= n) {
3579            break;
3580        }
3581    }
3582    Ok(ids)
3583}
3584
3585/// D6: list every session id in an OpenCode SQLite store (oldest first) —
3586/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
3587/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
3588/// silently picks just the primary one. Previously nothing surfaced this:
3589/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
3590/// and no way to name a different one.
3591pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
3592    let conn = opencode_sqlite_open(db_path)?;
3593    opencode_sqlite_all_session_ids(&conn, None)
3594}
3595
3596/// D6: the same "most-recently-updated top-level session" selection
3597/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
3598/// no explicit session id is given — exposed so a CLI-level warning can name
3599/// which one was chosen.
3600pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
3601    let conn = opencode_sqlite_open(db_path)?;
3602    opencode_sqlite_primary_session_id(&conn)
3603}
3604
3605/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
3606/// `inspect`'s "reports the audited real store's sessions, messages, and
3607/// parts" summary (PARITY-3 AC01).
3608#[derive(Debug, Clone, Copy, Default)]
3609#[non_exhaustive]
3610pub struct OpenCodeSqliteStoreStats {
3611    /// Row count of the `session` table.
3612    pub sessions: u64,
3613    /// Row count of the `message` table.
3614    pub messages: u64,
3615    /// Row count of the `part` table.
3616    pub parts: u64,
3617    /// Row count of the `todo` table.
3618    pub todos: u64,
3619}
3620
3621/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
3622/// without loading any of them (PARITY-3 AC01).
3623pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
3624    let conn = opencode_sqlite_open(db_path)?;
3625    let count = |table: &str| -> Result<u64> {
3626        let sql = format!("SELECT count(*) FROM {table}");
3627        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
3628            .map(|n| n.max(0) as u64)
3629            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
3630    };
3631    Ok(OpenCodeSqliteStoreStats {
3632        sessions: count("session")?,
3633        messages: count("message")?,
3634        parts: count("part")?,
3635        todos: count("todo")?,
3636    })
3637}
3638
3639/// Combined envelope text spanning every session in `db_path` (or up to
3640/// `limit_sessions`) — for corpus-style scanning
3641/// ([`crate::audit::audit_dir`]'s `Corpus::OpenCode` SQLite path, PARITY-4).
3642/// Safe to concatenate multiple sessions' records into one text even though
3643/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
3644/// (single-session semantics) — the audit line-classifier
3645/// (`audit_opencode_line`) scores each line independently and doesn't care
3646/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
3647/// one session as a real [`Session`].
3648pub fn opencode_sqlite_corpus_envelope_text(
3649    db_path: &Path,
3650    limit_sessions: Option<usize>,
3651) -> Result<String> {
3652    let conn = opencode_sqlite_open(db_path)?;
3653    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
3654    let mut out = String::new();
3655    for id in ids {
3656        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
3657            out.push_str(&line);
3658            out.push('\n');
3659        }
3660    }
3661    Ok(out)
3662}
3663
3664/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
3665/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
3666/// everywhere a loader walks lines looking for JSON *records*, where a blank
3667/// line is simply not a record and must not become a spurious parse
3668/// failure/empty entry. Deliberately NOT used for `raw` capture any more
3669/// (IX-1) — see [`split_lines_verbatim`] for that.
3670fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
3671    text.lines().map(str::trim).filter(|l| !l.is_empty())
3672}
3673
3674// ---- Claude Code ----------------------------------------------------------
3675
3676/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
3677/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
3678fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
3679    let dir = main_path.parent()?;
3680    let stem = main_path.file_stem()?.to_str()?;
3681    let candidate = dir.join(stem).join("subagents");
3682    candidate.is_dir().then_some(candidate)
3683}
3684
3685/// The first `agentId` recorded in a subagent transcript.
3686fn first_agent_id(jsonl: &str) -> Option<String> {
3687    for line in non_empty_lines(jsonl) {
3688        if let Ok(v) = serde_json::from_str::<Value>(line) {
3689            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
3690                return Some(id.to_string());
3691            }
3692        }
3693    }
3694    None
3695}
3696
3697/// Find the `tool_use_id` of each parent `Task` call that spawned one of
3698/// `agent_ids`, by locating the parent transcript's `tool_result` whose
3699/// serialized content mentions the agent id. Best effort: an id with no
3700/// qualifying match is simply absent from the returned map.
3701///
3702/// Single pass over `main_text` — each line is parsed at most once,
3703/// regardless of how many agent ids are being sought — with each id's result
3704/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
3705/// return: the first line (in file order) whose raw text contains the id and
3706/// which — the first qualifying `tool_result` block in that line, in block
3707/// order — has a string `tool_use_id` and a serialized form that also
3708/// contains the id. A `tool_result` block matching on raw-line/serialized
3709/// containment but lacking a `tool_use_id` yields nothing for that id and
3710/// does not shadow a later match.
3711fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
3712    let mut index: HashMap<String, String> = HashMap::new();
3713    if agent_ids.is_empty() {
3714        return index;
3715    }
3716
3717    for line in non_empty_lines(main_text) {
3718        if index.len() == agent_ids.len() {
3719            break;
3720        }
3721        // Cheap prefilter: every match this function can ever return comes
3722        // from a block whose raw line carries the literal JSON string value
3723        // `tool_result` (no JSON-escape variants of that ASCII literal).
3724        if !line.contains("tool_result") {
3725            continue;
3726        }
3727        let still_unmapped: Vec<&String> = agent_ids
3728            .iter()
3729            .filter(|id| !index.contains_key(id.as_str()))
3730            .collect();
3731        if still_unmapped.is_empty() {
3732            break;
3733        }
3734        let Ok(v) = serde_json::from_str::<Value>(line) else {
3735            continue;
3736        };
3737        let content = v.get("message").and_then(|m| m.get("content"));
3738        let Some(Value::Array(blocks)) = content else {
3739            continue;
3740        };
3741        for b in blocks {
3742            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
3743                continue;
3744            }
3745            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
3746                continue;
3747            };
3748            let block_str = b.to_string();
3749            for id in &still_unmapped {
3750                if index.contains_key(id.as_str()) {
3751                    continue;
3752                }
3753                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
3754                    index.insert((*id).clone(), tool_use_id.to_string());
3755                }
3756            }
3757        }
3758    }
3759
3760    index
3761}
3762
3763#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3764enum ClaudeReplayKind {
3765    User,
3766    Assistant,
3767    Attachment,
3768    System,
3769}
3770
3771impl ClaudeReplayKind {
3772    fn is_conversation(self) -> bool {
3773        matches!(self, Self::User | Self::Assistant)
3774    }
3775}
3776
3777#[derive(Debug, Clone)]
3778struct ClaudeReplayNode {
3779    line_index: usize,
3780    uuid: String,
3781    parent_uuid: Option<String>,
3782    kind: ClaudeReplayKind,
3783    is_sidechain: bool,
3784    assistant_message_id: Option<String>,
3785    is_tool_result: bool,
3786    compact: Option<ClaudeCompactBoundary>,
3787}
3788
3789#[derive(Debug, Clone)]
3790struct ClaudeCompactBoundary {
3791    anchor_uuid: Option<String>,
3792    preserved_uuids: Vec<String>,
3793    preserved_segment: Option<(String, String)>,
3794}
3795
3796/// One projection of a Claude transcript graph: the source lines to replay,
3797/// plus whatever the projection had to give up to produce them (always empty
3798/// below [`Fidelity::Semantic`], which is the only level that degrades
3799/// instead of failing).
3800#[derive(Debug, Default)]
3801struct ClaudeReplaySelection {
3802    lines: Vec<usize>,
3803    residue: Vec<String>,
3804}
3805
3806#[derive(Debug, Default)]
3807struct ClaudeReplayIndex {
3808    nodes: Vec<ClaudeReplayNode>,
3809    by_uuid: HashMap<String, usize>,
3810    segment_anchors: HashSet<String>,
3811    last_prompt: Option<(String, bool)>,
3812    linear_lines: Vec<usize>,
3813}
3814
3815impl ClaudeReplayIndex {
3816    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
3817        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
3818            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
3819                self.last_prompt = Some((
3820                    leaf.to_string(),
3821                    v.get("explicit").and_then(Value::as_bool) == Some(true),
3822                ));
3823            }
3824            return Ok(());
3825        }
3826
3827        // A fork-context-ref is a real Claude graph anchor, but not a replay
3828        // message. Its child is the first conversational record in the
3829        // exported fork, so reaching this UUID terminates the locally
3830        // replayable segment rather than indicating a broken parent edge.
3831        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
3832            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
3833                self.segment_anchors.insert(uuid.to_string());
3834            }
3835            return Ok(());
3836        }
3837
3838        let kind = match v.get("type").and_then(Value::as_str) {
3839            Some("user") => ClaudeReplayKind::User,
3840            Some("assistant") => ClaudeReplayKind::Assistant,
3841            Some("attachment") => ClaudeReplayKind::Attachment,
3842            Some("system") => ClaudeReplayKind::System,
3843            _ => return Ok(()),
3844        };
3845        self.linear_lines.push(line_index);
3846        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
3847            return Ok(());
3848        };
3849        if self.by_uuid.contains_key(uuid) {
3850            return Err(claude_replay_error(format!(
3851                "duplicate uuid `{uuid}` in Claude transcript"
3852            )));
3853        }
3854
3855        let compact = (kind == ClaudeReplayKind::System
3856            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
3857        .then(|| ClaudeCompactBoundary::from_value(v));
3858        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
3859            .then(|| claude_assistant_message_id(v).map(str::to_string))
3860            .flatten();
3861        let is_tool_result = kind == ClaudeReplayKind::User
3862            && v.get("message")
3863                .and_then(|m| m.get("content"))
3864                .and_then(Value::as_array)
3865                .is_some_and(|blocks| {
3866                    blocks
3867                        .iter()
3868                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
3869                });
3870        let node = ClaudeReplayNode {
3871            line_index,
3872            uuid: uuid.to_string(),
3873            parent_uuid: v
3874                .get("parentUuid")
3875                .and_then(Value::as_str)
3876                .map(str::to_string),
3877            kind,
3878            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
3879            assistant_message_id,
3880            is_tool_result,
3881            compact,
3882        };
3883        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
3884        self.nodes.push(node);
3885        Ok(())
3886    }
3887
3888    /// Project the transcript at `fidelity`.
3889    ///
3890    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
3891    /// continuation, transfer and export path depends on: reconstruct
3892    /// Claude's own single active post-compaction branch, or fail naming what
3893    /// could not be reconstructed.
3894    ///
3895    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
3896    /// that has been compacted, summarized, or resumed across files routinely
3897    /// contains a live record whose `parentUuid` names a record that is no
3898    /// longer on disk. Strict projection rightly refuses — a continuation
3899    /// built on a guessed graph is silent loss — but a VIEW does not need a
3900    /// continuation, so this mode anchors each dangling edge as a segment
3901    /// root, projects every severed segment exactly as the active branch is
3902    /// projected, splices them back together in transcript order, and names
3903    /// every degradation in the returned residue instead of erroring.
3904    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
3905        let lenient = fidelity.tolerates_residue();
3906        let mut residue = Vec::new();
3907        if self.nodes.is_empty() {
3908            // Older exports and many hand-authored compatibility fixtures do
3909            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
3910            // branch information to project in that shape, so preserve the
3911            // historical linear normalization behavior. Native graph-bearing
3912            // transcripts always take the projection below.
3913            return Ok(ClaudeReplaySelection {
3914                lines: self.linear_lines,
3915                residue,
3916            });
3917        }
3918        if lenient {
3919            self.anchor_dangling_parents(&mut residue);
3920        }
3921        // Last resort for a VIEW: a transcript whose graph is unprojectable
3922        // for some OTHER reason (a cycle, an unresolvable compact boundary)
3923        // still renders as the file's own record order. A read-only mirror
3924        // that cannot open a session at all is the defect this mode exists
3925        // to remove, so `Semantic` never returns an error.
3926        let fallback = lenient.then(|| self.linear_lines.clone());
3927        match self.project(lenient, &mut residue) {
3928            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
3929            Err(error) => match fallback {
3930                Some(lines) => {
3931                    residue.push(format!(
3932                        "the Claude record graph could not be projected ({error}); \
3933                         every record was stitched in transcript order instead"
3934                    ));
3935                    Ok(ClaudeReplaySelection { lines, residue })
3936                }
3937                None => Err(error),
3938            },
3939        }
3940    }
3941
3942    /// Turn every edge that points outside the transcript into a segment
3943    /// root, naming the dangling uuids as residue.
3944    ///
3945    /// A `fork-context-ref` anchor is already a declared segment boundary,
3946    /// not a break, so it is left alone.
3947    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
3948        let mut dangling = Vec::new();
3949        for idx in 0..self.nodes.len() {
3950            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
3951                continue;
3952            };
3953            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
3954                continue;
3955            }
3956            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
3957            self.nodes[idx].parent_uuid = None;
3958        }
3959        if dangling.is_empty() {
3960            return;
3961        }
3962        const NAMED: usize = 8;
3963        let total = dangling.len();
3964        let overflow = total.saturating_sub(NAMED);
3965        dangling.truncate(NAMED);
3966        let mut listed = dangling.join(", ");
3967        if overflow > 0 {
3968            listed.push_str(&format!(", and {overflow} more"));
3969        }
3970        residue.push(format!(
3971            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
3972             anchored as segment roots: {listed}"
3973        ));
3974    }
3975
3976    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
3977        let mut retained = vec![true; self.nodes.len()];
3978        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
3979            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
3980                self.nodes
3981                    .iter()
3982                    .map(|node| node.parent_uuid.clone())
3983                    .collect()
3984            });
3985            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
3986                let Some(parents) = parents else {
3987                    return Err(error);
3988                };
3989                // The boundary rewrites parents as it goes, so restore the
3990                // graph it half-edited before continuing without it.
3991                for (node, parent) in self.nodes.iter_mut().zip(parents) {
3992                    node.parent_uuid = parent;
3993                }
3994                retained.iter_mut().for_each(|keep| *keep = true);
3995                residue.push(format!(
3996                    "the latest Claude compact boundary could not be projected ({error}); \
3997                     no pre-compaction record was pruned from this view"
3998                ));
3999            }
4000        }
4001        let sidechain_only = self
4002            .nodes
4003            .iter()
4004            .enumerate()
4005            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4006            .all(|(_, node)| node.is_sidechain);
4007
4008        let explicit_leaf = self
4009            .last_prompt
4010            .as_ref()
4011            .filter(|(_, explicit)| *explicit)
4012            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4013            .filter(|idx| retained[*idx]);
4014        let newest_non_sidechain = self
4015            .nodes
4016            .iter()
4017            .enumerate()
4018            .rev()
4019            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4020            .map(|(idx, _)| idx);
4021        // Dedicated Claude subagent transcripts are sidechains by design:
4022        // every record, including their root user prompt, has
4023        // `isSidechain:true`. When there is no main-chain candidate, resume
4024        // the newest retained sidechain leaf instead of rejecting the child.
4025        let newest_sidechain = self
4026            .nodes
4027            .iter()
4028            .enumerate()
4029            .rev()
4030            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4031            .map(|(idx, _)| idx);
4032        let mut active = explicit_leaf
4033            .or(newest_non_sidechain)
4034            .or(newest_sidechain)
4035            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4036
4037        // Metadata descendants such as turn_duration are leaves in the raw
4038        // graph. Claude resumes from their nearest user/assistant ancestor,
4039        // then appends those descendants to the reconstructed chain.
4040        let mut seeking = HashSet::new();
4041        while !self.nodes[active].kind.is_conversation() {
4042            if !seeking.insert(active) {
4043                return Err(claude_replay_error(
4044                    "cycle while resolving active Claude leaf",
4045                ));
4046            }
4047            active = self.parent_index(active, &retained)?;
4048        }
4049
4050        let mut segments =
4051            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4052        if lenient {
4053            for leaf in self.severed_segment_leaves(active, &retained) {
4054                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4055            }
4056            if segments.len() > 1 {
4057                residue.push(format!(
4058                    "{} conversation segments were stitched in transcript order because the \
4059                     Claude record graph is severed",
4060                    segments.len()
4061                ));
4062            }
4063        }
4064        // Each segment keeps its own reconstructed order; the segments
4065        // themselves are spliced by where they start in the file.
4066        segments.retain(|segment| !segment.is_empty());
4067        segments.sort_by_key(|segment| {
4068            segment
4069                .iter()
4070                .map(|idx| self.nodes[*idx].line_index)
4071                .min()
4072                .unwrap_or(usize::MAX)
4073        });
4074        let mut ordered = Vec::new();
4075        let mut placed = HashSet::new();
4076        for idx in segments.into_iter().flatten() {
4077            if placed.insert(idx) {
4078                ordered.push(idx);
4079            }
4080        }
4081
4082        self.recover_parallel_assistant_chunks(ordered, &retained)
4083            .map(|indices| {
4084                indices
4085                    .into_iter()
4086                    .map(|idx| self.nodes[idx].line_index)
4087                    .collect()
4088            })
4089    }
4090
4091    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
4092    /// non-conversation descendants rooted at it.
4093    fn project_segment(
4094        &self,
4095        leaf: usize,
4096        retained: &[bool],
4097        sidechain_only: bool,
4098        lenient: bool,
4099    ) -> Result<Vec<usize>> {
4100        let mut reversed = Vec::new();
4101        let mut seen = HashSet::new();
4102        let mut cursor = Some(leaf);
4103        while let Some(idx) = cursor {
4104            if !seen.insert(idx) {
4105                return Err(claude_replay_error(format!(
4106                    "cycle in active Claude parentUuid chain at `{}`",
4107                    self.nodes[idx].uuid
4108                )));
4109            }
4110            reversed.push(idx);
4111            cursor = match self.nodes[idx].parent_uuid.as_deref() {
4112                Some(parent) => match self.by_uuid.get(parent).copied() {
4113                    Some(parent) => Some(parent),
4114                    None if self.segment_anchors.contains(parent) => None,
4115                    // Claude can resume a background child in-place while
4116                    // retaining only the new segment in that child's JSONL.
4117                    // Its first record then points to a UUID not present in
4118                    // the sidechain file. That external edge is a segment
4119                    // boundary, not corruption; the complete source remains
4120                    // available byte-for-byte in `raw`.
4121                    None if sidechain_only => None,
4122                    None => {
4123                        return Err(claude_replay_error(format!(
4124                            "active Claude record `{}` has missing parentUuid `{parent}`",
4125                            self.nodes[idx].uuid
4126                        )));
4127                    }
4128                },
4129                None => None,
4130            };
4131            if cursor.is_some_and(|parent| !retained[parent]) {
4132                if lenient {
4133                    // A compaction boundary is where this segment ends; the
4134                    // records it pruned stay pruned.
4135                    break;
4136                }
4137                return Err(claude_replay_error(format!(
4138                    "active Claude chain crosses an excluded compaction record from `{}`",
4139                    self.nodes[idx].uuid
4140                )));
4141            }
4142        }
4143        reversed.reverse();
4144
4145        // Include non-conversation descendants rooted at the segment's leaf
4146        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
4147        let mut descendants = Vec::new();
4148        let mut frontier = vec![leaf];
4149        let mut head = 0;
4150        while head < frontier.len() {
4151            let parent = frontier[head];
4152            head += 1;
4153            for (idx, node) in self.nodes.iter().enumerate() {
4154                if !retained[idx]
4155                    || node.kind.is_conversation()
4156                    || seen.contains(&idx)
4157                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
4158                {
4159                    continue;
4160                }
4161                seen.insert(idx);
4162                descendants.push(idx);
4163                frontier.push(idx);
4164            }
4165        }
4166        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
4167        reversed.extend(descendants);
4168        Ok(reversed)
4169    }
4170
4171    /// The newest retained conversation record of every component the active
4172    /// leaf's own component cannot reach.
4173    ///
4174    /// Only a severed graph produces any: a healthy transcript is one
4175    /// component, so the abandoned branches a rewind left behind stay
4176    /// abandoned here exactly as they do under strict projection.
4177    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
4178        let active_root = self.component_root(active, retained);
4179        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
4180        for idx in 0..self.nodes.len() {
4181            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
4182                continue;
4183            }
4184            let Some(root) = self.component_root(idx, retained) else {
4185                continue;
4186            };
4187            if Some(root) == active_root {
4188                continue;
4189            }
4190            let newest = newest_by_root.entry(root).or_insert(idx);
4191            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
4192                *newest = idx;
4193            }
4194        }
4195        newest_by_root.into_values().collect()
4196    }
4197
4198    /// Walk `idx` up to the record that anchors its component, stopping at a
4199    /// root, an edge that leaves the transcript, or a pruned parent. `None`
4200    /// when the walk cycles.
4201    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
4202        let mut cursor = idx;
4203        let mut seen = HashSet::new();
4204        loop {
4205            if !seen.insert(cursor) {
4206                return None;
4207            }
4208            let next = self.nodes[cursor]
4209                .parent_uuid
4210                .as_deref()
4211                .and_then(|parent| self.by_uuid.get(parent).copied())
4212                .filter(|parent| retained[*parent]);
4213            match next {
4214                Some(parent) => cursor = parent,
4215                None => return Some(cursor),
4216            }
4217        }
4218    }
4219
4220    fn apply_latest_compaction(
4221        &mut self,
4222        boundary_index: usize,
4223        retained: &mut [bool],
4224    ) -> Result<()> {
4225        let compact = self.nodes[boundary_index]
4226            .compact
4227            .clone()
4228            .expect("called with compact boundary");
4229        let mut preserved = compact.preserved_uuids;
4230        if preserved.is_empty() {
4231            if let Some((head, tail)) = compact.preserved_segment {
4232                preserved = self.walk_preserved_segment(&head, &tail)?;
4233            }
4234        }
4235
4236        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
4237        for uuid in &preserved {
4238            if !self.by_uuid.contains_key(uuid) {
4239                return Err(claude_replay_error(format!(
4240                    "latest compact boundary references missing preserved uuid `{uuid}`"
4241                )));
4242            }
4243        }
4244
4245        let removed_uuids: HashSet<String> = self
4246            .nodes
4247            .iter()
4248            .enumerate()
4249            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
4250            .map(|(_, node)| node.uuid.clone())
4251            .collect();
4252        for (idx, node) in self.nodes.iter().enumerate() {
4253            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
4254                retained[idx] = false;
4255            }
4256        }
4257
4258        if preserved.is_empty() {
4259            return Ok(());
4260        }
4261        let anchor = compact.anchor_uuid.ok_or_else(|| {
4262            claude_replay_error("preserved compact boundary is missing anchorUuid")
4263        })?;
4264        if !self.by_uuid.contains_key(&anchor) {
4265            return Err(claude_replay_error(format!(
4266                "latest compact boundary references missing anchor uuid `{anchor}`"
4267            )));
4268        }
4269        let tail = preserved.last().cloned().expect("non-empty preserved list");
4270        let mut parent = anchor.clone();
4271        for uuid in &preserved {
4272            let idx = self.by_uuid[uuid];
4273            self.nodes[idx].parent_uuid = Some(parent);
4274            parent = uuid.clone();
4275        }
4276        let first = &preserved[0];
4277        for node in &mut self.nodes {
4278            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
4279                node.parent_uuid = Some(tail.clone());
4280            }
4281        }
4282        for node in &mut self.nodes {
4283            if node.kind.is_conversation()
4284                && node
4285                    .parent_uuid
4286                    .as_ref()
4287                    .is_some_and(|parent| removed_uuids.contains(parent))
4288            {
4289                node.parent_uuid = Some(tail.clone());
4290            }
4291        }
4292        Ok(())
4293    }
4294
4295    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
4296        let mut reversed = Vec::new();
4297        let mut seen = HashSet::new();
4298        let mut cursor = tail;
4299        loop {
4300            if !seen.insert(cursor.to_string()) {
4301                return Err(claude_replay_error("cycle in compact preservedSegment"));
4302            }
4303            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
4304                claude_replay_error(format!(
4305                    "compact preservedSegment references missing uuid `{cursor}`"
4306                ))
4307            })?;
4308            reversed.push(cursor.to_string());
4309            if cursor == head {
4310                reversed.reverse();
4311                return Ok(reversed);
4312            }
4313            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
4314                claude_replay_error(format!(
4315                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
4316                ))
4317            })?;
4318        }
4319    }
4320
4321    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
4322        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
4323            claude_replay_error(format!(
4324                "Claude record `{}` has no conversational ancestor",
4325                self.nodes[idx].uuid
4326            ))
4327        })?;
4328        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
4329            claude_replay_error(format!(
4330                "Claude record `{}` has missing parentUuid `{parent}`",
4331                self.nodes[idx].uuid
4332            ))
4333        })?;
4334        if !retained[parent_idx] {
4335            return Err(claude_replay_error(format!(
4336                "Claude record `{}` points into compacted-out history",
4337                self.nodes[idx].uuid
4338            )));
4339        }
4340        Ok(parent_idx)
4341    }
4342
4343    fn recover_parallel_assistant_chunks(
4344        &self,
4345        base: Vec<usize>,
4346        retained: &[bool],
4347    ) -> Result<Vec<usize>> {
4348        let selected: HashSet<usize> = base.iter().copied().collect();
4349        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
4350        let mut skipped_positions = HashSet::new();
4351        let mut handled_ids = HashSet::new();
4352
4353        for (base_pos, idx) in base.iter().copied().enumerate() {
4354            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
4355                continue;
4356            };
4357            if !handled_ids.insert(message_id.to_string()) {
4358                continue;
4359            }
4360            let base_positions: Vec<usize> = base
4361                .iter()
4362                .enumerate()
4363                .filter(|(_, candidate)| {
4364                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
4365                })
4366                .map(|(pos, _)| pos)
4367                .collect();
4368            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
4369            skipped_positions.extend(base_positions.iter().copied().skip(1));
4370
4371            // A streamed Anthropic response can be stored as sibling records
4372            // rather than a literal parent chain. Reassemble every chunk at
4373            // the first active occurrence and restore raw chunk order before
4374            // the normalizer coalesces their content blocks.
4375            let mut chunks: Vec<usize> = self
4376                .nodes
4377                .iter()
4378                .enumerate()
4379                .filter(|(candidate, node)| {
4380                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
4381                })
4382                .map(|(candidate, _)| candidate)
4383                .collect();
4384            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
4385
4386            let assistant_uuids: HashSet<&str> = self
4387                .nodes
4388                .iter()
4389                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
4390                .map(|node| node.uuid.as_str())
4391                .collect();
4392            let mut results: Vec<usize> = self
4393                .nodes
4394                .iter()
4395                .enumerate()
4396                .filter(|(candidate, node)| {
4397                    retained[*candidate]
4398                        && !selected.contains(candidate)
4399                        && node.is_tool_result
4400                        && node
4401                            .parent_uuid
4402                            .as_deref()
4403                            .is_some_and(|parent| assistant_uuids.contains(parent))
4404                })
4405                .map(|(candidate, _)| candidate)
4406                .collect();
4407            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
4408            chunks.extend(results);
4409            replacements.insert(anchor_pos, chunks);
4410        }
4411
4412        let mut out = Vec::with_capacity(selected.len());
4413        for (pos, idx) in base.into_iter().enumerate() {
4414            if let Some(replacement) = replacements.remove(&pos) {
4415                out.extend(replacement);
4416            } else if !skipped_positions.contains(&pos) {
4417                out.push(idx);
4418            }
4419        }
4420        Ok(out)
4421    }
4422}
4423
4424impl ClaudeCompactBoundary {
4425    fn from_value(v: &Value) -> Self {
4426        let metadata = v.get("compactMetadata");
4427        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
4428        let anchor_uuid = preserved_messages
4429            .and_then(|p| p.get("anchorUuid"))
4430            .and_then(Value::as_str)
4431            .or_else(|| {
4432                metadata
4433                    .and_then(|m| m.get("preservedSegment"))
4434                    .and_then(|p| p.get("anchorUuid"))
4435                    .and_then(Value::as_str)
4436            })
4437            .map(str::to_string);
4438        let preserved_uuids = preserved_messages
4439            .and_then(|p| p.get("uuids"))
4440            .and_then(Value::as_array)
4441            .map(|uuids| {
4442                uuids
4443                    .iter()
4444                    .filter_map(Value::as_str)
4445                    .map(str::to_string)
4446                    .collect()
4447            })
4448            .unwrap_or_default();
4449        let preserved_segment =
4450            metadata
4451                .and_then(|m| m.get("preservedSegment"))
4452                .and_then(|segment| {
4453                    Some((
4454                        segment.get("headUuid")?.as_str()?.to_string(),
4455                        segment.get("tailUuid")?.as_str()?.to_string(),
4456                    ))
4457                });
4458        Self {
4459            anchor_uuid,
4460            preserved_uuids,
4461            preserved_segment,
4462        }
4463    }
4464}
4465
4466fn claude_replay_error(message: impl Into<String>) -> crate::Error {
4467    crate::Error::Other(format!(
4468        "cannot reconstruct lossless Claude continuation: {}",
4469        message.into()
4470    ))
4471}
4472
4473fn claude_assistant_message_id(v: &Value) -> Option<&str> {
4474    v.get("message")
4475        .and_then(|message| message.get("id"))
4476        .and_then(Value::as_str)
4477}
4478
4479fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
4480    let Some(target_message) = target.get_mut("message") else {
4481        return;
4482    };
4483    let Some(chunk_message) = chunk.get("message") else {
4484        return;
4485    };
4486    let mut content = target_message
4487        .get("content")
4488        .and_then(Value::as_array)
4489        .cloned()
4490        .unwrap_or_default();
4491    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
4492        content.extend(blocks.iter().cloned());
4493    }
4494    let mut merged_message = chunk_message.clone();
4495    merged_message["content"] = Value::Array(content);
4496    *target_message = merged_message;
4497}
4498
4499fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
4500    let Some(v) = pending.take() else {
4501        return;
4502    };
4503    let reasoning_only = claude_assistant_message_id(&v).is_some()
4504        && v.get("message")
4505            .and_then(|message| message.get("content"))
4506            .and_then(Value::as_array)
4507            .is_some_and(|blocks| {
4508                !blocks.is_empty()
4509                    && blocks.iter().all(|block| {
4510                        matches!(
4511                            block.get("type").and_then(Value::as_str),
4512                            Some("thinking" | "redacted_thinking")
4513                        )
4514                    })
4515            });
4516    if reasoning_only {
4517        return;
4518    }
4519    let before = out.len();
4520    push_claude_assistant(&v, out);
4521    capture_claude_record_provenance(&v, &mut out[before..]);
4522    restore_single_grok_message(&v, &mut out[before..]);
4523}
4524
4525/// Attach the record identity, clock, and actual assistant model to every
4526/// canonical message produced from one Claude JSONL record. These fields are
4527/// deliberately per-message: a continued transcript can cross a provider
4528/// boundary, so the session-level source model is not authoritative for its
4529/// appended tail.
4530fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
4531    let timestamp = v.get("timestamp").and_then(Value::as_str);
4532    let uuid = v.get("uuid").and_then(Value::as_str);
4533    let model = v
4534        .get("message")
4535        .and_then(|message| message.get("model"))
4536        .and_then(Value::as_str);
4537    for message in messages {
4538        if let Some(timestamp) = timestamp {
4539            message
4540                .metadata
4541                .entry("timestamp".to_string())
4542                .or_insert_with(|| timestamp.to_string());
4543        }
4544        if let Some(uuid) = uuid {
4545            message
4546                .metadata
4547                .entry("claude_uuid".to_string())
4548                .or_insert_with(|| uuid.to_string());
4549        }
4550        if let Some(model) = model {
4551            message
4552                .metadata
4553                .entry("model".to_string())
4554                .or_insert_with(|| model.to_string());
4555        }
4556    }
4557}
4558
4559fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
4560    restore_codex_provenance_from_top_level(v, meta)?;
4561    if meta.session_id.is_none() {
4562        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
4563            meta.session_id = Some(id.to_string());
4564        }
4565    }
4566    if meta.cwd.is_none() {
4567        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
4568            meta.cwd = Some(PathBuf::from(cwd));
4569        }
4570    }
4571    if meta.model.is_none() {
4572        if let Some(model) = v
4573            .get("message")
4574            .and_then(|m| m.get("model"))
4575            .and_then(Value::as_str)
4576        {
4577            meta.model = Some(model.to_string());
4578        }
4579    }
4580    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
4581    // real Claude Code record with no confirmed field shape (see
4582    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
4583    // named fields and risk silently mis-modeling it, stash the WHOLE raw
4584    // line verbatim under a lineage key. `write_claude_code_records` (below)
4585    // re-emits it byte-for-byte, so the record survives the Claude Code
4586    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
4587    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
4588    // so a Claude -> Codex -> Claude round trip can still reconstruct it
4589    // (dev/03). A session can only fork from one context, so the first one
4590    // seen wins, matching every other "first wins" field above.
4591    //
4592    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
4593    // a RE-SERIALIZATION of the parsed `Value`, not the original source
4594    // text. `serde_json::Value` here has no `preserve_order` feature (see
4595    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
4596    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
4597    // this very comment was false. Fixed the cheap+honest way: store the
4598    // caller's own already-verbatim source `raw_line` text instead of
4599    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
4600    // (key order, spacing, everything) rather than merely
4601    // structurally-equivalent JSON.
4602    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
4603        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
4604    {
4605        meta.lineage.insert(
4606            "claude_fork_context_ref_raw".to_string(),
4607            raw_line.to_string(),
4608        );
4609    }
4610    Ok(())
4611}
4612
4613fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
4614    let content = v.get("message").and_then(|m| m.get("content"));
4615    let provenance = claude_user_provenance(v);
4616    match content {
4617        Some(Value::String(s)) => {
4618            if !s.trim().is_empty() {
4619                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
4620            }
4621        }
4622        Some(Value::Array(blocks)) => {
4623            let mut text = String::new();
4624            // IX-5: image blocks alongside/instead of text — collected
4625            // separately (never synthesized on a malformed shape, see
4626            // `claude_image_block_to_part`) so a multimodal user turn
4627            // survives as `content_parts` instead of the image silently
4628            // vanishing.
4629            let mut images: Vec<Value> = Vec::new();
4630            // D5: an `image` block whose `source` isn't base64/url (e.g. a
4631            // Files-API `{"source":{"type":"file","file_id":..}}`
4632            // reference) makes `claude_image_block_to_part` return `None` —
4633            // track that it was SEEN even though it couldn't be converted,
4634            // so an image-ONLY record (no text, no convertible image) isn't
4635            // silently dropped below (the same vanishing-record bug-class
4636            // PARITY-11 fixed for reasoning-only turns).
4637            let mut saw_unconvertible_image = false;
4638            for b in blocks {
4639                match b.get("type").and_then(Value::as_str) {
4640                    Some("text") => push_text(&mut text, b.get("text")),
4641                    Some("tool_result") => {
4642                        let id = b
4643                            .get("tool_use_id")
4644                            .and_then(Value::as_str)
4645                            .unwrap_or_default();
4646                        // PARITY-11 (nested images): `extract_tool_result_content`
4647                        // captures any `image` blocks nested inside this
4648                        // `tool_result` into `content_parts` (via
4649                        // `claude_image_block_to_part`, the same conversion the
4650                        // top-level `image` block path already uses) instead of
4651                        // flattening them to the bare `[image]` marker text the
4652                        // old `extract_tool_result` emitted — the everyday
4653                        // "Read a PNG / screenshot tool output" shape.
4654                        let (result, images) =
4655                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
4656                        let mut msg = tool_message(id, result);
4657                        if !images.is_empty() {
4658                            // D-mix (Fable review, must-fix): `content_parts`
4659                            // is a self-contained contract — the pi writer
4660                            // (`pi_content_value`) reads ONLY `content_parts`
4661                            // for a `Role::Tool` message and never falls back
4662                            // to `msg.content`, so on a MIXED text+image
4663                            // tool_result a bare `content_parts: [image]`
4664                            // silently drops the sibling text on `convert
4665                            // --to pi` (a regression vs. the pre-PARITY-11
4666                            // baseline, which at least preserved the text).
4667                            // Prepend the text as part 0, exactly mirroring
4668                            // `pi_content_to_text_and_parts` and
4669                            // `push_opencode_user`'s identical
4670                            // self-contained-parts construction. `msg.content`
4671                            // keeps the text too (unchanged) for the writers
4672                            // that read text from `msg.content` and only scan
4673                            // `content_parts` for `image_url` entries
4674                            // (`claude_tool_result_content_value`,
4675                            // `codex_tool_output_text`, the opencode
4676                            // assistant writer) — those already filter
4677                            // strictly on `image_url`/text-typed lookups, so
4678                            // this text part is never double-counted.
4679                            let mut parts = Vec::new();
4680                            if let Some(t) = &msg.content {
4681                                if !t.is_empty() {
4682                                    parts.push(serde_json::json!({"type": "text", "text": t}));
4683                                }
4684                            }
4685                            parts.extend(images);
4686                            msg.content_parts = Some(parts);
4687                        }
4688                        // The assistant turn that issued this tool call — the
4689                        // tool-pairing graph edge (parallel to parentUuid).
4690                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
4691                        {
4692                            msg.metadata
4693                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
4694                        }
4695                        // TR-10: preserve the Claude wire `is_error` flag so
4696                        // the reduction layer's success/failure boundary
4697                        // (`ReductionKind::ToolInputElided` must never target
4698                        // an errored call) survives import — `ChatMessage`
4699                        // otherwise has no structural slot for it.
4700                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
4701                            crate::reduce::mark_tool_error(&mut msg);
4702                        } else {
4703                            restore_tool_outcome_extension(v, &mut msg);
4704                        }
4705                        out.push(msg);
4706                    }
4707                    Some("image") => match claude_image_block_to_part(b) {
4708                        Some(part) => images.push(part),
4709                        None => saw_unconvertible_image = true,
4710                    },
4711                    _ => {} // document / unknown — skip
4712                }
4713            }
4714            // D5: nothing convertible landed in `text`/`images` but an
4715            // image block WAS present — fold in the same short bracketed
4716            // marker convention already used for `[web_search]`/`[model
4717            // fallback: ...]` rather than letting the record vanish.
4718            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
4719                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
4720            }
4721            let before = out.len();
4722            if !images.is_empty() {
4723                let mut parts = Vec::new();
4724                if !text.trim().is_empty() {
4725                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
4726                }
4727                parts.extend(images);
4728                out.push(
4729                    ChatMessage {
4730                        role: Role::User,
4731                        content: None,
4732                        content_parts: Some(parts),
4733                        tool_calls: None,
4734                        tool_call_id: None,
4735                        name: None,
4736                        metadata: Default::default(),
4737                    }
4738                    .with_metas(&provenance),
4739                );
4740            } else if !text.trim().is_empty() {
4741                out.push(ChatMessage::user(text).with_metas(&provenance));
4742            }
4743            if saw_unconvertible_image && out.len() > before {
4744                if let Some(msg) = out.last_mut() {
4745                    msg.metadata
4746                        .insert("image_source_unconvertible".to_string(), "true".to_string());
4747                }
4748            }
4749        }
4750        _ => {}
4751    }
4752}
4753
4754/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
4755/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
4756/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
4757/// else in the record survives either — matches the existing
4758/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
4759/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
4760const UNCONVERTIBLE_IMAGE_MARKER: &str =
4761    "[image: source not captured — unsupported/unconvertible image reference]";
4762
4763/// Parse a Claude Code user-turn `image` content block
4764/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
4765/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
4766/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
4767/// bare URL for the url form) — the inverse of
4768/// [`claude_user_content_value`]'s emission. Only a well-formed source
4769/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
4770/// anything else — including a well-formed but unconvertible source like a
4771/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
4772/// residue rather than synthesizing a corrupt/empty part (mirrors the
4773/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
4774/// discipline). Callers must not let that turn the record invisible though:
4775/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
4776fn claude_image_block_to_part(b: &Value) -> Option<Value> {
4777    let source = b.get("source")?;
4778    match source.get("type").and_then(Value::as_str) {
4779        Some("base64") => {
4780            let mime = source.get("media_type").and_then(Value::as_str)?;
4781            let data = source.get("data").and_then(Value::as_str)?;
4782            if mime.is_empty() || data.is_empty() {
4783                return None;
4784            }
4785            Some(serde_json::json!({
4786                "type": "image_url",
4787                "image_url": {"url": format!("data:{mime};base64,{data}")},
4788            }))
4789        }
4790        Some("url") => {
4791            let url = source.get("url").and_then(Value::as_str)?;
4792            if url.is_empty() {
4793                return None;
4794            }
4795            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
4796        }
4797        _ => None,
4798    }
4799}
4800
4801/// Rebuild a Claude Code user-turn `message.content` value from a
4802/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
4803/// [`claude_image_block_to_part`]). When `content_parts` is absent this
4804/// MUST reproduce the historical plain-string `content` exactly (IX-5's
4805/// overriding constraint: a text-only message's export stays byte-identical)
4806/// — only a multimodal message (`content_parts` present, e.g. imported from
4807/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
4808/// content-array shape, one `text` block (if any non-empty text part) plus
4809/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
4810/// any other URL → `source.url`).
4811fn claude_user_content_value(msg: &ChatMessage) -> Value {
4812    match &msg.content_parts {
4813        Some(parts) => {
4814            let mut blocks = Vec::new();
4815            for p in parts {
4816                match p.get("type").and_then(Value::as_str) {
4817                    Some("text") => {
4818                        if let Some(t) = p.get("text").and_then(Value::as_str) {
4819                            if !t.is_empty() {
4820                                blocks.push(serde_json::json!({"type": "text", "text": t}));
4821                            }
4822                        }
4823                    }
4824                    Some("image_url") => {
4825                        if let Some(url) = p
4826                            .get("image_url")
4827                            .and_then(|u| u.get("url"))
4828                            .and_then(Value::as_str)
4829                        {
4830                            blocks.push(match parse_data_uri(url) {
4831                                Some((mime, data)) => serde_json::json!({
4832                                    "type": "image",
4833                                    "source": {"type": "base64", "media_type": mime, "data": data},
4834                                }),
4835                                None => serde_json::json!({
4836                                    "type": "image",
4837                                    "source": {"type": "url", "url": url},
4838                                }),
4839                            });
4840                        }
4841                    }
4842                    _ => {}
4843                }
4844            }
4845            Value::Array(blocks)
4846        }
4847        None => Value::String(msg.content.clone().unwrap_or_default()),
4848    }
4849}
4850
4851/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
4852/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
4853/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
4854/// the historical plain-string `content` exactly (same IX-5-style constraint
4855/// `claude_user_content_value` follows) — only a `tool_result` that actually
4856/// carries a captured nested image gets the Anthropic content-array shape,
4857/// one `text` block (the existing `msg.content`, if any) plus one `image`
4858/// block per `image_url` part (mirrors `claude_user_content_value`'s
4859/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
4860fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
4861    match &msg.content_parts {
4862        Some(parts) if !parts.is_empty() => {
4863            let mut blocks = Vec::new();
4864            if let Some(t) = &msg.content {
4865                if !t.is_empty() {
4866                    blocks.push(serde_json::json!({"type": "text", "text": t}));
4867                }
4868            }
4869            for p in parts {
4870                if p.get("type").and_then(Value::as_str) == Some("image_url") {
4871                    if let Some(url) = p
4872                        .get("image_url")
4873                        .and_then(|u| u.get("url"))
4874                        .and_then(Value::as_str)
4875                    {
4876                        blocks.push(match parse_data_uri(url) {
4877                            Some((mime, data)) => serde_json::json!({
4878                                "type": "image",
4879                                "source": {"type": "base64", "media_type": mime, "data": data},
4880                            }),
4881                            None => serde_json::json!({
4882                                "type": "image",
4883                                "source": {"type": "url", "url": url},
4884                            }),
4885                        });
4886                    }
4887                }
4888            }
4889            Value::Array(blocks)
4890        }
4891        _ => Value::String(msg.content.clone().unwrap_or_default()),
4892    }
4893}
4894
4895/// Collect the Claude Code user-turn provenance fields that distinguish real
4896/// human input from system-injected turns and record replay-relevant state.
4897fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
4898    let mut out = Vec::new();
4899    let mut take_str = |key: &str| {
4900        if let Some(s) = v.get(key).and_then(Value::as_str) {
4901            out.push((key.to_string(), s.to_string()));
4902        }
4903    };
4904    take_str("promptSource"); // typed | queued | system | sdk
4905    take_str("interruptedMessageId");
4906    take_str("sourceToolUseID");
4907    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
4908        if v.get(flag).and_then(Value::as_bool) == Some(true) {
4909            out.push((flag.to_string(), "true".to_string()));
4910        }
4911    }
4912    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
4913        out.push(("queuePriority".to_string(), n.to_string()));
4914    }
4915    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
4916    if let Some(kind) = v
4917        .get("origin")
4918        .and_then(|o| o.get("kind"))
4919        .and_then(Value::as_str)
4920    {
4921        out.push(("origin".to_string(), kind.to_string()));
4922    }
4923    out
4924}
4925
4926/// Content-bearing Claude `system` events (`scheduled_task_fire`,
4927/// `local_command`, `away_summary`) carry real text that's part of the
4928/// interaction; fold them in as system context. Marker/metric subtypes
4929/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
4930/// no conversational content and are skipped.
4931fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
4932    let keep = matches!(
4933        v.get("subtype").and_then(Value::as_str),
4934        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
4935    );
4936    if !keep {
4937        return;
4938    }
4939    if let Some(content) = v.get("content").and_then(Value::as_str) {
4940        if !content.trim().is_empty() {
4941            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
4942            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
4943        }
4944    }
4945}
4946
4947/// Fold content-bearing Claude Code `attachment` records into the conversation
4948/// as user-role messages. Most attachment subtypes (`task_reminder`,
4949/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
4950/// are regenerable system injections and are skipped; only the four that carry
4951/// non-regenerable user/external content are kept.
4952fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
4953    let att = match v.get("attachment") {
4954        Some(a) => a,
4955        None => return,
4956    };
4957    let kind = match att.get("type").and_then(Value::as_str) {
4958        Some(kind) => kind,
4959        None => return,
4960    };
4961    let text = match kind {
4962        // A queued prompt. `commandMode` says whose: `prompt` is the person's
4963        // own text, `task-notification` is the runtime reporting a finished
4964        // background task. Kept verbatim below.
4965        "queued_command" => att
4966            .get("prompt")
4967            .and_then(Value::as_str)
4968            .map(str::to_string),
4969        // A file the user attached: header + contents.
4970        "file" => attachment_with_path(att, "attached file", "filename", "content"),
4971        // A user-edited file snippet.
4972        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
4973        // Injected project memory (CLAUDE.md), point-in-time.
4974        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
4975        _ => None, // regenerable system injection — skip
4976    };
4977    let Some(text) = text else { return };
4978    if text.trim().is_empty() {
4979        return;
4980    }
4981    // An attachment record wears the user's ROLE, but the record itself says
4982    // who actually spoke — and that fact is lost the moment the attachment is
4983    // flattened to `[label: path]` text, so carry it as metadata the way
4984    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
4985    //
4986    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
4987    //                     `nested_memory` are envelopes the runtime built
4988    //                     around a file body; a frontend that trusts the role
4989    //                     shows the reader a numbered source listing in a
4990    //                     chat bubble apparently sent by themselves.
4991    //   `commandMode`     present on `queued_command` only, and the whole
4992    //                     story for it. Measured over the local Claude Code
4993    //                     corpus (2,512 `queued_command` attachments): 926
4994    //                     `prompt`, every one of them plain human text, and
4995    //                     1,586 `task-notification`, every one of them a
4996    //                     `<task-notification>` frame — the same text Claude
4997    //                     Code also writes as a `type:"user"` record stamped
4998    //                     `origin.kind = "task-notification"`.
4999    //
5000    // Presentation policy (which of these a frontend hides) belongs to the
5001    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5002    // job is to stop discarding the producer's own answer.
5003    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5004    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5005        message = message.with_meta("commandMode", mode);
5006    }
5007    out.push(message);
5008}
5009
5010/// Format an attachment as `[<label>: <path>]\n<body>`.
5011fn attachment_with_path(
5012    att: &Value,
5013    label: &str,
5014    path_key: &str,
5015    body_key: &str,
5016) -> Option<String> {
5017    let body = att.get(body_key).and_then(Value::as_str)?;
5018    let path = att
5019        .get(path_key)
5020        .or_else(|| att.get("displayPath"))
5021        .and_then(Value::as_str)
5022        .unwrap_or("");
5023    Some(format!("[{label}: {path}]\n{body}"))
5024}
5025
5026fn push_str_field(buf: &mut String, s: &str) {
5027    if !buf.is_empty() {
5028        buf.push('\n');
5029    }
5030    buf.push_str(s);
5031}
5032
5033/// N3: build a synthesized message for reasoning that could not attach to a
5034/// following assistant turn — either interrupted mid-stream by a
5035/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5036/// the three pending buffers (all empty/`false` afterward) so callers don't
5037/// separately have to remember to clear them.
5038fn orphaned_reasoning_message(
5039    reasoning: &mut String,
5040    reasoning_content: &mut String,
5041    encrypted: &mut bool,
5042) -> ChatMessage {
5043    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5044    if !reasoning.is_empty() {
5045        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5046    }
5047    if !reasoning_content.is_empty() {
5048        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5049    }
5050    if *encrypted {
5051        msg = msg.with_meta("reasoning_encrypted", "true");
5052        *encrypted = false;
5053    }
5054    msg
5055}
5056
5057fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5058    let content = v.get("message").and_then(|m| m.get("content"));
5059    let mut text = String::new();
5060    let mut calls: Vec<ToolCall> = Vec::new();
5061    // Legacy singular fields — kept for backward compatibility with every
5062    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5063    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5064    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5065    // message carries MULTIPLE `thinking` blocks, collapsing them down to
5066    // these singular fields silently drops every signature but the last
5067    // one's — a real Anthropic `thinking` block's `signature` cryptographically
5068    // covers ONLY that block's own text, so re-emitting block 1's text under
5069    // block 2's signature (or vice versa) produces a signature that will
5070    // never verify. `thinking_blocks` below is the fix: every block
5071    // preserved SEPARATELY, in order, each with its own (optional)
5072    // signature/data — the writer prefers it over the legacy fields
5073    // whenever present.
5074    let mut thinking = String::new();
5075    let mut signature: Option<String> = None;
5076    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5077    // `image` assistant blocks, and (rarely) a `fallback` model-routing
5078    // marker — none handled before, all silently vanishing (audit's own
5079    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5080    // `fallback` blocks in the reference corpus).
5081    //
5082    // D8: `redacted_thinking` is real data ONLY — never a fabricated
5083    // placeholder. The pre-fix code defaulted a missing `data` field to the
5084    // literal string `"<redacted>"`, which is indistinguishable from an
5085    // actual (if oddly-named) opaque payload on re-emit — a caller reading
5086    // it back has no way to tell "no data was ever captured" from "the
5087    // provider's own opaque blob happens to be the string `<redacted>`".
5088    // `redacted_thinking_seen` tracks block PRESENCE independently of
5089    // whether it had real data, so the reasoning-only-turn rescue below
5090    // still fires even when no block had a `data` field at all.
5091    let mut redacted_thinking: Option<String> = None;
5092    let mut redacted_thinking_seen = false;
5093    let mut images: Vec<Value> = Vec::new();
5094    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
5095    // `thinking` string alongside a real `signature` (the summarized/
5096    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
5097    // would miss those, so track "a thinking block existed at all"
5098    // separately from whether it had visible text.
5099    let mut thinking_block_seen = false;
5100    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
5101    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
5102    // above. Serialized as a single JSON-array metadata string
5103    // (`ChatMessage::metadata` is a flat string map) under
5104    // `"thinking_blocks"`.
5105    let mut thinking_blocks: Vec<Value> = Vec::new();
5106    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
5107    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
5108    // not silently vanish the whole record when nothing else survives.
5109    let mut saw_unconvertible_image = false;
5110
5111    match content {
5112        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
5113        Some(Value::Array(blocks)) => {
5114            for b in blocks {
5115                match b.get("type").and_then(Value::as_str) {
5116                    Some("text") => push_text(&mut text, b.get("text")),
5117                    Some("tool_use") => {
5118                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
5119                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
5120                        let args = b
5121                            .get("input")
5122                            .map(|i| i.to_string())
5123                            .unwrap_or_else(|| "{}".to_string());
5124                        calls.push(function_call(id, name, args));
5125                    }
5126                    // Thinking is not replayed across providers, but retain it in
5127                    // (skip-serialized) metadata so a same-model continuation can
5128                    // re-inject it. See P3.
5129                    Some("thinking") => {
5130                        thinking_block_seen = true;
5131                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
5132                        if !t.is_empty() {
5133                            push_str_field(&mut thinking, t); // legacy concatenated field
5134                        }
5135                        let sig = b.get("signature").and_then(Value::as_str);
5136                        if let Some(s) = sig {
5137                            signature = Some(s.to_string()); // legacy last-wins field
5138                        }
5139                        // D8: this block's OWN text + signature, not folded
5140                        // into the running concatenation above.
5141                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
5142                        if let Some(s) = sig {
5143                            block["signature"] = Value::String(s.to_string());
5144                        }
5145                        thinking_blocks.push(block);
5146                    }
5147                    // Anthropic's redacted reasoning: an opaque, provider-private
5148                    // payload (flagged content the API declines to show in the
5149                    // clear). Like `thinking`, it's not replayable, but the raw
5150                    // `data` is retained in metadata rather than silently
5151                    // vanishing — a same-model continuation can still replay it
5152                    // verbatim even though supercode never renders it.
5153                    Some("redacted_thinking") => {
5154                        redacted_thinking_seen = true;
5155                        let data = b.get("data").and_then(Value::as_str);
5156                        // D8: no fabricated fallback — `data` is only ever
5157                        // the real captured payload, or genuinely absent.
5158                        if let Some(d) = data {
5159                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
5160                        }
5161                        let mut block = serde_json::json!({"type": "redacted_thinking"});
5162                        if let Some(d) = data {
5163                            block["data"] = Value::String(d.to_string());
5164                        }
5165                        thinking_blocks.push(block);
5166                    }
5167                    // An assistant-emitted image block (e.g. a generated
5168                    // image) — collected exactly like `push_claude_user`'s
5169                    // user-turn image handling (`claude_image_block_to_part`
5170                    // is role-general), so it survives as `content_parts`
5171                    // instead of vanishing.
5172                    Some("image") => match claude_image_block_to_part(b) {
5173                        Some(part) => images.push(part),
5174                        None => saw_unconvertible_image = true,
5175                    },
5176                    // A provider-routing note (real shape:
5177                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
5178                    // — a mid-generation model swap, e.g. an overloaded model
5179                    // falling back to another). Carries no replayable
5180                    // conversational content, but folding it into `text` as a
5181                    // short bracketed marker — the same convention the Codex
5182                    // loader already uses for `[web_search]`/
5183                    // `[image_generation] ...` — keeps it visible instead of
5184                    // silently vanishing, including the case where it's the
5185                    // ONLY block in the turn (see the reasoning-only-turn fix
5186                    // below: before this, that shape dropped the entire
5187                    // message).
5188                    Some("fallback") => {
5189                        let from = b
5190                            .get("from")
5191                            .and_then(|f| f.get("model"))
5192                            .and_then(Value::as_str)
5193                            .unwrap_or("?");
5194                        let to = b
5195                            .get("to")
5196                            .and_then(|t| t.get("model"))
5197                            .and_then(Value::as_str)
5198                            .unwrap_or("?");
5199                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
5200                    }
5201                    _ => {}
5202                }
5203            }
5204        }
5205        _ => {}
5206    }
5207
5208    // D5: nothing convertible landed in `text`/`images` but an image block
5209    // WAS present — fold in the same bracketed-marker convention `fallback`
5210    // uses above, so a genuinely image-only (unconvertible source) turn
5211    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
5212    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5213        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5214    }
5215
5216    let before = out.len();
5217    if !images.is_empty() {
5218        let mut parts = Vec::new();
5219        if !text.trim().is_empty() {
5220            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5221        }
5222        parts.extend(images);
5223        out.push(ChatMessage {
5224            role: Role::Assistant,
5225            content: None,
5226            content_parts: Some(parts),
5227            tool_calls: (!calls.is_empty()).then_some(calls),
5228            tool_call_id: None,
5229            name: None,
5230            metadata: Default::default(),
5231        });
5232    } else {
5233        push_assistant(out, text, calls);
5234        // A recognized native assistant record remains transcript state even
5235        // when its content array is empty (for example, an interrupted model
5236        // turn). Force a bare message whenever `push_assistant` had nothing
5237        // to emit. This includes the reasoning-only case and also preserves
5238        // genuinely part-less records instead of silently changing turn
5239        // count/order during translation.
5240        if out.len() == before {
5241            let mut empty = ChatMessage {
5242                role: Role::Assistant,
5243                content: None,
5244                content_parts: None,
5245                tool_calls: None,
5246                tool_call_id: None,
5247                name: None,
5248                metadata: Default::default(),
5249            };
5250            if !thinking_block_seen && !redacted_thinking_seen {
5251                empty
5252                    .metadata
5253                    .insert("empty_assistant_record".to_string(), "true".to_string());
5254            }
5255            out.push(empty);
5256        }
5257    }
5258    // Attach retained reasoning + attribution to the message we just produced.
5259    if out.len() > before {
5260        if let Some(msg) = out.last_mut() {
5261            // Insert "thinking" (even as an empty string) whenever a
5262            // `thinking` block was actually seen, not just when it had
5263            // visible text — a real `thinking` block commonly carries an
5264            // empty `thinking` string alongside a real `signature` (the
5265            // summarized-away-but-still-replayable case), and the writer
5266            // below keys its re-emission decision off this metadata key's
5267            // PRESENCE, not its content.
5268            if thinking_block_seen {
5269                msg.metadata.insert("thinking".to_string(), thinking);
5270            }
5271            if let Some(sig) = signature {
5272                msg.metadata.insert("thinking_signature".to_string(), sig);
5273            }
5274            if let Some(rt) = redacted_thinking {
5275                msg.metadata.insert("redacted_thinking".to_string(), rt);
5276            }
5277            // D8: exact per-block re-emission list — every `thinking`/
5278            // `redacted_thinking` block preserved separately, in order, each
5279            // with its own (optional) signature/data. The writer prefers
5280            // this over the legacy singular fields above whenever present,
5281            // so a multi-block message round-trips losslessly instead of
5282            // collapsing to one block under one (now-unverifiable)
5283            // signature.
5284            if !thinking_blocks.is_empty() {
5285                msg.metadata.insert(
5286                    "thinking_blocks".to_string(),
5287                    Value::Array(thinking_blocks).to_string(),
5288                );
5289            }
5290            // D5: honest signal that this message contained an image block
5291            // whose source this loader couldn't convert — the actual image
5292            // content is NOT captured, only a marker/partial record.
5293            if saw_unconvertible_image {
5294                msg.metadata
5295                    .insert("image_source_unconvertible".to_string(), "true".to_string());
5296            }
5297            // Attribution: which skill / subagent / MCP server+tool produced
5298            // this turn, plus the model `slug`.
5299            for key in [
5300                "attributionSkill",
5301                "attributionAgent",
5302                "attributionMcpServer",
5303                "attributionMcpTool",
5304                "slug",
5305            ] {
5306                if let Some(s) = v.get(key).and_then(Value::as_str) {
5307                    msg.metadata.insert(key.to_string(), s.to_string());
5308                }
5309            }
5310        }
5311    }
5312}
5313
5314// ---- Codex ----------------------------------------------------------------
5315
5316const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
5317
5318fn codex_provenance_kind(record: &Value) -> Option<&str> {
5319    match record.get("type").and_then(Value::as_str) {
5320        Some("session_meta") => Some("session_meta"),
5321        Some("turn_context") => Some("turn_context"),
5322        Some("compacted") => Some("compacted"),
5323        Some("event_msg") => match record
5324            .get("payload")
5325            .and_then(|payload| payload.get("type"))
5326            .and_then(Value::as_str)
5327        {
5328            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
5329            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
5330            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
5331            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
5332            _ => None,
5333        },
5334        _ => None,
5335    }
5336}
5337
5338fn capture_codex_provenance_record(
5339    meta: &mut SessionMeta,
5340    record_index: usize,
5341    raw_line: &str,
5342    record: &Value,
5343) {
5344    let Some(kind) = codex_provenance_kind(record) else {
5345        return;
5346    };
5347    meta.codex_provenance.push(serde_json::json!({
5348        "record_index": record_index,
5349        "kind": kind,
5350        "raw": raw_line,
5351    }));
5352}
5353
5354fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
5355    (!meta.codex_provenance.is_empty()).then(|| {
5356        serde_json::json!({
5357            "version": 1,
5358            "records": &meta.codex_provenance,
5359        })
5360    })
5361}
5362
5363fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
5364    if extension.get("version").and_then(Value::as_u64) != Some(1) {
5365        return Err(Error::InvalidSession(
5366            "invalid portable Codex provenance: expected version 1".to_string(),
5367        ));
5368    }
5369    let Some(records) = extension.get("records").and_then(Value::as_array) else {
5370        return Err(Error::InvalidSession(
5371            "invalid portable Codex provenance: `records` must be an array".to_string(),
5372        ));
5373    };
5374    if records.is_empty() {
5375        return Err(Error::InvalidSession(
5376            "invalid portable Codex provenance: `records` must not be empty".to_string(),
5377        ));
5378    }
5379    let mut restored = Vec::with_capacity(records.len());
5380    for entry in records {
5381        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
5382            return Err(Error::InvalidSession(
5383                "invalid portable Codex provenance: record_index must be an integer".to_string(),
5384            ));
5385        };
5386        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
5387            return Err(Error::InvalidSession(
5388                "invalid portable Codex provenance: kind must be a string".to_string(),
5389            ));
5390        };
5391        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
5392            return Err(Error::InvalidSession(
5393                "invalid portable Codex provenance: raw must be a string".to_string(),
5394            ));
5395        };
5396        let Ok(record) = serde_json::from_str::<Value>(raw) else {
5397            return Err(Error::InvalidSession(
5398                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
5399            ));
5400        };
5401        if codex_provenance_kind(&record) != Some(kind) {
5402            return Err(Error::InvalidSession(format!(
5403                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
5404            )));
5405        }
5406        restored.push(entry.clone());
5407    }
5408    meta.codex_provenance = restored;
5409    meta.codex_headers.clear();
5410    for entry in &meta.codex_provenance {
5411        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
5412            continue;
5413        };
5414        let Ok(record) = serde_json::from_str::<Value>(raw) else {
5415            continue;
5416        };
5417        if matches!(
5418            record.get("type").and_then(Value::as_str),
5419            Some("session_meta") | Some("turn_context")
5420        ) {
5421            meta.codex_headers.push(record);
5422        }
5423    }
5424    Ok(true)
5425}
5426
5427fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
5428    match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
5429        Some(extension) => restore_codex_provenance(extension, meta),
5430        None => Ok(false),
5431    }
5432}
5433
5434fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
5435    let Some(line_end) = out.find('\n') else {
5436        return;
5437    };
5438    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
5439        return;
5440    };
5441    let Some(object) = record.as_object_mut() else {
5442        return;
5443    };
5444    object.insert(key.to_string(), extension);
5445    out.replace_range(..line_end, &record.to_string());
5446}
5447
5448fn inject_codex_provenance(out: &mut String, extension: Value) {
5449    let Some(line_end) = out.find('\n') else {
5450        return;
5451    };
5452    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
5453        return;
5454    };
5455    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
5456        return;
5457    }
5458    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
5459        return;
5460    };
5461    payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
5462    out.replace_range(..line_end, &record.to_string());
5463}
5464
5465/// Remove the last conversational turn from `messages`: everything from the
5466/// last `user` message to the end (the user prompt plus the assistant's
5467/// response and any tool calls/results it triggered).
5468fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
5469    if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
5470        messages.truncate(idx);
5471    } else {
5472        messages.clear();
5473    }
5474    // IX-6 fix: the new tail exposed by `truncate` may still carry
5475    // `__codex_open_turn` from when it was marked (it was NOT the last
5476    // message at that time — items after it, now removed by the rollback,
5477    // intervened). A bare `function_call` arriving after the rollback is a
5478    // genuinely NEW turn and must get its own message, not merge into this
5479    // stale marked tail — close it out here so `push_codex_item`'s
5480    // adjacency check (`out.last()` + marker) can't be fooled by the
5481    // truncation re-exposing it.
5482    if let Some(last) = messages.last_mut() {
5483        last.metadata.remove("__codex_open_turn");
5484    }
5485}
5486
5487/// The text of a Codex `agent_message` event. `message` is usually a string but
5488/// can be a structured object (e.g. review output) — fall back to its JSON.
5489fn agent_message_text(payload: &Value) -> String {
5490    match payload.get("message") {
5491        Some(Value::String(s)) => s.clone(),
5492        Some(other) => extract_text_content(Some(other)),
5493        None => String::new(),
5494    }
5495}
5496
5497/// Trimmed texts of all assistant messages present as `response_item` — the
5498/// dedup set for recovering collab-only `agent_message` narration.
5499fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
5500    let mut set = std::collections::HashSet::new();
5501    for line in non_empty_lines(jsonl) {
5502        let Ok(v) = serde_json::from_str::<Value>(line) else {
5503            continue;
5504        };
5505        if v.get("type").and_then(Value::as_str) != Some("response_item") {
5506            continue;
5507        }
5508        let payload = v.get("payload").unwrap_or(&Value::Null);
5509        if payload.get("type").and_then(Value::as_str) == Some("message")
5510            && payload.get("role").and_then(Value::as_str) == Some("assistant")
5511        {
5512            let text = extract_text_content(payload.get("content"));
5513            if !text.trim().is_empty() {
5514                set.insert(text.trim().to_string());
5515            }
5516        }
5517    }
5518    set
5519}
5520
5521fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
5522    if meta.session_id.is_none() {
5523        if let Some(id) = payload.get("id").and_then(Value::as_str) {
5524            meta.session_id = Some(id.to_string());
5525        }
5526    }
5527    if meta.cwd.is_none() {
5528        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
5529            meta.cwd = Some(PathBuf::from(cwd));
5530        }
5531    }
5532    if meta.system_prompt.is_none() {
5533        // `base_instructions` may be a string or `{ "text": "..." }`.
5534        let bi = payload.get("base_instructions");
5535        let text = match bi {
5536            Some(Value::String(s)) => Some(s.clone()),
5537            Some(Value::Object(_)) => bi
5538                .and_then(|b| b.get("text"))
5539                .and_then(Value::as_str)
5540                .map(str::to_string),
5541            _ => None,
5542        };
5543        meta.system_prompt = text;
5544    }
5545    if meta.model.is_none() {
5546        if let Some(m) = payload.get("model").and_then(Value::as_str) {
5547            meta.model = Some(m.to_string());
5548        }
5549    }
5550    // Cross-file lineage keys for multi-agent / forked sessions.
5551    let mut put = |key: &str, v: Option<&Value>| {
5552        if let Some(s) = v.and_then(Value::as_str) {
5553            meta.lineage.insert(key.to_string(), s.to_string());
5554        }
5555    };
5556    put("parent_thread_id", payload.get("parent_thread_id"));
5557    put("forked_from_id", payload.get("forked_from_id"));
5558    put("thread_source", payload.get("thread_source"));
5559    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
5560    // passthrough — restores a captured Claude `fork-context-ref` so a
5561    // Claude -> Codex -> Claude round trip reconstructs the original record
5562    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
5563    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
5564        if let Some(v) = payload.get("claude_fork_context_ref") {
5565            meta.lineage
5566                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
5567        }
5568    }
5569    if let Some(spawn) = payload
5570        .get("source")
5571        .and_then(|s| s.get("subagent"))
5572        .and_then(|s| s.get("thread_spawn"))
5573    {
5574        // parent_thread_id can also live here (preferred when both present).
5575        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
5576            meta.lineage
5577                .insert("parent_thread_id".to_string(), p.to_string());
5578        }
5579        for k in ["agent_role", "agent_nickname"] {
5580            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
5581                meta.lineage.insert(k.to_string(), s.to_string());
5582            }
5583        }
5584        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
5585            meta.lineage.insert("depth".to_string(), d.to_string());
5586        }
5587    }
5588}
5589
5590/// Depth of a node in the parent forest (root = 0), bounded against cycles.
5591fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
5592    let mut d = 0;
5593    let mut guard = 0;
5594    while let Some(p) = parent_of[i] {
5595        if p == i || guard > parent_of.len() {
5596            break;
5597        }
5598        i = p;
5599        d += 1;
5600        guard += 1;
5601    }
5602    d
5603}
5604
5605/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
5606fn codex_turn_id(payload: &Value) -> Option<&str> {
5607    payload
5608        .get("metadata")
5609        .and_then(|m| m.get("turn_id"))
5610        .and_then(Value::as_str)
5611}
5612
5613/// N2 (spliced-export hardening): every Codex group id already present in
5614/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
5615/// replays ahead of the appended tail it synthesizes via
5616/// `Session::write_codex_records`. This is the GROUND TRUTH of what
5617/// physically lands in the exported `out` string for the prefix: each line
5618/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
5619/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
5620/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
5621/// export) is extracted directly — no re-derivation from `self.messages`
5622/// needed (that would have to reconstruct which ids the ORIGINAL export
5623/// happened to assign, which this sidesteps entirely by reading them back
5624/// out of the bytes themselves). A line that fails to parse, isn't a
5625/// `response_item`, or carries no `turn_id` contributes nothing — headers
5626/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
5627/// never carry this field to begin with.
5628fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
5629    let mut ids = HashSet::new();
5630    for line in raw_prefix {
5631        if let Ok(v) = serde_json::from_str::<Value>(line) {
5632            if let Some(payload) = v.get("payload") {
5633                if let Some(tid) = codex_turn_id(payload) {
5634                    ids.insert(tid.to_string());
5635                }
5636            }
5637        }
5638    }
5639    ids
5640}
5641
5642/// Stamp every `ChatMessage` appended to `messages` since index `from` with
5643/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
5644/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
5645/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
5646/// message that already carries a more specific timestamp of its own is
5647/// never overwritten (none currently do on the Codex side, but this keeps
5648/// every loader consistent). A no-op when `ts` is `None` (a line with no
5649/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
5650fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
5651    let Some(ts) = ts else { return };
5652    let Some(slice) = messages.get_mut(from..) else {
5653        return;
5654    };
5655    for m in slice {
5656        m.metadata
5657            .entry("timestamp".to_string())
5658            .or_insert_with(|| ts.to_string());
5659    }
5660}
5661
5662fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
5663    match payload.get("type").and_then(Value::as_str) {
5664        Some("message") => {
5665            let role = match payload.get("role").and_then(Value::as_str) {
5666                Some("user") => Role::User,
5667                Some("assistant") => Role::Assistant,
5668                // "developer" and "system" both carry operator instructions.
5669                _ => Role::System,
5670            };
5671            let content = payload.get("content");
5672            let text = extract_text_content(content);
5673            // IX-5: `input_image` blocks alongside/instead of text — see
5674            // `codex_extract_images`. A text-only message (no image blocks)
5675            // takes the historical `content: Some(text)` shape unchanged.
5676            let images = codex_extract_images(content);
5677            let is_empty_assistant =
5678                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
5679            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
5680                let content_parts = if images.is_empty() {
5681                    None
5682                } else {
5683                    let mut parts = Vec::new();
5684                    if !text.trim().is_empty() {
5685                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5686                    }
5687                    parts.extend(images);
5688                    Some(parts)
5689                };
5690                let mut msg = ChatMessage {
5691                    role,
5692                    content: if content_parts.is_some() || text.is_empty() {
5693                        None
5694                    } else {
5695                        Some(text)
5696                    },
5697                    content_parts,
5698                    tool_calls: None,
5699                    tool_call_id: None,
5700                    name: None,
5701                    metadata: Default::default(),
5702                };
5703                // Preserve the assistant `phase` (commentary vs final_answer) so
5704                // a reloaded transcript can distinguish narration from the answer.
5705                if role == Role::Assistant {
5706                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
5707                        msg.metadata.insert("phase".to_string(), phase.to_string());
5708                    }
5709                    // IX-6: mark this as an open, mergeable combined-turn
5710                    // candidate — a `function_call` response_item found
5711                    // immediately after (still `out.last()` when reached,
5712                    // i.e. no other item intervened) merges into this SAME
5713                    // `ChatMessage` instead of splitting into a second one,
5714                    // matching how Claude's parser keeps a text+tool_use
5715                    // turn together. Stripped again before the loaded
5716                    // `Session` is returned (`from_codex_str`), so it never
5717                    // leaks as visible metadata.
5718                    msg.metadata
5719                        .insert("__codex_open_turn".to_string(), "true".to_string());
5720                }
5721                // The per-turn grouping key (Codex batches items by turn_id).
5722                if let Some(tid) = codex_turn_id(payload) {
5723                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
5724                }
5725                // PARITY-6 dev/02: restore the original Claude
5726                // `systemSubtype` for a `developer`/`system` message that
5727                // was itself synthesized FROM a real Claude system record
5728                // (`write_codex_records`'s `Role::System` arm stamps
5729                // `claude_system_subtype`) — the exact inverse, so
5730                // `write_claude_code_records`'s `Role::System` arm can
5731                // re-materialize the real Claude `type: "system"` record
5732                // faithfully on a Codex -> Claude Code hop instead of
5733                // guessing a fallback subtype.
5734                if role == Role::System {
5735                    if let Some(subtype) = payload
5736                        .get("metadata")
5737                        .and_then(|m| m.get("claude_system_subtype"))
5738                        .and_then(Value::as_str)
5739                    {
5740                        msg.metadata
5741                            .insert("systemSubtype".to_string(), subtype.to_string());
5742                    }
5743                }
5744                if is_empty_assistant {
5745                    msg.metadata
5746                        .insert("empty_assistant_record".to_string(), "true".to_string());
5747                }
5748                out.push(msg);
5749            }
5750        }
5751        Some("function_call") => {
5752            let id = payload
5753                .get("call_id")
5754                .and_then(Value::as_str)
5755                .unwrap_or_default();
5756            let raw_name = payload
5757                .get("name")
5758                .and_then(Value::as_str)
5759                .unwrap_or_default();
5760            // Preserve the MCP `namespace` by qualifying the tool name
5761            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
5762            // so the tool identity isn't ambiguous on round-trip.
5763            let qualified;
5764            let name = match payload.get("namespace").and_then(Value::as_str) {
5765                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
5766                    qualified = format!("{ns}__{raw_name}");
5767                    qualified.as_str()
5768                }
5769                _ => raw_name,
5770            };
5771            let args = payload
5772                .get("arguments")
5773                .map(value_to_arg_string)
5774                .unwrap_or_else(|| "{}".to_string());
5775            let call = function_call(id, name, args);
5776            // IX-6: a `function_call` immediately after an assistant `message`
5777            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
5778            // by the "message" arm above, and not yet closed by anything else)
5779            // merges into that ONE `ChatMessage` — text→`content`,
5780            // call→`tool_calls` — instead of splitting into a second message.
5781            // A bare `function_call` with no such preceding turn (the marker
5782            // absent, or `out.last()` not an assistant message) is unaffected:
5783            // it still gets its own synthesized message, exactly as before.
5784            //
5785            // Belt-and-suspenders (PARITY-6/7 tightened): if this
5786            // `function_call` response_item itself carries a `turn_id` (rare
5787            // in observed real-native-Codex corpora — Codex usually only
5788            // stamps it on `message` payloads — but ALWAYS present on OUR
5789            // OWN synthesized export whenever a `ChatMessage`'s own tool
5790            // calls need merge disambiguation, see `write_codex_records`),
5791            // it must match the marked assistant message's recorded
5792            // `turn_id` EXACTLY — including "the marked message has none at
5793            // all" counting as a mismatch. That's exactly the shape of two
5794            // genuinely separate, adjacent `ChatMessage`s (an unrelated
5795            // text-only turn immediately followed by a different,
5796            // tool-call-only turn): the tool-only turn's own `function_call`s
5797            // carry a synthetic id while the unrelated preceding text
5798            // message carries none, so this correctly refuses the merge
5799            // instead of falling through to a permissive default. Only when
5800            // this `function_call` carries NO `turn_id` at all (the ordinary
5801            // real-native-Codex shape) does this fall back to the original
5802            // permissive "adjacency + open marker is enough" rule —
5803            // unchanged from before for the vast majority of real Codex
5804            // data. The truncation/clear strip above is what actually closes
5805            // the marker across rollback/compaction boundaries; this is only
5806            // an extra guard for the case where a stale-but-unstripped
5807            // marker and a turn_id mismatch coincide.
5808            let can_merge = out.last().is_some_and(|last| {
5809                last.role == Role::Assistant
5810                    && last.metadata.contains_key("__codex_open_turn")
5811                    && match codex_turn_id(payload) {
5812                        Some(fc_tid) => {
5813                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
5814                        }
5815                        None => true,
5816                    }
5817            });
5818            if can_merge {
5819                out.last_mut()
5820                    .expect("can_merge implies out.last() is Some")
5821                    .tool_calls
5822                    .get_or_insert_with(Vec::new)
5823                    .push(call);
5824            } else {
5825                push_assistant(out, String::new(), vec![call]);
5826                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
5827                // in this turn, so nothing set `__codex_open_turn` above) can
5828                // still be the FIRST of several tool calls that all belong to
5829                // the SAME original `ChatMessage` (`write_codex_records`
5830                // stamps every one of a message's own tool calls with the
5831                // identical synthetic `turn_id`). Re-open THIS freshly
5832                // created message — but ONLY when a real `turn_id` is
5833                // present — so the NEXT `function_call` in the same group
5834                // merges into it instead of becoming its own message too.
5835                // Gated on `codex_turn_id(payload).is_some()` (not the bare
5836                // default `true` the belt-and-suspenders check above uses)
5837                // so real native Codex data — which almost never carries
5838                // this field on `function_call` payloads (see the comment
5839                // above) — keeps its existing "every bare tool call is its
5840                // own turn" behavior exactly as before.
5841                if let Some(tid) = codex_turn_id(payload) {
5842                    if let Some(last) = out.last_mut() {
5843                        last.metadata
5844                            .insert("__codex_open_turn".to_string(), "true".to_string());
5845                        last.metadata.insert("turn_id".to_string(), tid.to_string());
5846                    }
5847                }
5848            }
5849        }
5850        Some("function_call_output") => {
5851            let id = payload
5852                .get("call_id")
5853                .and_then(Value::as_str)
5854                .unwrap_or_default();
5855            let result = match payload.get("output") {
5856                Some(Value::String(s)) => s.clone(),
5857                Some(v) => extract_text_content(Some(v)),
5858                None => String::new(),
5859            };
5860            let mut message = tool_message(id, result);
5861            // TR-13: Codex v1 exposes no structured success/error field on
5862            // this record. Free-text output is not a safe classifier, so the
5863            // reduction engine must treat the outcome as explicitly unknown
5864            // and fail closed on both success-only and error-only pruning.
5865            crate::reduce::mark_tool_outcome_unknown(&mut message);
5866            out.push(message);
5867        }
5868        // Custom / MCP tool calls are shaped like function calls but carry their
5869        // arguments under `input` (a JSON-encoded string). Normalize them the
5870        // same way so MCP-using sessions don't lose those turns.
5871        Some("custom_tool_call") => {
5872            let id = payload
5873                .get("call_id")
5874                .and_then(Value::as_str)
5875                .unwrap_or_default();
5876            let name = payload
5877                .get("name")
5878                .and_then(Value::as_str)
5879                .unwrap_or_default();
5880            // Unlike `function_call.arguments`, Codex custom tools accept a
5881            // free-form `input` string (apply_patch is the common case).
5882            // Canonical `FunctionCall::arguments` must remain valid JSON, so
5883            // retain the input's JSON type instead of treating a free-form
5884            // string as if it were already a JSON document. This lets every
5885            // target harness carry the value rather than silently replacing
5886            // it with `{}` when `parsed_arguments()` fails.
5887            let args = payload
5888                .get("input")
5889                .map(Value::to_string)
5890                .unwrap_or_else(|| "{}".to_string());
5891            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
5892            if let Some(message) = out.last_mut() {
5893                message.metadata.insert(
5894                    "codex_custom_tool_call_ids".to_string(),
5895                    serde_json::json!([id]).to_string(),
5896                );
5897            }
5898        }
5899        Some("custom_tool_call_output") => {
5900            let id = payload
5901                .get("call_id")
5902                .and_then(Value::as_str)
5903                .unwrap_or_default();
5904            let result = match payload.get("output") {
5905                Some(Value::String(s)) => s.clone(),
5906                Some(v) => extract_text_content(Some(v)),
5907                None => String::new(),
5908            };
5909            let mut message = tool_message(id, result);
5910            crate::reduce::mark_tool_outcome_unknown(&mut message);
5911            out.push(message);
5912        }
5913        // Tool-search is a clean call/output pair keyed by call_id.
5914        //
5915        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
5916        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
5917        // its own synthesized `tool_search_call` records (see the PARITY-6/7
5918        // comment there and on `codex_turn_id`/the `function_call` arm
5919        // above). That left the same bug-class the turn_id work fixed for
5920        // `function_call` half-done here: a single Claude assistant message
5921        // containing text + a `tool_search` block reloaded as 2 messages
5922        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
5923        // reloaded as 3. Mirror the `function_call` arm's merge check
5924        // exactly so a `tool_search_call` immediately following an open
5925        // assistant turn (or another tool call sharing the same `turn_id`)
5926        // merges into that SAME `ChatMessage` instead of splitting.
5927        Some("tool_search_call") => {
5928            let id = payload
5929                .get("call_id")
5930                .and_then(Value::as_str)
5931                .unwrap_or_default();
5932            let args = payload
5933                .get("arguments")
5934                .map(value_to_arg_string)
5935                .unwrap_or_else(|| "{}".to_string());
5936            let call = function_call(id, "tool_search", args);
5937            let can_merge = out.last().is_some_and(|last| {
5938                last.role == Role::Assistant
5939                    && last.metadata.contains_key("__codex_open_turn")
5940                    && match codex_turn_id(payload) {
5941                        Some(fc_tid) => {
5942                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
5943                        }
5944                        None => true,
5945                    }
5946            });
5947            if can_merge {
5948                out.last_mut()
5949                    .expect("can_merge implies out.last() is Some")
5950                    .tool_calls
5951                    .get_or_insert_with(Vec::new)
5952                    .push(call);
5953            } else {
5954                push_assistant(out, String::new(), vec![call]);
5955                // Re-open the freshly created message so a FOLLOWING
5956                // `function_call`/`tool_search_call` sharing this same
5957                // `turn_id` merges into it too — matching the bare
5958                // `function_call` case's own re-open logic above.
5959                if let Some(tid) = codex_turn_id(payload) {
5960                    if let Some(last) = out.last_mut() {
5961                        last.metadata
5962                            .insert("__codex_open_turn".to_string(), "true".to_string());
5963                        last.metadata.insert("turn_id".to_string(), tid.to_string());
5964                    }
5965                }
5966            }
5967        }
5968        Some("tool_search_output") => {
5969            let id = payload
5970                .get("call_id")
5971                .and_then(Value::as_str)
5972                .unwrap_or_default();
5973            let result = payload
5974                .get("tools")
5975                .map(value_to_arg_string)
5976                .unwrap_or_default();
5977            out.push(tool_message(id, result));
5978        }
5979        // Web-search / image-generation response_items carry no paired output
5980        // here (results live in event_msg), so emit an assistant marker rather
5981        // than a dangling unanswered tool call.
5982        Some("web_search_call") => {
5983            push_assistant(out, "[web_search]".to_string(), Vec::new());
5984        }
5985        Some("image_generation_call") => {
5986            let prompt = payload
5987                .get("revised_prompt")
5988                .and_then(Value::as_str)
5989                .unwrap_or("");
5990            push_assistant(
5991                out,
5992                format!("[image_generation] {prompt}").trim().to_string(),
5993                Vec::new(),
5994            );
5995        }
5996        // "reasoning" and anything else — dropped.
5997        _ => {}
5998    }
5999}
6000
6001// ---- Grok -------------------------------------------------------------
6002
6003fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
6004    for key in keys {
6005        if let Some(value) = value.get(*key) {
6006            message.metadata.insert(
6007                format!("grok_{key}"),
6008                value
6009                    .as_str()
6010                    .map(str::to_string)
6011                    .unwrap_or_else(|| value.to_string()),
6012            );
6013        }
6014    }
6015}
6016
6017fn grok_human_user_text(raw: &str) -> Option<String> {
6018    let text = raw.trim();
6019    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
6020        return None;
6021    }
6022    let unwrapped = text
6023        .strip_prefix("<user_query>")
6024        .and_then(|value| value.strip_suffix("</user_query>"))
6025        .map(str::trim)
6026        .unwrap_or(text);
6027    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
6028}
6029
6030/// Portable extension for messages that originated in Grok or are being
6031/// written into Grok. The native formats do not share slots for all per-turn
6032/// metadata, multimodal content, or tool-result names, but their readers
6033/// tolerate unknown namespaced fields. Keeping one adapter-owned envelope
6034/// makes Grok a lossless intermediate without pretending its stock schema can
6035/// represent every other harness's fields directly.
6036const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
6037
6038/// Namespaced line-level extension carrying the one tool-result outcome state
6039/// Claude cannot represent natively. Keeping this narrower than the full Grok
6040/// portability envelope avoids changing unrelated target-message projection.
6041const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
6042
6043fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
6044    if !crate::reduce::is_tool_error(message)
6045        && value
6046            .get(SUPERCODE_TOOL_OUTCOME_KEY)
6047            .and_then(Value::as_str)
6048            == Some("unknown")
6049    {
6050        crate::reduce::mark_tool_outcome_unknown(message);
6051    }
6052}
6053
6054fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
6055    let metadata = message
6056        .metadata
6057        .iter()
6058        .filter(|(key, _)| key.starts_with("grok_"))
6059        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
6060        .collect::<serde_json::Map<_, _>>();
6061
6062    // `meta.source` changes after every reload. Keying portability only on
6063    // the immediate source therefore made Grok metadata survive one hop but
6064    // disappear on A -> B -> C translations. Once Grok-owned fields are
6065    // present, keep forwarding them regardless of the current container.
6066    let has_portable_metadata =
6067        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
6068    (source == SessionSource::Grok || has_portable_metadata || message.content_parts.is_some())
6069        .then(|| {
6070            serde_json::json!({
6071                "schema": 2,
6072                "role": message.role,
6073                "content": message.content,
6074                "content_parts": message.content_parts,
6075                "tool_calls": message.tool_calls,
6076                "tool_call_id": message.tool_call_id,
6077                "name": message.name,
6078                "metadata": message.metadata,
6079            })
6080        })
6081}
6082
6083fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
6084    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
6085        "schema": 2,
6086        "role": message.role,
6087        "content": message.content,
6088        "content_parts": message.content_parts,
6089        "tool_calls": message.tool_calls,
6090        "tool_call_id": message.tool_call_id,
6091        "name": message.name,
6092        "metadata": message.metadata,
6093    });
6094}
6095
6096fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
6097    if let Some(extension) = grok_message_extension(source, message) {
6098        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
6099    }
6100}
6101
6102fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
6103    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
6104        return;
6105    };
6106    // Codex temporarily marks a text assistant item so immediately-following
6107    // function-call items can merge back into the same canonical turn. The
6108    // portable envelope must not erase that loader-private marker before the
6109    // merge happens; `from_codex_str` removes it before returning.
6110    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
6111    let codex_turn_id = message.metadata.get("turn_id").cloned();
6112    let extension_has_turn_id = extension
6113        .get("metadata")
6114        .and_then(Value::as_object)
6115        .is_some_and(|metadata| metadata.contains_key("turn_id"));
6116    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
6117        if let Some(role) = extension
6118            .get("role")
6119            .and_then(|value| serde_json::from_value(value.clone()).ok())
6120        {
6121            message.role = role;
6122        }
6123        message.content = extension
6124            .get("content")
6125            .and_then(Value::as_str)
6126            .map(str::to_string);
6127        message.content_parts = extension
6128            .get("content_parts")
6129            .and_then(|value| serde_json::from_value(value.clone()).ok());
6130        // Tool calls are shared native structure in every supported format.
6131        // Keep the loader's reconstruction instead of restoring this copy:
6132        // Codex stores a combined text+tool turn across multiple records, so
6133        // eagerly restoring calls on its text record would duplicate them
6134        // when the following function-call records merge.
6135        message.tool_call_id = extension
6136            .get("tool_call_id")
6137            .and_then(Value::as_str)
6138            .map(str::to_string);
6139        message.name = extension
6140            .get("name")
6141            .and_then(Value::as_str)
6142            .map(str::to_string);
6143        message.metadata.clear();
6144    }
6145    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
6146        for (key, value) in metadata {
6147            if let Some(value) = value.as_str() {
6148                message.metadata.insert(key.clone(), value.to_string());
6149            }
6150        }
6151    }
6152    if let Some(name) = extension.get("name").and_then(Value::as_str) {
6153        message.name = Some(name.to_string());
6154    }
6155    if let Some(marker) = codex_open_turn {
6156        message
6157            .metadata
6158            .insert("__codex_open_turn".to_string(), marker);
6159    }
6160    if let Some(turn_id) = codex_turn_id {
6161        message.metadata.insert("turn_id".to_string(), turn_id);
6162        if !extension_has_turn_id {
6163            message.metadata.insert(
6164                "__grok_remove_synthetic_turn_id".to_string(),
6165                "true".to_string(),
6166            );
6167        }
6168    }
6169}
6170
6171fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
6172    if let [message] = messages {
6173        restore_grok_message_extension(value, message);
6174    }
6175}
6176
6177pub(crate) fn percent_decode_path(encoded: &str) -> Option<String> {
6178    fn hex(byte: u8) -> Option<u8> {
6179        match byte {
6180            b'0'..=b'9' => Some(byte - b'0'),
6181            b'a'..=b'f' => Some(byte - b'a' + 10),
6182            b'A'..=b'F' => Some(byte - b'A' + 10),
6183            _ => None,
6184        }
6185    }
6186
6187    let bytes = encoded.as_bytes();
6188    let mut decoded = Vec::with_capacity(bytes.len());
6189    let mut index = 0usize;
6190    while index < bytes.len() {
6191        if bytes[index] == b'%' {
6192            let high = *bytes.get(index + 1)?;
6193            let low = *bytes.get(index + 2)?;
6194            decoded.push(hex(high)? * 16 + hex(low)?);
6195            index += 3;
6196        } else {
6197            decoded.push(bytes[index]);
6198            index += 1;
6199        }
6200    }
6201    String::from_utf8(decoded).ok()
6202}
6203
6204// ---- Pi ---------------------------------------------------------------
6205
6206fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
6207    restore_codex_provenance_from_top_level(v, meta)?;
6208    if let Some(id) = v.get("id").and_then(Value::as_str) {
6209        meta.session_id = Some(id.to_string());
6210    }
6211    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
6212        meta.cwd = Some(PathBuf::from(cwd));
6213    }
6214    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
6215    let version = v
6216        .get("version")
6217        .and_then(Value::as_u64)
6218        .map(|n| n.to_string())
6219        .unwrap_or_else(|| "1".to_string());
6220    meta.lineage.insert("pi_version".to_string(), version);
6221    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
6222        meta.lineage
6223            .insert("created_at".to_string(), ts.to_string());
6224    }
6225    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
6226        meta.lineage
6227            .insert("parent_session_path".to_string(), ps.to_string());
6228    }
6229    // D7: the other half of `push_pi_header`'s passthrough — restores a
6230    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
6231    // trip reconstructs the original record (mirrors
6232    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
6233    // restore for the Codex hop).
6234    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6235        if let Some(v) = v.get("claude_fork_context_ref") {
6236            meta.lineage
6237                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6238        }
6239    }
6240    Ok(())
6241}
6242
6243/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
6244/// `(mime, data)` when it looks like a real image payload.
6245///
6246/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
6247/// `ai:316-350` for the `ImageContent` content-block union but does not
6248/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
6249/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
6250/// Anthropic multimodal wire shape) is this loader's best guess, not a
6251/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
6252/// against a real pi corpus. Until then this function VALIDATES rather than
6253/// assumes: both fields must be present, non-empty strings, and `data` must
6254/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
6255/// else is an unknown/unexpected image shape, and the caller must route the
6256/// whole message to raw-only survival (S6-style fail loud) instead of
6257/// silently synthesizing a corrupt/empty `image_url` part.
6258fn pi_image_shape(item: &Value) -> Option<(String, String)> {
6259    let mime = item.get("mimeType").and_then(Value::as_str)?;
6260    let data = item.get("data").and_then(Value::as_str)?;
6261    if mime.is_empty() || data.is_empty() {
6262        return None;
6263    }
6264    if !data
6265        .bytes()
6266        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
6267    {
6268        return None;
6269    }
6270    Some((mime.to_string(), data.to_string()))
6271}
6272
6273/// True if `content` (a pi content value: bare string or
6274/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
6275/// that does not match [`pi_image_shape`] — shared by the loader (which
6276/// routes such a message to raw-only survival, never a synthesized-empty
6277/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
6278/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
6279/// mismatch surfaces as a coverage FAILURE rather than vanishing.
6280pub(crate) fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
6281    let Some(Value::Array(items)) = content else {
6282        return false;
6283    };
6284    items.iter().any(|item| {
6285        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
6286    })
6287}
6288
6289/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
6290/// into concatenated text plus, when a WELL-FORMED image block is present,
6291/// the full `content_parts` array (leading text block + one `image_url` part
6292/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
6293/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
6294/// the identical union (`pi-fields.md` §3a/§3c/§3e).
6295///
6296/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
6297/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
6298/// value that isn't recognizable base64), this NEVER synthesizes an empty/
6299/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
6300/// every caller must treat that as raw-only survival for the whole message
6301/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
6302/// guessed wrong fails loud instead of silently dropping/corrupting the
6303/// image.
6304fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
6305    match content {
6306        Some(Value::String(s)) => (s.clone(), None, false),
6307        Some(Value::Array(items)) => {
6308            let mut text = String::new();
6309            let mut parts: Vec<Value> = Vec::new();
6310            let mut has_image = false;
6311            let mut unknown_image_shape = false;
6312            for item in items {
6313                match item.get("type").and_then(Value::as_str) {
6314                    Some("text") => {
6315                        if let Some(t) = item.get("text").and_then(Value::as_str) {
6316                            push_str_field(&mut text, t);
6317                        }
6318                    }
6319                    Some("image") => {
6320                        has_image = true;
6321                        match pi_image_shape(item) {
6322                            Some((mime, data)) => {
6323                                parts.push(serde_json::json!({
6324                                    "type": "image_url",
6325                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
6326                                }));
6327                            }
6328                            None => unknown_image_shape = true,
6329                        }
6330                    }
6331                    _ => {}
6332                }
6333            }
6334            if unknown_image_shape {
6335                // Never synthesize an empty/corrupt part for a shape we
6336                // don't recognize — raw-only survival for the whole message;
6337                // the coverage guard is what turns this into a visible
6338                // failure (S6-style).
6339                return (String::new(), None, true);
6340            }
6341            if has_image {
6342                if !text.trim().is_empty() {
6343                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
6344                }
6345                (text, Some(parts), false)
6346            } else {
6347                (text, None, false)
6348            }
6349        }
6350        _ => (String::new(), None, false),
6351    }
6352}
6353
6354fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6355    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
6356    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
6357    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
6358    // `message/UnknownImageShape` bucket is what turns this into a visible
6359    // coverage failure.
6360    if unknown_image_shape {
6361        return;
6362    }
6363    if text.trim().is_empty() && parts.is_none() {
6364        return;
6365    }
6366    let mut msg = match parts {
6367        Some(parts) => ChatMessage {
6368            role: Role::User,
6369            content: None,
6370            content_parts: Some(parts),
6371            tool_calls: None,
6372            tool_call_id: None,
6373            name: None,
6374            metadata: Default::default(),
6375        },
6376        None => ChatMessage::user(text),
6377    };
6378    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
6379    // (`message.timestamp`) is a DISTINCT field from the canonical
6380    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
6381    // carry genuinely different values in real corpora (the fixture's are
6382    // ~6 months apart). Preserve it separately so it isn't silently lost for
6383    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
6384    // native round-trip consumer) and the INHERENT residue note on
6385    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
6386    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6387        msg.metadata
6388            .insert("pi_msg_timestamp".to_string(), ts.to_string());
6389    }
6390    out.push(msg);
6391}
6392
6393fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6394    let mut text = String::new();
6395    let mut calls: Vec<ToolCall> = Vec::new();
6396    let mut thinking = String::new();
6397    let mut thinking_seen = false;
6398    let mut thinking_sig: Option<String> = None;
6399    let mut thinking_redacted = false;
6400    let mut text_sig: Option<String> = None;
6401    let mut thought_sig: Option<String> = None;
6402
6403    if let Some(Value::Array(blocks)) = msg_v.get("content") {
6404        for b in blocks {
6405            match b.get("type").and_then(Value::as_str) {
6406                Some("text") => {
6407                    if let Some(t) = b.get("text").and_then(Value::as_str) {
6408                        push_str_field(&mut text, t);
6409                    }
6410                    if let Some(sig) = b.get("textSignature") {
6411                        text_sig = Some(match sig {
6412                            Value::String(s) => s.clone(),
6413                            other => other.to_string(),
6414                        });
6415                    }
6416                }
6417                Some("thinking") => {
6418                    thinking_seen = true;
6419                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
6420                        push_str_field(&mut thinking, t);
6421                    }
6422                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
6423                        thinking_sig = Some(sig.to_string());
6424                    }
6425                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
6426                        thinking_redacted = true;
6427                    }
6428                }
6429                Some("toolCall") => {
6430                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
6431                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
6432                    // `arguments` is a JSON OBJECT on pi's wire, not a string
6433                    // (`pi-fields.md` §3b open question 4) — serialize to the
6434                    // string `FunctionCall::arguments` expects.
6435                    let args = b
6436                        .get("arguments")
6437                        .cloned()
6438                        .unwrap_or_else(|| Value::Object(Default::default()));
6439                    calls.push(function_call(id, name, args.to_string()));
6440                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
6441                        thought_sig = Some(sig.to_string());
6442                    }
6443                }
6444                _ => {}
6445            }
6446        }
6447    }
6448
6449    let before = out.len();
6450    push_assistant(out, text, calls);
6451    // A recognized native assistant entry remains transcript state even
6452    // when its content array is empty, except Pi's explicit empty error
6453    // response: that record has no replayable content and is established
6454    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
6455    // non-error turns and Pi's standalone thinking-block shape.
6456    let is_empty_error =
6457        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
6458    if out.len() == before && !is_empty_error {
6459        let mut empty = ChatMessage {
6460            role: Role::Assistant,
6461            content: None,
6462            content_parts: None,
6463            tool_calls: None,
6464            tool_call_id: None,
6465            name: None,
6466            metadata: Default::default(),
6467        };
6468        if !thinking_seen {
6469            empty
6470                .metadata
6471                .insert("empty_assistant_record".to_string(), "true".to_string());
6472        }
6473        out.push(empty);
6474    }
6475    if out.len() > before {
6476        let msg = out.last_mut().expect("just pushed");
6477        if thinking_seen {
6478            msg.metadata.insert("thinking".to_string(), thinking);
6479        }
6480        if let Some(s) = thinking_sig {
6481            msg.metadata.insert("thinking_signature".to_string(), s);
6482        }
6483        if thinking_redacted {
6484            msg.metadata
6485                .insert("pi_thinking_redacted".to_string(), "true".to_string());
6486        }
6487        if let Some(s) = text_sig {
6488            msg.metadata.insert("pi_text_signature".to_string(), s);
6489        }
6490        if let Some(s) = thought_sig {
6491            msg.metadata.insert("pi_thought_signature".to_string(), s);
6492        }
6493        for (key, field) in [
6494            ("pi_api", "api"),
6495            ("pi_provider", "provider"),
6496            ("pi_response_model", "responseModel"),
6497            ("pi_response_id", "responseId"),
6498            ("pi_stop_reason", "stopReason"),
6499            ("pi_error_message", "errorMessage"),
6500        ] {
6501            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
6502                msg.metadata.insert(key.to_string(), s.to_string());
6503            }
6504        }
6505        if let Some(diag) = msg_v.get("diagnostics") {
6506            if !diag.is_null() {
6507                msg.metadata
6508                    .insert("pi_diagnostics".to_string(), diag.to_string());
6509            }
6510        }
6511        if let Some(usage) = msg_v.get("usage") {
6512            if !usage.is_null() {
6513                msg.metadata
6514                    .insert("pi_usage".to_string(), usage.to_string());
6515            }
6516        }
6517        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6518        // separately from the canonical entry-level ISO `timestamp` — see
6519        // `push_pi_user`.
6520        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6521            msg.metadata
6522                .insert("pi_msg_timestamp".to_string(), ts.to_string());
6523        }
6524    }
6525}
6526
6527fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6528    let id = msg_v
6529        .get("toolCallId")
6530        .and_then(Value::as_str)
6531        .unwrap_or_default();
6532    let name = msg_v
6533        .get("toolName")
6534        .and_then(Value::as_str)
6535        .unwrap_or_default();
6536    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
6537    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
6538    // survival, never a synthesized-empty part. Dropping the toolResult
6539    // message here leaves its `toolCallId` unanswered, which
6540    // `ensure_tool_results_paired` already turns into a visible
6541    // "[no tool result recorded — turn interrupted]" placeholder — a loud
6542    // failure mode, not a silent one.
6543    if unknown_image_shape {
6544        return;
6545    }
6546    let mut msg = ChatMessage {
6547        role: Role::Tool,
6548        content: Some(text),
6549        content_parts: parts,
6550        tool_calls: None,
6551        tool_call_id: Some(id.to_string()),
6552        name: Some(name.to_string()),
6553        metadata: Default::default(),
6554    };
6555    if let Some(details) = msg_v.get("details") {
6556        if !details.is_null() {
6557            msg.metadata
6558                .insert("pi_tool_details".to_string(), details.to_string());
6559        }
6560    }
6561    let is_error = msg_v
6562        .get("isError")
6563        .and_then(Value::as_bool)
6564        .unwrap_or(false);
6565    msg.metadata
6566        .insert("pi_is_error".to_string(), is_error.to_string());
6567    if is_error {
6568        crate::reduce::mark_tool_error(&mut msg);
6569    }
6570    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6571    // separately from the canonical entry-level ISO `timestamp` — see
6572    // `push_pi_user`.
6573    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6574        msg.metadata
6575            .insert("pi_msg_timestamp".to_string(), ts.to_string());
6576    }
6577    out.push(msg);
6578}
6579
6580/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
6581/// pi itself sends the model, mirroring `bashExecutionToText`
6582/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
6583/// aren't reproduced in the frozen research doc (only cited by file:line),
6584/// so this is a faithful, clearly-labeled reconstruction — every structured
6585/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
6586fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6587    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
6588    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
6589    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
6590    let cancelled = msg_v
6591        .get("cancelled")
6592        .and_then(Value::as_bool)
6593        .unwrap_or(false);
6594    let truncated = msg_v
6595        .get("truncated")
6596        .and_then(Value::as_bool)
6597        .unwrap_or(false);
6598
6599    let mut text = format!("$ {command}\n{output}");
6600    if let Some(code) = exit_code {
6601        if code != 0 {
6602            text.push_str(&format!("\n[exit code: {code}]"));
6603        }
6604    }
6605    if cancelled {
6606        text.push_str("\n[cancelled]");
6607    }
6608    if truncated {
6609        text.push_str("\n[truncated]");
6610    }
6611
6612    let mut msg = ChatMessage::user(text);
6613    msg.metadata
6614        .insert("pi_bash_command".to_string(), command.to_string());
6615    msg.metadata
6616        .insert("pi_bash_output".to_string(), output.to_string());
6617    if let Some(code) = exit_code {
6618        msg.metadata
6619            .insert("pi_bash_exit_code".to_string(), code.to_string());
6620    }
6621    msg.metadata
6622        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
6623    msg.metadata
6624        .insert("pi_bash_truncated".to_string(), truncated.to_string());
6625    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
6626        msg.metadata
6627            .insert("pi_bash_full_output_path".to_string(), p.to_string());
6628    }
6629    // `!!` — hidden from the model context; honored by `is_replay_excluded`
6630    // on every writer, not just pi's own (§2.2).
6631    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
6632        msg.metadata
6633            .insert("pi_exclude_from_context".to_string(), "true".to_string());
6634    }
6635    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6636    // separately from the canonical entry-level ISO `timestamp` — see
6637    // `push_pi_user`.
6638    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6639        msg.metadata
6640            .insert("pi_msg_timestamp".to_string(), ts.to_string());
6641    }
6642    out.push(msg);
6643}
6644
6645/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
6646/// stamps on a re-materialized content-bearing Claude `system` record (see
6647/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
6648/// never collide with a real pi `CustomMessage.customType` — pi's own
6649/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
6650/// migration targets), never this literal string.
6651const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
6652
6653/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
6654/// `custom_message` entries (§9) — both enter context as a `User` message
6655/// with the same `customType`/`display`/`details` residue.
6656///
6657/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
6658/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
6659/// actually a re-materialized content-bearing Claude `system` record round-
6660/// tripping through pi, not a genuine pi extension message — restore
6661/// `Role::System` + `metadata["systemSubtype"]` (from `details.
6662/// claude_system_subtype`, falling back to `local_command` — still one of
6663/// `push_claude_system`'s own keep subtypes — exactly like
6664/// `write_codex_records`'s Codex-leg fallback) instead of the generic
6665/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
6666/// the exact original role, not just the text.
6667fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
6668    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
6669        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
6670        if content.trim().is_empty() {
6671            return;
6672        }
6673        let subtype = v
6674            .get("details")
6675            .and_then(|d| d.get("claude_system_subtype"))
6676            .and_then(Value::as_str)
6677            .unwrap_or("local_command");
6678        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
6679        return;
6680    }
6681    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
6682    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
6683    // survival, never a synthesized-empty part.
6684    if unknown_image_shape {
6685        return;
6686    }
6687    if text.trim().is_empty() && parts.is_none() {
6688        return;
6689    }
6690    let mut msg = match parts {
6691        Some(parts) => ChatMessage {
6692            role: Role::User,
6693            content: None,
6694            content_parts: Some(parts),
6695            tool_calls: None,
6696            tool_call_id: None,
6697            name: None,
6698            metadata: Default::default(),
6699        },
6700        None => ChatMessage::user(text),
6701    };
6702    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
6703        msg.metadata
6704            .insert("pi_custom_type".to_string(), ct.to_string());
6705    }
6706    if let Some(d) = v.get("display").and_then(Value::as_bool) {
6707        msg.metadata.insert("pi_display".to_string(), d.to_string());
6708    }
6709    if let Some(details) = v.get("details") {
6710        if !details.is_null() {
6711            msg.metadata
6712                .insert("pi_details".to_string(), details.to_string());
6713        }
6714    }
6715    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6716    // separately from the canonical entry-level ISO `timestamp` — see
6717    // `push_pi_user`. `v` here is the `message` object for the `role:
6718    // "custom"` case; for the top-level `custom_message` case `v` is the
6719    // entry itself, whose `timestamp` is the entry-level ISO string (not a
6720    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
6721    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
6722        msg.metadata
6723            .insert("pi_msg_timestamp".to_string(), ts.to_string());
6724    }
6725    out.push(msg);
6726}
6727
6728/// pi's own prefix-wrapped user text for a `compaction` entry summary
6729/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
6730/// The exact upstream wrapper string is cited (`msg:11-17`) but not
6731/// reproduced in the frozen research doc; this is a clearly-labeled
6732/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
6733fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
6734    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
6735    if summary.trim().is_empty() {
6736        return;
6737    }
6738    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
6739    msg.metadata
6740        .insert("pi_type".to_string(), "compaction".to_string());
6741    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
6742        msg.metadata
6743            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
6744    }
6745    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
6746        msg.metadata
6747            .insert("pi_tokens_before".to_string(), tb.to_string());
6748    }
6749    if let Some(d) = entry_v.get("details") {
6750        if !d.is_null() {
6751            msg.metadata.insert("pi_details".to_string(), d.to_string());
6752        }
6753    }
6754    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
6755        msg.metadata
6756            .insert("pi_from_hook".to_string(), "true".to_string());
6757    }
6758    out.push(msg);
6759}
6760
6761/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
6762/// rewind-with-summary) — same reconstruction caveat as
6763/// [`push_pi_compaction`].
6764fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
6765    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
6766    if summary.trim().is_empty() {
6767        return;
6768    }
6769    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
6770    msg.metadata
6771        .insert("pi_type".to_string(), "branch_summary".to_string());
6772    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
6773        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
6774    }
6775    if let Some(d) = entry_v.get("details") {
6776        if !d.is_null() {
6777            msg.metadata.insert("pi_details".to_string(), d.to_string());
6778        }
6779    }
6780    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
6781        msg.metadata
6782            .insert("pi_from_hook".to_string(), "true".to_string());
6783    }
6784    out.push(msg);
6785}
6786
6787// ---- OpenCode ---------------------------------------------------------
6788
6789/// The placeholder opencode's own replay substitutes for a `tool` part's
6790/// output once `state.completed.time.compacted` is set
6791/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
6792/// erased from the record (S1); it survives in `raw` and in this loader's
6793/// `metadata["oc_tool_output_compacted"]`.
6794pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
6795
6796fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
6797    restore_codex_provenance_from_top_level(si, meta)?;
6798    if let Some(id) = si.get("id").and_then(Value::as_str) {
6799        meta.session_id = Some(id.to_string());
6800    }
6801    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
6802        meta.cwd = Some(PathBuf::from(dir));
6803    }
6804    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
6805        meta.agent_id = Some(agent.to_string());
6806    }
6807    if let Some(model) = si.get("model") {
6808        let provider = model.get("providerID").and_then(Value::as_str);
6809        let id = model.get("id").and_then(Value::as_str);
6810        if let (Some(p), Some(i)) = (provider, id) {
6811            meta.model = Some(format!("{p}/{i}"));
6812        }
6813    }
6814    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
6815        meta.lineage
6816            .insert("projectID".to_string(), project_id.to_string());
6817    }
6818    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
6819        meta.lineage.insert("slug".to_string(), slug.to_string());
6820    }
6821    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
6822        meta.lineage
6823            .insert("workspaceID".to_string(), ws.to_string());
6824    }
6825    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
6826        meta.lineage
6827            .insert("parent_session_id".to_string(), parent.to_string());
6828        // Mirrored under the Codex-originated lineage key so the existing
6829        // generic `Session::reconstruct_tree` nests opencode subagent
6830        // sessions too, with no format-specific nesting pass (§2.1: "child
6831        // session's parentID ... → drives reconstruct_tree").
6832        meta.lineage
6833            .insert("parent_thread_id".to_string(), parent.to_string());
6834    }
6835    // D7: the other half of `synthesized_opencode_info`'s passthrough —
6836    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
6837    // -> Claude round trip reconstructs the original record (mirrors
6838    // `capture_codex_session_meta`/`capture_pi_header`'s identical
6839    // `claude_fork_context_ref` restore for the Codex/Pi hops).
6840    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6841        if let Some(v) = si.get("claude_fork_context_ref") {
6842            meta.lineage
6843                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6844        }
6845    }
6846    Ok(())
6847}
6848
6849/// An opencode `User`/`Assistant` `file` part's image data-URI →
6850/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
6851/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
6852/// a bare filesystem path, an `https:` link, or a non-image mime is left as
6853/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
6854/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
6855/// coverage with the SAME test this loader uses to canonicalize it (D5) —
6856/// one definition of "is this file part actually replayed", not two.
6857pub(crate) fn opencode_file_image_part(part: &Value) -> Option<Value> {
6858    let mime = part.get("mime").and_then(Value::as_str)?;
6859    let url = part.get("url").and_then(Value::as_str)?;
6860    if !mime.starts_with("image/") || !url.starts_with("data:") {
6861        return None;
6862    }
6863    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
6864}
6865
6866/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
6867/// `Role::System` arm stamps on the one `synthetic: true` text part of a
6868/// re-materialized content-bearing Claude `system` record (see that arm's
6869/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
6870/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
6871const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
6872
6873/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
6874/// `User` message with EXACTLY one `synthetic: true` text part carrying
6875/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
6876/// opencode data is never misclassified — a genuine opencode `synthetic`
6877/// text part never carries this supercode-namespaced key, and a real
6878/// multi-part user message (text + an attached file, say) never matches
6879/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
6880/// (e.g. `local_command`) on a match.
6881fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
6882    let [part] = parts else { return None };
6883    if part.get("type").and_then(Value::as_str) != Some("text") {
6884        return None;
6885    }
6886    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
6887        return None;
6888    }
6889    part.get("metadata")
6890        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
6891        .and_then(Value::as_str)
6892        .map(str::to_string)
6893}
6894
6895/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
6896/// and `metadata["systemSubtype"]` from the marked text part instead of
6897/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
6898/// OpenCode -> Claude round trip restores the exact original role, not just
6899/// the text. Content is never fabricated — only emitted when non-empty.
6900fn push_opencode_claude_system(
6901    msg_value: &Value,
6902    parts: &[Value],
6903    subtype: String,
6904    out: &mut Vec<ChatMessage>,
6905) {
6906    let Some(text) = parts
6907        .first()
6908        .and_then(|p| p.get("text"))
6909        .and_then(Value::as_str)
6910    else {
6911        return;
6912    };
6913    if text.trim().is_empty() {
6914        return;
6915    }
6916    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
6917    set_opencode_msg_timestamp(&mut msg, msg_value);
6918    out.push(msg);
6919}
6920
6921/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
6922/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
6923/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
6924/// the model"); `file` parts with a recognized image shape become
6925/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
6926/// `SessionMeta.system_prompt` on the first turn that carries it, and
6927/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
6928/// per-user-message, not per-session").
6929/// Fold an opencode message envelope's `time.created` (unix-ms) into the
6930/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
6931/// field claude/codex/pi loaders populate. Lossless to millisecond precision
6932/// (opencode's own wire granularity); a `None`/malformed `time.created`
6933/// leaves `metadata["timestamp"]` unset, so the writer falls back to
6934/// `SYNTH_TS`/`SYNTH_TS_MS`.
6935fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
6936    if let Some(ms) = msg_value
6937        .get("time")
6938        .and_then(|t| t.get("created"))
6939        .and_then(Value::as_i64)
6940    {
6941        msg.metadata
6942            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
6943    }
6944}
6945
6946fn push_opencode_user(
6947    msg_value: &Value,
6948    parts: &[Value],
6949    out: &mut Vec<ChatMessage>,
6950    meta: &mut SessionMeta,
6951    first_system_seen: &mut bool,
6952) {
6953    let mut text = String::new();
6954    let mut image_parts: Vec<Value> = Vec::new();
6955    let mut has_ignored = false;
6956    for p in parts {
6957        match p.get("type").and_then(Value::as_str) {
6958            Some("text") => {
6959                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
6960                    has_ignored = true;
6961                    continue; // must never be replayed (§2.2)
6962                }
6963                if let Some(t) = p.get("text").and_then(Value::as_str) {
6964                    push_str_field(&mut text, t);
6965                }
6966            }
6967            Some("file") => {
6968                if let Some(img) = opencode_file_image_part(p) {
6969                    image_parts.push(img);
6970                }
6971            }
6972            // reasoning/tool never appear on a User message; step-start,
6973            // step-finish, snapshot, patch, agent, subtask, retry have no
6974            // clean home (§2.3); compaction is read separately by the
6975            // caller (tail_start_id) and tagged onto the message below.
6976            _ => {}
6977        }
6978    }
6979
6980    let has_images = !image_parts.is_empty();
6981    if text.trim().is_empty() && !has_images {
6982        return;
6983    }
6984    let mut msg = if has_images {
6985        let mut all = Vec::new();
6986        if !text.trim().is_empty() {
6987            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
6988        }
6989        all.extend(image_parts);
6990        ChatMessage {
6991            role: Role::User,
6992            content: None,
6993            content_parts: Some(all),
6994            tool_calls: None,
6995            tool_call_id: None,
6996            name: None,
6997            metadata: Default::default(),
6998        }
6999    } else {
7000        ChatMessage::user(text)
7001    };
7002
7003    if has_ignored {
7004        msg.metadata
7005            .insert("oc_has_ignored_part".to_string(), "true".to_string());
7006    }
7007    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
7008        msg.metadata
7009            .insert("oc_message_id".to_string(), id.to_string());
7010    }
7011    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
7012        msg.metadata.insert("agent".to_string(), agent.to_string());
7013    }
7014    if let Some(model) = msg_value.get("model") {
7015        if !model.is_null() {
7016            msg.metadata.insert("model".to_string(), model.to_string());
7017        }
7018    }
7019    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
7020        if !*first_system_seen {
7021            meta.system_prompt = Some(system.to_string());
7022            *first_system_seen = true;
7023        }
7024        msg.metadata
7025            .insert("system".to_string(), system.to_string());
7026    }
7027    for p in parts {
7028        if p.get("type").and_then(Value::as_str) == Some("compaction") {
7029            msg.metadata
7030                .insert("phase".to_string(), "compaction".to_string());
7031            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
7032                msg.metadata
7033                    .insert("tail_start_id".to_string(), t.to_string());
7034            }
7035        }
7036    }
7037    set_opencode_msg_timestamp(&mut msg, msg_value);
7038    restore_grok_message_extension(msg_value, &mut msg);
7039    out.push(msg);
7040}
7041
7042/// Map an opencode `Assistant` message + its parts to a canonical
7043/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
7044/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
7045/// reached `completed`/`error` — the split-by-`callID` opencode's single
7046/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
7047/// interrupted turn) synthesize no tool call/result of their own here; the
7048/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
7049/// like the other three loaders. A `tool` part whose `state.status` is none
7050/// of the four known values is skipped entirely — raw-only survival, never
7051/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
7052fn push_opencode_assistant(
7053    msg_value: &Value,
7054    parts: &[Value],
7055    out: &mut Vec<ChatMessage>,
7056    meta: &mut SessionMeta,
7057) {
7058    let mut text = String::new();
7059    let mut calls: Vec<ToolCall> = Vec::new();
7060    let mut thinking = String::new();
7061    let mut reasoning_seen = false;
7062    let mut thinking_sig: Option<String> = None;
7063    // (call_id, tool_name, the tool part itself) — deferred so the
7064    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
7065    // every other loader's message ordering (call, then result).
7066    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
7067
7068    for p in parts {
7069        match p.get("type").and_then(Value::as_str) {
7070            Some("text") => {
7071                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
7072                    continue;
7073                }
7074                if let Some(t) = p.get("text").and_then(Value::as_str) {
7075                    push_str_field(&mut text, t);
7076                }
7077            }
7078            Some("reasoning") => {
7079                reasoning_seen = true;
7080                if let Some(t) = p.get("text").and_then(Value::as_str) {
7081                    push_str_field(&mut thinking, t);
7082                }
7083                if let Some(sig) = p
7084                    .get("metadata")
7085                    .and_then(|m| m.get("anthropic"))
7086                    .and_then(|a| a.get("signature"))
7087                    .and_then(Value::as_str)
7088                {
7089                    thinking_sig = Some(sig.to_string());
7090                }
7091            }
7092            Some("tool") => {
7093                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
7094                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
7095                let status = p
7096                    .get("state")
7097                    .and_then(|s| s.get("status"))
7098                    .and_then(Value::as_str);
7099                let known_status = matches!(
7100                    status,
7101                    Some("pending") | Some("running") | Some("completed") | Some("error")
7102                );
7103                if call_id.is_empty() || !known_status {
7104                    // Unknown/unrecognized status, or a malformed part with
7105                    // no callID — raw-only survival, never synthesized.
7106                    continue;
7107                }
7108                let input = p
7109                    .get("state")
7110                    .and_then(|s| s.get("input"))
7111                    .cloned()
7112                    .unwrap_or_else(|| Value::Object(Default::default()));
7113                calls.push(function_call(call_id, tool_name, input.to_string()));
7114                if matches!(status, Some("completed") | Some("error")) {
7115                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
7116                }
7117            }
7118            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
7119            // — no clean home on an Assistant turn (§2.3).
7120            _ => {}
7121        }
7122    }
7123
7124    let before = out.len();
7125    push_assistant(out, text, calls);
7126    // A native OpenCode assistant record is transcript state even when it
7127    // has no parts. Real stores contain these after an interrupted/empty
7128    // model turn; dropping the record here loses its id, timestamp, model,
7129    // token/cost metadata, and shifts the conversation on every export.
7130    // Keep one empty canonical assistant message so all target writers can
7131    // preserve the turn. This also covers reasoning-only records (whose
7132    // reasoning payload is attached as metadata just below).
7133    if out.len() == before {
7134        let mut empty = ChatMessage {
7135            role: Role::Assistant,
7136            content: None,
7137            content_parts: None,
7138            tool_calls: None,
7139            tool_call_id: None,
7140            name: None,
7141            metadata: Default::default(),
7142        };
7143        if !reasoning_seen {
7144            empty
7145                .metadata
7146                .insert("empty_assistant_record".to_string(), "true".to_string());
7147        }
7148        out.push(empty);
7149    }
7150    if out.len() > before {
7151        let msg = out.last_mut().expect("just pushed");
7152        if reasoning_seen {
7153            msg.metadata.insert("thinking".to_string(), thinking);
7154        }
7155        if let Some(sig) = thinking_sig {
7156            msg.metadata.insert("thinking_signature".to_string(), sig);
7157        }
7158        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
7159            msg.metadata
7160                .insert("oc_message_id".to_string(), id.to_string());
7161        }
7162        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
7163            msg.metadata.insert("agent".to_string(), agent.to_string());
7164            if meta.agent_id.is_none() {
7165                meta.agent_id = Some(agent.to_string());
7166            }
7167        }
7168        let provider = msg_value.get("providerID").and_then(Value::as_str);
7169        let model_id = msg_value.get("modelID").and_then(Value::as_str);
7170        if let (Some(p), Some(i)) = (provider, model_id) {
7171            let full = format!("{p}/{i}");
7172            msg.metadata.insert("model".to_string(), full.clone());
7173            if meta.model.is_none() {
7174                meta.model = Some(full);
7175            }
7176        }
7177        if let Some(cwd) = msg_value
7178            .get("path")
7179            .and_then(|p| p.get("cwd"))
7180            .and_then(Value::as_str)
7181        {
7182            if meta.cwd.is_none() {
7183                meta.cwd = Some(PathBuf::from(cwd));
7184            }
7185        }
7186        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
7187            msg.metadata
7188                .insert("is_summary".to_string(), "true".to_string());
7189        }
7190        for (key, field) in [
7191            ("finish", "finish"),
7192            ("variant", "variant"),
7193            ("mode", "mode"),
7194        ] {
7195            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
7196                msg.metadata.insert(key.to_string(), s.to_string());
7197            }
7198        }
7199        for (key, field) in [
7200            ("cost", "cost"),
7201            ("tokens", "tokens"),
7202            ("error", "error"),
7203            ("structured", "structured"),
7204        ] {
7205            if let Some(v) = msg_value.get(field) {
7206                if !v.is_null() {
7207                    msg.metadata.insert(key.to_string(), v.to_string());
7208                }
7209            }
7210        }
7211        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
7212        // the spawned child session id — keyed by callID so multiple `task`
7213        // calls in one message never collide.
7214        // `resolve_opencode_parent_tool_use_ids` reads these back once a
7215        // whole session set is loaded.
7216        for p in parts {
7217            if p.get("type").and_then(Value::as_str) == Some("tool")
7218                && p.get("tool").and_then(Value::as_str) == Some("task")
7219            {
7220                if let (Some(call_id), Some(child)) = (
7221                    p.get("callID").and_then(Value::as_str),
7222                    p.get("metadata")
7223                        .and_then(|m| m.get("sessionId"))
7224                        .and_then(Value::as_str),
7225                ) {
7226                    msg.metadata.insert(
7227                        format!("oc_task_child_session_id__{call_id}"),
7228                        child.to_string(),
7229                    );
7230                }
7231            }
7232        }
7233        set_opencode_msg_timestamp(msg, msg_value);
7234        restore_grok_message_extension(msg_value, msg);
7235    }
7236
7237    // Second pass: the paired Tool-role message for each completed/error
7238    // tool part, split by callID (§2.1 — "the SAME part carries call and
7239    // result").
7240    for (call_id, tool_name, part) in tool_results {
7241        let status = part
7242            .get("state")
7243            .and_then(|s| s.get("status"))
7244            .and_then(Value::as_str);
7245        let compacted_at = part
7246            .get("state")
7247            .and_then(|s| s.get("time"))
7248            .and_then(|t| t.get("compacted"))
7249            .and_then(Value::as_i64);
7250        let real_output = part
7251            .get("state")
7252            .and_then(|s| s.get("output"))
7253            .and_then(Value::as_str)
7254            .unwrap_or("")
7255            .to_string();
7256        let (content, is_error) = match status {
7257            Some("completed") => {
7258                if compacted_at.is_some() {
7259                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
7260                } else {
7261                    (real_output.clone(), false)
7262                }
7263            }
7264            Some("error") => {
7265                let err = part
7266                    .get("state")
7267                    .and_then(|s| s.get("error"))
7268                    .and_then(Value::as_str)
7269                    .unwrap_or("")
7270                    .to_string();
7271                (err, true)
7272            }
7273            _ => (String::new(), false),
7274        };
7275        let mut tmsg = ChatMessage {
7276            role: Role::Tool,
7277            content: Some(content),
7278            content_parts: None,
7279            tool_calls: None,
7280            tool_call_id: Some(call_id),
7281            name: Some(tool_name),
7282            metadata: Default::default(),
7283        };
7284        if let Some(original_position) = part
7285            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
7286            .and_then(Value::as_u64)
7287        {
7288            tmsg.metadata.insert(
7289                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
7290                original_position.to_string(),
7291            );
7292        }
7293        if is_error {
7294            crate::reduce::mark_tool_error(&mut tmsg);
7295        }
7296        restore_tool_outcome_extension(&part, &mut tmsg);
7297        if let Some(ts) = compacted_at {
7298            // S1: the real output is preserved — reversible, never erased.
7299            tmsg.metadata
7300                .insert("oc_tool_output_compacted".to_string(), real_output);
7301            tmsg.metadata
7302                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
7303        }
7304        if status == Some("completed") {
7305            if let Some(atts) = part
7306                .get("state")
7307                .and_then(|s| s.get("attachments"))
7308                .and_then(Value::as_array)
7309            {
7310                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
7311                if !images.is_empty() {
7312                    // D-mix consistency fix (Fable-recommended, same
7313                    // pattern as `push_claude_user`'s tool_result arm above):
7314                    // a completed opencode tool part with BOTH `state.output`
7315                    // text and `state.attachments` images is the same
7316                    // non-self-contained hybrid shape — `content_parts` here
7317                    // used to hold images only, so opencode -> pi silently
7318                    // dropped the output text (`pi_content_value` reads
7319                    // `content_parts` exclusively for `Role::Tool`). Prepend
7320                    // the text as part 0 so `content_parts` is
7321                    // self-contained; `tmsg.content` keeps the text too,
7322                    // unchanged, for writers that read it from there and
7323                    // only scan `content_parts` for `image_url` entries.
7324                    let mut parts = Vec::new();
7325                    if let Some(t) = &tmsg.content {
7326                        if !t.is_empty() {
7327                            parts.push(serde_json::json!({"type": "text", "text": t}));
7328                        }
7329                    }
7330                    parts.extend(images);
7331                    tmsg.content_parts = Some(parts);
7332                }
7333            }
7334        }
7335        if let Some(id) = part.get("id").and_then(Value::as_str) {
7336            tmsg.metadata
7337                .insert("oc_part_id".to_string(), id.to_string());
7338        }
7339        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
7340        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
7341        // cite `state.time.compacted`, but the SAME object also carries
7342        // `start`/`end` on every completed/error call) is this Tool
7343        // message's real source timestamp; prefer `end` (completion, closer
7344        // to when the RESULT — this message's content — was produced) and
7345        // fall back to `start` when only that is present.
7346        let tool_ts = part
7347            .get("state")
7348            .and_then(|s| s.get("time"))
7349            .and_then(|t| t.get("end").or_else(|| t.get("start")))
7350            .and_then(Value::as_i64);
7351        if let Some(ms) = tool_ts {
7352            tmsg.metadata
7353                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
7354        }
7355        out.push(tmsg);
7356    }
7357}
7358
7359// ---- shared helpers -------------------------------------------------------
7360
7361fn push_text(buf: &mut String, v: Option<&Value>) {
7362    if let Some(Value::String(s)) = v {
7363        if !buf.is_empty() {
7364            buf.push('\n');
7365        }
7366        buf.push_str(s);
7367    }
7368}
7369
7370/// Extract a Claude `tool_result` block's content, preserving non-text items
7371/// instead of silently dropping them:
7372///
7373/// - text blocks are concatenated;
7374/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
7375///   PNG / screenshot tool output" shape): `image` blocks are captured into
7376///   the returned `content_parts`-shaped `Vec<Value>` via
7377///   [`claude_image_block_to_part`] — the SAME base64/url conversion the
7378///   top-level `image` content-block path (`push_claude_user`) already uses
7379///   — instead of being flattened to the bare `[image]` marker text that used
7380///   to make the data unrecoverable from every writer. An unconvertible
7381///   source (D5 discipline — a Files-API `{"type":"file",...}` reference,
7382///   etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
7383///   vanishing, exactly like the top-level path;
7384/// - `tool_reference` blocks become `[tool_reference: <name>]`;
7385///
7386/// and if the block yields no text/images at all, fall back to the record's
7387/// `toolUseResult` field (string used directly, structured value serialized),
7388/// which is where Claude Code stores the actual result in many cases.
7389///
7390/// Returns `(text, images)`; callers that only need the old text-only
7391/// behavior can ignore the second element — every caller MUST fold non-empty
7392/// `images` into the resulting `ChatMessage.content_parts` themselves (this
7393/// function has no `ChatMessage` to attach to).
7394fn extract_tool_result_content(
7395    content: Option<&Value>,
7396    tool_use_result: Option<&Value>,
7397) -> (String, Vec<Value>) {
7398    let mut parts: Vec<String> = Vec::new();
7399    let mut images: Vec<Value> = Vec::new();
7400    match content {
7401        Some(Value::String(s)) => {
7402            if !s.is_empty() {
7403                parts.push(s.clone());
7404            }
7405        }
7406        Some(Value::Array(items)) => {
7407            for item in items {
7408                match item.get("type").and_then(Value::as_str) {
7409                    Some("text") => {
7410                        if let Some(t) = item.get("text").and_then(Value::as_str) {
7411                            parts.push(t.to_string());
7412                        }
7413                    }
7414                    Some("image") => match claude_image_block_to_part(item) {
7415                        Some(part) => images.push(part),
7416                        None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
7417                    },
7418                    Some("tool_reference") => {
7419                        let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
7420                        parts.push(format!("[tool_reference: {name}]"));
7421                    }
7422                    _ => {
7423                        if let Some(s) = item.as_str() {
7424                            parts.push(s.to_string());
7425                        }
7426                    }
7427                }
7428            }
7429        }
7430        Some(other) => parts.push(other.to_string()),
7431        None => {}
7432    }
7433
7434    let joined = parts.join("\n");
7435    if !joined.trim().is_empty() || !images.is_empty() {
7436        return (joined, images);
7437    }
7438    // Empty tool_result content — recover from toolUseResult.
7439    match tool_use_result {
7440        Some(Value::String(s)) => (s.clone(), images),
7441        Some(v) => (v.to_string(), images),
7442        None => (joined, images),
7443    }
7444}
7445
7446/// Pull readable text out of a content value that may be a plain string or an
7447/// array of `{ "text": "..." }`-bearing blocks (any block type).
7448fn extract_text_content(v: Option<&Value>) -> String {
7449    match v {
7450        Some(Value::String(s)) => s.clone(),
7451        Some(Value::Array(items)) => {
7452            let mut parts = Vec::new();
7453            for item in items {
7454                if let Some(t) = item.get("text").and_then(Value::as_str) {
7455                    parts.push(t.to_string());
7456                } else if let Some(s) = item.as_str() {
7457                    parts.push(s.to_string());
7458                }
7459            }
7460            parts.join("\n")
7461        }
7462        Some(other) => other.to_string(),
7463        None => String::new(),
7464    }
7465}
7466
7467/// Extract Codex `input_image` content blocks from a `message` response_item's
7468/// `content` value into `content_parts` `image_url` entries — the inverse of
7469/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
7470/// block whose `image_url` is a non-empty string is recognized; anything else
7471/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
7472/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
7473/// the pi/opencode/Claude loaders' image-shape discipline.
7474fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
7475    let Some(Value::Array(items)) = content else {
7476        return Vec::new();
7477    };
7478    items
7479        .iter()
7480        .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
7481        .filter_map(|item| {
7482            let url = item.get("image_url").and_then(Value::as_str)?;
7483            if url.is_empty() {
7484                return None;
7485            }
7486            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
7487        })
7488        .collect()
7489}
7490
7491fn value_to_arg_string(v: &Value) -> String {
7492    match v {
7493        Value::String(s) => s.clone(),
7494        other => other.to_string(),
7495    }
7496}
7497
7498fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
7499    ToolCall {
7500        id: id.to_string(),
7501        kind: "function".to_string(),
7502        function: FunctionCall {
7503            name: name.to_string(),
7504            arguments,
7505        },
7506    }
7507}
7508
7509fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
7510    ChatMessage {
7511        role: Role::Tool,
7512        content: Some(content),
7513        content_parts: None,
7514        tool_calls: None,
7515        tool_call_id: Some(tool_call_id.to_string()),
7516        name: None,
7517        metadata: Default::default(),
7518    }
7519}
7520
7521/// Emit a single assistant message combining accumulated text and tool calls.
7522/// A turn with neither (e.g. thinking-only) produces nothing.
7523fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
7524    let has_text = !text.trim().is_empty();
7525    if !has_text && calls.is_empty() {
7526        return;
7527    }
7528    out.push(ChatMessage {
7529        role: Role::Assistant,
7530        content: has_text.then_some(text),
7531        content_parts: None,
7532        tool_calls: (!calls.is_empty()).then_some(calls),
7533        tool_call_id: None,
7534        name: None,
7535        metadata: Default::default(),
7536    });
7537}
7538
7539// ---- writers --------------------------------------------------------------
7540
7541/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
7542/// fallback (`docs/interop` build brief): every writer now emits a message's
7543/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
7544/// field every loader populates) when one is present. `SYNTH_TS` fires only
7545/// for a message with no source timestamp at all — a turn synthesized/
7546/// appended after import (the live agent loop, a splice's appended tail,
7547/// ...), which was never loaded from a real per-message timestamp to begin
7548/// with. Both tools tolerate identical timestamps; callers that need real
7549/// ones for a synthesized turn can post-process.
7550const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
7551
7552/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
7553/// `time.created`/`time.updated` fields.
7554const SYNTH_TS_MS: i64 = 1_767_225_600_000;
7555
7556/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
7557/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
7558/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
7559/// parse, not just a presence check) so an absent, empty, or malformed
7560/// source value all degrade to the same documented fallback rather than
7561/// propagating garbage verbatim. Used by every writer that emits an
7562/// ISO-8601 timestamp field
7563/// (Claude Code, Codex, pi's entry-level `timestamp`).
7564fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
7565    match msg.metadata.get("timestamp") {
7566        Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
7567        _ => SYNTH_TS,
7568    }
7569}
7570
7571/// OpenCode reloads an export document by sorting messages on
7572/// `time.created`, so a timestamp-less appended continuation cannot reuse
7573/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
7574/// newer. Advance a deterministic cursor for synthesized clocks while still
7575/// preserving every real source timestamp verbatim.
7576fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
7577    if let Some(real) = msg
7578        .metadata
7579        .get("timestamp")
7580        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
7581    {
7582        // A NativeTurn timestamp is durable provenance minted by supercode,
7583        // not an OpenCode source clock that must be replayed verbatim.
7584        // Multiple turns may be recorded in the same millisecond, while
7585        // OpenCode sorts solely by `time.created`; allocate such turns after
7586        // the existing cursor so their persisted order cannot collapse. This
7587        // also preserves the fail-closed i64::MAX exhaustion behavior.
7588        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
7589            *cursor = cursor.checked_add(1).ok_or_else(|| {
7590                crate::Error::Other(
7591                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
7592                        .to_string(),
7593                )
7594            })?;
7595            return Ok(*cursor);
7596        }
7597        *cursor = (*cursor).max(real);
7598        return Ok(real);
7599    }
7600    let next = cursor.checked_add(1).ok_or_else(|| {
7601        crate::Error::Other(
7602            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
7603        )
7604    })?;
7605    *cursor = next.max(SYNTH_TS_MS);
7606    Ok(*cursor)
7607}
7608
7609/// Largest integer nested under any OpenCode `time` object. Imported
7610/// prefixes carry more clocks than `message.time.created` (assistant
7611/// completion, tool start/end, session updated); a synthesized continuation
7612/// must follow all of them, not merely sort after message creation times.
7613fn opencode_max_timestamp(value: &Value) -> Option<i64> {
7614    fn max_number(value: &Value) -> Option<i64> {
7615        match value {
7616            Value::Number(n) => n.as_i64(),
7617            Value::Array(values) => values.iter().filter_map(max_number).max(),
7618            Value::Object(fields) => fields.values().filter_map(max_number).max(),
7619            _ => None,
7620        }
7621    }
7622
7623    match value {
7624        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
7625        Value::Object(fields) => fields
7626            .iter()
7627            .filter_map(|(key, value)| {
7628                if key == "time" {
7629                    max_number(value)
7630                } else {
7631                    opencode_max_timestamp(value)
7632                }
7633            })
7634            .max(),
7635        _ => None,
7636    }
7637}
7638
7639/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
7640/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
7641/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
7642/// reads. The two carry genuinely different values in real pi corpora (a
7643/// message-level clock reading vs. the entry's own wall-clock stamp), so this
7644/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
7645/// nested `message.timestamp` field, so a pi -> pi native round-trip
7646/// preserves the source message-level clock value-exact instead of deriving
7647/// it from the (distinct) entry-level timestamp. Falls back to
7648/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
7649/// reading (non-pi-sourced, or a synthesized/appended turn).
7650fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
7651    msg.metadata
7652        .get("pi_msg_timestamp")
7653        .and_then(|s| s.parse::<i64>().ok())
7654        .unwrap_or(SYNTH_TS_MS)
7655}
7656
7657/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
7658fn synth_uuid(n: usize) -> String {
7659    format!("00000000-0000-4000-8000-{n:012x}")
7660}
7661
7662/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
7663/// class N2 closed for the Codex spliced path's group ids, see
7664/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
7665/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
7666/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
7667/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
7668/// ahead of the tail this counter mints. Without this, re-splicing a
7669/// previously-exported-then-reimported session (export -> reimport -> append
7670/// -> export again) restarts `counter` at 1 with no memory of the prior
7671/// export's tail uuids now sitting in the prefix, so the second tail
7672/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
7673/// — a uuid collision across prefix and tail that can mis-link any
7674/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
7675/// climbing monotonically even across skips. `used_ids` is also updated for
7676/// each minted or metadata-backed identity, so collisions are prevented both
7677/// against the replayed prefix and within the appended tail.
7678fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
7679    loop {
7680        let candidate = synth_uuid(*counter);
7681        *counter += 1;
7682        if used_ids.insert(candidate.clone()) {
7683            return candidate;
7684        }
7685    }
7686}
7687
7688/// Reuse a message's durable native/source UUID when available, falling back
7689/// to the deterministic synthesized sequence only for hand-built or legacy
7690/// messages that never carried identity metadata.
7691fn claude_message_uuid(
7692    msg: &ChatMessage,
7693    counter: &mut usize,
7694    used_ids: &mut HashSet<String>,
7695) -> String {
7696    for key in ["claude_uuid", "supercode_native_uuid"] {
7697        if let Some(candidate) = msg.metadata.get(key) {
7698            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
7699                return candidate.clone();
7700            }
7701        }
7702    }
7703    next_claude_uuid(counter, used_ids)
7704}
7705
7706/// Companion to [`next_claude_uuid`]: every `uuid` already present in
7707/// `raw_prefix` — the verbatim RAW lines
7708/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
7709/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
7710/// the GROUND TRUTH of what physically lands in the exported `out` string
7711/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
7712/// the Codex side): each line is parsed as a Claude Code JSONL record and
7713/// its own top-level `uuid` field is read back out of the bytes directly, no
7714/// re-derivation from `self.messages` needed. A line that fails to parse, or
7715/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
7716/// record), contributes nothing.
7717fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
7718    let mut ids = HashSet::new();
7719    for line in raw_prefix {
7720        if let Ok(v) = serde_json::from_str::<Value>(line) {
7721            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
7722                ids.insert(uuid.to_string());
7723            }
7724        }
7725    }
7726    ids
7727}
7728
7729fn push_jsonl(out: &mut String, value: &Value) {
7730    out.push_str(&value.to_string());
7731    out.push('\n');
7732}
7733
7734/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
7735/// `new_id` when the line parses as a JSON object carrying that key — used
7736/// by A12's Claude Code splice, where the session id lives at the top level
7737/// of (almost) every record under `key = "sessionId"`. A line that fails to
7738/// parse, or parses but lacks `key`, is copied through byte-for-byte
7739/// (nothing to patch, so nothing is reserialized).
7740fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
7741    if let Some(new_id) = new_id {
7742        if let Ok(mut v) = serde_json::from_str::<Value>(line) {
7743            if v.get(key).is_some() {
7744                v[key] = Value::String(new_id.to_string());
7745                out.push_str(&v.to_string());
7746                out.push('\n');
7747                return;
7748            }
7749        }
7750    }
7751    out.push_str(line);
7752    out.push('\n');
7753}
7754
7755impl Session {
7756    fn cwd_string(&self) -> String {
7757        self.meta
7758            .cwd
7759            .as_ref()
7760            .map(|p| p.to_string_lossy().into_owned())
7761            .unwrap_or_else(|| ".".to_string())
7762    }
7763
7764    /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
7765    /// leading `raw` lines / `messages` came from the imported log, as
7766    /// opposed to being appended after import.
7767    ///
7768    /// `imported_message_count` (see its doc comment) pins the message-side
7769    /// boundary directly. The raw-side boundary isn't separately tracked —
7770    /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
7771    /// `raw` line per appended message, so the two lists grow by the same
7772    /// `appended_count` from the same starting point, and
7773    /// `raw.len() - appended_count` recovers it without a second counter.
7774    fn spliced_prefix_lens(&self) -> (usize, usize) {
7775        let message_prefix_len = self
7776            .imported_message_count
7777            .unwrap_or(self.messages.len())
7778            .min(self.messages.len());
7779        let appended_count = self.messages.len() - message_prefix_len;
7780        let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
7781        (raw_prefix_len, message_prefix_len)
7782    }
7783
7784    /// Synthesize a Claude Code transcript.
7785    ///
7786    /// Claude Code transcripts have no slot for the *session-level system
7787    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
7788    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
7789    /// `ChatMessage`s (Claude's own `type: "system"` records with a
7790    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
7791    /// `away_summary` — see `push_claude_system`, the exact inverse of what
7792    /// this writer now does) DO have a first-class slot: the real `type:
7793    /// "system"` record itself. This function used to unconditionally drop
7794    /// every `System` message, silently losing e.g. a real
7795    /// `<local-command-stdout>` record on any format -> Claude Code hop
7796    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
7797    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
7798    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
7799    /// now re-materializes it instead.
7800    fn to_claude_code_jsonl(&self) -> String {
7801        let session_id = self
7802            .meta
7803            .session_id
7804            .clone()
7805            .unwrap_or_else(|| synth_uuid(0));
7806        let cwd = self.cwd_string();
7807        let mut out = String::new();
7808        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
7809        // re-emitted byte-for-byte, ahead of the conversation it applies to —
7810        // this is what makes the record survive the SEMANTIC Claude Code
7811        // writer (the raw-passthrough diagonal in `crates/cli` already
7812        // preserves it by construction; this covers the library `to_jsonl`
7813        // path too, e.g. a `--session-id` override that forces the semantic
7814        // writer).
7815        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
7816            out.push_str(raw);
7817            out.push('\n');
7818        }
7819        // Full synthesis: `out` at this point has no raw prefix ahead of it
7820        // (unlike the A12 splice below), so there are no uuids yet in play
7821        // to seed against — see `next_claude_uuid`'s doc comment.
7822        self.write_claude_code_records(
7823            &mut out,
7824            &self.messages,
7825            &session_id,
7826            &cwd,
7827            None,
7828            1,
7829            &HashSet::new(),
7830        );
7831        if let Some(extension) = codex_provenance_envelope(&self.meta) {
7832            if out.is_empty() {
7833                push_jsonl(
7834                    &mut out,
7835                    &serde_json::json!({
7836                        "type": "file-history-snapshot",
7837                        "messageId": synth_uuid(1),
7838                        "snapshot": {},
7839                        "sessionId": session_id,
7840                        "cwd": cwd,
7841                        "timestamp": SYNTH_TS,
7842                    }),
7843                );
7844            }
7845            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
7846        }
7847        out
7848    }
7849
7850    /// Synthesize Claude Code records for `messages` (a full session or an
7851    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
7852    /// the latter), starting the `parentUuid` chain at `parent` and the
7853    /// `synth_uuid` counter at `counter`. Factored out of
7854    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
7855    ///
7856    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
7857    /// every uuid that will ALREADY be present in `out` before this call
7858    /// ever runs — see that function's doc comment for why the A12 splice
7859    /// path needs this and full synthesis doesn't.
7860    // R1: this was already at clippy's `too_many_arguments` threshold (7,
7861    // including `&self`) before the fix; the added `seed_used_ids` param
7862    // pushes it to 8. Every argument here is independently meaningful (two
7863    // record-shape inputs, two id/parent-chain threading values, and now
7864    // the collision seed) — bundling them into a params struct is a larger
7865    // refactor of this already-widely-called private helper than the R1 fix
7866    // warrants, so this is allowed rather than restructured.
7867    #[allow(clippy::too_many_arguments)]
7868    fn write_claude_code_records(
7869        &self,
7870        out: &mut String,
7871        messages: &[ChatMessage],
7872        session_id: &str,
7873        cwd: &str,
7874        mut parent: Option<String>,
7875        mut counter: usize,
7876        seed_used_ids: &HashSet<String>,
7877    ) {
7878        let mut used_ids = seed_used_ids.clone();
7879        for msg in messages {
7880            if is_replay_excluded(msg) {
7881                continue;
7882            }
7883            let blocks: Vec<Value> = match msg.role {
7884                // PARITY-6 dev/02: re-materialize a content-bearing System
7885                // `ChatMessage` as a real Claude Code `type: "system"`
7886                // record — the exact inverse of `push_claude_system`, which
7887                // is what produced it in the first place for a message
7888                // loaded FROM a real Claude Code transcript. `subtype`
7889                // prefers the original `systemSubtype` metadata
7890                // (`push_claude_system`'s `.with_meta`, round-tripped
7891                // through the Codex hop via `write_codex_records`'s
7892                // `claude_system_subtype` metadata channel and restored by
7893                // `push_codex_item`); when that channel didn't carry it
7894                // (e.g. a genuinely native, non-Claude-origin developer
7895                // message), fall back to `local_command` — the observed
7896                // common case, and still one of `push_claude_system`'s own
7897                // `keep` subtypes, so the record survives a *subsequent*
7898                // reload rather than being silently re-dropped. This never
7899                // fabricates content: the real text is always carried
7900                // verbatim, only the subtype label is a best-effort guess
7901                // when the true one wasn't recoverable.
7902                Role::System => {
7903                    let content = msg.content.clone().unwrap_or_default();
7904                    if content.trim().is_empty() {
7905                        continue;
7906                    }
7907                    let subtype = msg
7908                        .metadata
7909                        .get("systemSubtype")
7910                        .cloned()
7911                        .unwrap_or_else(|| "local_command".to_string());
7912                    // R1/B3 union: this mint must ALSO route through
7913                    // `next_claude_uuid` + `seed_used_ids` like the other
7914                    // three arms below — otherwise this System arm (added by
7915                    // B3 after R1 landed) mints a raw `synth_uuid` that can
7916                    // collide with a uuid already sitting in the A12 splice's
7917                    // raw prefix (see `next_claude_uuid`'s doc comment).
7918                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
7919                    let mut line = serde_json::json!({
7920                        "parentUuid": parent,
7921                        "type": "system",
7922                        "subtype": subtype,
7923                        "content": content,
7924                        "uuid": uuid,
7925                        "sessionId": session_id,
7926                        "cwd": cwd,
7927                        "timestamp": msg_timestamp_or_synth(msg),
7928                    });
7929                    set_grok_message_extension(&mut line, self.meta.source, msg);
7930                    push_jsonl(out, &line);
7931                    parent = Some(uuid);
7932                    continue;
7933                }
7934                Role::User => {
7935                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
7936                    let mut line = serde_json::json!({
7937                        "parentUuid": parent,
7938                        "type": "user",
7939                        "message": {
7940                            "role": "user",
7941                            "content": claude_user_content_value(msg),
7942                        },
7943                        "uuid": uuid,
7944                        "sessionId": session_id,
7945                        "cwd": cwd,
7946                        "timestamp": msg_timestamp_or_synth(msg),
7947                    });
7948                    set_grok_message_extension(&mut line, self.meta.source, msg);
7949                    push_jsonl(out, &line);
7950                    parent = Some(uuid);
7951                    continue;
7952                }
7953                Role::Tool => {
7954                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
7955                    let mut line = serde_json::json!({
7956                        "parentUuid": parent,
7957                        "type": "user",
7958                        "message": {
7959                            "role": "user",
7960                            "content": [{
7961                                "type": "tool_result",
7962                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
7963                                "content": claude_tool_result_content_value(msg),
7964                            }],
7965                        },
7966                        "uuid": uuid,
7967                        "sessionId": session_id,
7968                        "cwd": cwd,
7969                        "timestamp": msg_timestamp_or_synth(msg),
7970                    });
7971                    if crate::reduce::tool_outcome(msg) == crate::reduce::ToolOutcome::Unknown {
7972                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
7973                    }
7974                    set_grok_message_extension(&mut line, self.meta.source, msg);
7975                    push_jsonl(out, &line);
7976                    parent = Some(uuid);
7977                    continue;
7978                }
7979                Role::Assistant => {
7980                    let mut blocks = Vec::new();
7981                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
7982                    // dev/01): thinking/redacted_thinking must be re-emitted
7983                    // BEFORE text/tool_use, unconditionally whenever
7984                    // retained metadata is present — not only when `blocks`
7985                    // is otherwise empty. The previous `if blocks.is_empty()`
7986                    // gate (now below, applied unconditionally instead)
7987                    // meant a turn that thinks AND THEN answers/calls a tool
7988                    // in the SAME turn — pi's own default emission shape,
7989                    // and the overwhelmingly common real-world case for any
7990                    // reasoning model, not the rare reasoning-only edge case
7991                    // this gate's comment described — silently dropped its
7992                    // entire `thinking` block on Pi -> Claude Code export. A
7993                    // genuine multi-turn pi session driven through pi's own
7994                    // real Agent loop (faux provider, see
7995                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
7996                    // thinking+text turns lost the thinking block entirely.
7997                    // D8: prefer the exact per-block list when present —
7998                    // every `thinking`/`redacted_thinking` block re-emitted
7999                    // SEPARATELY with its own signature/data, exactly as
8000                    // captured (`push_claude_assistant`), instead of the
8001                    // legacy singular fields' lossy collapse (which drops
8002                    // every signature but the last one's on a multi-block
8003                    // message). Falls back to the legacy fields only for a
8004                    // `Session` that never populated `thinking_blocks` (e.g.
8005                    // hand-constructed in another loader/test, or loaded
8006                    // from a non-Claude-Code source like Pi).
8007                    match msg
8008                        .metadata
8009                        .get("thinking_blocks")
8010                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
8011                        .and_then(|v| v.as_array().cloned())
8012                    {
8013                        Some(saved_blocks) => blocks.extend(saved_blocks),
8014                        None => {
8015                            if let Some(t) = msg.metadata.get("thinking") {
8016                                let mut block =
8017                                    serde_json::json!({"type": "thinking", "thinking": t});
8018                                if let Some(sig) = msg.metadata.get("thinking_signature") {
8019                                    block["signature"] = Value::String(sig.clone());
8020                                }
8021                                blocks.push(block);
8022                            }
8023                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
8024                                blocks.push(
8025                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
8026                                );
8027                            }
8028                        }
8029                    }
8030                    if let Some(t) = &msg.content {
8031                        if !t.is_empty() {
8032                            blocks.push(serde_json::json!({"type": "text", "text": t}));
8033                        }
8034                    }
8035                    // PARITY-11: an assistant-emitted image (`content_parts`,
8036                    // e.g. a generated image — `push_claude_assistant`'s
8037                    // load-side counterpart) has no slot in `msg.content`;
8038                    // without this, `blocks` stayed empty for an image-only
8039                    // turn and the whole message vanished on Claude Code
8040                    // semantic export, same failure mode the IX-6 Codex
8041                    // writer fix already closed on that side.
8042                    if let Some(parts) = &msg.content_parts {
8043                        for p in parts {
8044                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
8045                                if let Some(url) = p
8046                                    .get("image_url")
8047                                    .and_then(|u| u.get("url"))
8048                                    .and_then(Value::as_str)
8049                                {
8050                                    blocks.push(match parse_data_uri(url) {
8051                                        Some((mime, data)) => serde_json::json!({
8052                                            "type": "image",
8053                                            "source": {"type": "base64", "media_type": mime, "data": data},
8054                                        }),
8055                                        None => serde_json::json!({
8056                                            "type": "image",
8057                                            "source": {"type": "url", "url": url},
8058                                        }),
8059                                    });
8060                                }
8061                            }
8062                        }
8063                    }
8064                    for tc in msg.tool_calls() {
8065                        let input = tc
8066                            .function
8067                            .parsed_arguments()
8068                            .unwrap_or_else(|_| Value::Object(Default::default()));
8069                        blocks.push(serde_json::json!({
8070                            "type": "tool_use",
8071                            "id": tc.id,
8072                            "name": tc.function.name,
8073                            "input": input,
8074                        }));
8075                    }
8076                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
8077                    // (no text, no tool_use, no image) still doesn't vanish
8078                    // — the thinking/redacted_thinking prepend above already
8079                    // ran unconditionally, so `blocks` is non-empty here
8080                    // whenever any of those were present.
8081                    blocks
8082                }
8083            };
8084
8085            // An empty assistant content array is a valid native interrupted
8086            // turn and must remain a record. Every non-assistant arm above
8087            // already `continue`s after writing its own shape, so an empty
8088            // `blocks` value here belongs specifically to that assistant.
8089            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
8090            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
8091            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
8092                message["model"] = Value::String(model.clone());
8093            }
8094            let mut line = serde_json::json!({
8095                "parentUuid": parent,
8096                "type": "assistant",
8097                "message": message,
8098                "uuid": uuid,
8099                "sessionId": session_id,
8100                "cwd": cwd,
8101                "timestamp": msg_timestamp_or_synth(msg),
8102            });
8103            set_grok_message_extension(&mut line, self.meta.source, msg);
8104            push_jsonl(out, &line);
8105            parent = Some(uuid);
8106        }
8107    }
8108
8109    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
8110    /// (patching `sessionId` on each line when `session_id` is `Some`), then
8111    /// synthesize records only for the appended tail, via
8112    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
8113    /// last original `uuid` found anywhere in the raw prefix (not just its
8114    /// final line: a trailing loader-skipped record, e.g.
8115    /// `file-history-snapshot`, may carry no `uuid` of its own).
8116    fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
8117        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
8118        let sid = session_id
8119            .map(str::to_string)
8120            .or_else(|| self.meta.session_id.clone())
8121            .unwrap_or_else(|| synth_uuid(0));
8122        let cwd = self.cwd_string();
8123
8124        let mut out = String::new();
8125        let mut parent: Option<String> = None;
8126        for line in &self.raw[..raw_prefix_len] {
8127            push_spliced_line(&mut out, line, session_id, "sessionId");
8128            if let Ok(v) = serde_json::from_str::<Value>(line) {
8129                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8130                    parent = Some(uuid.to_string());
8131                }
8132            }
8133        }
8134
8135        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
8136        // the tail's collision guard with every uuid the just-replayed RAW
8137        // prefix already carries, so `write_claude_code_records` never
8138        // fabricates a `synth_uuid` for the appended tail that collides with
8139        // one already sitting in the prefix (see `next_claude_uuid`'s and
8140        // `collect_claude_uuids_from_raw`'s doc comments).
8141        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
8142        self.write_claude_code_records(
8143            &mut out,
8144            &self.messages[message_prefix_len..],
8145            &sid,
8146            &cwd,
8147            parent,
8148            1,
8149            &seed_used_ids,
8150        );
8151        out
8152    }
8153
8154    /// Synthesize a Codex rollout.
8155    fn to_codex_jsonl(&self) -> String {
8156        let mut out = String::new();
8157
8158        if self.meta.codex_headers.is_empty() {
8159            self.write_synthesized_codex_header(&mut out);
8160        } else {
8161            // Replay the exact header records the original tool wrote — Codex's
8162            // reader validates the header shape strictly — overriding only the
8163            // session id when the caller changed it.
8164            for header in &self.meta.codex_headers {
8165                let mut header = header.clone();
8166                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
8167                    if let Some(id) = &self.meta.session_id {
8168                        if let Some(payload) = header.get_mut("payload") {
8169                            payload["id"] = Value::String(id.clone());
8170                        }
8171                    }
8172                }
8173                push_jsonl(&mut out, &header);
8174            }
8175        }
8176
8177        // Full synthesis: `out` at this point is only the header, so there
8178        // are no group ids yet in play to seed against (see
8179        // `write_codex_records`'s doc comment).
8180        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
8181        if let Some(extension) = codex_provenance_envelope(&self.meta) {
8182            inject_codex_provenance(&mut out, extension);
8183        }
8184        out
8185    }
8186
8187    /// Synthesize Codex `response_item` records for `messages` (a full
8188    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
8189    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
8190    /// the record shape is defined once; `tool_search_call_ids` pairing is
8191    /// scoped to this call's `messages`, matching the header-replay
8192    /// contract that only appended records need synthesizing.
8193    ///
8194    /// `seed_used_ids` primes the N2 collision guard below with every group
8195    /// id that will ALREADY be present in `out` before this call ever runs —
8196    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
8197    /// header) passes an empty set, since every group id in that case is
8198    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
8199    /// splice) passes the ids already used by the verbatim RAW prefix it
8200    /// replayed into `out` just before calling this for the appended tail —
8201    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
8202    /// start blind to the prefix and can fabricate/reuse a group id that
8203    /// COLLIDES with one still "open" at the end of the prefix, letting
8204    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
8205    /// an unrelated appended message into a historical one — the same
8206    /// bug-class N2 closed for full synthesis, reopened here because the
8207    /// spliced tail's tracking set used to always start empty regardless of
8208    /// what the replayed prefix already contained.
8209    fn write_codex_records(
8210        &self,
8211        out: &mut String,
8212        messages: &[ChatMessage],
8213        seed_used_ids: &std::collections::HashSet<String>,
8214    ) {
8215        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
8216        // the matching tool result below can be emitted as the paired
8217        // `tool_search_output` record rather than a generic
8218        // `function_call_output` — the exact inverse of the importer's
8219        // `tool_search_call`/`tool_search_output` normalization
8220        // (`push_codex_item`, above).
8221        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
8222        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
8223        // records (e.g. a text-only narration turn immediately followed by a
8224        // bare tool-call turn, no user turn between — a real, common Claude
8225        // Code shape) each become their own Codex `message`/`function_call`
8226        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
8227        // opportunistically RE-MERGES an assistant `message` immediately
8228        // followed by a `function_call` back into ONE `ChatMessage`, to match
8229        // how a genuinely single Claude turn (text+tool_use in the SAME
8230        // record) round-trips — but with no distinguishing signal, it can't
8231        // tell that case apart from two originally-separate records that
8232        // just happen to be adjacent, so it wrongly recombines them too,
8233        // silently shrinking the message count on every Claude -> Codex ->
8234        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
8235        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
8236        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
8237        // itself emits. `push_codex_item`'s merge already treats a turn_id
8238        // mismatch as "different turn, do not merge" (the pre-existing
8239        // belt-and-suspenders check); real native Codex data almost never
8240        // carries this field (per that check's own comment), so this is a
8241        // no-op there and only sharpens fidelity for OUR OWN synthesized
8242        // export.
8243        let mut next_group_id: u64 = 0;
8244        // N2 (Fable-5 review, turn_id-collision hardening): every group id
8245        // this export has already assigned — whether REUSED from a real
8246        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
8247        // `ChatMessage` never emits one that's already in use. Two concrete
8248        // mis-merge scenarios motivate this:
8249        //
8250        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
8251        //     own text+tool_use); reload makes A carry REAL turn_id
8252        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
8253        //     its own) is then appended. Re-export: A reuses its real
8254        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
8255        //     from `next_group_id == 0` again (nothing bumped it when A's id
8256        //     was reused rather than fabricated) — also `sc-grp-0`.
8257        //     Collision. If A's call has no output (interrupted session),
8258        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
8259        //     adjacent with nothing to break the run and merges all three
8260        //     into ONE message (2 -> 1).
8261        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
8262        //     truncation/clear event strips `__codex_open_turn` (closing the
8263        //     turn without changing the id), then `function_call(turn-7)`
8264        //     loads as a SECOND, separate `ChatMessage` that still carries
8265        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
8266        //     restamps it). Full-synthesis export naively reuses `turn-7`
8267        //     verbatim for BOTH messages (they're two different loop
8268        //     iterations, each independently reusing its own `real_turn_id`)
8269        //     and emits them adjacent — reimport's merge check can't tell
8270        //     this apart from a single message's own multi-call turn and
8271        //     recombines them (2 -> 1).
8272        //
8273        // Fix: the fabricated-id counter is advanced (skipped) past any id
8274        // already in `used_group_ids`, AND a real id that's already been
8275        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
8276        // — never letting two DIFFERENT `ChatMessage`s in this export share
8277        // one group id, since `push_codex_item`'s merge check treats a
8278        // shared id as "same turn, merge". A single `ChatMessage`'s own
8279        // message record + its own tool call records still share ONE group
8280        // id (computed once per loop iteration below, before insertion), so
8281        // the D1 tool_search merge and ordinary same-turn multi-call
8282        // grouping are unaffected — this only stops REUSE across iterations.
8283        //
8284        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
8285        // spliced-export tail is likewise blind-proof against the prefix it
8286        // doesn't itself write.
8287        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
8288
8289        for msg in messages {
8290            if is_replay_excluded(msg) {
8291                continue;
8292            }
8293            // D3 (Fable-5 review): a message loaded FROM real native Codex
8294            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
8295            // (`push_codex_item`'s "message" arm stamps it whenever the
8296            // source record itself has one). The group-id logic below used
8297            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
8298            // silently overwriting/discarding that real id on any
8299            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
8300            // when present; only fabricate a synthetic id as a fallback for
8301            // our own merge-disambiguation need (PARITY-6/7) when the
8302            // message has no real one of its own.
8303            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
8304            match msg.role {
8305                Role::System => {
8306                    // PARITY-6 dev/02: carry the original Claude
8307                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
8308                    // through as `metadata.claude_system_subtype`, so
8309                    // `push_codex_item`'s reverse load can restore it and
8310                    // `write_claude_code_records`'s `Role::System` arm can
8311                    // re-materialize the EXACT original subtype rather than
8312                    // guessing on a Codex -> Claude hop.
8313                    let subtype_meta = msg
8314                        .metadata
8315                        .get("systemSubtype")
8316                        .map(|s| ("claude_system_subtype", s.as_str()));
8317                    self.push_codex_message(
8318                        out,
8319                        "developer",
8320                        "input_text",
8321                        msg,
8322                        real_turn_id,
8323                        subtype_meta,
8324                    )
8325                }
8326                Role::User => {
8327                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
8328                }
8329                Role::Assistant => {
8330                    // Emit the message record whenever there is text OR
8331                    // content_parts (IX-6 follow-up): an image-only assistant
8332                    // message has `content: None, content_parts:
8333                    // Some([image])` (the loader's `codex_extract_images` is
8334                    // role-general, so this shape can occur on the assistant
8335                    // side too) — gating on `msg.content` alone silently
8336                    // dropped the whole message, image included. A
8337                    // text-only message (content_parts: None) keeps taking
8338                    // the historical byte-identical path via
8339                    // `codex_message_content_blocks`'s `None` arm. A real
8340                    // empty native assistant record carries the
8341                    // loader's explicit marker and must also be emitted.
8342                    // Reasoning-only cross-provider turns deliberately lack
8343                    // that marker and keep the documented Codex residue.
8344                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
8345                    let has_message_record = has_text
8346                        || msg.content_parts.is_some()
8347                        || msg.metadata.contains_key("empty_assistant_record");
8348                    // Only assign a synthetic group id when there's actual
8349                    // merge ambiguity to resolve (a message AND its own tool
8350                    // calls, or 2+ of this message's own tool calls) — a
8351                    // pure-text message with no tool calls, or a lone tool
8352                    // call with nothing else from the same `ChatMessage`,
8353                    // has nothing to disambiguate, so it keeps the exact
8354                    // historical byte shape (no `metadata` key at all).
8355                    let group_id: Option<String> = if let Some(real) = real_turn_id {
8356                        if used_group_ids.contains(real) {
8357                            // N2: this real turn_id was already used by an
8358                            // earlier (now-closed) `ChatMessage` in this same
8359                            // export — reusing it verbatim would let the
8360                            // reimport merge check recombine two originally
8361                            // separate messages (see the doc comment above).
8362                            let mut n = 1u64;
8363                            let mut candidate = format!("{real}~dup{n}");
8364                            while used_group_ids.contains(&candidate) {
8365                                n += 1;
8366                                candidate = format!("{real}~dup{n}");
8367                            }
8368                            Some(candidate)
8369                        } else {
8370                            Some(real.to_string())
8371                        }
8372                    } else if !msg.tool_calls().is_empty() {
8373                        // N2: skip past any id already used (e.g. a REAL
8374                        // turn_id that happens to look like `sc-grp-N`, or an
8375                        // id an earlier reused-real case landed on).
8376                        let mut candidate = format!("sc-grp-{next_group_id}");
8377                        next_group_id += 1;
8378                        while used_group_ids.contains(&candidate) {
8379                            candidate = format!("sc-grp-{next_group_id}");
8380                            next_group_id += 1;
8381                        }
8382                        Some(candidate)
8383                    } else {
8384                        None
8385                    };
8386                    if let Some(g) = &group_id {
8387                        used_group_ids.insert(g.clone());
8388                    }
8389                    if has_message_record {
8390                        self.push_codex_message(
8391                            out,
8392                            "assistant",
8393                            "output_text",
8394                            msg,
8395                            group_id.as_deref(),
8396                            None,
8397                        );
8398                    }
8399                    for tc in msg.tool_calls() {
8400                        let custom_tool_call = msg
8401                            .metadata
8402                            .get("codex_custom_tool_call_ids")
8403                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
8404                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
8405                        if custom_tool_call {
8406                            let input = tc
8407                                .function
8408                                .parsed_arguments()
8409                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
8410                            let mut payload = with_turn_id(
8411                                serde_json::json!({
8412                                    "type": "custom_tool_call",
8413                                    "name": tc.function.name,
8414                                    "input": input,
8415                                    "call_id": tc.id,
8416                                }),
8417                                group_id.as_deref(),
8418                            );
8419                            set_grok_message_extension(&mut payload, self.meta.source, msg);
8420                            push_jsonl(
8421                                out,
8422                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8423                            );
8424                        } else if tc.function.name == "tool_search" {
8425                            tool_search_call_ids.insert(tc.id.clone());
8426                            let mut payload = with_turn_id(
8427                                serde_json::json!({
8428                                    "type": "tool_search_call",
8429                                    "arguments": tc.function.arguments,
8430                                    "call_id": tc.id,
8431                                }),
8432                                group_id.as_deref(),
8433                            );
8434                            set_grok_message_extension(&mut payload, self.meta.source, msg);
8435                            push_jsonl(
8436                                out,
8437                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8438                            );
8439                        } else {
8440                            let mut payload = with_turn_id(
8441                                serde_json::json!({
8442                                    "type": "function_call",
8443                                    "name": tc.function.name,
8444                                    "arguments": tc.function.arguments,
8445                                    "call_id": tc.id,
8446                                }),
8447                                group_id.as_deref(),
8448                            );
8449                            set_grok_message_extension(&mut payload, self.meta.source, msg);
8450                            push_jsonl(
8451                                out,
8452                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8453                            );
8454                        }
8455                    }
8456                    // PARITY-11: a genuinely reasoning-only turn (Claude
8457                    // `thinking`/`redacted_thinking` with no text, tool_use,
8458                    // or image — `push_claude_assistant`'s load-side fix for
8459                    // the ~21% of real assistant records that are exactly
8460                    // this shape) has no message record and no tool calls,
8461                    // so nothing above writes anything for it. This is
8462                    // DELIBERATE, not a residual gap: Codex's `reasoning`
8463                    // response_item is understood on import (see the
8464                    // `response_item`/`"reasoning"` arm above), but its
8465                    // real-native semantics is "the reasoning immediately
8466                    // BEFORE the next turn" — the reader attaches it to
8467                    // whatever response_item comes next, unconditionally.
8468                    // For a genuinely standalone Claude reasoning-only turn
8469                    // (no related turn follows in Codex's export at all),
8470                    // emitting one here would get silently misattributed as
8471                    // belonging to some later, unrelated turn instead —
8472                    // strictly worse than the current honest, accounted-for
8473                    // absence (thinking/redacted_thinking is provider-
8474                    // private and "not replayed across providers" by
8475                    // original design; the audit correctly classifies it
8476                    // `Coverage::Dropped`, not `Unmodeled`). See the
8477                    // PARITY-6/7 corpus test's `is_replayable` filter for
8478                    // why this doesn't count as a message-count regression.
8479                }
8480                Role::Tool
8481                    if msg
8482                        .tool_call_id
8483                        .as_deref()
8484                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
8485                {
8486                    let content = msg.content.clone().unwrap_or_default();
8487                    let tools =
8488                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
8489                    let mut payload = serde_json::json!({
8490                        "type": "tool_search_output",
8491                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
8492                        "tools": tools,
8493                    });
8494                    set_grok_message_extension(&mut payload, self.meta.source, msg);
8495                    push_jsonl(
8496                        out,
8497                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8498                    );
8499                }
8500                Role::Tool => {
8501                    let mut payload = serde_json::json!({
8502                        "type": "function_call_output",
8503                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
8504                        "output": codex_tool_output_text(msg),
8505                    });
8506                    set_grok_message_extension(&mut payload, self.meta.source, msg);
8507                    push_jsonl(
8508                        out,
8509                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8510                    );
8511                }
8512            }
8513        }
8514    }
8515
8516    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
8517    /// line, not just the `session_meta`/`turn_context` headers
8518    /// [`Self::to_codex_jsonl`] replays — overriding only
8519    /// `session_meta.payload.id` when `session_id` is `Some` (every other
8520    /// line, including `response_item`s the stock synthesis would otherwise
8521    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
8522    /// `response_item` records only for the appended tail, via
8523    /// [`Self::write_codex_records`].
8524    fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
8525        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
8526
8527        let mut out = String::new();
8528        for line in &self.raw[..raw_prefix_len] {
8529            match session_id {
8530                Some(id) => {
8531                    let patched = serde_json::from_str::<Value>(line)
8532                        .ok()
8533                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
8534                        .map(|mut v| {
8535                            if let Some(payload) = v.get_mut("payload") {
8536                                payload["id"] = Value::String(id.to_string());
8537                            }
8538                            v.to_string()
8539                        });
8540                    out.push_str(patched.as_deref().unwrap_or(line));
8541                }
8542                None => out.push_str(line),
8543            }
8544            out.push('\n');
8545        }
8546
8547        // N2 (spliced-path hardening): seed the tail's collision guard with
8548        // every group id the just-replayed RAW prefix already carries, so
8549        // `write_codex_records` never fabricates/reuses an id for the
8550        // appended tail that collides with one still open at the end of the
8551        // prefix (see that fn's doc comment, and
8552        // `collect_codex_group_ids_from_raw`'s).
8553        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
8554        // Belt-and-suspenders: also union in the prefix `messages`' own
8555        // recorded `turn_id` metadata. In the ordinary case this is already
8556        // a subset of what the raw-line scan above found (the loader stamps
8557        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
8558        // field the scan reads) — but scanning `messages` too costs nothing
8559        // and means this stays correct even if some future loader path ever
8560        // derives a message's `turn_id` by some means other than a literal
8561        // `payload.metadata.turn_id` copy.
8562        for msg in &self.messages[..message_prefix_len] {
8563            if let Some(tid) = msg.metadata.get("turn_id") {
8564                seed_used_ids.insert(tid.clone());
8565            }
8566        }
8567        self.write_codex_records(
8568            &mut out,
8569            &self.messages[message_prefix_len..],
8570            &seed_used_ids,
8571        );
8572        out
8573    }
8574
8575    /// Build a Codex header from scratch (used when converting from another
8576    /// format, where no original Codex header exists to replay). Emits the
8577    /// fields Codex requires on `session_meta`.
8578    fn write_synthesized_codex_header(&self, out: &mut String) {
8579        let mut meta_payload = serde_json::json!({
8580            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
8581            "timestamp": SYNTH_TS,
8582            "cwd": self.cwd_string(),
8583            "originator": "supercode",
8584            "cli_version": env!("CARGO_PKG_VERSION"),
8585            "source": "exec",
8586            "thread_source": "user",
8587            "model_provider": "openai",
8588        });
8589        if let Some(sp) = &self.meta.system_prompt {
8590            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
8591        }
8592        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
8593        // `capture_claude_meta`) through the Codex hop under a clearly
8594        // namespaced custom field — real Codex tooling ignores unknown
8595        // `session_meta.payload` keys, and `capture_codex_session_meta`
8596        // reads this same key back on import, so a Claude -> Codex -> Claude
8597        // round trip still reconstructs the original record instead of
8598        // silently losing the lineage note on the cross-format hop.
8599        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
8600            meta_payload["claude_fork_context_ref"] =
8601                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
8602        }
8603        push_jsonl(
8604            out,
8605            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
8606        );
8607        if let Some(model) = &self.meta.model {
8608            push_jsonl(
8609                out,
8610                &serde_json::json!({
8611                    "timestamp": SYNTH_TS,
8612                    "type": "turn_context",
8613                    "payload": {"model": model, "cwd": self.cwd_string()},
8614                }),
8615            );
8616        }
8617    }
8618
8619    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
8620    /// [`Self::write_codex_records`] — `Some` when the source message
8621    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
8622    /// (assistant only) a synthetic disambiguation id when it owns tool
8623    /// calls needing merge disambiguation and has no real id of its own;
8624    /// `None` reproduces the exact historical shape (no `metadata` key at
8625    /// all).
8626    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
8627    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
8628    /// `Role::System` case in [`Self::write_codex_records`] to carry
8629    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
8630    /// system record's subtype survives the Claude -> Codex -> Claude round
8631    /// trip instead of only its text; `None` for every other caller,
8632    /// preserving the exact historical shape).
8633    fn push_codex_message(
8634        &self,
8635        out: &mut String,
8636        role: &str,
8637        text_type: &str,
8638        msg: &ChatMessage,
8639        turn_id: Option<&str>,
8640        extra_metadata: Option<(&str, &str)>,
8641    ) {
8642        let mut payload = with_turn_id(
8643            serde_json::json!({
8644                "type": "message",
8645                "role": role,
8646                "content": codex_message_content_blocks(text_type, msg),
8647            }),
8648            turn_id,
8649        );
8650        if let Some((k, v)) = extra_metadata {
8651            if payload.get("metadata").is_none() {
8652                payload["metadata"] = serde_json::json!({});
8653            }
8654            payload["metadata"][k] = serde_json::json!(v);
8655        }
8656        set_grok_message_extension(&mut payload, self.meta.source, msg);
8657        push_jsonl(
8658            out,
8659            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8660        );
8661    }
8662
8663    /// Synthesize a fresh pi v3 session from the canonical `messages`
8664    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
8665    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
8666    /// through `raw` + `to_native_jsonl(_v2)` instead).
8667    fn to_pi_jsonl(&self) -> String {
8668        let session_id = self
8669            .meta
8670            .session_id
8671            .clone()
8672            .unwrap_or_else(|| synth_uuid(0));
8673        let cwd = self.cwd_string();
8674        let mut out = String::new();
8675        push_pi_header(
8676            &mut out,
8677            &session_id,
8678            &cwd,
8679            self.meta
8680                .lineage
8681                .get("parent_session_path")
8682                .map(String::as_str),
8683            self.meta.lineage.get("created_at").map(String::as_str),
8684            // D7: carry a captured Claude `fork-context-ref` (see
8685            // `capture_claude_meta`) through the Pi hop too — mirrors the
8686            // Codex hop's `claude_fork_context_ref` passthrough
8687            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
8688            // round trip doesn't silently lose fork lineage just because Pi
8689            // has no native slot for it.
8690            self.meta
8691                .lineage
8692                .get("claude_fork_context_ref_raw")
8693                .map(String::as_str),
8694        );
8695        let mut used_ids: HashSet<String> = HashSet::new();
8696        let mut counter: u64 = 0;
8697        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
8698        if let Some(extension) = codex_provenance_envelope(&self.meta) {
8699            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8700        }
8701        out
8702    }
8703
8704    /// Synthesize pi `message` entries for `messages` (a full session, or —
8705    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
8706    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
8707    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
8708    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
8709    fn write_pi_entries(
8710        &self,
8711        out: &mut String,
8712        messages: &[ChatMessage],
8713        mut parent: Option<String>,
8714        used_ids: &mut HashSet<String>,
8715        counter: &mut u64,
8716    ) {
8717        // Claude Code and Codex do not repeat the tool name on their native
8718        // tool-result records. Recover that redundant Pi field from the
8719        // paired assistant call when a cross-format round trip therefore
8720        // returns a canonical Tool message with `name == None`.
8721        let mut paired_tool_names = HashMap::<String, String>::new();
8722        for msg in messages {
8723            if is_replay_excluded(msg) {
8724                continue;
8725            }
8726            for call in msg.tool_calls() {
8727                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
8728            }
8729            let id = pi_fresh_id(used_ids, counter);
8730            let mut entry = match msg.role {
8731                // B4: pi has no session-level system/developer PROMPT slot
8732                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
8733                // content-bearing `Role::System` message loaded from a real
8734                // Claude Code `type: "system"` record (`push_claude_system`'s
8735                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
8736                // `away_summary`) is NOT a system prompt — it's a real,
8737                // non-regenerable transcript event. Pi's own `role:"custom"`
8738                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
8739                // as a user message") is the closest existing, non-fabricated
8740                // slot pi's own parser already understands, so this
8741                // re-materializes the record there instead of silently
8742                // dropping it — the exact allowance push_claude_system's own
8743                // doc comment describes in reverse. `customType` is a
8744                // supercode-namespaced marker (`push_pi_custom_common`
8745                // recognizes it on reload and restores `Role::System` +
8746                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
8747                // produced in the first place); a real pi customType never
8748                // collides with this name. `details.claude_system_subtype`
8749                // carries the original subtype losslessly through the pi leg
8750                // (mirrors `write_codex_records`'s `claude_system_subtype`
8751                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
8752                // is never fabricated — only emitted when non-empty.
8753                Role::System => {
8754                    let content = msg.content.clone().unwrap_or_default();
8755                    if content.trim().is_empty() {
8756                        continue;
8757                    }
8758                    let subtype = msg
8759                        .metadata
8760                        .get("systemSubtype")
8761                        .cloned()
8762                        .unwrap_or_else(|| "local_command".to_string());
8763                    serde_json::json!({
8764                        "type": "message",
8765                        "id": id,
8766                        "parentId": parent,
8767                        "timestamp": msg_timestamp_or_synth(msg),
8768                        "message": {
8769                            "role": "custom",
8770                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
8771                            "content": content,
8772                            "display": true,
8773                            "details": {"claude_system_subtype": subtype},
8774                            "timestamp": msg_pi_native_timestamp_ms(msg),
8775                        },
8776                    })
8777                }
8778                Role::User => serde_json::json!({
8779                    "type": "message",
8780                    "id": id,
8781                    "parentId": parent,
8782                    "timestamp": msg_timestamp_or_synth(msg),
8783                    "message": {
8784                        "role": "user",
8785                        "content": pi_content_value(msg),
8786                        "timestamp": msg_pi_native_timestamp_ms(msg),
8787                    },
8788                }),
8789                Role::Assistant => {
8790                    let api = msg
8791                        .metadata
8792                        .get("pi_api")
8793                        .cloned()
8794                        .unwrap_or_else(|| "anthropic-messages".to_string());
8795                    let provider = msg
8796                        .metadata
8797                        .get("pi_provider")
8798                        .cloned()
8799                        .unwrap_or_else(|| "anthropic".to_string());
8800                    let model = self
8801                        .meta
8802                        .model
8803                        .clone()
8804                        .unwrap_or_else(|| "unknown".to_string());
8805                    let usage = msg
8806                        .metadata
8807                        .get("pi_usage")
8808                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
8809                        .unwrap_or_else(default_pi_usage);
8810                    let stop_reason = msg
8811                        .metadata
8812                        .get("pi_stop_reason")
8813                        .cloned()
8814                        .unwrap_or_else(|| "stop".to_string());
8815                    serde_json::json!({
8816                        "type": "message",
8817                        "id": id,
8818                        "parentId": parent,
8819                        "timestamp": msg_timestamp_or_synth(msg),
8820                        "message": {
8821                            "role": "assistant",
8822                            "content": pi_assistant_content_value(msg),
8823                            "api": api,
8824                            "provider": provider,
8825                            "model": model,
8826                            "usage": usage,
8827                            "stopReason": stop_reason,
8828                            "timestamp": msg_pi_native_timestamp_ms(msg),
8829                        },
8830                    })
8831                }
8832                Role::Tool => serde_json::json!({
8833                    "type": "message",
8834                    "id": id,
8835                    "parentId": parent,
8836                    "timestamp": msg_timestamp_or_synth(msg),
8837                    "message": {
8838                        "role": "toolResult",
8839                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
8840                        "toolName": msg.name.as_deref().or_else(|| {
8841                            msg.tool_call_id
8842                                .as_deref()
8843                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
8844                        }).unwrap_or_default(),
8845                        "content": pi_content_value(msg),
8846                        "isError": is_tool_error_flag(msg),
8847                        "timestamp": msg_pi_native_timestamp_ms(msg),
8848                    },
8849                }),
8850            };
8851            if msg.role == Role::Tool
8852                && crate::reduce::tool_outcome(msg) == crate::reduce::ToolOutcome::Unknown
8853            {
8854                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
8855            }
8856            set_grok_message_extension(&mut entry, self.meta.source, msg);
8857            push_jsonl(out, &entry);
8858            parent = Some(id);
8859            if msg.role == Role::Tool {
8860                if let Some(call_id) = msg.tool_call_id.as_deref() {
8861                    paired_tool_names.remove(call_id);
8862                }
8863            }
8864        }
8865    }
8866
8867    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
8868    /// **verbatim** — the header line always has its `version` normalized to
8869    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
8870    /// byte-identity, so the writer never re-emits one; this intentionally
8871    /// breaks byte-identity for pre-v3 originals only, the accepted
8872    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
8873    /// other raw line — every entry — is untouched (pi repeats the session
8874    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
8875    /// entries only for the appended tail via [`Self::write_pi_entries`],
8876    /// chaining from the last entry `id` found in the raw prefix.
8877    fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
8878        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
8879        if raw_prefix_len == 0 {
8880            return Ok(self.to_pi_jsonl());
8881        }
8882
8883        let mut out = String::new();
8884        let mut used_ids: HashSet<String> = HashSet::new();
8885        let mut leaf: Option<String> = None;
8886        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
8887            if i == 0 {
8888                if let Ok(v) = serde_json::from_str::<Value>(line) {
8889                    if v.get("type").and_then(Value::as_str) == Some("session") {
8890                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
8891                        // Only reparse+reserialize the header when something
8892                        // actually needs to change — this crate doesn't
8893                        // enable serde_json's `preserve_order`, so a no-op
8894                        // round-trip through `Value` would reorder keys
8895                        // alphabetically and silently break the "prefix
8896                        // bytes unchanged" splice guarantee for the (common)
8897                        // already-v3, no-override case.
8898                        if needs_v3 || session_id.is_some() {
8899                            let mut v = v;
8900                            v["version"] = serde_json::json!(3);
8901                            if let Some(new_id) = session_id {
8902                                v["id"] = Value::String(new_id.to_string());
8903                            }
8904                            out.push_str(&v.to_string());
8905                            out.push('\n');
8906                            continue;
8907                        }
8908                    }
8909                }
8910            }
8911            out.push_str(line);
8912            out.push('\n');
8913            if let Ok(v) = serde_json::from_str::<Value>(line) {
8914                if let Some(id) = v.get("id").and_then(Value::as_str) {
8915                    used_ids.insert(id.to_string());
8916                    leaf = Some(id.to_string());
8917                }
8918            }
8919        }
8920
8921        let mut counter: u64 = 0;
8922        self.write_pi_entries(
8923            &mut out,
8924            &self.messages[message_prefix_len..],
8925            leaf,
8926            &mut used_ids,
8927            &mut counter,
8928        );
8929        Ok(out)
8930    }
8931
8932    // ---- Grok writers -----------------------------------------------
8933
8934    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
8935    fn to_grok_jsonl(&self) -> String {
8936        let mut out = String::new();
8937        if let Some(prompt) = self
8938            .meta
8939            .system_prompt
8940            .as_deref()
8941            .filter(|prompt| !prompt.is_empty())
8942        {
8943            push_jsonl(
8944                &mut out,
8945                &serde_json::json!({
8946                    "type": "system",
8947                    "content": prompt,
8948                }),
8949            );
8950        }
8951        self.write_grok_records(&mut out, &self.messages);
8952        if let Some(extension) = codex_provenance_envelope(&self.meta) {
8953            if out.is_empty() {
8954                push_jsonl(
8955                    &mut out,
8956                    &serde_json::json!({"type": "system", "content": ""}),
8957                );
8958            }
8959            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8960        }
8961        out
8962    }
8963
8964    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
8965        for message in messages {
8966            if is_replay_excluded(message) {
8967                continue;
8968            }
8969            let mut value = match message.role {
8970                Role::System => serde_json::json!({
8971                    "type": "user",
8972                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
8973                    "synthetic_reason": "supercode_system_event",
8974                }),
8975                Role::User => {
8976                    let mut value = serde_json::json!({
8977                        "type": "user",
8978                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
8979                    });
8980                    if let Some(object) = value.as_object_mut() {
8981                        for (metadata, field) in [
8982                            ("grok_prompt_index", "prompt_index"),
8983                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
8984                            ("grok_synthetic_reason", "synthetic_reason"),
8985                        ] {
8986                            if let Some(raw) = message.metadata.get(metadata) {
8987                                object.insert(
8988                                    field.to_string(),
8989                                    serde_json::from_str(raw)
8990                                        .unwrap_or_else(|_| Value::String(raw.clone())),
8991                                );
8992                            }
8993                        }
8994                    }
8995                    value
8996                }
8997                Role::Assistant => {
8998                    let calls = message
8999                        .tool_calls()
9000                        .iter()
9001                        .map(|call| {
9002                            serde_json::json!({
9003                                "id": call.id,
9004                                "name": call.function.name,
9005                                "arguments": call.function.arguments,
9006                            })
9007                        })
9008                        .collect::<Vec<_>>();
9009                    let mut value = serde_json::json!({
9010                        "type": "assistant",
9011                        "content": message.content.clone().unwrap_or_default(),
9012                        "tool_calls": calls,
9013                        "model_id": message.metadata.get("grok_model_id")
9014                            .or(self.meta.model.as_ref())
9015                            .cloned()
9016                            .unwrap_or_else(|| "unknown".to_string()),
9017                    });
9018                    if let Some(object) = value.as_object_mut() {
9019                        for (metadata, field) in [
9020                            ("grok_model_fingerprint", "model_fingerprint"),
9021                            ("grok_reasoning_effort", "reasoning_effort"),
9022                        ] {
9023                            if let Some(raw) = message.metadata.get(metadata) {
9024                                object.insert(field.to_string(), Value::String(raw.clone()));
9025                            }
9026                        }
9027                    }
9028                    value
9029                }
9030                Role::Tool => serde_json::json!({
9031                    "type": "tool_result",
9032                    "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
9033                    "content": message.content.clone().unwrap_or_default(),
9034                }),
9035            };
9036            set_grok_target_message_extension(&mut value, message);
9037            push_jsonl(out, &value);
9038        }
9039    }
9040
9041    /// Replay a Grok imported prefix verbatim, then append newly-created
9042    /// canonical turns. Grok stores the session id in the directory name,
9043    /// not in transcript records, so there is no in-file id to rewrite.
9044    fn to_grok_jsonl_spliced(&self) -> String {
9045        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9046        if raw_prefix_len == 0 {
9047            return self.to_grok_jsonl();
9048        }
9049        let mut out = String::new();
9050        for line in &self.raw[..raw_prefix_len] {
9051            out.push_str(line);
9052            out.push('\n');
9053        }
9054        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
9055        out
9056    }
9057
9058    // ---- OpenCode writers ---------------------------------------------
9059
9060    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
9061    /// `(message value, part values)` list) directly from `self.raw`'s
9062    /// envelope lines — the same classification
9063    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
9064    /// rather than canonical `ChatMessage`s. Used by
9065    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
9066    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
9067    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
9068    /// path for excess keys/timestamps/side-records `opencode import`
9069    /// cannot restore).
9070    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
9071        let mut session_info: Option<Value> = None;
9072        let mut msg_order: Vec<String> = Vec::new();
9073        let mut msg_values: HashMap<String, Value> = HashMap::new();
9074        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
9075        for line in &self.raw {
9076            let Ok(env) = serde_json::from_str::<Value>(line) else {
9077                continue;
9078            };
9079            let Some(key) = env.get("key").and_then(Value::as_array) else {
9080                continue;
9081            };
9082            let value = env.get("value").cloned().unwrap_or(Value::Null);
9083            match key.first().and_then(Value::as_str) {
9084                Some("session") => session_info = Some(value),
9085                Some("message") => {
9086                    if let Some(id) = value.get("id").and_then(Value::as_str) {
9087                        if !msg_values.contains_key(id) {
9088                            msg_order.push(id.to_string());
9089                        }
9090                        msg_values.insert(id.to_string(), value);
9091                    }
9092                }
9093                Some("part") => {
9094                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
9095                        msg_parts.entry(mid.to_string()).or_default().push(value);
9096                    }
9097                }
9098                _ => {}
9099            }
9100        }
9101        let mut ordered: Vec<(String, i64)> = msg_order
9102            .iter()
9103            .map(|id| {
9104                let tc = msg_values
9105                    .get(id)
9106                    .and_then(|v| v.get("time"))
9107                    .and_then(|t| t.get("created"))
9108                    .and_then(Value::as_i64)
9109                    .unwrap_or(0);
9110                (id.clone(), tc)
9111            })
9112            .collect();
9113        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
9114        let mut out = Vec::new();
9115        for (id, _) in ordered {
9116            let mut parts = msg_parts.remove(&id).unwrap_or_default();
9117            parts.sort_by(|a, b| {
9118                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
9119                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
9120                ai.cmp(bi)
9121            });
9122            if let Some(v) = msg_values.remove(&id) {
9123                out.push((v, parts));
9124            }
9125        }
9126        (session_info, out)
9127    }
9128
9129    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
9130    /// `raw` prefix exists to replay (a fresh/cross-format-converted
9131    /// session). T3 tier: only what `SessionMeta` carries survives.
9132    fn synthesized_opencode_info(&self) -> Value {
9133        let id = self
9134            .meta
9135            .session_id
9136            .clone()
9137            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
9138        let mut info = serde_json::json!({
9139            "id": id,
9140            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
9141            // OpenCode 1.2.15's import path writes this into a NOT NULL
9142            // SQLite column. Preserve a real source slug when available and
9143            // mint a stable, human-readable fallback for foreign sessions.
9144            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
9145            "directory": self.cwd_string(),
9146            "title": "supercode export",
9147            "version": env!("CARGO_PKG_VERSION"),
9148            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
9149        });
9150        if let Some(agent) = &self.meta.agent_id {
9151            info["agent"] = Value::String(agent.clone());
9152        }
9153        if let Some(model) = &self.meta.model {
9154            if let Some((provider, mid)) = model.split_once('/') {
9155                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
9156            }
9157        }
9158        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
9159            info["parentID"] = Value::String(parent.clone());
9160        }
9161        // D7: carry a captured Claude `fork-context-ref` through the
9162        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
9163        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
9164        // the `session` header) hops already do — namespaced so real
9165        // OpenCode tooling ignores it, and `capture_opencode_session_info`
9166        // reads this same key back on import so a Claude -> OpenCode ->
9167        // Claude round trip doesn't silently lose fork lineage either.
9168        //
9169        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
9170        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
9171        // this `claude_fork_context_ref` key on `SessionInfo` survives
9172        // supercode's OWN round-trip (write here, read back by
9173        // `capture_opencode_session_info` above) but NOT a real upstream
9174        // `opencode import` ingestion — that path decodes with
9175        // `Schema.decodeUnknownSync`, which strips any key its schema
9176        // doesn't declare. The direct-file/DB fallback (bypassing
9177        // `opencode import` entirely) is the per-spec fidelity path for
9178        // this lineage to actually reach real OpenCode.
9179        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9180            info["claude_fork_context_ref"] =
9181                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9182        }
9183        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9184            info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
9185        }
9186        info
9187    }
9188
9189    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
9190    /// synthesized continuation message therefore has to advance the
9191    /// session clock along with its own `time.created` value.
9192    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
9193        if !info.get("time").is_some_and(Value::is_object) {
9194            info["time"] = serde_json::json!({});
9195        }
9196        info["time"]["updated"] = serde_json::json!(timestamp);
9197    }
9198
9199    /// Synthesize opencode `{info, parts}` message objects for `messages`
9200    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
9201    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
9202    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
9203    /// back into its call's assistant `tool` part (match by
9204    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
9205    /// whole slice)
9206    /// — the exact inverse of the loader's call/result split. This is a
9207    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
9208    /// immediately following each assistant: two-or-more consecutive
9209    /// assistant-with-tool-call messages before their results (streamed /
9210    /// parallel tool calls) otherwise strand the earlier call's real result
9211    /// behind a later assistant message, silently downgrading it to
9212    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
9213    /// messages exactly like every other writer.
9214    fn append_synthesized_opencode_messages(
9215        &self,
9216        out: &mut Vec<Value>,
9217        messages: &[ChatMessage],
9218        session_id: &str,
9219        counter: &mut u64,
9220        timestamp_cursor: &mut i64,
9221    ) -> Result<()> {
9222        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
9223        // over the ENTIRE slice being processed, rather than by scanning
9224        // only the contiguous run of `Role::Tool` messages immediately
9225        // following a given assistant message. Two-or-more consecutive
9226        // assistant-with-tool-call messages before their results (streamed
9227        // / parallel tool calls — extremely common in real Claude Code and
9228        // Codex sessions) break the contiguous-run assumption: the first
9229        // assistant's own result(s) land AFTER a second assistant message,
9230        // not immediately after the first, so a contiguous scan starting
9231        // right after the first assistant finds nothing and silently drops
9232        // its real tool output into the `None => "pending"` branch below.
9233        // A single `id -> result` map is still insufficient: long real
9234        // sessions can reuse provider call ids. Last-write-wins then attaches
9235        // the final output to every earlier occurrence. Collect calls and
9236        // results independently and zip their occurrences in transcript
9237        // order, giving every concrete call position its own result.
9238        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
9239        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
9240        for (message_index, message) in messages.iter().enumerate() {
9241            if message.role == Role::Assistant {
9242                for (tool_index, call) in message.tool_calls().iter().enumerate() {
9243                    calls_by_id
9244                        .entry(call.id.as_str())
9245                        .or_default()
9246                        .push((message_index, tool_index));
9247                }
9248            } else if message.role == Role::Tool {
9249                if let Some(id) = &message.tool_call_id {
9250                    results_by_id
9251                        .entry(id.as_str())
9252                        .or_default()
9253                        .push((message_index, message));
9254                }
9255            }
9256        }
9257        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
9258        for (id, calls) in calls_by_id {
9259            let Some(results) = results_by_id.get(id) else {
9260                continue;
9261            };
9262            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
9263                paired_results.insert(call_position, result);
9264            }
9265        }
9266        let mut i = 0;
9267        while i < messages.len() {
9268            let msg = &messages[i];
9269            if is_replay_excluded(msg) {
9270                i += 1;
9271                continue;
9272            }
9273            match msg.role {
9274                // B4: opencode V1 has no session-level system-PROMPT slot
9275                // either — `User.system` is a per-turn system-PROMPT
9276                // OVERRIDE (§2.1), a different thing from a content-bearing
9277                // `Role::System` message loaded from a real Claude `type:
9278                // "system"` record (`push_claude_system`'s keep-listed
9279                // subtypes). Stuffing real transcript content into
9280                // `User.system` would be a genuine misuse — it overrides the
9281                // replayed system prompt, not just annotates a turn — so
9282                // this instead reuses opencode's own `text` part `synthetic`
9283                // flag (§3.1: "injected by opencode, not typed by user"),
9284                // which is EXACTLY the right existing, non-fabricated
9285                // semantic for "system-originated content presented as a
9286                // user turn": a dedicated `User` message with one
9287                // `synthetic: true` text part, tagged with a
9288                // supercode-namespaced part-`metadata` key so
9289                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
9290                // recognize it on reload and restore `Role::System` +
9291                // `metadata["systemSubtype"]` rather than treating it as a
9292                // real user turn. Content is never fabricated — only
9293                // emitted when non-empty.
9294                Role::System => {
9295                    let content = msg.content.clone().unwrap_or_default();
9296                    if content.trim().is_empty() {
9297                        i += 1;
9298                        continue;
9299                    }
9300                    let subtype = msg
9301                        .metadata
9302                        .get("systemSubtype")
9303                        .cloned()
9304                        .unwrap_or_else(|| "local_command".to_string());
9305                    let msg_id = opencode_fresh_id("msg", counter);
9306                    let part_id = opencode_fresh_id("prt", counter);
9307                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
9308                    let mut info = serde_json::json!({
9309                        "id": msg_id,
9310                        "sessionID": session_id,
9311                        "role": "user",
9312                        "time": {"created": timestamp},
9313                    });
9314                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
9315                    let parts = vec![serde_json::json!({
9316                        "id": part_id,
9317                        "sessionID": session_id,
9318                        "messageID": msg_id,
9319                        "type": "text",
9320                        "text": content,
9321                        "synthetic": true,
9322                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
9323                    })];
9324                    out.push(serde_json::json!({"info": info, "parts": parts}));
9325                    i += 1;
9326                }
9327                Role::User => {
9328                    let msg_id = opencode_fresh_id("msg", counter);
9329                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
9330                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
9331                    let mut info = serde_json::json!({
9332                        "id": msg_id,
9333                        "sessionID": session_id,
9334                        "role": "user",
9335                        "time": {"created": timestamp},
9336                    });
9337                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
9338                    opencode_restore_agent_model_fields(
9339                        &mut info, msg, /* is_assistant */ false,
9340                    );
9341                    set_grok_message_extension(&mut info, self.meta.source, msg);
9342                    out.push(serde_json::json!({
9343                        "info": info,
9344                        "parts": parts,
9345                    }));
9346                    i += 1;
9347                }
9348                Role::Assistant => {
9349                    let msg_id = opencode_fresh_id("msg", counter);
9350                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
9351                    let mut parts = Vec::new();
9352                    if let Some(thinking) = msg.metadata.get("thinking") {
9353                        let mut part = serde_json::json!({
9354                            "id": opencode_fresh_id("prt", counter),
9355                            "sessionID": session_id,
9356                            "messageID": msg_id,
9357                            "type": "reasoning",
9358                            "text": thinking,
9359                            // Required by OpenCode V1's native reasoning
9360                            // schema. A synthesized part has no distinct
9361                            // stream start/end, so the source message clock
9362                            // is the honest zero-duration span.
9363                            "time": {"start": timestamp, "end": timestamp},
9364                        });
9365                        if let Some(signature) = msg.metadata.get("thinking_signature") {
9366                            part["metadata"] = serde_json::json!({
9367                                "anthropic": {"signature": signature},
9368                            });
9369                        }
9370                        parts.push(part);
9371                    }
9372                    if let Some(t) = &msg.content {
9373                        if !t.is_empty() {
9374                            parts.push(serde_json::json!({
9375                                "id": opencode_fresh_id("prt", counter),
9376                                "sessionID": session_id,
9377                                "messageID": msg_id,
9378                                "type": "text",
9379                                "text": t,
9380                            }));
9381                        }
9382                    }
9383                    // Fold each tool call's result back into ONE `tool`
9384                    // part, matched by tool_call_id via the GLOBAL
9385                    // `all_results` map built above (not a contiguous scan)
9386                    // — a result may be many messages away when other
9387                    // assistant turns with their own pending calls
9388                    // intervene before it appears.
9389                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
9390                        let input = tc
9391                            .function
9392                            .parsed_arguments()
9393                            .unwrap_or_else(|_| Value::Object(Default::default()));
9394                        let paired_result = paired_results.get(&(i, tool_index)).copied();
9395                        let state = match paired_result {
9396                            Some((_, result)) if crate::reduce::is_tool_error(result) => {
9397                                let result_timestamp =
9398                                    opencode_message_timestamp(result, timestamp_cursor)?;
9399                                serde_json::json!({
9400                                    "status": "error",
9401                                    "input": input,
9402                                    "error": result.content.clone().unwrap_or_default(),
9403                                    "time": {"end": result_timestamp},
9404                                })
9405                            }
9406                            Some((_, result)) => {
9407                                let result_timestamp =
9408                                    opencode_message_timestamp(result, timestamp_cursor)?;
9409                                let mut s = serde_json::json!({
9410                                    "status": "completed",
9411                                    "input": input,
9412                                    "output": result.content.clone().unwrap_or_default(),
9413                                    "title": tc.function.name,
9414                                    "time": {"end": result_timestamp},
9415                                });
9416                                // PARITY-11 (nested images): the LOADER already
9417                                // reads a completed tool part's
9418                                // `state.attachments` back into `content_parts`
9419                                // (`opencode_file_image_part`, above) — this is
9420                                // the missing WRITE-side inverse. Without it, a
9421                                // Claude `tool_result`'s nested image (now
9422                                // captured into `content_parts` by
9423                                // `extract_tool_result_content`) reached
9424                                // `content_parts` on the canonical `ChatMessage`
9425                                // but was silently dropped again on re-export to
9426                                // OpenCode, because nothing ever read it back
9427                                // out. `mime`/`url` shape matches exactly what
9428                                // `opencode_file_image_part` expects on reload.
9429                                if let Some(cps) = &result.content_parts {
9430                                    let atts: Vec<Value> = cps
9431                                        .iter()
9432                                        .filter(|p| {
9433                                            p.get("type").and_then(Value::as_str)
9434                                                == Some("image_url")
9435                                        })
9436                                        .filter_map(|p| {
9437                                            let url = p
9438                                                .get("image_url")
9439                                                .and_then(|u| u.get("url"))
9440                                                .and_then(Value::as_str)?;
9441                                            let mime = url
9442                                                .strip_prefix("data:")
9443                                                .and_then(|r| r.split_once(','))
9444                                                .map(|(m, _)| m.trim_end_matches(";base64"))
9445                                                .unwrap_or("application/octet-stream");
9446                                            Some(serde_json::json!({
9447                                                "mime": mime,
9448                                                "url": url,
9449                                            }))
9450                                        })
9451                                        .collect();
9452                                    if !atts.is_empty() {
9453                                        s["attachments"] = Value::Array(atts);
9454                                    }
9455                                }
9456                                s
9457                            }
9458                            None => serde_json::json!({"status": "pending", "input": input}),
9459                        };
9460                        let mut part = serde_json::json!({
9461                            "id": opencode_fresh_id("prt", counter),
9462                            "sessionID": session_id,
9463                            "messageID": msg_id,
9464                            "type": "tool",
9465                            "callID": tc.id,
9466                            "tool": tc.function.name,
9467                            "state": state,
9468                        });
9469                        if let Some((result_position, _)) = paired_result {
9470                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
9471                                serde_json::json!(result_position);
9472                        }
9473                        if paired_result.is_some_and(|(_, result)| {
9474                            crate::reduce::tool_outcome(result)
9475                                == crate::reduce::ToolOutcome::Unknown
9476                        }) {
9477                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9478                        }
9479                        parts.push(part);
9480                    }
9481                    let mut info = serde_json::json!({
9482                        "id": msg_id,
9483                        "sessionID": session_id,
9484                        "role": "assistant",
9485                        "time": {"created": timestamp},
9486                    });
9487                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
9488                    opencode_restore_agent_model_fields(
9489                        &mut info, msg, /* is_assistant */ true,
9490                    );
9491                    set_grok_message_extension(&mut info, self.meta.source, msg);
9492                    out.push(serde_json::json!({
9493                        "info": info,
9494                        "parts": parts,
9495                    }));
9496                    i += 1;
9497                }
9498                // A Tool message is always folded into its call's assistant
9499                // `tool` part above (via occurrence-aware global pairing, not
9500                // positional adjacency), so it never needs its own entry
9501                // here — just advance past it.
9502                Role::Tool => i += 1,
9503            }
9504        }
9505        Ok(())
9506    }
9507
9508    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
9509    /// `messages` (T3 cross-format/full synthesis tier — mirrors
9510    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
9511    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
9512    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
9513    /// (§1.2 — the `opencode export`/`import` interchange shape).
9514    fn to_opencode_jsonl(&self) -> Result<String> {
9515        let mut info = self.synthesized_opencode_info();
9516        let ses_id = info
9517            .get("id")
9518            .and_then(Value::as_str)
9519            .unwrap_or("ses_new")
9520            .to_string();
9521        let mut messages_json: Vec<Value> = Vec::new();
9522        let mut counter: u64 = 0;
9523        let mut timestamp_cursor =
9524            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
9525        self.append_synthesized_opencode_messages(
9526            &mut messages_json,
9527            &self.messages,
9528            &ses_id,
9529            &mut counter,
9530            &mut timestamp_cursor,
9531        )?;
9532        if !messages_json.is_empty() {
9533            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
9534        }
9535        let doc = serde_json::json!({"info": info, "messages": messages_json});
9536        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
9537    }
9538
9539    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
9540    /// imported records **value-equal at their position** in the export
9541    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
9542    /// via [`Self::opencode_records_from_raw`], never re-derived from the
9543    /// lossy canonical `messages` — then append freshly synthesized
9544    /// `{info, parts}` objects for the tail via
9545    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
9546    /// line-oriented formats' splice, `out` here is a single export
9547    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
9548    /// assertion accordingly: value-equality at position, not byte
9549    /// equality of a line range).
9550    fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
9551        if self.raw.is_empty() {
9552            return self.to_opencode_jsonl();
9553        }
9554        let (session_info, records) = self.opencode_records_from_raw();
9555        let (_, message_prefix_len) = self.spliced_prefix_lens();
9556
9557        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
9558        if let Some(id) = session_id {
9559            info["id"] = Value::String(id.to_string());
9560        }
9561        let ses_id_for_new = info
9562            .get("id")
9563            .and_then(Value::as_str)
9564            .unwrap_or("ses_new")
9565            .to_string();
9566
9567        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
9568            .chain(records.iter().flat_map(|(msg, parts)| {
9569                std::iter::once(opencode_max_timestamp(msg))
9570                    .chain(parts.iter().map(opencode_max_timestamp))
9571            }))
9572            .flatten()
9573            .max()
9574            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
9575
9576        let mut messages_json: Vec<Value> = records
9577            .into_iter()
9578            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
9579            .collect();
9580        let imported_len = messages_json.len();
9581
9582        let mut counter: u64 = 0;
9583        self.append_synthesized_opencode_messages(
9584            &mut messages_json,
9585            &self.messages[message_prefix_len..],
9586            &ses_id_for_new,
9587            &mut counter,
9588            &mut timestamp_cursor,
9589        )?;
9590        if messages_json.len() > imported_len {
9591            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
9592        }
9593
9594        let doc = serde_json::json!({"info": info, "messages": messages_json});
9595        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
9596    }
9597
9598    /// The **required** direct-write fallback (S5): write the imported
9599    /// OpenCode records **verbatim** — excess/unknown keys, part-row
9600    /// timestamps, and `session_diff`/`todo` side-records intact — to a
9601    /// generation-B JSON-file storage tree
9602    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
9603    /// `opencode import` cannot provide (S5: import re-decodes through a
9604    /// strict schema and STRIPS excess keys; inserts part rows without
9605    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
9606    /// has no ingestion path for `session_diff`/`todo` at all).
9607    ///
9608    /// Writes the JSON-FILE layout rather than a live SQLite write
9609    /// specifically to avoid a new `rusqlite`-class dependency on this
9610    /// build's memory-constrained box (see the build report); `session_diff`
9611    /// itself is still JSON-written by upstream even on SQLite installs
9612    /// (§1.3), so this is a real fidelity path, not a fictional one.
9613    ///
9614    /// Returns the `storage/session/<projectID>/` directory written to.
9615    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
9616        let (session_info, mut records) = self.opencode_records_from_raw();
9617        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
9618        let ses_id = info
9619            .get("id")
9620            .and_then(Value::as_str)
9621            .unwrap_or("ses_new")
9622            .to_string();
9623        if info.get("id").is_none() {
9624            info["id"] = Value::String(ses_id.clone());
9625        }
9626        let project_id = info
9627            .get("projectID")
9628            .and_then(Value::as_str)
9629            .unwrap_or("global")
9630            .to_string();
9631
9632        // Appended tail (messages produced after import): synthesize fresh
9633        // message/part VALUES via the same T3 synthesis the splice writer
9634        // uses, so continuation turns get files too. Do this BEFORE creating
9635        // any directories: timestamp exhaustion must fail atomically rather
9636        // than leave a partial direct-write tree behind.
9637        let (_, message_prefix_len) = self.spliced_prefix_lens();
9638        let mut counter: u64 = 0;
9639        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
9640            .chain(records.iter().flat_map(|(msg, parts)| {
9641                std::iter::once(opencode_max_timestamp(msg))
9642                    .chain(parts.iter().map(opencode_max_timestamp))
9643            }))
9644            .flatten()
9645            .max()
9646            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
9647        let mut appended_json: Vec<Value> = Vec::new();
9648        self.append_synthesized_opencode_messages(
9649            &mut appended_json,
9650            &self.messages[message_prefix_len..],
9651            &ses_id,
9652            &mut counter,
9653            &mut timestamp_cursor,
9654        )?;
9655        if !appended_json.is_empty() {
9656            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
9657        }
9658        for entry in appended_json {
9659            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
9660            let parts = entry
9661                .get("parts")
9662                .and_then(Value::as_array)
9663                .cloned()
9664                .unwrap_or_default();
9665            records.push((msg, parts));
9666        }
9667
9668        let storage = data_root.join("storage");
9669        let session_dir = storage.join("session").join(&project_id);
9670        std::fs::create_dir_all(&session_dir)?;
9671        std::fs::write(
9672            session_dir.join(format!("{ses_id}.json")),
9673            serde_json::to_string_pretty(&info).unwrap_or_default(),
9674        )?;
9675
9676        let message_dir = storage.join("message").join(&ses_id);
9677        let part_dir = storage.join("part");
9678        std::fs::create_dir_all(&message_dir)?;
9679
9680        for (msg, parts) in &records {
9681            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
9682                continue;
9683            };
9684            std::fs::write(
9685                message_dir.join(format!("{msg_id}.json")),
9686                serde_json::to_string_pretty(msg).unwrap_or_default(),
9687            )?;
9688            let this_part_dir = part_dir.join(msg_id);
9689            std::fs::create_dir_all(&this_part_dir)?;
9690            for part in parts {
9691                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
9692                    continue;
9693                };
9694                std::fs::write(
9695                    this_part_dir.join(format!("{part_id}.json")),
9696                    serde_json::to_string_pretty(part).unwrap_or_default(),
9697                )?;
9698            }
9699        }
9700
9701        // Side-records (S5c): session_diff / todo have NO ingestion path via
9702        // `opencode import` at all — the direct write is their only
9703        // fidelity path.
9704        for header in &self.meta.opencode_headers {
9705            let Some(key) = header.get("key").and_then(Value::as_array) else {
9706                continue;
9707            };
9708            let Some(kind) = key.first().and_then(Value::as_str) else {
9709                continue;
9710            };
9711            let value = header.get("value").cloned().unwrap_or(Value::Null);
9712            if !matches!(kind, "session_diff" | "todo") {
9713                continue;
9714            }
9715            let dir = storage.join(kind);
9716            std::fs::create_dir_all(&dir)?;
9717            std::fs::write(
9718                dir.join(format!("{ses_id}.json")),
9719                serde_json::to_string_pretty(&value).unwrap_or_default(),
9720            )?;
9721        }
9722
9723        Ok(session_dir)
9724    }
9725}
9726
9727fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
9728    *counter += 1;
9729    format!("{prefix}_synth{counter:06}")
9730}
9731
9732/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
9733/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
9734/// EXACT native shape opencode's own loaders (`push_opencode_user` /
9735/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
9736/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
9737/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
9738/// ONLY when its metadata key is present (a synthesized continuation turn, or
9739/// a User message that never carried `agent`, stays clean — no spurious
9740/// null/empty fields).
9741///
9742/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
9743///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
9744///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
9745/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
9746///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
9747///   inverse must match per-role:
9748///   - User: `push_opencode_user` stores `metadata["model"]` as the
9749///     STRINGIFIED `{providerID, modelID, variant?}` object
9750///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
9751///     as that same object under `"model"`.
9752///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
9753///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
9754///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
9755///     join; a `modelID` containing further `/`s round-trips correctly since
9756///     `split_once` only consumes the first) and re-emitted as the two
9757///     top-level `providerID`/`modelID` fields the loader actually reads.
9758/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
9759///   fields exist on opencode's `User` schema) — `is_summary` re-expands
9760///   `"true"` back to the native `summary: true` bool (the loader only ever
9761///   sets the metadata key on `Some(true)`, never on absent/false, so the
9762///   inverse never needs to emit `false`); `finish` is a plain string;
9763///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
9764///   `Value` (a number and an object respectively), so they're re-parsed
9765///   from that stringified form and re-emitted as the native JSON value —
9766///   NOT as strings — matching `msg_value.get(field)` shape exactly.
9767fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
9768    if let Some(agent) = msg.metadata.get("agent") {
9769        info["agent"] = Value::String(agent.clone());
9770    }
9771    if let Some(model) = msg.metadata.get("model") {
9772        if is_assistant {
9773            if let Some((provider, model_id)) = model.split_once('/') {
9774                info["providerID"] = Value::String(provider.to_string());
9775                info["modelID"] = Value::String(model_id.to_string());
9776            }
9777        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
9778            info["model"] = v;
9779        }
9780    }
9781    if !is_assistant {
9782        return;
9783    }
9784    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
9785        info["summary"] = Value::Bool(true);
9786    }
9787    if let Some(finish) = msg.metadata.get("finish") {
9788        info["finish"] = Value::String(finish.clone());
9789    }
9790    if let Some(cost) = msg.metadata.get("cost") {
9791        if let Ok(v) = serde_json::from_str::<Value>(cost) {
9792            info["cost"] = v;
9793        }
9794    }
9795    if let Some(tokens) = msg.metadata.get("tokens") {
9796        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
9797            info["tokens"] = v;
9798        }
9799    }
9800}
9801
9802fn opencode_user_parts_from_message(
9803    msg: &ChatMessage,
9804    msg_id: &str,
9805    session_id: &str,
9806    counter: &mut u64,
9807) -> Vec<Value> {
9808    let mut parts = Vec::new();
9809    if let Some(cps) = &msg.content_parts {
9810        for p in cps {
9811            match p.get("type").and_then(Value::as_str) {
9812                Some("text") => {
9813                    if let Some(t) = p.get("text").and_then(Value::as_str) {
9814                        parts.push(serde_json::json!({
9815                            "id": opencode_fresh_id("prt", counter),
9816                            "sessionID": session_id,
9817                            "messageID": msg_id,
9818                            "type": "text",
9819                            "text": t,
9820                        }));
9821                    }
9822                }
9823                Some("image_url") => {
9824                    if let Some(url) = p
9825                        .get("image_url")
9826                        .and_then(|u| u.get("url"))
9827                        .and_then(Value::as_str)
9828                    {
9829                        let mime = url
9830                            .strip_prefix("data:")
9831                            .and_then(|r| r.split_once(','))
9832                            .map(|(m, _)| m.trim_end_matches(";base64"))
9833                            .unwrap_or("application/octet-stream");
9834                        parts.push(serde_json::json!({
9835                            "id": opencode_fresh_id("prt", counter),
9836                            "sessionID": session_id,
9837                            "messageID": msg_id,
9838                            "type": "file",
9839                            "mime": mime,
9840                            "url": url,
9841                        }));
9842                    }
9843                }
9844                _ => {}
9845            }
9846        }
9847    } else if let Some(t) = &msg.content {
9848        if !t.is_empty() {
9849            parts.push(serde_json::json!({
9850                "id": opencode_fresh_id("prt", counter),
9851                "sessionID": session_id,
9852                "messageID": msg_id,
9853                "type": "text",
9854                "text": t,
9855            }));
9856        }
9857    }
9858    parts
9859}
9860
9861fn codex_response_item(payload: Value, ts: &str) -> Value {
9862    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
9863}
9864
9865/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
9866/// see [`Session::write_codex_records`]); a no-op returning `payload`
9867/// untouched when `None`, so the historical byte shape is preserved for
9868/// every record that has no merge ambiguity to disambiguate.
9869fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
9870    if let Some(tid) = turn_id {
9871        payload["metadata"] = serde_json::json!({"turn_id": tid});
9872    }
9873    payload
9874}
9875
9876/// Build a Codex `message` response_item's `content` block array from a
9877/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
9878/// parse. When `content_parts` is `None` this MUST reproduce the historical
9879/// single-block shape exactly (IX-5's overriding constraint: a text-only
9880/// message's export stays byte-identical) — only a multimodal message gets
9881/// one `{text_type}` block per non-empty text part plus one native Codex
9882/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
9883/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
9884/// `output_text` blocks already follow the family of) per `image_url` part.
9885fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
9886    match &msg.content_parts {
9887        Some(parts) => {
9888            let mut blocks = Vec::new();
9889            for p in parts {
9890                match p.get("type").and_then(Value::as_str) {
9891                    Some("text") => {
9892                        if let Some(t) = p.get("text").and_then(Value::as_str) {
9893                            if !t.is_empty() {
9894                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
9895                            }
9896                        }
9897                    }
9898                    Some("image_url") => {
9899                        if let Some(url) = p
9900                            .get("image_url")
9901                            .and_then(|u| u.get("url"))
9902                            .and_then(Value::as_str)
9903                        {
9904                            blocks.push(serde_json::json!({
9905                                "type": "input_image",
9906                                "image_url": url,
9907                            }));
9908                        }
9909                    }
9910                    _ => {}
9911                }
9912            }
9913            Value::Array(blocks)
9914        }
9915        None => {
9916            let text = msg.content.clone().unwrap_or_default();
9917            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
9918        }
9919    }
9920}
9921
9922/// PARITY-11 (nested images, honest-residue side): a Codex
9923/// `function_call_output` response_item's `output` field is a BARE STRING
9924/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
9925/// no structured content array, so [`codex_message_content_blocks`]'s
9926/// `input_image` slot genuinely does not apply here). A nested image captured
9927/// off a Claude `tool_result` (`extract_tool_result_content`,
9928/// `content_parts`) therefore CANNOT be carried through this hop — but rather
9929/// than silently re-emitting the old bare `[image]` marker (indistinguishable
9930/// from a real, intentional annotation and impossible to tell apart from
9931/// "the data survived") or dropping it with zero trace, fold in an honest,
9932/// countable disclosure of exactly how many images were dropped and why —
9933/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
9934/// on the WRITE side instead of the read side. `content_parts` being `None`
9935/// (every pre-existing call site, and any tool result with no nested image)
9936/// reproduces the historical `msg.content` text byte-for-byte.
9937fn codex_tool_output_text(msg: &ChatMessage) -> String {
9938    let mut text = msg.content.clone().unwrap_or_default();
9939    if let Some(parts) = &msg.content_parts {
9940        let n = parts
9941            .iter()
9942            .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
9943            .count();
9944        if n > 0 {
9945            if !text.is_empty() {
9946                text.push('\n');
9947            }
9948            text.push_str(&format!(
9949                "[image: {n} nested image(s) dropped — codex tool output has no \
9950                 structured content slot to carry them]"
9951            ));
9952        }
9953    }
9954    text
9955}
9956
9957// ---- Pi writer helpers -----------------------------------------------------
9958
9959/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
9960/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
9961/// file in place on first resume (`pi-fields.md` sm:848-850).
9962fn push_pi_header(
9963    out: &mut String,
9964    id: &str,
9965    cwd: &str,
9966    parent_session: Option<&str>,
9967    created_at: Option<&str>,
9968    claude_fork_context_ref: Option<&str>,
9969) {
9970    let mut header = serde_json::json!({
9971        "type": "session",
9972        "version": 3,
9973        "id": id,
9974        "timestamp": created_at.unwrap_or(SYNTH_TS),
9975        "cwd": cwd,
9976    });
9977    if let Some(ps) = parent_session {
9978        header["parentSession"] = Value::String(ps.to_string());
9979    }
9980    // D7: namespaced passthrough field, exactly like the Codex writer's
9981    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
9982    // header keys, and `capture_pi_header` reads this same key back on
9983    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
9984    // fork-context-ref record instead of silently losing it on this hop.
9985    if let Some(raw) = claude_fork_context_ref {
9986        header["claude_fork_context_ref"] =
9987            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
9988    }
9989    push_jsonl(out, &header);
9990}
9991
9992/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
9993/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
9994/// deterministic here rather than random, which still satisfies "fresh,
9995/// collision-free" without an extra RNG dependency).
9996fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
9997    loop {
9998        *counter += 1;
9999        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
10000        let id = format!("{:08x}", (h >> 32) as u32);
10001        if used.insert(id.clone()) {
10002            return id;
10003        }
10004    }
10005}
10006
10007/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
10008/// inverse of the loader's `data:{mime};base64,{data}` construction.
10009fn parse_data_uri(url: &str) -> Option<(String, String)> {
10010    let rest = url.strip_prefix("data:")?;
10011    let (meta, data) = rest.split_once(',')?;
10012    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
10013    Some((mime.to_string(), data.to_string()))
10014}
10015
10016/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
10017/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
10018/// `toolResult` entries (both use the identical union on the wire).
10019fn pi_content_value(msg: &ChatMessage) -> Value {
10020    if let Some(parts) = &msg.content_parts {
10021        let mut arr = Vec::new();
10022        for p in parts {
10023            match p.get("type").and_then(Value::as_str) {
10024                Some("text") => {
10025                    if let Some(t) = p.get("text").and_then(Value::as_str) {
10026                        arr.push(serde_json::json!({"type": "text", "text": t}));
10027                    }
10028                }
10029                Some("image_url") => {
10030                    if let Some(url) = p
10031                        .get("image_url")
10032                        .and_then(|u| u.get("url"))
10033                        .and_then(Value::as_str)
10034                    {
10035                        if let Some((mime, data)) = parse_data_uri(url) {
10036                            arr.push(
10037                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
10038                            );
10039                        }
10040                    }
10041                }
10042                _ => {}
10043            }
10044        }
10045        Value::Array(arr)
10046    } else {
10047        Value::String(msg.content.clone().unwrap_or_default())
10048    }
10049}
10050
10051fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
10052    let mut arr = Vec::new();
10053    if let Some(thinking) = msg.metadata.get("thinking") {
10054        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
10055        if let Some(sig) = msg.metadata.get("thinking_signature") {
10056            block["thinkingSignature"] = Value::String(sig.clone());
10057        }
10058        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
10059            block["redacted"] = Value::Bool(true);
10060        }
10061        arr.push(block);
10062    }
10063    if let Some(text) = &msg.content {
10064        if !text.is_empty() {
10065            let mut block = serde_json::json!({"type": "text", "text": text});
10066            if let Some(sig) = msg.metadata.get("pi_text_signature") {
10067                block["textSignature"] = Value::String(sig.clone());
10068            }
10069            arr.push(block);
10070        }
10071    }
10072    for tc in msg.tool_calls() {
10073        let args = tc
10074            .function
10075            .parsed_arguments()
10076            .unwrap_or_else(|_| Value::Object(Default::default()));
10077        let mut block = serde_json::json!({
10078            "type": "toolCall",
10079            "id": tc.id,
10080            "name": tc.function.name,
10081            "arguments": args,
10082        });
10083        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
10084            block["thoughtSignature"] = Value::String(sig.clone());
10085        }
10086        arr.push(block);
10087    }
10088    Value::Array(arr)
10089}
10090
10091fn default_pi_usage() -> Value {
10092    serde_json::json!({
10093        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
10094        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
10095    })
10096}
10097
10098fn is_tool_error_flag(msg: &ChatMessage) -> bool {
10099    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true")
10100        || crate::reduce::is_tool_error(msg)
10101}
10102
10103#[cfg(test)]
10104mod tests {
10105    use super::{opencode_message_timestamp, parent_tool_use_index, Session, SessionFormat};
10106    use crate::message::ChatMessage;
10107
10108    #[test]
10109    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
10110        let base = Session::from_native_messages(Vec::new());
10111        let mut native = base.to_native_jsonl_v2(&[]);
10112        native.push_str("{\"supercode_turn\":1}\n");
10113
10114        let parsed = Session::from_native_str(&native).unwrap();
10115        assert_eq!(parsed.parse_error_lines, 1);
10116        assert!(parsed.messages.is_empty());
10117        assert_eq!(
10118            parsed.raw.last().map(String::as_str),
10119            Some("{\"supercode_turn\":1}")
10120        );
10121    }
10122
10123    #[test]
10124    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
10125        let imported = Session::from_claude_code_str(
10126            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
10127        )
10128        .unwrap();
10129        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
10130        native.push_str("{\"supercode_turn\":1}\n");
10131
10132        let parsed = Session::from_native_str(&native).unwrap();
10133        let error = parsed
10134            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
10135            .unwrap_err();
10136        assert!(error.to_string().contains("parse loss"), "{error}");
10137    }
10138
10139    #[test]
10140    fn sidecar_loader_requires_a_supported_native_header() {
10141        for malformed in [
10142            "",
10143            "not-json\n",
10144            "{}\n",
10145            "{\"supercode_native\":2}\n",
10146            "{\"supercode_native\":99,\"source\":\"native\"}\n",
10147        ] {
10148            let error = Session::from_sidecar_str(malformed).unwrap_err();
10149            assert!(error.to_string().contains("sidecar header"), "{error}");
10150        }
10151    }
10152
10153    #[test]
10154    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
10155        let msg = ChatMessage::user("continuation");
10156        let mut cursor = i64::MAX - 1;
10157        assert_eq!(
10158            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
10159            i64::MAX
10160        );
10161        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
10162        assert!(err.to_string().contains("after i64::MAX"));
10163        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
10164    }
10165
10166    /// Pin of the single-pass indexer against the committed fixture (SUP-21).
10167    /// `crates/core/tests/fixtures/claude_code_session.jsonl` line 8 carries a
10168    /// `tool_result` block with `tool_use_id = "toolu_01SYjhg9qRCzUWY2GTa3iazQ"`
10169    /// whose serialized text mentions agent id `ad8dc6cf98b49eea6` — the exact
10170    /// value the old per-agent `parent_tool_use_for_agent(main_text,
10171    /// "ad8dc6cf98b49eea6")` returned before this refactor. An id absent from
10172    /// the transcript must still map to nothing.
10173    #[test]
10174    fn parent_tool_use_index_matches_known_fixture_linkage() {
10175        let main_text = std::fs::read_to_string(
10176            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
10177                .join("tests/fixtures/claude_code_session.jsonl"),
10178        )
10179        .expect("fixture present");
10180
10181        let ids = vec![
10182            "ad8dc6cf98b49eea6".to_string(),
10183            "no-such-agent-id".to_string(),
10184        ];
10185        let index = parent_tool_use_index(&main_text, &ids);
10186
10187        assert_eq!(
10188            index.get("ad8dc6cf98b49eea6").map(String::as_str),
10189            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
10190            "known agent id must resolve to the pinned parent tool_use_id"
10191        );
10192        assert_eq!(
10193            index.get("no-such-agent-id"),
10194            None,
10195            "unknown agent id must yield no entry (best-effort None)"
10196        );
10197    }
10198
10199    #[test]
10200    fn parent_tool_use_index_empty_ids_returns_empty_map() {
10201        let index = parent_tool_use_index("irrelevant text", &[]);
10202        assert!(index.is_empty());
10203    }
10204}