Skip to main content

supercode_interchange/
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::io::{BufRead, BufReader, Read, Seek, SeekFrom};
36use std::path::{Path, PathBuf};
37
38use rusqlite::Connection;
39use serde_json::Value;
40
41use crate::{
42    ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
43};
44
45/// Which tool produced a session log.
46///
47/// This is **read-provenance**: a fact recovered when a log is loaded (stored
48/// in [`SessionMeta::source`], filled in by auto-detection in
49/// `detect_source`), describing which tool originally wrote the file on
50/// disk. It answers "where did this session come from?" — e.g. for
51/// `inspect`/`convert` display in the CLI.
52///
53/// It is deliberately distinct from [`SessionFormat`], even though the two
54/// enums' variant lists currently coincide: [`SessionFormat`] selects a
55/// serialization codec (what to parse/export *as*), while `SessionSource`
56/// records history (what wrote the file). The pair is intentionally kept
57/// separate rather than merged — a session loaded from one tool's log can
58/// still be exported in the other tool's format, and the two concepts could
59/// diverge further (e.g. a format that is readable but not attributable, or
60/// multiple versioned formats sharing one source).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SessionSource {
63    /// A `~/.claude/projects/.../<id>.jsonl` transcript.
64    ClaudeCode,
65    /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
66    Codex,
67    /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
68    /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
69    /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
70    /// 1:1 per the frozen interop spec (§0).
71    OpenCode,
72    /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
73    /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
74    /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
75    /// round-trip property.
76    Pi,
77    /// A Grok session transcript stored as
78    /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
79    Grok,
80    /// A Gemini CLI transcript stored under
81    /// `~/.gemini/tmp/<project>/chats/session-*.jsonl`.
82    Gemini,
83    /// A Goose session exported through `_goose/unstable/session/export`, or
84    /// reconstructed from Goose's `sessions/sessions.db` native store.
85    Goose,
86    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
87    /// cosmetic"): a session that was never imported from ANY foreign
88    /// tool's log at all — authored directly by supercode's own agent loop,
89    /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
90    /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
91    /// natively-spawned `spawn_subagent` children) uses this — before this
92    /// variant existed, that call site built its blank `Session` via
93    /// `Session::from_claude_code_str("")` purely as an "empty parser to
94    /// get a blank skeleton" trick, which left `meta.source ==
95    /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
96    /// was ever involved, mislabeling a native supercode spawn as an
97    /// imported CC session on disk (and in any `inspect`/`convert` reading
98    /// it back). Never produced by auto-detection (`detect_source`) or any
99    /// `from_<tool>_str` loader — only by code that explicitly constructs
100    /// a `SessionMeta` with this source, so no existing imported-session
101    /// path can ever observe this variant appearing where it didn't before.
102    Native,
103}
104
105/// An on-disk session format supercode can both read and write.
106///
107/// Like an image editor that opens and exports several file formats, supercode
108/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
109/// supported format on the edges.
110///
111/// This is a **write-target** / codec selector: a caller's request, passed to
112/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
113/// choosing which on-disk dialect to parse or emit. It answers "what format
114/// should I read/write?" — as opposed to [`SessionSource`], which records the
115/// provenance fact of what actually produced a loaded file. The two enums are
116/// intentionally kept separate (provenance fact vs. serialization choice) and
117/// should not be unified, even though their variants currently match
118/// one-to-one.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SessionFormat {
121    /// Claude Code transcript JSONL.
122    ClaudeCode,
123    /// Codex rollout JSONL.
124    Codex,
125    /// OpenCode export-document / envelope JSONL (wave B; see
126    /// [`SessionSource::OpenCode`]).
127    OpenCode,
128    /// Pi session JSONL (see [`SessionSource::Pi`]).
129    Pi,
130    /// Grok `chat_history.jsonl` transcript.
131    Grok,
132    /// Gemini CLI session JSONL.
133    Gemini,
134    /// Goose native session-export JSON.
135    Goose,
136}
137
138impl SessionFormat {
139    /// The [`SessionSource`] a file of this format reports.
140    ///
141    /// This is the deliberate one-way bridge between the two concepts: a file
142    /// saved in this format will, when reloaded, report this provenance (see
143    /// `crates/harness/tests/session_saving.rs`), making the relationship
144    /// discoverable from the method itself.
145    pub fn source(self) -> SessionSource {
146        match self {
147            SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
148            SessionFormat::Codex => SessionSource::Codex,
149            SessionFormat::OpenCode => SessionSource::OpenCode,
150            SessionFormat::Pi => SessionSource::Pi,
151            SessionFormat::Grok => SessionSource::Grok,
152            SessionFormat::Gemini => SessionSource::Gemini,
153            SessionFormat::Goose => SessionSource::Goose,
154        }
155    }
156}
157
158/// Metadata recovered from a session log.
159#[derive(Debug, Clone)]
160#[non_exhaustive]
161pub struct SessionMeta {
162    /// The tool that wrote the log.
163    pub source: SessionSource,
164    /// The session/rollout id.
165    pub session_id: Option<String>,
166    /// The model the session was running.
167    pub model: Option<String>,
168    /// The working directory the session ran in.
169    pub cwd: Option<PathBuf>,
170    /// The system / base-instructions prompt, when the log records it.
171    pub system_prompt: Option<String>,
172    /// Verbatim source-format header records (the Codex `session_meta` /
173    /// `turn_context` lines), preserved so re-export can replay the exact header
174    /// the original tool expects rather than guessing its required fields.
175    pub codex_headers: Vec<Value>,
176    /// Exact source lines for Codex execution/provenance records that affect
177    /// continuation semantics but must not be replayed as active events after
178    /// a foreign-format hop. Each entry records its original physical-line
179    /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
180    /// list in a namespaced extension; a later Codex export restores headers
181    /// from it while keeping compaction/rollback/review records non-operative,
182    /// avoiding a second rollback or compaction of the already-normalized view.
183    pub codex_provenance: Vec<Value>,
184    /// The OpenCode analogue of [`Self::codex_headers`]
185    /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
186    /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
187    /// absent), plus any captured `session_diff`/`todo` side-records — each
188    /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
189    /// shape `raw` uses, so a consumer can tell which storage key a header
190    /// record belongs to. These replay only via the direct-write fallback
191    /// (`Session::to_opencode_direct_write`); `opencode import` has no
192    /// ingestion path for `session_diff`/`todo` (S5).
193    pub opencode_headers: Vec<Value>,
194    /// Goose's native session-export object with `conversation` removed.
195    /// Goose stores sessions in SQLite but defines this JSON object as its
196    /// official import/export boundary. Keeping the shell lets an unchanged
197    /// direct round-trip remain byte exact while appended turns are spliced
198    /// into a stock-importable artifact without guessing native metadata.
199    pub goose_header: Option<Value>,
200    /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
201    /// stem). `None` for top-level sessions.
202    pub agent_id: Option<String>,
203    /// For a subagent session: the `tool_use_id` of the parent `Task` call that
204    /// spawned it, recovered from the parent transcript's tool result. Best
205    /// effort — `None` if the link could not be established.
206    pub parent_tool_use_id: Option<String>,
207    /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
208    /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
209    /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
210    /// `depth`). Empty for a plain top-level session. Used by
211    /// [`Session::reconstruct_tree`] to nest children under their parents.
212    pub lineage: std::collections::BTreeMap<String, String>,
213}
214
215impl SessionMeta {
216    fn new(source: SessionSource) -> Self {
217        SessionMeta {
218            source,
219            session_id: None,
220            model: None,
221            cwd: None,
222            system_prompt: None,
223            codex_headers: Vec::new(),
224            codex_provenance: Vec::new(),
225            opencode_headers: Vec::new(),
226            goose_header: None,
227            agent_id: None,
228            parent_tool_use_id: None,
229            lineage: std::collections::BTreeMap::new(),
230        }
231    }
232}
233
234/// A normalized, replayable conversation loaded from a tool's session log.
235#[derive(Debug, Clone)]
236pub struct Session {
237    /// Recovered metadata.
238    pub meta: SessionMeta,
239    /// The conversation, normalized to the OpenAI chat-completions shape.
240    pub messages: Vec<ChatMessage>,
241    /// Subagent (Task) sub-conversations. Claude Code stores these as separate
242    /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
243    /// discovers and attaches them here (each is a full [`Session`] whose
244    /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
245    pub subagents: Vec<Session>,
246    /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
247    /// captured via `split_lines_verbatim`, not the blank-skipping/trimming
248    /// `non_empty_lines` parse view, so a blank line, a CRLF (`\r\n`)
249    /// terminator, or trailing whitespace on a line all survive bit-for-bit
250    /// rather than being dropped/normalized away. Normalization into
251    /// `messages` is still lossy by design (it targets the OpenAI replay
252    /// shape), but these raw lines retain *everything* — including records
253    /// with no canonical representation (e.g. Claude `file-history-snapshot`)
254    /// — so a round-trip through the supercode-native format
255    /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
256    /// formats (Claude Code/Codex/Pi), for ANY input (see
257    /// [`Self::raw_trailing_newline`] for the one piece of information a line
258    /// list alone can't carry).
259    pub raw: Vec<String>,
260    /// Whether the source text `raw` was captured from ended with a trailing
261    /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
262    /// trailing newline from one that doesn't (both split into the same
263    /// lines) — this flag carries that fact out-of-band so
264    /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
265    /// original source bytes exactly, including the presence/absence of a
266    /// final newline. `true` for a `Session` whose `raw` isn't captured
267    /// verbatim from real source text (e.g. OpenCode's re-synthesized
268    /// export-document `raw`, or a `Session` assembled programmatically) —
269    /// matching the historical always-terminated-by-newline behavior for
270    /// those cases.
271    pub raw_trailing_newline: bool,
272    /// How many of `messages` (and, symmetrically, of `raw` — see below) came
273    /// from parsing the imported log, as opposed to being appended after
274    /// import. Set once, at the end of [`Self::from_claude_code_str`] /
275    /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
276    /// before [`Self::from_native_str`]'s subsequent loop reattaches any
277    /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
278    /// That loop pushes exactly one `raw` line and one message per appended
279    /// turn, so the two lists grow in lockstep from here on: the raw-prefix
280    /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
281    /// as `raw.len() - (messages.len() - imported_message_count)`, without a
282    /// second counter. `None` only when a `Session` is constructed some other
283    /// way than through those two loaders — splicing then has no boundary to
284    /// honor and treats every message as imported (equivalent to
285    /// `Some(messages.len())`).
286    pub imported_message_count: Option<usize>,
287    /// Whether `raw` was captured strict-verbatim from real source text
288    /// (`true`) or re-synthesized by this crate (`false`) — the fact
289    /// [`Self::raw_verbatim`]'s callers need to know before claiming a
290    /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
291    /// `true` for every line-oriented loader (`from_claude_code_str`,
292    /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
293    /// surface (`from_opencode_str`'s per-line loop) — each of those splits
294    /// `raw` directly out of the source text via `split_lines_verbatim`, so
295    /// replaying it reproduces the original bytes exactly. `false` for
296    /// OpenCode's EXPORT-DOCUMENT read surface
297    /// (`Session::from_opencode_export_doc`): a pretty-printed
298    /// `{info, messages:[...]}` document has no per-line envelope structure
299    /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
300    /// record — faithful in value, but not the original document's bytes.
301    /// A `Session` assembled programmatically (not through a `from_*_str`
302    /// loader) also defaults to `false` — no real source text was captured
303    /// at all.
304    pub raw_is_verbatim: bool,
305    /// PARITY-15: how many non-empty lines of the source text FAILED to
306    /// deserialize at all (a genuinely malformed/truncated JSON line — not
307    /// a well-formed-but-unmodeled record type, which is a normal,
308    /// intentional "skip", tracked separately by `crate::audit`). Every
309    /// line-oriented loader tolerates a stray corrupt line rather than
310    /// hard-failing the whole load (a single bad line must not make an
311    /// otherwise-healthy multi-thousand-line session unloadable) — but that
312    /// tolerance used to be completely invisible: `Session::load` returned
313    /// `Ok` either way, with no signal that anything was skipped. This
314    /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
315    /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
316    /// and for a `Session` assembled programmatically.
317    pub parse_error_lines: usize,
318    /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
319    /// failing — the same "say exactly what was given up" residue list
320    /// `harness.v1.sessions.export` already reports for artifacts.
321    ///
322    /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
323    /// transcript it cannot reconstruct exactly, which is what keeps
324    /// continuation/transfer/export guarantees intact. A non-empty list means
325    /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
326    /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
327    pub load_residue: Vec<String>,
328}
329
330impl Session {
331    /// The fidelity this reconstruction actually achieved.
332    ///
333    /// Same rule the export path applies to an artifact: named residue means
334    /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
335    /// [`Fidelity::ByteLossless`] and a re-synthesized one is
336    /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
337    /// session's: the whole reconstruction is only as faithful as its least
338    /// faithful part, and each child still reports its own residue where it
339    /// was measured.
340    pub fn load_fidelity(&self) -> Fidelity {
341        let own = if !self.load_residue.is_empty() {
342            Fidelity::Semantic
343        } else if self.raw_is_verbatim {
344            Fidelity::ByteLossless
345        } else {
346            Fidelity::ValueLossless
347        };
348        if own != Fidelity::Semantic
349            && self
350                .subagents
351                .iter()
352                .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
353        {
354            return Fidelity::Semantic;
355        }
356        own
357    }
358
359    /// Assemble a session from supercode's own flat store transcript (one
360    /// [`ChatMessage`] per JSONL line). These files are the native working
361    /// format written by Supercode's native session store, not a foreign
362    /// harness log, so routing them through format auto-detection would
363    /// misclassify them as an empty Claude Code session.
364    pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
365        Session {
366            meta: SessionMeta::new(SessionSource::Native),
367            messages,
368            subagents: Vec::new(),
369            raw: Vec::new(),
370            raw_trailing_newline: true,
371            imported_message_count: None,
372            raw_is_verbatim: false,
373            parse_error_lines: 0,
374            load_residue: Vec::new(),
375        }
376    }
377
378    /// Load a session, auto-detecting whether it's a Claude Code or Codex log
379    /// — or, when `path` looks like a SQLite database, a real OpenCode
380    /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
381    /// UTF-8 text read, so a binary `.db` file is routed to
382    /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
383    /// did not contain valid UTF-8" error (the confirmed footgun these items
384    /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
385    ///
386    /// A DIRECTORY is also accepted directly: `path` is probed with
387    /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
388    /// checks below (both of which assume a file and would otherwise surface
389    /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
390    /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
391    /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
392    /// `audit --format opencode` already does. A resolved `Sqlite` surface
393    /// loads exactly like pointing `load` at that `opencode*.db` file
394    /// directly (most-recently-updated top-level session). The legacy
395    /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
396    /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
397    /// that case returns a clear error naming the `.db` file / `audit` as the
398    /// way in, rather than silently doing nothing or crashing.
399    pub fn load(path: impl AsRef<Path>) -> Result<Session> {
400        Self::load_with_fidelity(path, Fidelity::ByteLossless)
401    }
402
403    /// Load a session at a declared [`Fidelity`].
404    ///
405    /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
406    /// record graph cannot be reconstructed exactly (the everyday case for a
407    /// Claude Code session that has been compacted or resumed across files,
408    /// where a live record's `parentUuid` names a record that was pruned)
409    /// still loads, stitched best-effort in transcript order, and names what
410    /// it gave up in [`Session::load_residue`]. Every stricter level keeps
411    /// the historical behavior — refuse loudly — because a continuation,
412    /// transfer or export built on a guessed graph is exactly the loss
413    /// supercode exists to prevent. Callers that go on to RESUME a session
414    /// must therefore use [`Session::load`].
415    pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
416        Self::load_with_fidelity_and_subagents(path, fidelity, true)
417    }
418
419    /// Load only the selected session's own transcript at a declared fidelity.
420    ///
421    /// This is the read-only frontend path: Claude Code can place hundreds of
422    /// child transcripts beside a parent, but a chat viewport displaying the
423    /// parent must not eagerly parse and transport that entire child tree.
424    /// Translation, continuation, export, and the ordinary [`Self::load`]
425    /// path keep attaching every subagent unchanged.
426    #[doc(hidden)]
427    pub fn load_parent_with_fidelity(
428        path: impl AsRef<Path>,
429        fidelity: Fidelity,
430    ) -> Result<Session> {
431        Self::load_with_fidelity_and_subagents(path, fidelity, false)
432    }
433
434    /// Load a bounded, parent-only transcript for human display.
435    ///
436    /// Unlike the continuation loader, Codex compaction records do not erase
437    /// earlier visible assistant turns here: the native rollout still holds
438    /// those records, and a scrollback view should show what the human saw,
439    /// not only the compacted context the next model call will receive.
440    #[doc(hidden)]
441    pub fn load_display_view(
442        path: impl AsRef<Path>,
443        fidelity: Fidelity,
444        message_limit: usize,
445    ) -> Result<Session> {
446        let path = path.as_ref();
447        if path.is_dir() || looks_like_sqlite(path) {
448            let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
449            truncate_session_messages(&mut session, message_limit);
450            return Ok(session);
451        }
452        let (source, text) = read_display_jsonl(path, message_limit)?;
453        let mut session = match source {
454            Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
455            Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
456            Some(SessionSource::Grok) => {
457                let mut session = Self::from_grok_str(&text)?;
458                session.capture_grok_path_metadata(path);
459                session
460            }
461            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
462            _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
463        };
464        truncate_session_messages(&mut session, message_limit);
465        Ok(session)
466    }
467
468    fn load_with_fidelity_and_subagents(
469        path: impl AsRef<Path>,
470        fidelity: Fidelity,
471        include_subagents: bool,
472    ) -> Result<Session> {
473        let path = path.as_ref();
474        if path.is_dir() {
475            return match detect_opencode_storage_surface(path) {
476                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
477                    Self::from_opencode_sqlite(&db_path, None)
478                }
479                Some((
480                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
481                    _,
482                )) => Err(crate::Error::Other(format!(
483                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
484                         supercode does not load directly — point `inspect`/`convert`/`resume` \
485                         at the store's `opencode*.db` SQLite file if this install has one, or \
486                         use `audit --format opencode {}` instead",
487                    path.display(),
488                    path.display()
489                ))),
490                None => Err(crate::Error::Other(format!(
491                    "{} is a directory, but no session file or OpenCode store was found in it \
492                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
493                     tree)",
494                    path.display()
495                ))),
496            };
497        }
498        if looks_like_sqlite(path) {
499            return Self::from_opencode_sqlite(path, None);
500        }
501        let text = read_utf8_or_diagnose(path)?;
502        match detect_source(&text) {
503            Some(SessionSource::Codex) => Self::from_codex_str(&text),
504            Some(SessionSource::Pi) => Self::from_pi_str(&text),
505            Some(SessionSource::Grok) => {
506                let mut session = Self::from_grok_str(&text)?;
507                session.capture_grok_path_metadata(path);
508                Ok(session)
509            }
510            Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
511            Some(SessionSource::Goose) => Self::from_goose_str(&text),
512            // IX-3: a detected OpenCode session must route to its own
513            // loader, not the Claude Code fallback below
514            // (`docs/interop/build-followups.md`).
515            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
516            _ => {
517                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
518                if include_subagents {
519                    session.attach_claude_subagents(path, &text, fidelity)?;
520                }
521                Ok(session)
522            }
523        }
524    }
525
526    /// Load a Claude Code transcript from a file, attaching any subagent
527    /// (`Task`) sub-conversations stored alongside it.
528    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
529        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
530    }
531
532    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
533    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
534    pub fn from_claude_code_with_fidelity(
535        path: impl AsRef<Path>,
536        fidelity: Fidelity,
537    ) -> Result<Session> {
538        let text = std::fs::read_to_string(path.as_ref())?;
539        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
540        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
541        Ok(session)
542    }
543
544    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
545    /// Claude Code transcript at `main_path`, linking each back to the parent
546    /// `Task` tool call via the agent id embedded in the parent's tool result.
547    fn attach_claude_subagents(
548        &mut self,
549        main_path: &Path,
550        main_text: &str,
551        fidelity: Fidelity,
552    ) -> Result<()> {
553        let Some(dir) = subagents_dir_for(main_path) else {
554            return Ok(());
555        };
556        let entries = std::fs::read_dir(&dir).map_err(|error| {
557            crate::Error::Other(format!(
558                "failed to enumerate Claude subagents at {}: {error}",
559                dir.display()
560            ))
561        })?;
562        let mut files = Vec::new();
563        for entry in entries {
564            let entry = entry.map_err(|error| {
565                crate::Error::Other(format!(
566                    "failed to enumerate Claude subagents at {}: {error}",
567                    dir.display()
568                ))
569            })?;
570            let path = entry.path();
571            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
572                files.push(path);
573            }
574        }
575        files.sort();
576
577        // Phase 1 — collect each subagent + its recovered agent id, without
578        // touching the main transcript yet.
579        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
580        for file in files {
581            let text = read_utf8_or_diagnose(&file).map_err(|error| {
582                crate::Error::Other(format!(
583                    "failed to read Claude subagent {}: {error}",
584                    file.display()
585                ))
586            })?;
587            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
588                Ok(sub) => sub,
589                // A read-only VIEW keeps the main conversation rather than
590                // losing the whole session to one unreconstructable child;
591                // the skip is named, not silent. Every stricter fidelity
592                // still propagates the child's failure.
593                Err(error) if fidelity.tolerates_residue() => {
594                    self.load_residue.push(format!(
595                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
596                        file.display()
597                    ));
598                    continue;
599                }
600                Err(error) => {
601                    return Err(crate::Error::Other(format!(
602                        "failed to reconstruct Claude subagent {}: {error}",
603                        file.display()
604                    )))
605                }
606            };
607            // agentId: prefer the file's own record, fall back to the filename stem.
608            let agent_id = first_agent_id(&text).or_else(|| {
609                file.file_stem()
610                    .and_then(|s| s.to_str())
611                    .map(|s| s.trim_start_matches("agent-").to_string())
612            });
613            collected.push((sub, agent_id));
614        }
615
616        // Phase 2 — single pass over the main transcript to index every
617        // requested agent id at once, then assign each subagent's parent by
618        // an O(1) lookup.
619        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
620        let index = parent_tool_use_index(main_text, &agent_ids);
621
622        for (mut sub, agent_id) in collected {
623            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
624            sub.meta.agent_id = agent_id;
625            self.subagents.push(sub);
626        }
627        Ok(())
628    }
629
630    /// Load a Codex rollout from a file.
631    pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
632        Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
633    }
634
635    /// Parse a Claude Code transcript from an in-memory JSONL string.
636    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
637        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
638    }
639
640    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
641    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
642    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
643        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
644        let mut messages = Vec::new();
645        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
646        // whitespace all preserved) — separate from the blank-skipping
647        // `non_empty_lines` walk just below, which still parses records only
648        // (a blank line is not a JSON record and must not become one).
649        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
650        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
651        // PARITY-15: a malformed/truncated line is still tolerated (a
652        // single bad line must not make an otherwise-healthy multi-
653        // thousand-line session unloadable) — but it's no longer INVISIBLE.
654        let mut parse_error_lines = 0usize;
655        let mut index = ClaudeReplayIndex::default();
656
657        // Claude transcripts are append-only trees, not linear chat logs.
658        // Build a lightweight graph index first so normalization sees the
659        // same single active, post-compaction branch Claude Code would
660        // resume. `raw` above deliberately remains the complete source.
661        for (line_index, line) in raw_lines.iter().enumerate() {
662            if line.trim().is_empty() {
663                continue;
664            }
665            let v: Value = match serde_json::from_str(line) {
666                Ok(v) => v,
667                Err(_) => {
668                    parse_error_lines += 1; // tolerate stray/corrupt lines
669                    continue;
670                }
671            };
672            capture_claude_meta(&v, &mut meta, line)?;
673            index.observe(line_index, &v)?;
674        }
675
676        let ClaudeReplaySelection {
677            lines: replay_lines,
678            residue: load_residue,
679        } = index.select_lines(fidelity)?;
680        let mut pending_assistant: Option<Value> = None;
681
682        for line_index in replay_lines {
683            let line = raw_lines[line_index];
684            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
685
686            if v.get("type").and_then(Value::as_str) == Some("assistant") {
687                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
688                    flush_claude_assistant(&mut pending_assistant, &mut messages);
689                    continue;
690                }
691                if let Some(pending) = pending_assistant.as_mut() {
692                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
693                        claude_assistant_message_id(&v) == Some(message_id)
694                    }) {
695                        merge_claude_assistant_chunk(pending, &v);
696                        continue;
697                    }
698                    flush_claude_assistant(&mut pending_assistant, &mut messages);
699                }
700                pending_assistant = Some(v);
701                continue;
702            }
703
704            flush_claude_assistant(&mut pending_assistant, &mut messages);
705
706            // WAVE-2 item 1: every Claude Code record carries a real
707            // top-level `timestamp` (ISO-8601) — provenance stamping below
708            // attaches it to every canonical `ChatMessage` this line
709            // produces, together with the record UUID and assistant model.
710            // `entry(...).or_insert_with` preserves any more-precise value a
711            // role-specific loader already supplied.
712            let before = messages.len();
713            match v.get("type").and_then(Value::as_str) {
714                Some("user") => push_claude_user(&v, &mut messages),
715                Some("assistant") => push_claude_assistant(&v, &mut messages),
716                Some("attachment") => push_claude_attachment(&v, &mut messages),
717                Some("system") => push_claude_system(&v, &mut messages),
718                _ => {} // mode, queue-operation, ... — skip
719            }
720            // UUID/model provenance remains meaningful even for legacy
721            // records that predate Claude Code's timestamp field.
722            capture_claude_record_provenance(&v, &mut messages[before..]);
723            restore_single_grok_message(&v, &mut messages[before..]);
724        }
725        flush_claude_assistant(&mut pending_assistant, &mut messages);
726
727        reorder_tool_results_after_calls(&mut messages);
728        ensure_tool_results_paired(&mut messages);
729        let imported_message_count = Some(messages.len());
730        Ok(Session {
731            meta,
732            messages,
733            subagents: Vec::new(),
734            raw,
735            raw_trailing_newline,
736            imported_message_count,
737            // Claude Code is line-oriented: `raw` is split directly out of
738            // the source text (strict-verbatim, IX-1).
739            raw_is_verbatim: true,
740            parse_error_lines,
741            load_residue,
742        })
743    }
744
745    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
746    ///
747    /// Codex stores subagents as separate rollout files linked to their parent
748    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
749    /// collection of sessions, this nests each child into its parent's
750    /// [`Session::subagents`] and returns only the roots. Children whose parent
751    /// isn't in the set are returned as roots themselves (best effort).
752    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
753        use std::collections::HashMap;
754        // Index each session's position by its session_id.
755        let mut idx: HashMap<String, usize> = HashMap::new();
756        for (i, s) in sessions.iter().enumerate() {
757            if let Some(id) = &s.meta.session_id {
758                idx.insert(id.clone(), i);
759            }
760        }
761        // Determine each session's parent (by index), if present in the set.
762        let parent_of: Vec<Option<usize>> = sessions
763            .iter()
764            .map(|s| {
765                s.meta
766                    .lineage
767                    .get("parent_thread_id")
768                    .and_then(|p| idx.get(p).copied())
769            })
770            .collect();
771
772        // Move children into parents, deepest-first so chains nest correctly.
773        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
774        let mut order: Vec<usize> = (0..slots.len()).collect();
775        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
776        for i in order {
777            if let Some(p) = parent_of[i] {
778                if p != i {
779                    if let Some(child) = slots[i].take() {
780                        if let Some(parent) = slots[p].as_mut() {
781                            parent.subagents.push(child);
782                        } else {
783                            slots[i] = Some(child); // parent already moved; keep as root
784                        }
785                    }
786                }
787            }
788        }
789        slots.into_iter().flatten().collect()
790    }
791
792    /// Parse a session of a known format from an in-memory JSONL string.
793    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
794        match format {
795            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
796            SessionFormat::Codex => Self::from_codex_str(jsonl),
797            SessionFormat::Pi => Self::from_pi_str(jsonl),
798            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
799            SessionFormat::Grok => Self::from_grok_str(jsonl),
800            SessionFormat::Gemini => Self::from_gemini_str(jsonl),
801            SessionFormat::Goose => Self::from_goose_str(jsonl),
802        }
803    }
804
805    /// Serialize this session to JSONL in the given format.
806    ///
807    /// The conversation is synthesized from the canonical messages, so this
808    /// works for sessions loaded from *either* tool as well as ones supercode
809    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
810    /// "export": format-specific framing that has no slot in the target may be
811    /// dropped, but the user/assistant/tool conversation is preserved.
812    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
813        match format {
814            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
815            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
816            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
817            SessionFormat::OpenCode => self.to_opencode_jsonl(),
818            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
819            SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
820            SessionFormat::Goose => Ok(self.to_goose_json()),
821        }
822    }
823
824    /// Export back to `format`, replaying the imported `raw` prefix
825    /// **verbatim** — original uuids/ids, real timestamps, and
826    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
827    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
828    /// is the session's own origin (`format.source() == self.meta.source`,
829    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
830    /// Only messages appended *after* import (tracked by
831    /// [`Self::imported_message_count`]) are synthesized, chained onto the
832    /// last original record found in the raw prefix.
833    ///
834    /// `session_id` of `Some(new)` rewrites the session id on every emitted
835    /// line, raw and synthesized alike (`sessionId` for Claude Code,
836    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
837    ///
838    /// Cross-format export (no verbatim prefix exists in the target dialect,
839    /// by definition) and a session with no `raw` lines both fall back
840    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
841    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
842    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
843    /// cross-format stays at the documented semantic tier.
844    pub fn to_jsonl_spliced(
845        &self,
846        format: SessionFormat,
847        session_id: Option<&str>,
848    ) -> Result<String> {
849        if self.parse_error_lines > 0
850            || self
851                .subagents
852                .iter()
853                .any(|subagent| subagent.parse_error_lines > 0)
854        {
855            return Err(Error::InvalidSession(
856                "refusing spliced export because the loaded session contains parse loss"
857                    .to_string(),
858            ));
859        }
860        if self.raw.is_empty() || format.source() != self.meta.source {
861            if let Some(session_id) = session_id {
862                let mut rewritten = self.clone();
863                rewritten.meta.session_id = Some(session_id.to_string());
864                return rewritten.to_jsonl(format);
865            }
866            return self.to_jsonl(format);
867        }
868        match format {
869            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
870            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
871            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
872            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
873            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
874            SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
875            SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
876        }
877    }
878
879    /// Write this session to `path` in the given format.
880    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
881        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
882        Ok(())
883    }
884
885    /// Reconstruct the exact source bytes this `Session` was loaded from,
886    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
887    /// inverse of the strict-verbatim capture those two fields record — see
888    /// `join_lines_verbatim`).
889    ///
890    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
891    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
892    /// original text, so this reproduces the original file byte-for-byte —
893    /// the P008/P009 diagonal-convert fix (`convert <file> --to
894    /// <same-format>` is byte-identical to `<file>`) is built on exactly
895    /// this. The one documented exception is an OpenCode **export-document**
896    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
897    /// is RE-SYNTHESIZED as one envelope line per record (see
898    /// `from_opencode_export_doc`'s contract), so this returns a
899    /// verbatim reproduction of THAT captured representation rather than the
900    /// original pretty-printed document — a known, narrow residue, not a
901    /// silent loss (the same records are all still present).
902    pub fn raw_verbatim(&self) -> String {
903        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
904    }
905
906    /// Serialize to the **supercode-native** lossless format: a header line
907    /// recording the original source, followed by every original JSONL line
908    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
909    /// schema and is necessarily lossy), this preserves *everything* — including
910    /// records with no canonical representation — so [`Self::from_native_str`]
911    /// reconstructs the session with full fidelity.
912    pub fn to_native_jsonl(&self) -> String {
913        let source = match self.meta.source {
914            SessionSource::ClaudeCode => "claude_code",
915            SessionSource::Codex => "codex",
916            SessionSource::Pi => "pi",
917            SessionSource::OpenCode => "opencode",
918            SessionSource::Grok => "grok",
919            SessionSource::Gemini => "gemini",
920            SessionSource::Goose => "goose",
921            // P5-3 safety-hardening fix: a natively-spawned session must
922            // never be written to disk labeled as an imported CC session.
923            SessionSource::Native => "native",
924        };
925        let header = serde_json::json!({
926            "supercode_native": 1,
927            "source": source,
928            // IX-1: carries whether the ORIGINAL imported source text ended
929            // with a trailing newline — `from_native_str` needs this to
930            // reconstruct the exact source bytes (not just the `raw` line
931            // list) when re-parsing the body with the per-source loader.
932            "raw_trailing_newline": self.raw_trailing_newline,
933        })
934        .to_string();
935        let mut out =
936            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
937        out.push_str(&header);
938        out.push('\n');
939        for line in &self.raw {
940            out.push_str(line);
941            out.push('\n');
942        }
943        out
944    }
945
946    /// Serialize to the **supercode-native v2** format: the same imported-body
947    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
948    /// followed by every `Session.raw` line verbatim), plus one
949    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
950    /// produced after import, which have no backing `raw` line of their own.
951    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
952    /// serde), so nothing the live agent loop records is lost to disk.
953    ///
954    /// `appended` is caller-supplied rather than inferred from
955    /// `self.messages`: A1 doesn't track which of `self.messages` came from
956    /// import vs. the live loop — that bookkeeping belongs to the live writer
957    /// built on top of this (A2/A3).
958    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
959        self.to_native_jsonl_v2_with_timestamp(appended, None)
960    }
961
962    pub(crate) fn to_native_jsonl_v2_with_timestamp(
963        &self,
964        appended: &[ChatMessage],
965        fixed_timestamp: Option<&str>,
966    ) -> String {
967        let source = match self.meta.source {
968            SessionSource::ClaudeCode => "claude_code",
969            SessionSource::Codex => "codex",
970            SessionSource::Pi => "pi",
971            SessionSource::OpenCode => "opencode",
972            SessionSource::Grok => "grok",
973            SessionSource::Gemini => "gemini",
974            SessionSource::Goose => "goose",
975            // P5-3 safety-hardening fix: a natively-spawned session must
976            // never be written to disk labeled as an imported CC session.
977            SessionSource::Native => "native",
978        };
979        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
980        // already parses CC sidechains + CX lineage on import"): a
981        // natively-spawned subagent's own `Session` carries its lineage on
982        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
983        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
984        // all, so a native-spawned child's lineage was lost the instant it
985        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
986        // (`skip_serializing_if`-equivalent via manual omission below) so a
987        // plain top-level session's header is byte-identical to before this
988        // change.
989        let mut header_obj = serde_json::json!({
990            "supercode_native": 2,
991            "source": source,
992            "session_id": self.meta.session_id,
993            "created": fixed_timestamp
994                .map(ToOwned::to_owned)
995                .unwrap_or_else(crate::sidecar::now_rfc3339),
996            // IX-1: see `to_native_jsonl`'s header field of the same name.
997            "raw_trailing_newline": self.raw_trailing_newline,
998        });
999        if let Some(obj) = header_obj.as_object_mut() {
1000            if let Some(agent_id) = &self.meta.agent_id {
1001                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
1002            }
1003            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
1004                obj.insert(
1005                    "parent_tool_use_id".to_string(),
1006                    Value::String(parent_tool_use_id.clone()),
1007                );
1008            }
1009            if !self.meta.lineage.is_empty() {
1010                obj.insert(
1011                    "lineage".to_string(),
1012                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
1013                );
1014            }
1015        }
1016        let header = header_obj.to_string();
1017        let mut out =
1018            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
1019        out.push_str(&header);
1020        out.push('\n');
1021        for line in &self.raw {
1022            out.push_str(line);
1023            out.push('\n');
1024        }
1025        for (turn_index, msg) in appended.iter().enumerate() {
1026            let turn = match fixed_timestamp {
1027                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1028                    msg,
1029                    timestamp.to_string(),
1030                    turn_index as u64,
1031                ),
1032                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1033                    msg,
1034                    crate::sidecar::now_rfc3339(),
1035                    turn_index as u64,
1036                ),
1037            };
1038            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
1039            out.push('\n');
1040        }
1041        out
1042    }
1043
1044    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
1045    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
1046    /// exactly as before. A v2 file's appended `NativeTurn` records —
1047    /// discriminated by the `supercode_turn` key, which never appears in a v1
1048    /// body — are split out before the imported body is handed to the
1049    /// per-source loader, then reattached in file order: to `messages` (via
1050    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
1051    /// so a v2 file round-trips byte-for-byte through
1052    /// [`Self::to_native_jsonl_v2`] again.
1053    pub fn from_native_str(jsonl: &str) -> Result<Session> {
1054        // IX-1: the native WRAPPER's own lines are split verbatim (not via
1055        // the blank-skipping `non_empty_lines`) so that any `raw` line it
1056        // carries — which can itself be blank, CRLF-terminated, or
1057        // whitespace-padded, now that raw-capture is strict-verbatim —
1058        // survives being embedded in (and re-extracted from) this wrapper
1059        // bit-for-bit. The wrapper we ourselves emit never has a blank line
1060        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
1061        // record per line), so this is a behavior-preserving switch for any
1062        // native text this crate produced; it also makes a hand-fed/legacy
1063        // native string tolerated exactly as `non_empty_lines` used to.
1064        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
1065        let mut lines = all_lines.into_iter();
1066        let header = lines.next().unwrap_or("");
1067        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
1068        let source = hv.get("source").and_then(Value::as_str);
1069        // IX-1: whether the ORIGINAL imported source (before it was wrapped
1070        // in this native format) ended with a trailing newline — a property
1071        // of the pre-wrap source, not of this wrapper (which always
1072        // LF-terminates every line it writes, regardless). Missing on a
1073        // native file written before IX-1 (or a hand-built header in an
1074        // older test/sidecar) — default `true`, the historical
1075        // always-newline-terminated assumption.
1076        let raw_trailing_newline = hv
1077            .get("raw_trailing_newline")
1078            .and_then(Value::as_bool)
1079            .unwrap_or(true);
1080
1081        // Split appended NativeTurn records (v2) out of the imported body. A
1082        // v1 body never carries a `supercode_turn` key, so this is a no-op
1083        // there — one code path serves both versions.
1084        let mut body_lines: Vec<String> = Vec::new();
1085        let mut turn_lines: Vec<&str> = Vec::new();
1086        for line in lines {
1087            let is_turn = serde_json::from_str::<Value>(line)
1088                .ok()
1089                .is_some_and(|v| v.get("supercode_turn").is_some());
1090            if is_turn {
1091                turn_lines.push(line);
1092            } else {
1093                body_lines.push(line.to_string());
1094            }
1095        }
1096        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1097        // `body_lines.join("\n")` alone would silently gain a trailing
1098        // newline the original source never had (or lose one it did have).
1099        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1100
1101        // The remaining lines are the original log; re-parse with the right loader.
1102        let mut session = match source {
1103            Some("codex") => Self::from_codex_str(&body)?,
1104            Some("claude_code") => Self::from_claude_code_str(&body)?,
1105            Some("pi") => Self::from_pi_str(&body)?,
1106            Some("opencode") => Self::from_opencode_str(&body)?,
1107            Some("grok") => Self::from_grok_str(&body)?,
1108            Some("gemini") => Self::from_gemini_str(&body)?,
1109            Some("goose") => Self::from_goose_str(&body)?,
1110            // P5-3 safety-hardening fix: a natively-spawned session's body
1111            // is always empty (it never had any foreign-tool prefix to
1112            // begin with — see `SessionSource::Native`'s doc comment), so
1113            // any loader would parse it identically; `from_claude_code_str`
1114            // is reused purely as a blank-skeleton builder (empty
1115            // `raw`/`messages`), then its `meta.source` is corrected to
1116            // `Native` — never left mislabeled as `ClaudeCode`.
1117            Some("native") => {
1118                let mut s = Self::from_claude_code_str(&body)?;
1119                s.meta.source = SessionSource::Native;
1120                s
1121            }
1122            // No/unknown header — auto-detect the body.
1123            _ => match detect_source(&body) {
1124                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1125                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1126                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1127                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1128                Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
1129                Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
1130                _ => Self::from_claude_code_str(&body)?,
1131            },
1132        };
1133
1134        for line in turn_lines {
1135            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1136                Ok(turn) => {
1137                    session.raw.push(line.to_string());
1138                    session.messages.push(turn.into_message());
1139                }
1140                Err(_) => {
1141                    // A valid JSON object carrying the native-turn
1142                    // discriminator belongs to this wrapper, not to the
1143                    // imported body. If its required fields are malformed,
1144                    // count it as parse loss so every fail-loud caller can
1145                    // refuse continuation instead of silently dropping a
1146                    // native history record. Keep the rejected source line
1147                    // in `raw` as well: diagnostics must count it in their
1148                    // denominator, and even corrupt input must not disappear
1149                    // merely because it reached the parser.
1150                    session.raw.push(line.to_string());
1151                    session.parse_error_lines += 1;
1152                }
1153            }
1154        }
1155
1156        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1157        // header block): recover a natively-spawned subagent's own lineage
1158        // from the v2 header, when present. Overlays (rather than merges
1159        // into) whatever the per-source body loader may have already set on
1160        // `session.meta` — these three keys are ONLY ever written by
1161        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1162        // header that carries them is authoritative for a file this crate
1163        // produced.
1164        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1165            session.meta.agent_id = Some(agent_id.to_string());
1166        }
1167        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1168            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1169        }
1170        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1171            for (k, v) in lineage {
1172                if let Some(s) = v.as_str() {
1173                    session.meta.lineage.insert(k.clone(), s.to_string());
1174                }
1175            }
1176        }
1177
1178        Ok(session)
1179    }
1180
1181    /// The full-fidelity [`Session`] a sidecar denotes.
1182    ///
1183    /// The sidecar (native-v2 format, D1) is the imported body plus every
1184    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1185    /// tolerant lower-level native parser, this persisted-store entry point
1186    /// validates its framing header before loading anything: a missing,
1187    /// malformed, or unsupported header must never become a zero-message
1188    /// session that callers could continue as if it were complete.
1189    pub fn from_sidecar_str(s: &str) -> Result<Session> {
1190        let header = s.lines().next().ok_or_else(|| {
1191            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1192        })?;
1193        let value: Value = serde_json::from_str(header).map_err(|error| {
1194            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1195        })?;
1196        let version = value.get("supercode_native").and_then(Value::as_u64);
1197        if !matches!(version, Some(1 | 2)) {
1198            return Err(Error::InvalidSession(
1199                "sidecar header must declare supported `supercode_native` version 1 or 2"
1200                    .to_string(),
1201            ));
1202        }
1203        let source = value.get("source").and_then(Value::as_str);
1204        if !matches!(
1205            source,
1206            Some(
1207                "native"
1208                    | "claude_code"
1209                    | "codex"
1210                    | "gemini"
1211                    | "goose"
1212                    | "opencode"
1213                    | "pi"
1214                    | "grok"
1215            )
1216        ) {
1217            return Err(Error::InvalidSession(
1218                "sidecar header must declare a supported `source`".to_string(),
1219            ));
1220        }
1221        Self::from_native_str(s)
1222    }
1223
1224    /// Parse a Codex rollout from an in-memory JSONL string.
1225    pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1226        let mut meta = SessionMeta::new(SessionSource::Codex);
1227        let mut messages = Vec::new();
1228
1229        // First pass: collect the text of every assistant message that exists as
1230        // a canonical `response_item`. In normal sessions the streamed
1231        // `event_msg/agent_message` events duplicate these and are safely
1232        // skipped; in collab/multi-agent sessions the assistant narration lives
1233        // ONLY as `agent_message` events, so we recover the ones with no
1234        // response_item counterpart (deduping by exact text).
1235        let assistant_texts = collect_codex_assistant_texts(jsonl);
1236        // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1237        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1238        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1239        let mut pending_reasoning = String::new();
1240        let mut pending_reasoning_content = String::new();
1241        let mut pending_reasoning_encrypted = false;
1242        // PARITY-15: see `from_claude_code_str`'s identical counter.
1243        let mut parse_error_lines = 0usize;
1244        let mut restored_embedded_codex_provenance = false;
1245
1246        for (record_index, raw_line) in raw_lines.iter().enumerate() {
1247            let line = raw_line.trim();
1248            if line.is_empty() {
1249                continue;
1250            }
1251            let v: Value = match serde_json::from_str(line) {
1252                Ok(v) => v,
1253                Err(_) => {
1254                    parse_error_lines += 1;
1255                    continue;
1256                }
1257            };
1258            let payload = v.get("payload").unwrap_or(&Value::Null);
1259            if !restored_embedded_codex_provenance
1260                && v.get("type").and_then(Value::as_str) == Some("session_meta")
1261                && payload
1262                    .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1263                    .map(|extension| restore_codex_provenance(extension, &mut meta))
1264                    .transpose()?
1265                    .unwrap_or(false)
1266            {
1267                restored_embedded_codex_provenance = true;
1268            }
1269            if !restored_embedded_codex_provenance {
1270                capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1271            }
1272            // WAVE-2 item 1: every Codex record carries a real top-level
1273            // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1274            // line produces via `stamp_new_codex_messages` below, at each
1275            // arm that pushes messages.
1276            let line_ts = v.get("timestamp").and_then(Value::as_str);
1277
1278            match v.get("type").and_then(Value::as_str) {
1279                Some("session_meta") => {
1280                    capture_codex_session_meta(payload, &mut meta);
1281                    if !restored_embedded_codex_provenance {
1282                        meta.codex_headers.push(v.clone());
1283                    }
1284                }
1285                Some("turn_context") => {
1286                    if meta.model.is_none() {
1287                        meta.model = payload
1288                            .get("model")
1289                            .and_then(Value::as_str)
1290                            .map(str::to_string);
1291                    }
1292                    if !restored_embedded_codex_provenance {
1293                        meta.codex_headers.push(v.clone());
1294                    }
1295                }
1296                Some("response_item")
1297                    if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1298                {
1299                    // Retain reasoning (P3): summary text if any, the raw
1300                    // `content` chain-of-thought text if any (N2 — this used
1301                    // to be dropped despite `Coverage::Retained` claiming the
1302                    // whole item survived; see `crate::audit`'s doc comment),
1303                    // plus a flag for the opaque encrypted_content a
1304                    // same-model continuation can replay. Stashed onto the
1305                    // next assistant message below.
1306                    let summary = extract_text_content(payload.get("summary"));
1307                    if !summary.trim().is_empty() {
1308                        push_str_field(&mut pending_reasoning, &summary);
1309                    }
1310                    // N2: `content` is `null` on the vast majority of real
1311                    // turns (raw reasoning text is only ever populated for
1312                    // certain reasoning-transcript configurations) — guard
1313                    // on non-null BEFORE calling `extract_text_content`,
1314                    // since `Some(&Value::Null)` would otherwise fall into
1315                    // its `Some(other) => other.to_string()` arm and
1316                    // stringify to the literal text `"null"`.
1317                    if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1318                        let text = extract_text_content(Some(raw_content));
1319                        if !text.trim().is_empty() {
1320                            push_str_field(&mut pending_reasoning_content, &text);
1321                        }
1322                    }
1323                    // N1: `serde_json` returns `Some(&Value::Null)` for a
1324                    // present-but-null `encrypted_content` key — which is
1325                    // what EVERY real rollout's reasoning item carries
1326                    // (upstream always serializes the field, never
1327                    // `skip_serializing_if`, `codex-rs/protocol/src/
1328                    // models.rs:970-983`). The old `.is_some()` check
1329                    // false-flagged every single reasoning item as
1330                    // "encrypted" on real data; only a genuinely non-null
1331                    // value means the model actually returned an opaque
1332                    // blob that a same-model continuation could replay.
1333                    if payload
1334                        .get("encrypted_content")
1335                        .is_some_and(|v| !v.is_null())
1336                    {
1337                        pending_reasoning_encrypted = true;
1338                    }
1339                }
1340                Some("response_item") => {
1341                    let before = messages.len();
1342                    push_codex_item(payload, &mut messages);
1343                    // Attach any pending reasoning to a newly produced assistant turn.
1344                    if messages.len() > before
1345                        && (!pending_reasoning.is_empty()
1346                            || !pending_reasoning_content.is_empty()
1347                            || pending_reasoning_encrypted)
1348                    {
1349                        let is_assistant = messages
1350                            .last()
1351                            .map(|m| m.role == Role::Assistant)
1352                            .unwrap_or(false);
1353                        if is_assistant {
1354                            let last = messages.last_mut().expect("checked above");
1355                            if !pending_reasoning.is_empty() {
1356                                last.metadata.insert(
1357                                    "reasoning".to_string(),
1358                                    std::mem::take(&mut pending_reasoning),
1359                                );
1360                            }
1361                            if !pending_reasoning_content.is_empty() {
1362                                last.metadata.insert(
1363                                    "reasoning_content".to_string(),
1364                                    std::mem::take(&mut pending_reasoning_content),
1365                                );
1366                            }
1367                            if pending_reasoning_encrypted {
1368                                last.metadata
1369                                    .insert("reasoning_encrypted".to_string(), "true".to_string());
1370                                pending_reasoning_encrypted = false;
1371                            }
1372                        } else {
1373                            // N3: the item that just landed is NOT the
1374                            // assistant turn the pending reasoning was for
1375                            // (e.g. an aborted turn's reasoning directly
1376                            // followed by a user message) — the old code
1377                            // unconditionally cleared the pending state
1378                            // here, silently discarding it. Flush it as its
1379                            // own message instead, inserted just before the
1380                            // interrupting item so replay order stays
1381                            // chronological, keeping `Coverage::Retained`
1382                            // honest for this shape too.
1383                            let orphan = orphaned_reasoning_message(
1384                                &mut pending_reasoning,
1385                                &mut pending_reasoning_content,
1386                                &mut pending_reasoning_encrypted,
1387                            );
1388                            messages.insert(before, orphan);
1389                        }
1390                    }
1391                    stamp_new_codex_messages(&mut messages, before, line_ts);
1392                    restore_single_grok_message(payload, &mut messages[before..]);
1393                }
1394                // A compaction record replaces all prior turns with its
1395                // summarized `replacement_history` — exactly how Codex itself
1396                // resumes a compacted session.
1397                Some("compacted") => {
1398                    messages.clear();
1399                    if let Some(Value::Array(history)) = payload.get("replacement_history") {
1400                        for item in history {
1401                            push_codex_item(item, &mut messages);
1402                        }
1403                    }
1404                    // `replacement_history` items carry no per-item
1405                    // timestamp of their own (observed corpora) — the
1406                    // `compacted` record's own timestamp (when it happened)
1407                    // is the best-effort real source for every message it
1408                    // synthesizes, so it stamps the whole rebuilt vec (index
1409                    // 0, since `clear()` reset it above).
1410                    stamp_new_codex_messages(&mut messages, 0, line_ts);
1411                    // IX-6 fix: replaying `replacement_history` through
1412                    // `push_codex_item` can leave the LAST replayed message
1413                    // marked `__codex_open_turn` (if it's an assistant
1414                    // `message`, per the combined-turn merge below). That
1415                    // marker must not survive past the compaction boundary —
1416                    // a live `function_call` arriving after this record is a
1417                    // NEW turn, not a continuation of the compaction
1418                    // summary's synthetic turn, so it must not merge into it.
1419                    if let Some(last) = messages.last_mut() {
1420                        last.metadata.remove("__codex_open_turn");
1421                    }
1422                }
1423                Some("event_msg")
1424                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1425                {
1426                    let before = messages.len();
1427                    let text = agent_message_text(payload);
1428                    if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1429                        push_assistant(&mut messages, text, Vec::new());
1430                        if let Some(last) = messages.last_mut() {
1431                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1432                                last.metadata.insert("phase".to_string(), phase.to_string());
1433                            }
1434                        }
1435                    }
1436                    stamp_new_codex_messages(&mut messages, before, line_ts);
1437                }
1438                // The user rolled back (undid) the last N turns — replay must
1439                // drop them so the reloaded conversation matches what the user
1440                // actually kept.
1441                Some("event_msg")
1442                    if payload.get("type").and_then(Value::as_str)
1443                        == Some("thread_rolled_back") =>
1444                {
1445                    let n = payload
1446                        .get("num_turns")
1447                        .and_then(Value::as_u64)
1448                        .unwrap_or(1);
1449                    for _ in 0..n {
1450                        remove_last_turn(&mut messages);
1451                    }
1452                }
1453                // The natural-language goal assigned to this thread (sometimes
1454                // the only place the objective text is recorded).
1455                Some("event_msg")
1456                    if payload.get("type").and_then(Value::as_str)
1457                        == Some("thread_goal_updated") =>
1458                {
1459                    let before = messages.len();
1460                    let goal = payload.get("goal");
1461                    if let Some(obj) = goal
1462                        .and_then(|g| g.get("objective"))
1463                        .and_then(Value::as_str)
1464                    {
1465                        if !obj.trim().is_empty() {
1466                            messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1467                            // D4: `goal.objective` alone used to be the ONLY
1468                            // captured field, but the audit labeled this
1469                            // `Retained` as if the whole record survived.
1470                            // `goal.status`/`goal.tokenBudget` (real
1471                            // `ThreadGoal` wire fields, camelCase) are
1472                            // captured too so that label is honest — see
1473                            // `crate::audit::event_msg_coverage`'s doc
1474                            // comment.
1475                            if let Some(last) = messages.last_mut() {
1476                                if let Some(status) =
1477                                    goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1478                                {
1479                                    last.metadata
1480                                        .insert("goal_status".to_string(), status.to_string());
1481                                }
1482                                if let Some(budget) = goal
1483                                    .and_then(|g| g.get("tokenBudget"))
1484                                    .and_then(Value::as_i64)
1485                                {
1486                                    last.metadata.insert(
1487                                        "goal_token_budget".to_string(),
1488                                        budget.to_string(),
1489                                    );
1490                                }
1491                            }
1492                        }
1493                    }
1494                    stamp_new_codex_messages(&mut messages, before, line_ts);
1495                }
1496                // Code-review output — unique assistant-generated content with no
1497                // `message` counterpart.
1498                Some("event_msg")
1499                    if payload.get("type").and_then(Value::as_str)
1500                        == Some("exited_review_mode") =>
1501                {
1502                    let before = messages.len();
1503                    if let Some(review) = payload.get("review_output") {
1504                        let text = review
1505                            .get("overall_explanation")
1506                            .and_then(Value::as_str)
1507                            .map(str::to_string)
1508                            .unwrap_or_else(|| review.to_string());
1509                        push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1510                        // D4: `overall_explanation` alone used to be the ONLY
1511                        // captured field, but the audit labeled this
1512                        // `Retained` as if `review_output.findings` survived
1513                        // too. Capture `findings` verbatim (as JSON, onto
1514                        // metadata) so that label is honest — this is the
1515                        // only place review-mode findings (title/body/
1516                        // confidence_score/priority/code_location) live.
1517                        if let Some(findings) = review.get("findings") {
1518                            if findings.as_array().is_some_and(|a| !a.is_empty()) {
1519                                if let Some(last) = messages.last_mut() {
1520                                    if let Ok(s) = serde_json::to_string(findings) {
1521                                        last.metadata.insert("review_findings".to_string(), s);
1522                                    }
1523                                }
1524                            }
1525                        }
1526                        // N4: `overall_correctness`/`overall_confidence_score`
1527                        // are the review's actual verdict — distinct from the
1528                        // findings list and the explanation prose already
1529                        // captured above — and were neither captured nor
1530                        // disclosed as residue while the audit doc stayed
1531                        // silent about them. Capture both onto the same
1532                        // message's metadata, same pattern as `findings`.
1533                        if let Some(last) = messages.last_mut() {
1534                            if let Some(correctness) =
1535                                review.get("overall_correctness").and_then(Value::as_str)
1536                            {
1537                                last.metadata.insert(
1538                                    "review_overall_correctness".to_string(),
1539                                    correctness.to_string(),
1540                                );
1541                            }
1542                            if let Some(score) = review
1543                                .get("overall_confidence_score")
1544                                .and_then(Value::as_f64)
1545                            {
1546                                last.metadata.insert(
1547                                    "review_overall_confidence_score".to_string(),
1548                                    score.to_string(),
1549                                );
1550                            }
1551                        }
1552                    }
1553                    stamp_new_codex_messages(&mut messages, before, line_ts);
1554                }
1555                _ => {} // other event_msg, token_count, ... — UI events, skip
1556            }
1557        }
1558
1559        // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1560        // shape a real rollout can leave behind (the process was
1561        // interrupted mid-turn, after the model reasoned but before it
1562        // replied — end of file, or a rollback/compaction boundary that
1563        // clears the pending state some other way) — the old code silently
1564        // dropped it here (nothing ever consumed the pending buffers once
1565        // the loop ended). Flush it as its own trailing message instead, so
1566        // `Coverage::Retained` holds for this shape too. Superset of the
1567        // independently-discovered PARITY-11 fix: also folds in
1568        // `pending_reasoning_content` (the raw chain-of-thought, distinct
1569        // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1570        // message` helper, which the interrupted-by-a-user-message shape
1571        // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1572        // relies on — a trailing-EOF-only flush here would miss that case.
1573        if !pending_reasoning.is_empty()
1574            || !pending_reasoning_content.is_empty()
1575            || pending_reasoning_encrypted
1576        {
1577            let orphan = orphaned_reasoning_message(
1578                &mut pending_reasoning,
1579                &mut pending_reasoning_content,
1580                &mut pending_reasoning_encrypted,
1581            );
1582            messages.push(orphan);
1583        }
1584
1585        ensure_tool_results_paired(&mut messages);
1586        // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1587        // combined-turn merge above — strip it so it never leaks out as
1588        // visible `ChatMessage` metadata.
1589        for m in &mut messages {
1590            m.metadata.remove("__codex_open_turn");
1591            if m.metadata
1592                .remove("__grok_remove_synthetic_turn_id")
1593                .is_some()
1594            {
1595                m.metadata.remove("turn_id");
1596            }
1597        }
1598        let imported_message_count = Some(messages.len());
1599        Ok(Session {
1600            meta,
1601            messages,
1602            subagents: Vec::new(),
1603            raw,
1604            raw_trailing_newline,
1605            imported_message_count,
1606            // Codex is line-oriented: `raw` is split directly out of the
1607            // source text (strict-verbatim, IX-1).
1608            raw_is_verbatim: true,
1609            parse_error_lines,
1610            load_residue: Vec::new(),
1611        })
1612    }
1613
1614    /// Parse a Codex rollout as bounded human-visible history rather than as
1615    /// resumable model context. This deliberately ignores outer `compacted`
1616    /// replacement semantics: the original `response_item` records remain in
1617    /// the rollout and are the authoritative UI history.
1618    fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
1619        let mut meta = SessionMeta::new(SessionSource::Codex);
1620        let mut messages: Vec<ChatMessage> = Vec::new();
1621        let mut preceding_user = None;
1622        let mut parse_error_lines = 0usize;
1623        let mut record_count = 0usize;
1624        let retain = message_limit.max(1).saturating_add(64);
1625        let mut canonical_assistant_texts = HashSet::new();
1626
1627        for raw_line in non_empty_lines(jsonl) {
1628            record_count += 1;
1629            let value: Value = match serde_json::from_str(raw_line) {
1630                Ok(value) => value,
1631                Err(_) => {
1632                    parse_error_lines += 1;
1633                    continue;
1634                }
1635            };
1636            let payload = value.get("payload").unwrap_or(&Value::Null);
1637            let line_ts = value.get("timestamp").and_then(Value::as_str);
1638            match value.get("type").and_then(Value::as_str) {
1639                Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
1640                Some("turn_context") if meta.model.is_none() => {
1641                    meta.model = payload
1642                        .get("model")
1643                        .and_then(Value::as_str)
1644                        .map(str::to_string);
1645                }
1646                Some("response_item")
1647                    if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
1648                {
1649                    let assistant_text = (payload.get("type").and_then(Value::as_str)
1650                        == Some("message")
1651                        && payload.get("role").and_then(Value::as_str) == Some("assistant"))
1652                    .then(|| extract_text_content(payload.get("content")))
1653                    .filter(|text| !text.trim().is_empty());
1654                    if let Some(text) = assistant_text.as_deref() {
1655                        if let Some(index) = messages.iter().rposition(|message| {
1656                            message.metadata.contains_key("codex_event_message")
1657                                && message.content.as_deref() == Some(text)
1658                        }) {
1659                            messages.remove(index);
1660                        }
1661                        canonical_assistant_texts.insert(text.trim().to_string());
1662                    }
1663                    let before = messages.len();
1664                    push_codex_item(payload, &mut messages);
1665                    stamp_new_codex_messages(&mut messages, before, line_ts);
1666                    restore_single_grok_message(payload, &mut messages[before..]);
1667                }
1668                Some("event_msg")
1669                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1670                {
1671                    let text = agent_message_text(payload);
1672                    if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
1673                        let before = messages.len();
1674                        push_assistant(&mut messages, text, Vec::new());
1675                        if let Some(last) = messages.last_mut() {
1676                            last.metadata
1677                                .insert("codex_event_message".to_string(), "true".to_string());
1678                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1679                                last.metadata.insert("phase".to_string(), phase.to_string());
1680                            }
1681                        }
1682                        stamp_new_codex_messages(&mut messages, before, line_ts);
1683                    }
1684                }
1685                // `compacted` changes continuation context, not what was
1686                // already visible in scrollback. Other event records are UI
1687                // lifecycle noise or duplicate canonical response items.
1688                _ => {}
1689            }
1690            if messages.len() > retain {
1691                let remove = messages.len() - retain;
1692                for message in messages.drain(..remove) {
1693                    if message.role == Role::User {
1694                        preceding_user = Some(message);
1695                    }
1696                }
1697            }
1698        }
1699
1700        for message in &mut messages {
1701            message.metadata.remove("__codex_open_turn");
1702            message.metadata.remove("codex_event_message");
1703            if message
1704                .metadata
1705                .remove("__grok_remove_synthetic_turn_id")
1706                .is_some()
1707            {
1708                message.metadata.remove("turn_id");
1709            }
1710        }
1711        truncate_messages_with_anchor(&mut messages, message_limit, preceding_user);
1712        let imported_message_count = Some(messages.len());
1713        Ok(Session {
1714            meta,
1715            messages,
1716            subagents: Vec::new(),
1717            // Preserve the cheap count without retaining hundreds of
1718            // megabytes of source lines in a display-only value.
1719            raw: vec![String::new(); record_count],
1720            raw_trailing_newline: jsonl.ends_with('\n'),
1721            imported_message_count,
1722            raw_is_verbatim: false,
1723            parse_error_lines,
1724            load_residue: vec![
1725                "display history is a bounded native-record projection, not resumable model context"
1726                    .to_string(),
1727            ],
1728        })
1729    }
1730
1731    /// Load a Pi session from a file.
1732    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1733        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1734    }
1735
1736    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1737    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1738    ///
1739    /// Line 1 is the `session` header; every other line is one `SessionEntry`
1740    /// in a tree keyed by `id`/`parentId` — file order is append order, not
1741    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1742    /// exactly like Claude Code/Codex). `messages` is the **active path
1743    /// only**: pi's own leaf rule is "the last entry in file order"
1744    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1745    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1746    /// state records (`thinking_level_change`/`model_change`/`custom`/
1747    /// `session_info`) are never visited by that walk — they survive in
1748    /// `raw` only, pi's defining residue (§1.1).
1749    ///
1750    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1751    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1752    /// `custom`) produces no canonical message — raw-only survival, never a
1753    /// panic — and the Pi corpus audit turns that into a
1754    /// visible coverage failure rather than a silent drop.
1755    ///
1756    /// Same fail-loud discipline applies to `ImageContent` blocks
1757    /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1758    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1759    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1760    /// cites the containing union) — a follow-up TR tracks confirming it
1761    /// against a real corpus. Until then, an image block that doesn't match
1762    /// that shape never gets silently synthesized as an empty/corrupt
1763    /// `image_url` part; the containing message survives in `raw` only and
1764    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1765    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1766        let mut meta = SessionMeta::new(SessionSource::Pi);
1767        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1768        // blank-skipping PARSE walk (`lines_v`) below, which must keep
1769        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1770        // records (a blank line is never a record, on either view).
1771        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1772        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1773        let non_empty_line_count = non_empty_lines(jsonl).count();
1774        let lines_v: Vec<Value> = non_empty_lines(jsonl)
1775            .filter_map(|l| serde_json::from_str(l).ok())
1776            .collect();
1777        // PARITY-15: every line that failed to even deserialize as JSON at
1778        // all (never mind whether it then parsed as a recognized
1779        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1780        // counter.
1781        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1782
1783        if let Some(header) = lines_v.first() {
1784            capture_pi_header(header, &mut meta)?;
1785        }
1786
1787        // Every non-header entry that parses as an object carrying an `id`.
1788        // (A line that fails to parse, or a header re-parsed as an entry,
1789        // simply never enters `by_id` — it survives in `raw` only, exactly
1790        // like a malformed/non-conversational line in the other loaders.)
1791        struct PiEntry {
1792            id: String,
1793            parent_id: Option<String>,
1794            value: Value,
1795        }
1796        let mut entries: Vec<PiEntry> = Vec::new();
1797        let mut by_id: HashMap<String, usize> = HashMap::new();
1798        for v in lines_v.iter().skip(1) {
1799            let Some(id) = v.get("id").and_then(Value::as_str) else {
1800                continue;
1801            };
1802            let parent_id = v
1803                .get("parentId")
1804                .and_then(Value::as_str)
1805                .map(str::to_string);
1806            by_id.insert(id.to_string(), entries.len());
1807            entries.push(PiEntry {
1808                id: id.to_string(),
1809                parent_id,
1810                value: v.clone(),
1811            });
1812        }
1813
1814        if entries.is_empty() {
1815            return Ok(Session {
1816                meta,
1817                messages: Vec::new(),
1818                subagents: Vec::new(),
1819                raw,
1820                raw_trailing_newline,
1821                imported_message_count: Some(0),
1822                // Pi is line-oriented: `raw` is split directly out of the
1823                // source text (strict-verbatim, IX-1), even for this
1824                // no-entries early return.
1825                raw_is_verbatim: true,
1826                parse_error_lines,
1827                load_residue: Vec::new(),
1828            });
1829        }
1830
1831        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1832        // necessarily a `message` entry — a trailing `label`/`session_info`
1833        // still anchors the walk correctly since the walk just follows
1834        // `parentId` regardless of the leaf's own type.
1835        let leaf_idx = entries.len() - 1;
1836        let mut chain_rev: Vec<usize> = Vec::new();
1837        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1838        let mut guard = 0usize;
1839        while let Some(id) = cur {
1840            let Some(&idx) = by_id.get(&id) else { break };
1841            chain_rev.push(idx);
1842            cur = entries[idx].parent_id.clone();
1843            guard += 1;
1844            if guard > entries.len() + 1 {
1845                break; // cycle guard — malformed parentId chain
1846            }
1847        }
1848        chain_rev.reverse();
1849        let active = chain_rev; // indices into `entries`, root..leaf order
1850
1851        let pos_in_active: HashMap<&str, usize> = active
1852            .iter()
1853            .enumerate()
1854            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1855            .collect();
1856
1857        // First pass: compaction discipline (§2.1 S3) — every message from an
1858        // entry before the LATEST `firstKeptEntryId` on the active path is
1859        // excluded from replay (`compacted_out`), mirroring pi's own
1860        // `buildContextEntries` slice (`sm:414-450`).
1861        let mut kept_from_pos = 0usize;
1862        for &idx in &active {
1863            let e = &entries[idx];
1864            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1865                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1866                    if let Some(&p) = pos_in_active.get(fk) {
1867                        kept_from_pos = kept_from_pos.max(p);
1868                    }
1869                }
1870            }
1871        }
1872
1873        let mut messages = Vec::new();
1874        let mut current_model: Option<String> = None;
1875        for (pos, &idx) in active.iter().enumerate() {
1876            let e = &entries[idx];
1877            let v = &e.value;
1878            let entry_ts = v
1879                .get("timestamp")
1880                .and_then(Value::as_str)
1881                .map(str::to_string);
1882            let before = messages.len();
1883            match v.get("type").and_then(Value::as_str) {
1884                Some("message") => {
1885                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1886                    match msg_v.get("role").and_then(Value::as_str) {
1887                        Some("user") => push_pi_user(&msg_v, &mut messages),
1888                        Some("assistant") => {
1889                            push_pi_assistant(&msg_v, &mut messages);
1890                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1891                                current_model = Some(m.to_string());
1892                            }
1893                        }
1894                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1895                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1896                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1897                        // OPEN UNION (S6): any other role — raw-only survival.
1898                        _ => {}
1899                    }
1900                }
1901                Some("custom_message") => push_pi_custom_common(v, &mut messages),
1902                Some("compaction") => push_pi_compaction(v, &mut messages),
1903                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1904                Some("model_change") => {
1905                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1906                        current_model = Some(m.to_string());
1907                    }
1908                }
1909                Some("session_info") => {
1910                    if let Some(name) = v.get("name").and_then(Value::as_str) {
1911                        if !name.is_empty() {
1912                            meta.lineage
1913                                .insert("session_name".to_string(), name.to_string());
1914                        }
1915                    }
1916                }
1917                // thinking_level_change, custom (entry-level state), label —
1918                // no clean home, raw-only (§2.3).
1919                _ => {}
1920            }
1921            let is_summary = matches!(
1922                v.get("type").and_then(Value::as_str),
1923                Some("compaction") | Some("branch_summary")
1924            );
1925            for m in &mut messages[before..] {
1926                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1927                if let Some(p) = &e.parent_id {
1928                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
1929                }
1930                if let Some(ts) = &entry_ts {
1931                    m.metadata
1932                        .entry("timestamp".to_string())
1933                        .or_insert_with(|| ts.clone());
1934                }
1935                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1936                // is pi's authoritative, always-monotonic-in-file-order
1937                // wall-clock (mandatory on every entry) and wins whenever
1938                // present. The nested `message.timestamp` (unix-ms) is only
1939                // reached here — via `entry(...).or_insert_with`, so it
1940                // never overwrites the entry-level value — in the rare case
1941                // an entry lacks its own `timestamp`. This intentionally
1942                // does NOT prefer the msg-level field even though it LOOKS
1943                // more precise: unlike the entry-level timestamp, it is not
1944                // guaranteed monotonic with this loader's root->leaf
1945                // linearization (e.g. a rewound-branch entry can carry an
1946                // earlier msg-level clock reading than its file-order
1947                // neighbors), and OpenCode's own loader re-sorts messages by
1948                // this canonical timestamp — a non-monotonic source would
1949                // silently scramble replay order on a pi->opencode hop.
1950                if let Some(ms) = v
1951                    .get("message")
1952                    .and_then(|mm| mm.get("timestamp"))
1953                    .and_then(Value::as_u64)
1954                {
1955                    m.metadata
1956                        .entry("timestamp".to_string())
1957                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
1958                }
1959                // A compaction/branch-summary message IS the retained marker
1960                // — never mark it excluded, regardless of its own position.
1961                if !is_summary && pos < kept_from_pos {
1962                    m.metadata
1963                        .insert("compacted_out".to_string(), "true".to_string());
1964                }
1965            }
1966            restore_single_grok_message(v, &mut messages[before..]);
1967            for message in &mut messages[before..] {
1968                restore_tool_outcome_extension(v, message);
1969            }
1970        }
1971
1972        meta.model = current_model;
1973        ensure_tool_results_paired(&mut messages);
1974        let imported_message_count = Some(messages.len());
1975        Ok(Session {
1976            meta,
1977            messages,
1978            subagents: Vec::new(),
1979            raw,
1980            raw_trailing_newline,
1981            imported_message_count,
1982            // Pi is line-oriented: `raw` is split directly out of the
1983            // source text (strict-verbatim, IX-1).
1984            raw_is_verbatim: true,
1985            parse_error_lines,
1986            load_residue: Vec::new(),
1987        })
1988    }
1989
1990    /// Load Grok's resumable `chat_history.jsonl` transcript.
1991    ///
1992    /// The surrounding session directory carries the session id, workspace,
1993    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
1994    /// itself while this path-aware entry point overlays that directory
1995    /// metadata.
1996    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
1997        let path = path.as_ref();
1998        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
1999        session.capture_grok_path_metadata(path);
2000        Ok(session)
2001    }
2002
2003    /// Parse Grok's line-oriented `chat_history.jsonl` format.
2004    ///
2005    /// Conversational records are `user`, `assistant`, and `tool_result`.
2006    /// `system` is the regenerated base prompt and is retained in
2007    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2008    /// state remain byte-exact in [`Session::raw`] but are intentionally not
2009    /// replayed as chat turns.
2010    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2011        let mut meta = SessionMeta::new(SessionSource::Grok);
2012        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2013        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2014        let mut messages = Vec::new();
2015        let mut parse_error_lines = 0usize;
2016        let mut tool_names: HashMap<String, String> = HashMap::new();
2017
2018        for line in non_empty_lines(jsonl) {
2019            let value: Value = match serde_json::from_str(line) {
2020                Ok(value) => value,
2021                Err(_) => {
2022                    parse_error_lines += 1;
2023                    continue;
2024                }
2025            };
2026            restore_codex_provenance_from_top_level(&value, &mut meta)?;
2027            match value.get("type").and_then(Value::as_str) {
2028                Some("system") => {
2029                    if meta.system_prompt.is_none() {
2030                        meta.system_prompt = value
2031                            .get("content")
2032                            .and_then(Value::as_str)
2033                            .map(str::to_string);
2034                    }
2035                }
2036                Some("user") => {
2037                    let content = extract_text_content(value.get("content"));
2038                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
2039                        == Some("supercode_system_event")
2040                    {
2041                        Role::System
2042                    } else {
2043                        Role::User
2044                    };
2045                    let content = if role == Role::User {
2046                        match grok_human_user_text(&content) {
2047                            Some(content) => content,
2048                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2049                                String::new()
2050                            }
2051                            None => continue,
2052                        }
2053                    } else {
2054                        content
2055                    };
2056                    let mut message = ChatMessage {
2057                        role,
2058                        content: Some(content),
2059                        content_parts: None,
2060                        tool_calls: None,
2061                        tool_call_id: None,
2062                        name: None,
2063                        metadata: Default::default(),
2064                    };
2065                    capture_grok_scalar_metadata(
2066                        &value,
2067                        &mut message,
2068                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2069                    );
2070                    restore_grok_message_extension(&value, &mut message);
2071                    messages.push(message);
2072                }
2073                Some("assistant") => {
2074                    let calls: Vec<ToolCall> = value
2075                        .get("tool_calls")
2076                        .and_then(Value::as_array)
2077                        .into_iter()
2078                        .flatten()
2079                        .filter_map(|call| {
2080                            let id = call.get("id")?.as_str()?.to_string();
2081                            let name = call.get("name")?.as_str()?.to_string();
2082                            let arguments = call
2083                                .get("arguments")
2084                                .map(value_to_arg_string)
2085                                .unwrap_or_else(|| "{}".to_string());
2086                            tool_names.insert(id.clone(), name.clone());
2087                            Some(function_call(&id, &name, arguments))
2088                        })
2089                        .collect();
2090                    let content = value
2091                        .get("content")
2092                        .and_then(Value::as_str)
2093                        .filter(|content| !content.is_empty())
2094                        .map(str::to_string);
2095                    let mut message = ChatMessage {
2096                        role: Role::Assistant,
2097                        content,
2098                        content_parts: None,
2099                        tool_calls: (!calls.is_empty()).then_some(calls),
2100                        tool_call_id: None,
2101                        name: None,
2102                        metadata: Default::default(),
2103                    };
2104                    capture_grok_scalar_metadata(
2105                        &value,
2106                        &mut message,
2107                        &["model_id", "model_fingerprint", "reasoning_effort"],
2108                    );
2109                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2110                        meta.model = Some(model.to_string());
2111                    }
2112                    restore_grok_message_extension(&value, &mut message);
2113                    messages.push(message);
2114                }
2115                Some("tool_result") => {
2116                    let id = value
2117                        .get("tool_call_id")
2118                        .and_then(Value::as_str)
2119                        .unwrap_or_default();
2120                    let content = value
2121                        .get("content")
2122                        .map(|value| match value {
2123                            Value::String(text) => text.clone(),
2124                            other => extract_text_content(Some(other)),
2125                        })
2126                        .unwrap_or_default();
2127                    let mut message = tool_message(id, content);
2128                    message.name = tool_names.get(id).cloned();
2129                    restore_grok_message_extension(&value, &mut message);
2130                    messages.push(message);
2131                }
2132                // `reasoning` contains encrypted chain-of-thought and
2133                // `backend_tool_call` is execution bookkeeping. Both survive
2134                // verbatim in raw without being replayed to another model.
2135                _ => {}
2136            }
2137        }
2138
2139        ensure_tool_results_paired(&mut messages);
2140        let imported_message_count = Some(messages.len());
2141        Ok(Session {
2142            meta,
2143            messages,
2144            subagents: Vec::new(),
2145            raw,
2146            raw_trailing_newline,
2147            imported_message_count,
2148            raw_is_verbatim: true,
2149            parse_error_lines,
2150            load_residue: Vec::new(),
2151        })
2152    }
2153
2154    fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2155        let Some(session_dir) = transcript.parent() else {
2156            return;
2157        };
2158        self.meta.session_id = session_dir
2159            .file_name()
2160            .and_then(|name| name.to_str())
2161            .map(str::to_string);
2162        self.meta.cwd = session_dir
2163            .parent()
2164            .and_then(Path::file_name)
2165            .and_then(|name| name.to_str())
2166            .and_then(percent_decode_path)
2167            .map(PathBuf::from);
2168
2169        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2170            return;
2171        };
2172        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2173            return;
2174        };
2175        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2176            self.meta.model = Some(model.to_string());
2177        }
2178        for (source, target) in [
2179            ("generated_title", "session_name"),
2180            ("created_at", "created_at"),
2181            ("updated_at", "updated_at"),
2182            ("chat_format_version", "grok_chat_format_version"),
2183        ] {
2184            if let Some(value) = summary.get(source) {
2185                self.meta.lineage.insert(
2186                    target.to_string(),
2187                    value
2188                        .as_str()
2189                        .map(str::to_string)
2190                        .unwrap_or_else(|| value.to_string()),
2191                );
2192            }
2193        }
2194    }
2195
2196    /// Load a Gemini CLI transcript from disk.
2197    pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2198        Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2199    }
2200
2201    /// Parse Gemini CLI's line-oriented session format.
2202    ///
2203    /// Gemini stores a header without a `type`, followed by `user` and
2204    /// `gemini` records. Function calls are embedded in assistant content
2205    /// parts and function responses in user content parts. Unknown records
2206    /// remain byte-exact in [`Session::raw`] instead of silently entering the
2207    /// replay conversation.
2208    pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2209        let mut meta = SessionMeta::new(SessionSource::Gemini);
2210        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2211        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2212        let mut messages = Vec::new();
2213        let mut parse_error_lines = 0usize;
2214        let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2215
2216        for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2217            let value: Value = match serde_json::from_str(line) {
2218                Ok(value) => value,
2219                Err(_) => {
2220                    parse_error_lines += 1;
2221                    continue;
2222                }
2223            };
2224            let kind = value.get("type").and_then(Value::as_str);
2225            if kind.is_none() {
2226                if meta.session_id.is_none() {
2227                    meta.session_id = value
2228                        .get("sessionId")
2229                        .and_then(Value::as_str)
2230                        .map(str::to_string);
2231                }
2232                for (source, target) in [
2233                    ("projectHash", "gemini_project_hash"),
2234                    ("startTime", "created_at"),
2235                    ("lastUpdated", "updated_at"),
2236                    ("kind", "gemini_session_kind"),
2237                ] {
2238                    if let Some(raw) = value.get(source) {
2239                        meta.lineage.insert(
2240                            target.to_string(),
2241                            raw.as_str()
2242                                .map(str::to_string)
2243                                .unwrap_or_else(|| raw.to_string()),
2244                        );
2245                    }
2246                }
2247                continue;
2248            }
2249            if kind != Some("user") && kind != Some("gemini") {
2250                continue;
2251            }
2252
2253            let timestamp = value.get("timestamp").and_then(Value::as_str);
2254            let model = value.get("model").and_then(Value::as_str);
2255            if let Some(model) = model {
2256                meta.model = Some(model.to_string());
2257            }
2258            let content = value.get("content").unwrap_or(&Value::Null);
2259            let parts = content.as_array();
2260            let text = match content {
2261                Value::String(text) => text.clone(),
2262                Value::Array(parts) => parts
2263                    .iter()
2264                    .filter_map(|part| part.get("text").and_then(Value::as_str))
2265                    .collect::<Vec<_>>()
2266                    .join(" ")
2267                    .trim()
2268                    .to_string(),
2269                _ => String::new(),
2270            };
2271
2272            if kind == Some("gemini") {
2273                let legacy_calls = parts
2274                    .into_iter()
2275                    .flatten()
2276                    .filter_map(|part| part.get("functionCall"));
2277                let native_calls = value
2278                    .get("toolCalls")
2279                    .and_then(Value::as_array)
2280                    .into_iter()
2281                    .flatten();
2282                let calls = native_calls
2283                    .chain(legacy_calls)
2284                    .enumerate()
2285                    .filter_map(|(call_index, call)| {
2286                        let name = call.get("name")?.as_str()?.to_string();
2287                        let id = call
2288                            .get("id")
2289                            .and_then(Value::as_str)
2290                            .map(str::to_string)
2291                            .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2292                        pending_by_name
2293                            .entry(name.clone())
2294                            .or_default()
2295                            .push(id.clone());
2296                        let arguments = call
2297                            .get("args")
2298                            .map(value_to_arg_string)
2299                            .unwrap_or_else(|| "{}".to_string());
2300                        Some(function_call(&id, &name, arguments))
2301                    })
2302                    .collect::<Vec<_>>();
2303                let mut message = ChatMessage {
2304                    role: Role::Assistant,
2305                    content: (!text.is_empty()).then_some(text),
2306                    content_parts: None,
2307                    tool_calls: (!calls.is_empty()).then_some(calls),
2308                    tool_call_id: None,
2309                    name: None,
2310                    metadata: Default::default(),
2311                };
2312                if let Some(timestamp) = timestamp {
2313                    message
2314                        .metadata
2315                        .insert("timestamp".into(), timestamp.into());
2316                }
2317                if let Some(model) = model {
2318                    message.metadata.insert("gemini_model".into(), model.into());
2319                }
2320                if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2321                    message
2322                        .metadata
2323                        .insert("gemini_thoughts".into(), thoughts.to_string());
2324                }
2325                restore_gemini_message_extension(&value, &mut message);
2326                if message.content.is_some() || message.tool_calls.is_some() {
2327                    messages.push(message);
2328                }
2329                continue;
2330            }
2331
2332            let mut user_parts = Vec::new();
2333            if let Some(parts) = parts {
2334                for part in parts {
2335                    if let Some(response) = part.get("functionResponse") {
2336                        push_gemini_user_parts(
2337                            &mut messages,
2338                            std::mem::take(&mut user_parts),
2339                            timestamp,
2340                            &value,
2341                        );
2342                        let name = response
2343                            .get("name")
2344                            .and_then(Value::as_str)
2345                            .unwrap_or("tool")
2346                            .to_string();
2347                        let explicit_id = response
2348                            .get("id")
2349                            .and_then(Value::as_str)
2350                            .map(str::to_string);
2351                        if let Some(id) = explicit_id.as_deref() {
2352                            if let Some(ids) = pending_by_name.get_mut(&name) {
2353                                if let Some(position) = ids.iter().position(|pending| pending == id)
2354                                {
2355                                    ids.remove(position);
2356                                }
2357                            }
2358                        }
2359                        let id = explicit_id
2360                            .or_else(|| {
2361                                pending_by_name
2362                                    .get_mut(&name)
2363                                    .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2364                            })
2365                            .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2366                        let output = response
2367                            .get("response")
2368                            .and_then(|response| response.get("output"))
2369                            .map(|output| {
2370                                output
2371                                    .as_str()
2372                                    .map(str::to_string)
2373                                    .unwrap_or_else(|| output.to_string())
2374                            })
2375                            .or_else(|| response.get("response").map(Value::to_string))
2376                            .unwrap_or_default();
2377                        let mut message = tool_message(&id, output);
2378                        message.name = Some(name);
2379                        if let Some(timestamp) = timestamp {
2380                            message
2381                                .metadata
2382                                .insert("timestamp".into(), timestamp.into());
2383                        }
2384                        restore_gemini_message_extension(&value, &mut message);
2385                        messages.push(message);
2386                        continue;
2387                    }
2388                    if let Some(text) = part.get("text").and_then(Value::as_str) {
2389                        user_parts.push(serde_json::json!({"type": "text", "text": text}));
2390                        continue;
2391                    }
2392                    if let Some(inline) = part.get("inlineData") {
2393                        let Some(data) = inline.get("data").and_then(Value::as_str) else {
2394                            continue;
2395                        };
2396                        let media_type = inline
2397                            .get("mimeType")
2398                            .and_then(Value::as_str)
2399                            .unwrap_or("application/octet-stream");
2400                        user_parts.push(serde_json::json!({
2401                            "type": "image_url",
2402                            "image_url": {"url": format!("data:{media_type};base64,{data}")},
2403                        }));
2404                    }
2405                }
2406            } else if !text.is_empty() {
2407                user_parts.push(serde_json::json!({"type": "text", "text": text}));
2408            }
2409            push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2410        }
2411
2412        ensure_tool_results_paired(&mut messages);
2413        let imported_message_count = Some(messages.len());
2414        Ok(Session {
2415            meta,
2416            messages,
2417            subagents: Vec::new(),
2418            raw,
2419            raw_trailing_newline,
2420            imported_message_count,
2421            raw_is_verbatim: true,
2422            parse_error_lines,
2423            load_residue: Vec::new(),
2424        })
2425    }
2426
2427    /// Load a Goose session-export JSON document from disk.
2428    pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2429        Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2430    }
2431
2432    /// Parse Goose's official native import/export document.
2433    ///
2434    /// Goose's durable store is SQLite, but its own
2435    /// `_goose/unstable/session/export` and `/session/import` boundary is one
2436    /// JSON object containing a `conversation` array. Unknown native content
2437    /// blocks are retained on the first canonical message in a namespaced
2438    /// portability envelope; unchanged same-format exports replay the exact
2439    /// source bytes.
2440    pub fn from_goose_str(json: &str) -> Result<Session> {
2441        let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2442        let object = document.as_object().ok_or_else(|| {
2443            Error::InvalidSession("Goose session export must be a JSON object".to_string())
2444        })?;
2445        let conversation = object
2446            .get("conversation")
2447            .and_then(Value::as_array)
2448            .ok_or_else(|| {
2449                Error::InvalidSession(
2450                    "Goose session export must contain a conversation array".to_string(),
2451                )
2452            })?;
2453
2454        let mut meta = SessionMeta::new(SessionSource::Goose);
2455        meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2456        meta.cwd = object
2457            .get("working_dir")
2458            .or_else(|| object.get("workingDir"))
2459            .and_then(Value::as_str)
2460            .map(PathBuf::from);
2461        meta.model = object
2462            .get("model_config")
2463            .or_else(|| object.get("modelConfig"))
2464            .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2465            .and_then(Value::as_str)
2466            .map(str::to_string);
2467        for (source, target) in [
2468            ("name", "session_name"),
2469            ("created_at", "created_at"),
2470            ("updated_at", "updated_at"),
2471            ("session_type", "goose_session_type"),
2472            ("goose_mode", "goose_mode"),
2473            ("provider_name", "goose_provider_name"),
2474            ("parent_session_id", "parent_session_id"),
2475        ] {
2476            if let Some(value) = object.get(source) {
2477                meta.lineage.insert(
2478                    target.to_string(),
2479                    value
2480                        .as_str()
2481                        .map(str::to_string)
2482                        .unwrap_or_else(|| value.to_string()),
2483                );
2484            }
2485        }
2486        let mut header = document.clone();
2487        if let Some(header) = header.as_object_mut() {
2488            header.remove("conversation");
2489        }
2490        meta.goose_header = Some(header.clone());
2491
2492        let mut messages = Vec::new();
2493        for (native_index, native) in conversation.iter().enumerate() {
2494            let before = messages.len();
2495            normalize_goose_message(native, native_index, &mut messages);
2496            if let Some(first) = messages.get_mut(before) {
2497                first
2498                    .metadata
2499                    .insert("goose_native_message".to_string(), native.to_string());
2500                first
2501                    .metadata
2502                    .insert("goose_native_index".to_string(), native_index.to_string());
2503                if native_index == 0 {
2504                    first
2505                        .metadata
2506                        .insert("goose_session_header".to_string(), header.to_string());
2507                }
2508                restore_grok_message_extension(native, first);
2509            }
2510            for message in messages.iter_mut().skip(before + 1) {
2511                message
2512                    .metadata
2513                    .insert("goose_native_index".to_string(), native_index.to_string());
2514            }
2515        }
2516        ensure_tool_results_paired(&mut messages);
2517
2518        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2519        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2520        let imported_message_count = Some(messages.len());
2521        Ok(Session {
2522            meta,
2523            messages,
2524            subagents: Vec::new(),
2525            raw,
2526            raw_trailing_newline,
2527            imported_message_count,
2528            raw_is_verbatim: true,
2529            parse_error_lines: 0,
2530            load_residue: Vec::new(),
2531        })
2532    }
2533
2534    /// Load one Goose session directly from its native SQLite store.
2535    ///
2536    /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2537    /// uses Goose's own public export shape, so the ordinary Goose codec is
2538    /// the single normalization boundary for both files and the live store.
2539    pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2540        Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2541    }
2542
2543    /// Bounded Goose store read for transcript UI surfaces. The inner query
2544    /// selects only the newest native rows; the outer query restores their
2545    /// chronological order. Export/continue callers deliberately use the
2546    /// unbounded public loader above.
2547    #[doc(hidden)]
2548    pub fn from_goose_sqlite_display(
2549        db_path: &Path,
2550        session_id: &str,
2551        message_limit: usize,
2552    ) -> Result<Session> {
2553        Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2554    }
2555
2556    fn from_goose_sqlite_with_limit(
2557        db_path: &Path,
2558        session_id: &str,
2559        message_limit: Option<usize>,
2560    ) -> Result<Session> {
2561        let connection = Connection::open_with_flags(
2562            db_path,
2563            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2564        )
2565        .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2566        let mut statement = connection
2567            .prepare(
2568                "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2569                    extension_data, goose_mode, provider_name, model_config_json \
2570             FROM sessions WHERE id = ?1",
2571            )
2572            .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2573        let mut document = statement
2574            .query_row([session_id], |row| {
2575                let extension_data: Option<String> = row.get(6)?;
2576                let model_config: Option<String> = row.get(9)?;
2577                Ok(serde_json::json!({
2578                    "id": row.get::<_, String>(0)?,
2579                    "working_dir": row.get::<_, String>(2)?,
2580                    "name": row.get::<_, String>(1)?,
2581                    "user_set_name": false,
2582                    "session_type": row.get::<_, String>(5)?,
2583                    "created_at": row.get::<_, String>(3)?,
2584                    "updated_at": row.get::<_, String>(4)?,
2585                    "extension_data": extension_data
2586                        .as_deref()
2587                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2588                        .unwrap_or_else(|| serde_json::json!({})),
2589                    "usage": {},
2590                    "accumulated_usage": {},
2591                    "accumulated_cost": Value::Null,
2592                    "schedule_id": Value::Null,
2593                    "recipe": Value::Null,
2594                    "user_recipe_values": Value::Null,
2595                    "conversation": [],
2596                    "message_count": 0,
2597                    "last_message_at": Value::Null,
2598                    "provider_name": row.get::<_, Option<String>>(8)?,
2599                    "model_config": model_config
2600                        .as_deref()
2601                        .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2602                    "goose_mode": row.get::<_, String>(7)?,
2603                    "archived_at": Value::Null,
2604                    "project_id": Value::Null,
2605                    "parent_session_id": Value::Null,
2606                    "last_message_snippet": Value::Null,
2607                }))
2608            })
2609            .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2610
2611        let message_query = message_limit.map_or_else(
2612            || {
2613                "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2614                 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2615                    .to_string()
2616            },
2617            |limit| {
2618                format!(
2619                    "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2620                     FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2621                                  created_timestamp, metadata_json \
2622                           FROM messages WHERE session_id = ?1 \
2623                           ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2624                     ORDER BY created_timestamp, native_row_id"
2625                )
2626            },
2627        );
2628        let mut message_statement = connection
2629            .prepare(&message_query)
2630            .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2631        let rows = message_statement
2632            .query_map([session_id], |row| {
2633                let content: String = row.get(2)?;
2634                let metadata: Option<String> = row.get(4)?;
2635                Ok(serde_json::json!({
2636                    "id": row.get::<_, Option<String>>(0)?,
2637                    "role": row.get::<_, String>(1)?,
2638                    "created": row.get::<_, i64>(3)?,
2639                    "content": serde_json::from_str::<Value>(&content)
2640                        .unwrap_or_else(|_| Value::Array(Vec::new())),
2641                    "metadata": metadata
2642                        .as_deref()
2643                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2644                        .unwrap_or_else(|| serde_json::json!({
2645                            "userVisible": true,
2646                            "agentVisible": true
2647                        })),
2648                }))
2649            })
2650            .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2651        let conversation = rows
2652            .collect::<std::result::Result<Vec<_>, _>>()
2653            .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2654        document["message_count"] = Value::from(conversation.len());
2655        document["conversation"] = Value::Array(conversation);
2656        let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2657        let mut session = Self::from_goose_str(&json)?;
2658        // SQLite was reconstructed through values, not captured byte-for-byte.
2659        session.raw_is_verbatim = false;
2660        Ok(session)
2661    }
2662
2663    /// Load an OpenCode session from a file — either read surface, see
2664    /// [`Self::from_opencode_str`].
2665    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2666        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2667    }
2668
2669    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2670    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2671    /// most-recently-updated top-level session, see
2672    /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2673    /// envelope form [`Self::from_opencode_str`] already parses for the
2674    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2675    /// discipline, S1 tool-output masking, …) is shared code, not
2676    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2677    /// for the envelope-construction rules this follows (all-columns rule,
2678    /// raw `revert` column carried verbatim).
2679    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2680        let conn = opencode_sqlite_open(db_path)?;
2681        let id = match session_id {
2682            Some(id) => id.to_string(),
2683            None => opencode_sqlite_primary_session_id(&conn)?,
2684        };
2685        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2686        let mut text = lines.join("\n");
2687        text.push('\n');
2688        let mut session = Self::from_opencode_str(&text)?;
2689        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2690        // not the original source bytes (a binary `.db` file has no
2691        // "verbatim" line-oriented form to begin with). `from_opencode_str`
2692        // defaults `raw_is_verbatim` to `true` because for its OTHER two
2693        // callers (an actual envelope-form file's own text, an actual
2694        // export-document's text) that really is the source. It is NEVER
2695        // true for this diagonal — mirrors the export-document fix just
2696        // above for the same reason (`from_opencode_export_doc`, `false`).
2697        // `convert opencode.db --to opencode` must not claim byte-identical.
2698        session.raw_is_verbatim = false;
2699        Ok(session)
2700    }
2701
2702    /// Parse an OpenCode session from either of its two frozen **read
2703    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2704    /// `opencode-fields.md`):
2705    ///
2706    /// - the **envelope form**: each line is
2707    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
2708    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
2709    ///   generations;
2710    /// - the **export-document form**: a single pretty-printed JSON document
2711    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2712    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
2713    ///   what the OpenCode writer emits.
2714    ///
2715    /// Both forms are parsed into the same `(session_info, side_records,
2716    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2717    /// `opencode_session_from_records` — so the same underlying records
2718    /// produce identical `messages` regardless of which surface carried
2719    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2720    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2721    /// exercises): previously this function parsed the envelope form only
2722    /// and silently returned an empty-but-`Ok` `Session` for an export
2723    /// document — the confirmed footgun this now closes.
2724    ///
2725    /// Record classification (envelope form) is driven by the envelope
2726    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2727    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2728    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2729    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2730    /// every column, `data` and non-`data` alike — e.g. the `session` row's
2731    /// `revert` column under the V2 `Revert.State` schema, whose extra
2732    /// `files` field the CLI's own row→V1 reconstruction drops; the
2733    /// envelope's `raw` capture keeps that raw column value regardless of
2734    /// what this loader's canonicalization understands).
2735    ///
2736    /// Mapping to canonical `messages` (§2.1, shared by both forms via
2737    /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2738    /// text parts → `content`; a `User` `file` part whose `mime` is an image
2739    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2740    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2741    /// `ToolCall`, and the SAME part's `state.completed.output` /
2742    /// `state.error.error` → a paired `Tool` message split by `callID`
2743    /// (opencode keeps call+result on one record; this loader splits it
2744    /// into the two OpenAI-shape messages the other loaders already
2745    /// produce).
2746    ///
2747    /// **S1 (`time.compacted`):** when a `tool` part's
2748    /// `state.completed.time.compacted` is set, the emitted `Tool`
2749    /// message's `content` is the placeholder
2750    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2751    /// own `toModelMessage` replays — while the REAL output survives in
2752    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2753    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2754    /// it is reversible, never actually lost.
2755    ///
2756    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2757    /// every message strictly before that message id
2758    /// `metadata["compacted_out"]="true"` (honored uniformly by
2759    /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2760    /// message, which opencode itself hoists in FRONT of the retained tail
2761    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2762    /// regardless of its position, mirroring pi's identical exemption for
2763    /// its own compaction/branch-summary entries.
2764    ///
2765    /// **Unknown part `type` or unknown `tool.state.status`:** never
2766    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2767    /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2768    /// is what turns that into a visible coverage failure rather than a
2769    /// silent drop.
2770    ///
2771    /// **Export-document `raw`:** an export document is a single
2772    /// pretty-printed JSON value with no per-line envelope structure of its
2773    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2774    /// envelope line per `session`/`message`/`part` record found in the
2775    /// document, in the exact `{"key":[...],"value":...}` shape the native
2776    /// envelope form uses — so every native/T1-value-tier path
2777    /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2778    /// splice/direct-write writers) stays consistent regardless of which
2779    /// read surface produced this `Session`.
2780    ///
2781    /// **Malformed input:** input that reaches this function non-empty but
2782    /// yields zero session/message/part records under EITHER form returns a
2783    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2784    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2785    /// input must not silently succeed with an empty session). A
2786    /// legitimately-empty session — a real `session` record with zero
2787    /// messages, or a valid export document with an empty `messages` array
2788    /// — is not an error.
2789    pub fn from_opencode_str(text: &str) -> Result<Session> {
2790        let trimmed = text.trim();
2791
2792        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2793        // own precedence: try the whole-text parse before the per-line
2794        // envelope loop below, since a pretty-printed multi-line document
2795        // has no individually-valid-JSON lines for that loop to match.
2796        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2797            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2798            {
2799                return Self::from_opencode_export_doc(&doc);
2800            }
2801        }
2802
2803        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2804        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2805        // blank-skipping PARSE walk just below, which keeps skipping
2806        // blank/whitespace-only lines when it looks for envelope records.
2807        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2808        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2809        let mut session_info: Option<Value> = None;
2810        let mut side_records: Vec<Value> = Vec::new();
2811        let mut msgs: Vec<OcMsg> = Vec::new();
2812        let mut msg_index: HashMap<String, usize> = HashMap::new();
2813        // PARITY-15: see `from_claude_code_str`'s identical counter — only
2814        // a genuinely malformed line (fails to deserialize as JSON at all),
2815        // not a well-formed envelope this loader simply doesn't recognize.
2816        let mut parse_error_lines = 0usize;
2817
2818        for line in non_empty_lines(text) {
2819            let Ok(env) = serde_json::from_str::<Value>(line) else {
2820                parse_error_lines += 1;
2821                continue; // malformed line — raw-only, exactly like the other loaders
2822            };
2823            let Some(key) = env.get("key").and_then(Value::as_array) else {
2824                continue; // not an envelope record — raw-only
2825            };
2826            let value = env.get("value").cloned().unwrap_or(Value::Null);
2827            match key.first().and_then(Value::as_str) {
2828                Some("session") => session_info = Some(value),
2829                Some("message") => {
2830                    let Some(id) = value.get("id").and_then(Value::as_str) else {
2831                        continue;
2832                    };
2833                    let time_created = value
2834                        .get("time")
2835                        .and_then(|t| t.get("created"))
2836                        .and_then(Value::as_i64)
2837                        .unwrap_or(0);
2838                    msg_index.insert(id.to_string(), msgs.len());
2839                    msgs.push(OcMsg {
2840                        id: id.to_string(),
2841                        time_created,
2842                        value,
2843                        parts: Vec::new(),
2844                    });
2845                }
2846                Some("part") => {
2847                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2848                        if let Some(&idx) = msg_index.get(msg_id) {
2849                            msgs[idx].parts.push(value);
2850                        }
2851                        // A part whose message wasn't captured (out-of-order
2852                        // envelope) — still fully present in `raw`, just not
2853                        // attached to a canonical message.
2854                    }
2855                }
2856                Some("session_diff") | Some("todo") => {
2857                    side_records.push(serde_json::json!({"key": key, "value": value}));
2858                }
2859                _ => {} // unrecognized top-level key — raw-only
2860            }
2861        }
2862
2863        opencode_guard_against_silent_empty(
2864            !trimmed.is_empty(),
2865            &session_info,
2866            &msgs,
2867            &side_records,
2868        )?;
2869        opencode_session_from_records(
2870            session_info,
2871            side_records,
2872            msgs,
2873            raw,
2874            raw_trailing_newline,
2875            // Envelope form: `raw` is split directly out of the source text
2876            // (strict-verbatim, IX-1) — genuinely reproduces the original
2877            // bytes on replay.
2878            true,
2879            parse_error_lines,
2880        )
2881    }
2882
2883    /// The **export-document** read surface of [`Self::from_opencode_str`]
2884    /// — see that function's doc comment for the shared canonicalization
2885    /// and the `raw` re-synthesis this performs. `doc` is already known to
2886    /// have the `{info, messages:[...]}` shape (the caller checks this,
2887    /// matching `detect_source`'s own S9a check) before calling this.
2888    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2889        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2890        let messages_arr = doc
2891            .get("messages")
2892            .and_then(Value::as_array)
2893            .cloned()
2894            .unwrap_or_default();
2895
2896        let session_id = session_info
2897            .as_ref()
2898            .and_then(|si| si.get("id"))
2899            .and_then(Value::as_str)
2900            .unwrap_or("ses_unknown")
2901            .to_string();
2902        let project_id = session_info
2903            .as_ref()
2904            .and_then(|si| si.get("projectID"))
2905            .and_then(Value::as_str)
2906            .unwrap_or("global")
2907            .to_string();
2908
2909        // Re-synthesize one envelope line per record — see the doc comment
2910        // on `from_opencode_str` ("Export-document `raw`").
2911        let mut raw: Vec<String> = Vec::new();
2912        if let Some(si) = &session_info {
2913            raw.push(
2914                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2915                    .to_string(),
2916            );
2917        }
2918
2919        let mut msgs: Vec<OcMsg> = Vec::new();
2920        for entry in &messages_arr {
2921            let Some(info) = entry.get("info") else {
2922                continue; // malformed message entry — no clean home, raw-only
2923            };
2924            let Some(id) = info.get("id").and_then(Value::as_str) else {
2925                continue;
2926            };
2927            let time_created = info
2928                .get("time")
2929                .and_then(|t| t.get("created"))
2930                .and_then(Value::as_i64)
2931                .unwrap_or(0);
2932            let parts: Vec<Value> = entry
2933                .get("parts")
2934                .and_then(Value::as_array)
2935                .cloned()
2936                .unwrap_or_default();
2937
2938            raw.push(
2939                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2940            );
2941            for p in &parts {
2942                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
2943                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
2944            }
2945
2946            msgs.push(OcMsg {
2947                id: id.to_string(),
2948                time_created,
2949                value: info.clone(),
2950                parts,
2951            });
2952        }
2953
2954        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
2955        opencode_session_from_records(
2956            session_info,
2957            Vec::new(),
2958            msgs,
2959            raw,
2960            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
2961            // line re-derived per record, no real per-line source bytes to
2962            // measure) — matches the historical always-newline-terminated
2963            // behavior; see `Session::raw_trailing_newline`'s doc comment.
2964            true,
2965            // Export-document form: `raw` above is RE-SYNTHESIZED, one
2966            // envelope line derived per record — not the original document's
2967            // bytes (see this function's doc comment). `convert`'s
2968            // byte-identical claim must not fire on this diagonal.
2969            false,
2970            // PARITY-15: a pretty-printed export document is parsed WHOLE
2971            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
2972            // there's no per-line parse-loss concept here; a malformed
2973            // document fails that top-level parse and never reaches this
2974            // function at all.
2975            0,
2976        )
2977    }
2978
2979    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
2980    /// core.session(tree-addressable transcript)"): materialize this
2981    /// session's linear [`Self::messages`] into a native in-place
2982    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
2983    /// FIRST time it wants to run a tree operation (rewind/branch/label)
2984    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
2985    /// synthesized node (see
2986    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
2987    /// why a single timestamp is used: the source linear messages carry no
2988    /// per-turn timestamp of their own here).
2989    ///
2990    /// This does not mutate `self` or persist anything — see
2991    /// the composition layer's session-store tree writer for persistence, and
2992    /// [`Self::apply_session_tree`] for the inverse bridge.
2993    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
2994        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
2995    }
2996
2997    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
2998    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
2999    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3000    /// existing linear consumer — the agent loop, exporters — working
3001    /// unchanged after a tree operation runs). Nothing else on `self`
3002    /// (`meta`, `raw`, ...) is touched.
3003    ///
3004    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3005    /// `Err` rather than applying anything — a structurally-corrupt tree
3006    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3007    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3008    /// `self` is left untouched on `Err` (the assignment only happens after
3009    /// the projection has already succeeded).
3010    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3011        self.messages = tree.linear_projection()?;
3012        Ok(())
3013    }
3014}
3015
3016/// One opencode `message` record plus its `part` children, gathered from
3017/// EITHER read surface (envelope-form records or export-document
3018/// `{info, parts}` entries) before the shared per-record canonicalization
3019/// in [`opencode_session_from_records`].
3020struct OcMsg {
3021    id: String,
3022    time_created: i64,
3023    value: Value,
3024    parts: Vec<Value>,
3025}
3026
3027const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3028const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3029const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3030
3031/// Guard against the confirmed footgun: input that reached
3032/// [`Session::from_opencode_str`] non-empty but produced no
3033/// session/message/part record under either read surface returns `Err`
3034/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3035/// (a real session record with zero messages, or a valid empty `messages`
3036/// array) is not an error — only genuinely unparseable content is.
3037fn opencode_guard_against_silent_empty(
3038    non_empty_input: bool,
3039    session_info: &Option<Value>,
3040    msgs: &[OcMsg],
3041    side_records: &[Value],
3042) -> Result<()> {
3043    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3044        || !msgs.is_empty()
3045        || !side_records.is_empty();
3046    if non_empty_input && !has_any_record {
3047        return Err(crate::Error::Other(
3048            "opencode input was recognized as an OpenCode source (envelope or \
3049             export-document form) but no session/message/part record could be parsed from \
3050             it — refusing to silently return an empty session"
3051                .to_string(),
3052        ));
3053    }
3054    Ok(())
3055}
3056
3057/// The shared per-record canonicalization for BOTH of
3058/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3059/// export-document form): frozen ordering, `SessionMeta` capture, the
3060/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3061/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3062/// same underlying `(session_info, side_records, msgs)` regardless of which
3063/// surface produced them, this produces byte-for-byte identical `messages`
3064/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3065fn opencode_session_from_records(
3066    session_info: Option<Value>,
3067    side_records: Vec<Value>,
3068    mut msgs: Vec<OcMsg>,
3069    raw: Vec<String>,
3070    raw_trailing_newline: bool,
3071    raw_is_verbatim: bool,
3072    parse_error_lines: usize,
3073) -> Result<Session> {
3074    let mut meta = SessionMeta::new(SessionSource::OpenCode);
3075
3076    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3077    // each message id to its PRE-sort position — used only to resolve a
3078    // `tail_start_id` reference in the compaction-boundary pass further
3079    // down. In every real opencode session (either surface) records
3080    // already arrive/are listed in creation order, so pre- and post-sort
3081    // positions coincide; this mirrors the original envelope-only
3082    // implementation's behavior exactly (not a new invariant introduced by
3083    // sharing this code across both surfaces).
3084    let msg_index: HashMap<String, usize> = msgs
3085        .iter()
3086        .enumerate()
3087        .map(|(i, m)| (m.id.clone(), i))
3088        .collect();
3089
3090    // Frozen order (§1.2): messages by (time.created, id); each
3091    // message's parts by id.
3092    msgs.sort_by(|a, b| {
3093        a.time_created
3094            .cmp(&b.time_created)
3095            .then_with(|| a.id.cmp(&b.id))
3096    });
3097    for m in &mut msgs {
3098        m.parts.sort_by(|a, b| {
3099            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3100            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3101            ai.cmp(bi)
3102        });
3103    }
3104
3105    meta.opencode_headers
3106        .push(session_info.clone().unwrap_or(Value::Null));
3107    meta.opencode_headers.extend(side_records);
3108    if let Some(si) = &session_info {
3109        capture_opencode_session_info(si, &mut meta)?;
3110    }
3111
3112    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3113    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3114    // active path in opencode's own linear message list, so no branch
3115    // walk is needed the way pi's tree requires).
3116    let mut tail_start_pos: Option<usize> = None;
3117    for m in &msgs {
3118        for p in &m.parts {
3119            if p.get("type").and_then(Value::as_str) == Some("compaction") {
3120                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3121                    if let Some(&tp) = msg_index.get(t) {
3122                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3123                    }
3124                }
3125            }
3126        }
3127    }
3128
3129    let mut messages = Vec::new();
3130    let mut first_system_seen = false;
3131    for (pos, m) in msgs.iter().enumerate() {
3132        let before = messages.len();
3133        match m.value.get("role").and_then(Value::as_str) {
3134            // B4: a `User` message that's actually
3135            // `append_synthesized_opencode_messages`'s own re-materialized
3136            // Claude `system` record (one `synthetic: true` text part
3137            // carrying the supercode marker key — see
3138            // `opencode_claude_system_subtype`'s doc comment) restores
3139            // `Role::System`, not a genuine user turn.
3140            Some("user") => match opencode_claude_system_subtype(&m.parts) {
3141                Some(subtype) => {
3142                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3143                }
3144                None => push_opencode_user(
3145                    &m.value,
3146                    &m.parts,
3147                    &mut messages,
3148                    &mut meta,
3149                    &mut first_system_seen,
3150                ),
3151            },
3152            Some("assistant") => {
3153                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3154            }
3155            // Unrecognized/missing role — raw-only survival;
3156            // `audit::Corpus::OpenCode` scores this as Unmodeled.
3157            _ => {}
3158        }
3159        if let Some(original_position) = m
3160            .value
3161            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3162            .and_then(Value::as_u64)
3163        {
3164            if let Some(message) = messages[before..]
3165                .iter_mut()
3166                .find(|message| message.role != Role::Tool)
3167            {
3168                message.metadata.insert(
3169                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3170                    original_position.to_string(),
3171                );
3172            }
3173        }
3174        for msg in &mut messages[before..] {
3175            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3176            if !is_summary {
3177                if let Some(tsp) = tail_start_pos {
3178                    if pos < tsp {
3179                        msg.metadata
3180                            .insert("compacted_out".to_string(), "true".to_string());
3181                    }
3182                }
3183            }
3184        }
3185    }
3186
3187    let marked_slots = messages
3188        .iter()
3189        .enumerate()
3190        .filter_map(|(index, message)| {
3191            message
3192                .metadata
3193                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3194                .then_some(index)
3195        })
3196        .collect::<Vec<_>>();
3197    if !marked_slots.is_empty() {
3198        // A spliced OpenCode export can contain an unmarked native prefix
3199        // followed by a marked synthesized tail. Reorder only among the
3200        // marked slots so the tail never jumps in front of its raw prefix.
3201        let mut marked_messages = marked_slots
3202            .iter()
3203            .map(|index| messages[*index].clone())
3204            .collect::<Vec<_>>();
3205        marked_messages.sort_by_key(|message| {
3206            message
3207                .metadata
3208                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3209                .and_then(|position| position.parse::<usize>().ok())
3210                .unwrap_or(usize::MAX)
3211        });
3212        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3213            messages[slot] = message;
3214        }
3215        for message in &mut messages {
3216            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3217        }
3218    }
3219    ensure_tool_results_paired(&mut messages);
3220    let imported_message_count = Some(messages.len());
3221    Ok(Session {
3222        meta,
3223        messages,
3224        subagents: Vec::new(),
3225        raw,
3226        raw_trailing_newline,
3227        imported_message_count,
3228        raw_is_verbatim,
3229        parse_error_lines,
3230        load_residue: Vec::new(),
3231    })
3232}
3233
3234/// Resolve each opencode subagent (`task`) child session's
3235/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3236/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3237/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3238/// `opencode-fields.md` `task.ts:145,171-176`).
3239///
3240/// Nesting itself needs no opencode-specific pass:
3241/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3242/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3243/// so the existing generic [`Session::reconstruct_tree`] nests these
3244/// sessions correctly on its own. Call this FIRST — it only reads
3245/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3246/// the same `Vec` to `reconstruct_tree`.
3247pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3248    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3249    for i in 0..sessions.len() {
3250        let child_id = sessions[i].meta.session_id.clone();
3251        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3252        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3253            continue;
3254        };
3255        let Some(parent_idx) = ids
3256            .iter()
3257            .position(|id| id.as_deref() == Some(parent_id.as_str()))
3258        else {
3259            continue;
3260        };
3261        for m in &sessions[parent_idx].messages {
3262            for (k, v) in &m.metadata {
3263                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3264                    if v == &child_id {
3265                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3266                    }
3267                }
3268            }
3269        }
3270    }
3271}
3272
3273/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3274///
3275/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3276/// structure but are NOT guaranteed to be well-formed in raw file order: async
3277/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3278/// line BEFORE the assistant `tool_use` line that owns it, even though the
3279/// parent/child tree itself is fine. The active-branch projection restores
3280/// parent-before-child order, but a result can still trail a later assistant
3281/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3282/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3283/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3284///
3285/// This reorders `messages` so every OWNED `Role::Tool` result (its
3286/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3287/// message anywhere in the list) sits immediately after the `Role::Assistant`
3288/// message that owns it, while leaving every other message's relative order
3289/// untouched. Orphan tool results — no matching call anywhere in the list —
3290/// are left in their ORIGINAL position, untouched; they are never moved. It
3291/// is a pure reorder: same message count, same multiset of messages, in/out.
3292///
3293/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3294/// — each appears exactly once as a call and once as its result — so a
3295/// simple id -> owning-assistant map is sufficient; no special-casing is
3296/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3297/// already pushes those inline with their own distinct ids.
3298///
3299/// Results whose matching call is missing entirely (no owner found) are left
3300/// in place untouched — `ensure_tool_results_paired` (which runs right after
3301/// this) is responsible for synthesizing a placeholder result for any call
3302/// that ends up unanswered; this pass never drops or fabricates anything.
3303fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3304    // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3305    //    some assistant message anywhere in the list — and the position of
3306    //    that owning assistant. Owned as `String` (not borrowed) so this map
3307    //    can outlive the later `messages.drain(..)`.
3308    let mut owner_positions: HashMap<String, usize> = HashMap::new();
3309    for (index, m) in messages.iter().enumerate() {
3310        if m.role == Role::Assistant {
3311            for c in m.tool_calls() {
3312                if !c.id.is_empty() {
3313                    owner_positions.entry(c.id.clone()).or_insert(index);
3314                }
3315            }
3316        }
3317    }
3318
3319    // Fast, cheap detection of "nothing to do": every owned result must be
3320    // in the contiguous tool-result block immediately following its owning
3321    // assistant. Checking only result-before-owner inversions is insufficient
3322    // after Claude's active-branch projection: that projection can put the
3323    // owner first while leaving its result behind a later assistant turn.
3324    // Mere orphans never set this flag. A canonical session returns with
3325    // `messages` byte-for-byte unchanged, mirroring
3326    // `ensure_tool_results_paired`'s own no-op guard.
3327    let mut contiguous_owner = None;
3328    let needs_reorder =
3329        messages
3330            .iter()
3331            .enumerate()
3332            .any(|(message_index, message)| match message.role {
3333                Role::Assistant => {
3334                    contiguous_owner = Some(message_index);
3335                    false
3336                }
3337                Role::Tool => match message
3338                    .tool_call_id
3339                    .as_deref()
3340                    .and_then(|id| owner_positions.get(id))
3341                    .copied()
3342                {
3343                    Some(owner) => Some(owner) != contiguous_owner,
3344                    None => {
3345                        // An orphan or unlinked tool message interrupts the
3346                        // owner's contiguous result block but never moves by
3347                        // itself.
3348                        contiguous_owner = None;
3349                        false
3350                    }
3351                },
3352                _ => {
3353                    contiguous_owner = None;
3354                    false
3355                }
3356            });
3357    if !needs_reorder {
3358        return;
3359    }
3360
3361    // 1. Second pass: route messages into the "spine" (everything that stays
3362    //    at its own position — non-tool messages AND orphan tool results)
3363    //    versus owned tool results (pulled out, to be reattached right after
3364    //    their owner). Record, for each spine index that's an assistant, the
3365    //    set of tool_call_ids it owns.
3366    let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3367    let mut call_owner: HashMap<String, usize> = HashMap::new();
3368    // Buffer of (original_position, message) for every OWNED tool result,
3369    // built alongside the spine; a result can reference a call emitted later
3370    // in file order, so owner spine-index is resolved in a later step once
3371    // `call_owner` is complete.
3372    let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3373
3374    let drained: Vec<ChatMessage> = std::mem::take(messages);
3375    for (orig_pos, msg) in drained.into_iter().enumerate() {
3376        if msg.role == Role::Tool {
3377            let is_owned = msg
3378                .tool_call_id
3379                .as_deref()
3380                .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3381                .unwrap_or(false);
3382            if is_owned {
3383                owned_results.push((orig_pos, msg));
3384                continue;
3385            }
3386            // Orphan: no matching call anywhere. Treat exactly like a
3387            // non-tool message for placement — it joins the spine at its
3388            // current position and is never moved.
3389            spine.push(msg);
3390            continue;
3391        }
3392        if msg.role == Role::Assistant {
3393            let spine_idx = spine.len();
3394            for c in msg.tool_calls() {
3395                if !c.id.is_empty() {
3396                    call_owner.entry(c.id.clone()).or_insert(spine_idx);
3397                }
3398            }
3399        }
3400        spine.push(msg);
3401    }
3402
3403    // 2. Resolve each owned result's owner spine-index now that `call_owner`
3404    //    is complete, then bucket results by owner spine-index. Every result
3405    //    here was routed as "owned" because its id was found in `owned_ids`,
3406    //    which was built from the exact same `tool_calls()` scan that
3407    //    populates `call_owner` below, so the lookup is guaranteed to hit.
3408    let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3409    for (orig_pos, msg) in owned_results.into_iter() {
3410        let id = msg
3411            .tool_call_id
3412            .as_deref()
3413            .filter(|id| !id.is_empty())
3414            .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3415        let idx = *call_owner
3416            .get(id)
3417            .expect("owned id must have an owning assistant in call_owner");
3418        buckets.entry(idx).or_default().push((orig_pos, msg));
3419    }
3420    // Keep each bucket's results in their original relative file order.
3421    for v in buckets.values_mut() {
3422        v.sort_by_key(|(pos, _)| *pos);
3423    }
3424
3425    // 3. Rebuild: emit each spine message (which now includes orphans at
3426    //    their original position, untouched) in order; immediately after
3427    //    emitting an assistant message that owns one or more tool results,
3428    //    emit its owned results, in original relative order.
3429    let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3430    for (idx, msg) in spine.into_iter().enumerate() {
3431        out.push(msg);
3432        if let Some(results) = buckets.remove(&idx) {
3433            for (_, r) in results {
3434                out.push(r);
3435            }
3436        }
3437    }
3438    *messages = out;
3439}
3440
3441/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3442/// result. Interrupted/aborted turns leave a tool call with no result, which
3443/// many chat-completions endpoints reject when the conversation is replayed.
3444/// We insert a synthetic placeholder result immediately after the assistant
3445/// turn so the transcript stays valid for continuation. (Orphan results — a
3446/// tool message with no preceding call — do not occur in practice and are left
3447/// untouched.)
3448fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3449    let answered: HashSet<String> = messages
3450        .iter()
3451        .filter(|m| m.role == Role::Tool)
3452        .filter_map(|m| m.tool_call_id.clone())
3453        .collect();
3454
3455    // Nothing missing? Leave the vector byte-for-byte unchanged.
3456    let any_missing = messages.iter().any(|m| {
3457        m.tool_calls()
3458            .iter()
3459            .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3460    });
3461    if !any_missing {
3462        return;
3463    }
3464
3465    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3466    for msg in messages.drain(..) {
3467        let synth: Vec<ChatMessage> = msg
3468            .tool_calls()
3469            .iter()
3470            .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3471            .map(|c| {
3472                let mut m = ChatMessage::tool_result(
3473                    c.id.clone(),
3474                    c.function.name.clone(),
3475                    "[no tool result recorded — turn interrupted]".to_string(),
3476                );
3477                // TR-10: an interrupted call never executed to completion —
3478                // never a candidate for `ReductionKind::ToolInputElided`
3479                // (the "still-pending calls are never input-elided"
3480                // boundary).
3481                crate::mark_tool_error(&mut m);
3482                m
3483            })
3484            .collect();
3485        out.push(msg);
3486        out.extend(synth);
3487    }
3488    *messages = out;
3489}
3490
3491/// Whether `msg` is excluded from every replay/export path — the frozen
3492/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3493/// a message marked `compacted_out` (pre-compaction history a source harness
3494/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3495/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3496/// format, not just the one that produced the marker — so a translated
3497/// compacted session replays the same sliced context the source harness
3498/// would, instead of double-including history plus its own summary.
3499fn is_replay_excluded(msg: &ChatMessage) -> bool {
3500    msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3501        || msg
3502            .metadata
3503            .get("pi_exclude_from_context")
3504            .map(String::as_str)
3505            == Some("true")
3506}
3507
3508// ---- detection ------------------------------------------------------------
3509
3510fn detect_source(text: &str) -> Option<SessionSource> {
3511    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3512    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3513    // pretty-printed, MULTI-LINE JSON document, unlike every other format
3514    // this crate reads. It cannot be recognized by the per-line loop below
3515    // (no individual line of a pretty-printed document is itself valid
3516    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3517    // attempt: a real JSONL file (many newline-separated objects) fails this
3518    // parse immediately (trailing-data error) and falls through unaffected.
3519    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3520        if v.get("conversation").and_then(Value::as_array).is_some()
3521            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3522        {
3523            return Some(SessionSource::Goose);
3524        }
3525        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3526            return Some(SessionSource::OpenCode);
3527        }
3528    }
3529    for line in non_empty_lines(text) {
3530        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3531        // than abandoning detection — the loaders themselves skip bad lines, so
3532        // bailing here would silently misroute an otherwise-valid Codex file.
3533        let Ok(v) = serde_json::from_str::<Value>(line) else {
3534            continue;
3535        };
3536        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3537        // one record per line — the synthesized raw-capture unit for the
3538        // JSON-tree/SQLite generations alike. No other format's lines carry
3539        // both a top-level `key` ARRAY and a `value` field, so this is
3540        // unambiguous against Codex/Pi/Claude Code.
3541        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3542            return Some(SessionSource::OpenCode);
3543        }
3544        // Codex envelopes always carry a `payload`; Claude Code lines never do.
3545        if v.get("payload").is_some() {
3546            return Some(SessionSource::Codex);
3547        }
3548        // Gemini CLI starts with an untyped session header. Its project hash
3549        // and timestamps distinguish it from Claude Code records that also
3550        // carry `sessionId`.
3551        if v.get("sessionId").and_then(Value::as_str).is_some()
3552            && (v.get("projectHash").is_some()
3553                || v.get("startTime").is_some()
3554                || v.get("lastUpdated").is_some())
3555            && v.get("type").is_none()
3556        {
3557            return Some(SessionSource::Gemini);
3558        }
3559        // Grok's resumable `chat_history.jsonl` stores the role/type and
3560        // content directly on each record. Claude Code uses a nested
3561        // `message` envelope for the overlapping `user`/`assistant` tags.
3562        let tag = v.get("type").and_then(Value::as_str);
3563        if tag == Some("gemini") && v.get("content").is_some() {
3564            return Some(SessionSource::Gemini);
3565        }
3566        if v.get("message").is_none()
3567            && v.get("uuid").is_none()
3568            && v.get("sessionId").is_none()
3569            && matches!(
3570                tag,
3571                Some(
3572                    "system"
3573                        | "user"
3574                        | "assistant"
3575                        | "tool_result"
3576                        | "reasoning"
3577                        | "backend_tool_call"
3578                )
3579            )
3580            && (v.get("content").is_some()
3581                || v.get("tool_calls").is_some()
3582                || v.get("tool_call_id").is_some()
3583                || v.get("encrypted_content").is_some()
3584                || v.get("kind").is_some())
3585        {
3586            return Some(SessionSource::Grok);
3587        }
3588        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3589        // (the session id) with no `message`/`uuid` — Claude Code's own
3590        // `type`-bearing lines always carry one or the other, never a
3591        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3592        // §1).
3593        if v.get("type").and_then(Value::as_str) == Some("session")
3594            && v.get("id").and_then(Value::as_str).is_some()
3595            && v.get("message").is_none()
3596            && v.get("uuid").is_none()
3597        {
3598            return Some(SessionSource::Pi);
3599        }
3600        if v.get("type").is_some() || v.get("message").is_some() {
3601            return Some(SessionSource::ClaudeCode);
3602        }
3603    }
3604    None
3605}
3606
3607/// Which on-disk OpenCode storage surface is present under a data root
3608/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3609/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3610/// generation A. This is a **filesystem classifier only** — it answers
3611/// "which generation is this?" for a corpus-discovery tool; it does not
3612/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3613/// for the envelope form any of these three surfaces synthesizes into, and
3614/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3615/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3616/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3617/// round-trips via the JSON store per upstream's own behavior even on a
3618/// SQLite install, so nothing is silently lost by not reading the legacy
3619/// trees directly).
3620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3621pub enum OpenCodeStorageSurface {
3622    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3623    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3624    /// [`opencode_sqlite_corpus_envelope_text`].
3625    Sqlite,
3626    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3627    /// marker file `storage/migration`.
3628    JsonTreeB,
3629    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3630    JsonTreeA,
3631}
3632
3633/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3634/// storage surface present, per the discovery rules frozen in
3635/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3636/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3637/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3638/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3639/// `project/` subtree. Returns `None` if nothing is found.
3640pub fn detect_opencode_storage_surface(
3641    data_root: &Path,
3642) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3643    if let Ok(p) = std::env::var("OPENCODE_DB") {
3644        let pb = PathBuf::from(p);
3645        if pb.is_file() {
3646            return Some((OpenCodeStorageSurface::Sqlite, pb));
3647        }
3648    }
3649    if let Ok(entries) = std::fs::read_dir(data_root) {
3650        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3651        // NOT deterministic — a store with both a default-channel
3652        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3653        // are legal, e.g. after switching install channels) previously
3654        // returned "whichever the OS happened to list first", which could
3655        // differ between two `inspect`/`audit`/`convert` runs against the
3656        // exact same directory. Collect every `opencode*.db` candidate and
3657        // pick deterministically: the exact `opencode.db` name wins if
3658        // present (the default/most-common channel); otherwise the
3659        // lexicographically-smallest match, so repeated runs always agree.
3660        let mut candidates: Vec<PathBuf> = entries
3661            .flatten()
3662            .map(|entry| entry.path())
3663            .filter(|p| {
3664                p.file_name()
3665                    .and_then(|n| n.to_str())
3666                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3667            })
3668            .collect();
3669        candidates.sort();
3670        if let Some(exact) = candidates
3671            .iter()
3672            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3673        {
3674            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3675        }
3676        if let Some(first) = candidates.into_iter().next() {
3677            return Some((OpenCodeStorageSurface::Sqlite, first));
3678        }
3679    }
3680    let storage = data_root.join("storage");
3681    if storage.join("migration").is_file() {
3682        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3683    }
3684    let project_dir = data_root.join("project");
3685    if project_dir.is_dir() {
3686        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3687    }
3688    None
3689}
3690
3691/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3692/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3693///
3694/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3695/// `\r` survives as part of the returned line's own content; blank lines and
3696/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3697/// trimmed. This is what makes `Session.raw` — populated from this at every
3698/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3699/// just well-formed LF JSONL with no blank lines.
3700///
3701/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3702/// distinguish a source that ended with a trailing newline from one that
3703/// didn't (both split into the same line list), so `ends_with_newline`
3704/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3705/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3706/// source has zero lines, not one blank line.
3707fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3708    if text.is_empty() {
3709        return (Vec::new(), false);
3710    }
3711    let ends_with_newline = text.ends_with('\n');
3712    let body = if ends_with_newline {
3713        &text[..text.len() - 1]
3714    } else {
3715        text
3716    };
3717    (body.split('\n').collect(), ends_with_newline)
3718}
3719
3720/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3721/// source bytes from its verbatim lines plus the trailing-newline flag.
3722fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3723    let mut out = lines.join("\n");
3724    if ends_with_newline {
3725        out.push('\n');
3726    }
3727    out
3728}
3729
3730// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3731//
3732// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3733// SQLite — no system library dependency) and reconstructs the SAME envelope
3734// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3735// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3736// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3737// `session.ts` `fromRow` (session table: columnar fields recombined into the
3738// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3739// carried as the RAW column value, not upstream's own `fromRow`
3740// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3741// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3742// `message`/`part` rows are simpler: their `data` column is already the V1
3743// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3744// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3745// `id`/`sessionID`(/`messageID`).
3746
3747/// First 16 bytes of every SQLite database file — the format's own magic,
3748/// independent of file extension.
3749const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3750
3751/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3752/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3753/// the SQLite magic, OR its extension is `.db` — the latter so a
3754/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3755/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3756/// A non-existent path is NOT considered SQLite here — the missing-file
3757/// diagnostic in that case comes from the normal load path (`with_context`
3758/// at the CLI call sites), which already names the path clearly.
3759pub fn looks_like_sqlite(path: &Path) -> bool {
3760    if !path.is_file() {
3761        return false;
3762    }
3763    if path.extension().and_then(|e| e.to_str()) == Some("db") {
3764        return true;
3765    }
3766    use std::io::Read;
3767    let Ok(mut f) = std::fs::File::open(path) else {
3768        return false;
3769    };
3770    let mut buf = [0u8; 16];
3771    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3772}
3773
3774/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3775/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3776/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3777/// accept there. Binary SQLite input never reaches this function: callers
3778/// check [`looks_like_sqlite`] first and route to
3779/// [`Session::from_opencode_sqlite`] instead.
3780fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3781    let bytes = std::fs::read(path)?;
3782    String::from_utf8(bytes).map_err(|_| {
3783        crate::Error::Other(format!(
3784            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3785             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3786             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3787            path.display()
3788        ))
3789    })
3790}
3791
3792/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3793///
3794/// The first record carries durable session metadata (especially for Codex),
3795/// while the trailing window carries the messages the viewport will render.
3796/// Full lossless loaders intentionally continue to read every byte.
3797fn read_display_jsonl(
3798    path: &Path,
3799    message_limit: usize,
3800) -> Result<(Option<SessionSource>, String)> {
3801    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3802    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3803    const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3804
3805    let mut first = String::new();
3806    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3807    let source = detect_source(&first);
3808    if !matches!(
3809        source,
3810        Some(SessionSource::ClaudeCode | SessionSource::Codex)
3811    ) {
3812        let text = read_utf8_or_diagnose(path)?;
3813        return Ok((detect_source(&text), text));
3814    }
3815
3816    let mut file = std::fs::File::open(path)?;
3817    let file_len = file.metadata()?.len();
3818    let requested = (message_limit.max(1) as u64)
3819        .saturating_mul(BYTES_PER_MESSAGE)
3820        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3821    if file_len <= requested {
3822        let text = read_utf8_or_diagnose(path)?;
3823        return Ok((source, text));
3824    }
3825
3826    let start = file_len - requested;
3827    file.seek(SeekFrom::Start(start))?;
3828    let mut bytes = Vec::with_capacity(requested as usize);
3829    file.read_to_end(&mut bytes)?;
3830    // The window normally starts in the middle of a JSON record. Discard that
3831    // partial prefix so every line passed to the existing parsers is valid.
3832    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3833        bytes.drain(..=newline);
3834    }
3835    let mut tail = String::from_utf8(bytes).map_err(|_| {
3836        crate::Error::Other(format!(
3837            "{} contains non-UTF-8 data in its display window",
3838            path.display()
3839        ))
3840    })?;
3841    if !tail
3842        .lines()
3843        .any(|line| native_display_human_line(line, source))
3844    {
3845        // A single tool-heavy turn can exceed the ordinary byte window. Search backward through a
3846        // separately bounded native slice for only its nearest human record, then prepend that one
3847        // line to the cheap tail. The skipped megabytes are never normalized or sent over RPC.
3848        let search_bytes = file_len.min(MAX_TAIL_BYTES);
3849        let search_start = file_len - search_bytes;
3850        file.seek(SeekFrom::Start(search_start))?;
3851        let mut search = Vec::with_capacity(search_bytes as usize);
3852        file.read_to_end(&mut search)?;
3853        if search_start > 0 {
3854            if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3855                search.drain(..=newline);
3856            }
3857        }
3858        if let Ok(search) = std::str::from_utf8(&search) {
3859            if let Some(anchor) = search
3860                .lines()
3861                .rev()
3862                .find(|line| native_display_human_line(line, source))
3863            {
3864                tail = format!("{anchor}\n{tail}");
3865            }
3866        }
3867    }
3868    let text = if source == Some(SessionSource::Codex) {
3869        format!("{first}{tail}")
3870    } else {
3871        tail
3872    };
3873    Ok((source, text))
3874}
3875
3876fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3877    if !line
3878        .as_bytes()
3879        .windows(6)
3880        .any(|window| window == b"\"user\"")
3881    {
3882        return false;
3883    }
3884    let Ok(value) = serde_json::from_str::<Value>(line) else {
3885        return false;
3886    };
3887    match source {
3888        Some(SessionSource::Codex) => {
3889            value.get("type").and_then(Value::as_str) == Some("response_item")
3890                && value
3891                    .get("payload")
3892                    .and_then(|payload| payload.get("type"))
3893                    .and_then(Value::as_str)
3894                    == Some("message")
3895                && value
3896                    .get("payload")
3897                    .and_then(|payload| payload.get("role"))
3898                    .and_then(Value::as_str)
3899                    == Some("user")
3900        }
3901        Some(SessionSource::ClaudeCode) => {
3902            value.get("type").and_then(Value::as_str) == Some("user")
3903                && value
3904                    .get("message")
3905                    .and_then(|message| message.get("content"))
3906                    .is_some_and(|content| match content {
3907                        Value::String(text) => !text.trim().is_empty(),
3908                        Value::Array(parts) => parts.iter().any(|part| {
3909                            part.get("type").and_then(Value::as_str) == Some("text")
3910                                && part
3911                                    .get("text")
3912                                    .and_then(Value::as_str)
3913                                    .is_some_and(|text| !text.trim().is_empty())
3914                        }),
3915                        _ => false,
3916                    })
3917        }
3918        _ => false,
3919    }
3920}
3921
3922fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3923    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3924}
3925
3926/// Open `db_path` read-only and confirm it carries the expected V1 schema
3927/// (a `session` table) — the shared entry point for every SQLite read below,
3928/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
3929/// path, not-a-database, and wrong/unsupported schema are each named
3930/// distinctly rather than surfacing later as "zero sessions" or a generic
3931/// parse failure.
3932fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
3933    if !db_path.is_file() {
3934        return Err(crate::Error::Other(format!(
3935            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
3936             (see `docs/interop/opencode-pi-spec.md` §1.2)",
3937            db_path.display()
3938        )));
3939    }
3940    let conn = Connection::open_with_flags(
3941        db_path,
3942        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3943    )
3944    .map_err(|e| {
3945        crate::Error::Other(format!(
3946            "{} does not look like a valid OpenCode SQLite database: {e}",
3947            db_path.display()
3948        ))
3949    })?;
3950    let has_session_table: i64 = conn
3951        .query_row(
3952            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
3953            [],
3954            |r| r.get(0),
3955        )
3956        .map_err(|e| {
3957            crate::Error::Other(format!(
3958                "failed to read the OpenCode SQLite schema at {}: {e}",
3959                db_path.display()
3960            ))
3961        })?;
3962    if has_session_table == 0 {
3963        return Err(crate::Error::Other(format!(
3964            "{} is a SQLite database but has no `session` table — not a recognized \
3965             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
3966            db_path.display()
3967        )));
3968    }
3969    Ok(conn)
3970}
3971
3972/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
3973/// …). D7: an unparseable non-empty column previously degraded to
3974/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
3975/// absent/NULL column, so a corrupt `data`/`metadata` value silently
3976/// vanished (e.g. a message whose `data` fails to parse loses its entire
3977/// canonical content with no trace). A `tracing::warn!` now surfaces the
3978/// column name and context (session/record id) whenever this happens, so
3979/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
3980/// (still the least-wrong placeholder for a broken column; changing it to a
3981/// sentinel would risk misleading every legitimate `.is_null()` check
3982/// elsewhere) but the frontend/log now knows it happened.
3983fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
3984    match s.as_deref() {
3985        None => Value::Null,
3986        Some(t) => match serde_json::from_str::<Value>(t) {
3987            Ok(v) => v,
3988            Err(e) => {
3989                tracing::warn!(
3990                    column = col,
3991                    context,
3992                    error = %e,
3993                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
3994                );
3995                Value::Null
3996            }
3997        },
3998    }
3999}
4000
4001/// Columns the `session` table has in a GIVEN store, read once per session
4002/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4003/// `opencode` generation may lack columns the newest schema added, e.g.
4004/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4005/// "Invalid column name" on an absent column, so callers must check
4006/// membership before reading a not-guaranteed column instead of reading it
4007/// unconditionally).
4008fn opencode_session_columns(
4009    conn: &Connection,
4010) -> rusqlite::Result<std::collections::HashSet<String>> {
4011    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4012    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4013    names.collect()
4014}
4015
4016/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4017/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4018/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4019/// `revert` carries the raw column value verbatim rather than upstream's
4020/// field-selecting reconstruction (spec S9c: that reconstruction silently
4021/// drops the V2 `Revert.State` schema's extra `files` field).
4022///
4023/// D3: not every column this loader would like to read is guaranteed to
4024/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4025/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4026/// `agent`/`model` entirely. Those are read defensively (guarded by
4027/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4028/// generation this loader has ever targeted are still read unconditionally.
4029fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4030    let cols = opencode_session_columns(conn)
4031        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4032    let has = |name: &str| cols.contains(name);
4033
4034    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4035        let id: String = r.get("id")?;
4036        let project_id: String = r.get("project_id")?;
4037        let workspace_id: Option<String> = if has("workspace_id") {
4038            r.get("workspace_id")?
4039        } else {
4040            None
4041        };
4042        let parent_id: Option<String> = r.get("parent_id")?;
4043        let slug: String = r.get("slug")?;
4044        let directory: String = r.get("directory")?;
4045        let path: Option<String> = if has("path") { r.get("path")? } else { None };
4046        let title: String = r.get("title")?;
4047        let version: String = r.get("version")?;
4048        let share_url: Option<String> = r.get("share_url")?;
4049        let summary_additions: Option<i64> = r.get("summary_additions")?;
4050        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4051        let summary_files: Option<i64> = r.get("summary_files")?;
4052        let summary_diffs: Option<String> = r.get("summary_diffs")?;
4053        let metadata: Option<String> = if has("metadata") {
4054            r.get("metadata")?
4055        } else {
4056            None
4057        };
4058        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4059        let tokens_input: i64 = if has("tokens_input") {
4060            r.get("tokens_input")?
4061        } else {
4062            0
4063        };
4064        let tokens_output: i64 = if has("tokens_output") {
4065            r.get("tokens_output")?
4066        } else {
4067            0
4068        };
4069        let tokens_reasoning: i64 = if has("tokens_reasoning") {
4070            r.get("tokens_reasoning")?
4071        } else {
4072            0
4073        };
4074        let tokens_cache_read: i64 = if has("tokens_cache_read") {
4075            r.get("tokens_cache_read")?
4076        } else {
4077            0
4078        };
4079        let tokens_cache_write: i64 = if has("tokens_cache_write") {
4080            r.get("tokens_cache_write")?
4081        } else {
4082            0
4083        };
4084        let revert: Option<String> = r.get("revert")?;
4085        let permission: Option<String> = if has("permission") {
4086            r.get("permission")?
4087        } else {
4088            None
4089        };
4090        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4091        let model: Option<String> = if has("model") { r.get("model")? } else { None };
4092        let time_created: i64 = r.get("time_created")?;
4093        let time_updated: i64 = r.get("time_updated")?;
4094        let time_compacting: Option<i64> = if has("time_compacting") {
4095            r.get("time_compacting")?
4096        } else {
4097            None
4098        };
4099        let time_archived: Option<i64> = if has("time_archived") {
4100            r.get("time_archived")?
4101        } else {
4102            None
4103        };
4104
4105        let summary =
4106            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4107                .then(|| {
4108                    serde_json::json!({
4109                        "additions": summary_additions.unwrap_or(0),
4110                        "deletions": summary_deletions.unwrap_or(0),
4111                        "files": summary_files.unwrap_or(0),
4112                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4113                    })
4114                });
4115        let share = share_url.map(|u| serde_json::json!({"url": u}));
4116
4117        Ok(serde_json::json!({
4118            "id": id,
4119            "slug": slug,
4120            "projectID": project_id,
4121            "workspaceID": workspace_id,
4122            "directory": directory,
4123            "path": path,
4124            "parentID": parent_id,
4125            "summary": summary,
4126            "cost": cost,
4127            "tokens": {
4128                "input": tokens_input,
4129                "output": tokens_output,
4130                "reasoning": tokens_reasoning,
4131                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4132            },
4133            "share": share,
4134            "title": title,
4135            "agent": agent,
4136            "model": opencode_json_col(model, "model", session_id),
4137            "version": version,
4138            "metadata": opencode_json_col(metadata, "metadata", session_id),
4139            "time": {
4140                "created": time_created,
4141                "updated": time_updated,
4142                "compacting": time_compacting,
4143                "archived": time_archived,
4144            },
4145            "permission": opencode_json_col(permission, "permission", session_id),
4146            // S9c: raw column value, not a field-selecting reconstruction —
4147            // see this function's doc comment.
4148            "revert": opencode_json_col(revert, "revert", session_id),
4149        }))
4150    })
4151    .map_err(|e| match e {
4152        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4153            "OpenCode session `{session_id}` not found in this SQLite store"
4154        )),
4155        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4156    })
4157}
4158
4159/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4160/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4161/// re-inject them, matching what a JSON-tree file (or the export document)
4162/// carries at this same key. Also re-injects the row's own `time_created`/
4163/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4164/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4165/// in the envelope so `raw` is value-complete and re-writable without
4166/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4167/// which is a different, in-schema field with different semantics).
4168fn opencode_row_message_value(
4169    id: &str,
4170    session_id: &str,
4171    data_json: &str,
4172    time_created: i64,
4173    time_updated: i64,
4174) -> Value {
4175    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4176    if let Value::Object(map) = &mut v {
4177        map.insert("id".to_string(), Value::String(id.to_string()));
4178        map.insert(
4179            "sessionID".to_string(),
4180            Value::String(session_id.to_string()),
4181        );
4182        map.insert("time_created".to_string(), Value::from(time_created));
4183        map.insert("time_updated".to_string(), Value::from(time_updated));
4184    }
4185    v
4186}
4187
4188fn opencode_row_part_value(
4189    id: &str,
4190    session_id: &str,
4191    message_id: &str,
4192    data_json: &str,
4193    time_created: i64,
4194    time_updated: i64,
4195) -> Value {
4196    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4197    if let Value::Object(map) = &mut v {
4198        map.insert("id".to_string(), Value::String(id.to_string()));
4199        map.insert(
4200            "sessionID".to_string(),
4201            Value::String(session_id.to_string()),
4202        );
4203        map.insert(
4204            "messageID".to_string(),
4205            Value::String(message_id.to_string()),
4206        );
4207        map.insert("time_created".to_string(), Value::from(time_created));
4208        map.insert("time_updated".to_string(), Value::from(time_updated));
4209    }
4210    v
4211}
4212
4213/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4214/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4215/// info first, then each message (by `time_created, id`) immediately
4216/// followed by its own parts (by `id`) — parts MUST directly follow their
4217/// owning message line, since `Session::from_opencode_str`'s envelope parser
4218/// attaches a `part` line to whichever message id is already in its index
4219/// and silently leaves an out-of-order part `raw`-only otherwise — then
4220/// `todo` side-records, then a `session_diff` side-record if the JSON
4221/// sidecar file for this session exists (order-independent).
4222///
4223/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4224/// it "is still JSON-written even on SQLite installs" — verified against
4225/// `packages/opencode/src/session/revert.ts:76` /
4226/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4227/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4228/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4229/// separate from the `session.revert` DB column this loader already
4230/// captures. Without this, revert diffs vanish from `raw` and audit
4231/// under-counts `session_diff` records for real reverted sessions.
4232fn opencode_sqlite_session_envelope_lines(
4233    conn: &Connection,
4234    db_path: &Path,
4235    session_id: &str,
4236) -> Result<Vec<String>> {
4237    let mut lines = Vec::new();
4238
4239    let session_info = opencode_row_session_info(conn, session_id)?;
4240    let project_id = session_info
4241        .get("projectID")
4242        .and_then(Value::as_str)
4243        .unwrap_or("global")
4244        .to_string();
4245    lines.push(
4246        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4247            .to_string(),
4248    );
4249
4250    let mut msg_stmt = conn
4251        .prepare(
4252            "SELECT id, data, time_created, time_updated FROM message \
4253             WHERE session_id = ?1 ORDER BY time_created, id",
4254        )
4255        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4256    let msg_rows = msg_stmt
4257        .query_map([session_id], |r| {
4258            let id: String = r.get("id")?;
4259            let data: String = r.get("data")?;
4260            let time_created: i64 = r.get("time_created")?;
4261            let time_updated: i64 = r.get("time_updated")?;
4262            Ok((id, data, time_created, time_updated))
4263        })
4264        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4265
4266    let mut part_stmt = conn
4267        .prepare(
4268            "SELECT id, data, time_created, time_updated FROM part \
4269             WHERE message_id = ?1 ORDER BY id",
4270        )
4271        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4272
4273    for row in msg_rows {
4274        let (msg_id, data, msg_time_created, msg_time_updated) =
4275            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4276        let msg_value = opencode_row_message_value(
4277            &msg_id,
4278            session_id,
4279            &data,
4280            msg_time_created,
4281            msg_time_updated,
4282        );
4283        lines.push(
4284            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4285                .to_string(),
4286        );
4287
4288        let part_rows = part_stmt
4289            .query_map([&msg_id], |r| {
4290                let id: String = r.get("id")?;
4291                let data: String = r.get("data")?;
4292                let time_created: i64 = r.get("time_created")?;
4293                let time_updated: i64 = r.get("time_updated")?;
4294                Ok((id, data, time_created, time_updated))
4295            })
4296            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4297        for prow in part_rows {
4298            let (part_id, pdata, part_time_created, part_time_updated) =
4299                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4300            let part_value = opencode_row_part_value(
4301                &part_id,
4302                session_id,
4303                &msg_id,
4304                &pdata,
4305                part_time_created,
4306                part_time_updated,
4307            );
4308            lines.push(
4309                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4310                    .to_string(),
4311            );
4312        }
4313    }
4314
4315    let mut todo_stmt = conn
4316        .prepare(
4317            "SELECT content, status, priority, position, time_created, time_updated \
4318             FROM todo WHERE session_id = ?1 ORDER BY position",
4319        )
4320        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4321    let todo_rows = todo_stmt
4322        .query_map([session_id], |r| {
4323            let content: String = r.get("content")?;
4324            let status: String = r.get("status")?;
4325            let priority: String = r.get("priority")?;
4326            let position: i64 = r.get("position")?;
4327            let time_created: i64 = r.get("time_created")?;
4328            let time_updated: i64 = r.get("time_updated")?;
4329            Ok(serde_json::json!({
4330                "sessionID": session_id,
4331                "content": content,
4332                "status": status,
4333                "priority": priority,
4334                "position": position,
4335                "time": {"created": time_created, "updated": time_updated},
4336            }))
4337        })
4338        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4339    for trow in todo_rows {
4340        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4341        let position = tv.get("position").cloned().unwrap_or(Value::Null);
4342        lines.push(
4343            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4344        );
4345    }
4346
4347    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4348        lines.push(
4349            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4350                .to_string(),
4351        );
4352    }
4353
4354    Ok(lines)
4355}
4356
4357/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4358/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4359/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4360/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4361/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4362/// case (most sessions never revert) and is not an error; an existing-but-
4363/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4364/// vanishing.
4365fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4366    let dir = db_path.parent()?;
4367    let sidecar = dir
4368        .join("storage")
4369        .join("session_diff")
4370        .join(format!("{session_id}.json"));
4371    let text = std::fs::read_to_string(&sidecar).ok()?;
4372    match serde_json::from_str::<Value>(&text) {
4373        Ok(v) => Some(v),
4374        Err(e) => {
4375            tracing::warn!(
4376                path = %sidecar.display(),
4377                error = %e,
4378                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4379            );
4380            None
4381        }
4382    }
4383}
4384
4385/// Pick the "primary" session for a bare `.db` path with no explicit session
4386/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4387/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4388/// descending) — a subagent/task child session is never picked over an
4389/// available root session, mirroring `most_recent_session`'s "latest wins"
4390/// convention used elsewhere in this crate for supercode's own store.
4391fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4392    conn.query_row(
4393        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4394        [],
4395        |r| r.get::<_, String>(0),
4396    )
4397    .map_err(|e| match e {
4398        rusqlite::Error::QueryReturnedNoRows => {
4399            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4400        }
4401        e => opencode_sql_err(e, "selecting the primary session"),
4402    })
4403}
4404
4405fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4406    let mut stmt = conn
4407        .prepare("SELECT id FROM session ORDER BY time_created, id")
4408        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4409    let rows = stmt
4410        .query_map([], |r| r.get::<_, String>(0))
4411        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4412    let mut ids = Vec::new();
4413    for row in rows {
4414        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4415        if limit.is_some_and(|n| ids.len() >= n) {
4416            break;
4417        }
4418    }
4419    Ok(ids)
4420}
4421
4422/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4423/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4424/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4425/// silently picks just the primary one. Previously nothing surfaced this:
4426/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4427/// and no way to name a different one.
4428pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4429    let conn = opencode_sqlite_open(db_path)?;
4430    opencode_sqlite_all_session_ids(&conn, None)
4431}
4432
4433/// D6: the same "most-recently-updated top-level session" selection
4434/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4435/// no explicit session id is given — exposed so a CLI-level warning can name
4436/// which one was chosen.
4437pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4438    let conn = opencode_sqlite_open(db_path)?;
4439    opencode_sqlite_primary_session_id(&conn)
4440}
4441
4442/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4443/// `inspect`'s "reports the audited real store's sessions, messages, and
4444/// parts" summary (PARITY-3 AC01).
4445#[derive(Debug, Clone, Copy, Default)]
4446#[non_exhaustive]
4447pub struct OpenCodeSqliteStoreStats {
4448    /// Row count of the `session` table.
4449    pub sessions: u64,
4450    /// Row count of the `message` table.
4451    pub messages: u64,
4452    /// Row count of the `part` table.
4453    pub parts: u64,
4454    /// Row count of the `todo` table.
4455    pub todos: u64,
4456}
4457
4458/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4459/// without loading any of them (PARITY-3 AC01).
4460pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4461    let conn = opencode_sqlite_open(db_path)?;
4462    let count = |table: &str| -> Result<u64> {
4463        let sql = format!("SELECT count(*) FROM {table}");
4464        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4465            .map(|n| n.max(0) as u64)
4466            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4467    };
4468    Ok(OpenCodeSqliteStoreStats {
4469        sessions: count("session")?,
4470        messages: count("message")?,
4471        parts: count("part")?,
4472        todos: count("todo")?,
4473    })
4474}
4475
4476/// Combined envelope text spanning every session in `db_path` (or up to
4477/// `limit_sessions`) — for corpus-style scanning
4478/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4479/// Safe to concatenate multiple sessions' records into one text even though
4480/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4481/// (single-session semantics) — the audit line-classifier
4482/// (`audit_opencode_line`) scores each line independently and doesn't care
4483/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4484/// one session as a real [`Session`].
4485pub fn opencode_sqlite_corpus_envelope_text(
4486    db_path: &Path,
4487    limit_sessions: Option<usize>,
4488) -> Result<String> {
4489    let conn = opencode_sqlite_open(db_path)?;
4490    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4491    let mut out = String::new();
4492    for id in ids {
4493        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4494            out.push_str(&line);
4495            out.push('\n');
4496        }
4497    }
4498    Ok(out)
4499}
4500
4501/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4502/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4503/// everywhere a loader walks lines looking for JSON *records*, where a blank
4504/// line is simply not a record and must not become a spurious parse
4505/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4506/// (IX-1) — see [`split_lines_verbatim`] for that.
4507fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4508    text.lines().map(str::trim).filter(|l| !l.is_empty())
4509}
4510
4511// ---- Claude Code ----------------------------------------------------------
4512
4513/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4514/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4515fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4516    let dir = main_path.parent()?;
4517    let stem = main_path.file_stem()?.to_str()?;
4518    let candidate = dir.join(stem).join("subagents");
4519    candidate.is_dir().then_some(candidate)
4520}
4521
4522/// The first `agentId` recorded in a subagent transcript.
4523fn first_agent_id(jsonl: &str) -> Option<String> {
4524    for line in non_empty_lines(jsonl) {
4525        if let Ok(v) = serde_json::from_str::<Value>(line) {
4526            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4527                return Some(id.to_string());
4528            }
4529        }
4530    }
4531    None
4532}
4533
4534/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4535/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4536/// serialized content mentions the agent id. Best effort: an id with no
4537/// qualifying match is simply absent from the returned map.
4538///
4539/// Single pass over `main_text` — each line is parsed at most once,
4540/// regardless of how many agent ids are being sought — with each id's result
4541/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4542/// return: the first line (in file order) whose raw text contains the id and
4543/// which — the first qualifying `tool_result` block in that line, in block
4544/// order — has a string `tool_use_id` and a serialized form that also
4545/// contains the id. A `tool_result` block matching on raw-line/serialized
4546/// containment but lacking a `tool_use_id` yields nothing for that id and
4547/// does not shadow a later match.
4548fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4549    let mut index: HashMap<String, String> = HashMap::new();
4550    if agent_ids.is_empty() {
4551        return index;
4552    }
4553
4554    for line in non_empty_lines(main_text) {
4555        if index.len() == agent_ids.len() {
4556            break;
4557        }
4558        // Cheap prefilter: every match this function can ever return comes
4559        // from a block whose raw line carries the literal JSON string value
4560        // `tool_result` (no JSON-escape variants of that ASCII literal).
4561        if !line.contains("tool_result") {
4562            continue;
4563        }
4564        let still_unmapped: Vec<&String> = agent_ids
4565            .iter()
4566            .filter(|id| !index.contains_key(id.as_str()))
4567            .collect();
4568        if still_unmapped.is_empty() {
4569            break;
4570        }
4571        let Ok(v) = serde_json::from_str::<Value>(line) else {
4572            continue;
4573        };
4574        let content = v.get("message").and_then(|m| m.get("content"));
4575        let Some(Value::Array(blocks)) = content else {
4576            continue;
4577        };
4578        for b in blocks {
4579            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4580                continue;
4581            }
4582            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4583                continue;
4584            };
4585            let block_str = b.to_string();
4586            for id in &still_unmapped {
4587                if index.contains_key(id.as_str()) {
4588                    continue;
4589                }
4590                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4591                    index.insert((*id).clone(), tool_use_id.to_string());
4592                }
4593            }
4594        }
4595    }
4596
4597    index
4598}
4599
4600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4601enum ClaudeReplayKind {
4602    User,
4603    Assistant,
4604    Attachment,
4605    System,
4606}
4607
4608impl ClaudeReplayKind {
4609    fn is_conversation(self) -> bool {
4610        matches!(self, Self::User | Self::Assistant)
4611    }
4612}
4613
4614#[derive(Debug, Clone)]
4615struct ClaudeReplayNode {
4616    line_index: usize,
4617    uuid: String,
4618    parent_uuid: Option<String>,
4619    kind: ClaudeReplayKind,
4620    is_sidechain: bool,
4621    assistant_message_id: Option<String>,
4622    is_tool_result: bool,
4623    compact: Option<ClaudeCompactBoundary>,
4624}
4625
4626#[derive(Debug, Clone)]
4627struct ClaudeCompactBoundary {
4628    anchor_uuid: Option<String>,
4629    preserved_uuids: Vec<String>,
4630    preserved_segment: Option<(String, String)>,
4631}
4632
4633/// One projection of a Claude transcript graph: the source lines to replay,
4634/// plus whatever the projection had to give up to produce them (always empty
4635/// below [`Fidelity::Semantic`], which is the only level that degrades
4636/// instead of failing).
4637#[derive(Debug, Default)]
4638struct ClaudeReplaySelection {
4639    lines: Vec<usize>,
4640    residue: Vec<String>,
4641}
4642
4643#[derive(Debug, Default)]
4644struct ClaudeReplayIndex {
4645    nodes: Vec<ClaudeReplayNode>,
4646    by_uuid: HashMap<String, usize>,
4647    segment_anchors: HashSet<String>,
4648    last_prompt: Option<(String, bool)>,
4649    linear_lines: Vec<usize>,
4650}
4651
4652impl ClaudeReplayIndex {
4653    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4654        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4655            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4656                self.last_prompt = Some((
4657                    leaf.to_string(),
4658                    v.get("explicit").and_then(Value::as_bool) == Some(true),
4659                ));
4660            }
4661            return Ok(());
4662        }
4663
4664        // A fork-context-ref is a real Claude graph anchor, but not a replay
4665        // message. Its child is the first conversational record in the
4666        // exported fork, so reaching this UUID terminates the locally
4667        // replayable segment rather than indicating a broken parent edge.
4668        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4669            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4670                self.segment_anchors.insert(uuid.to_string());
4671            }
4672            return Ok(());
4673        }
4674
4675        let kind = match v.get("type").and_then(Value::as_str) {
4676            Some("user") => ClaudeReplayKind::User,
4677            Some("assistant") => ClaudeReplayKind::Assistant,
4678            Some("attachment") => ClaudeReplayKind::Attachment,
4679            Some("system") => ClaudeReplayKind::System,
4680            _ => return Ok(()),
4681        };
4682        self.linear_lines.push(line_index);
4683        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4684            return Ok(());
4685        };
4686        if self.by_uuid.contains_key(uuid) {
4687            return Err(claude_replay_error(format!(
4688                "duplicate uuid `{uuid}` in Claude transcript"
4689            )));
4690        }
4691
4692        let compact = (kind == ClaudeReplayKind::System
4693            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4694        .then(|| ClaudeCompactBoundary::from_value(v));
4695        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4696            .then(|| claude_assistant_message_id(v).map(str::to_string))
4697            .flatten();
4698        let is_tool_result = kind == ClaudeReplayKind::User
4699            && v.get("message")
4700                .and_then(|m| m.get("content"))
4701                .and_then(Value::as_array)
4702                .is_some_and(|blocks| {
4703                    blocks
4704                        .iter()
4705                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4706                });
4707        let node = ClaudeReplayNode {
4708            line_index,
4709            uuid: uuid.to_string(),
4710            parent_uuid: v
4711                .get("parentUuid")
4712                .and_then(Value::as_str)
4713                .map(str::to_string),
4714            kind,
4715            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4716            assistant_message_id,
4717            is_tool_result,
4718            compact,
4719        };
4720        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4721        self.nodes.push(node);
4722        Ok(())
4723    }
4724
4725    /// Project the transcript at `fidelity`.
4726    ///
4727    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4728    /// continuation, transfer and export path depends on: reconstruct
4729    /// Claude's own single active post-compaction branch, or fail naming what
4730    /// could not be reconstructed.
4731    ///
4732    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4733    /// that has been compacted, summarized, or resumed across files routinely
4734    /// contains a live record whose `parentUuid` names a record that is no
4735    /// longer on disk. Strict projection rightly refuses — a continuation
4736    /// built on a guessed graph is silent loss — but a VIEW does not need a
4737    /// continuation, so this mode anchors each dangling edge as a segment
4738    /// root, projects every severed segment exactly as the active branch is
4739    /// projected, splices them back together in transcript order, and names
4740    /// every degradation in the returned residue instead of erroring.
4741    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4742        let lenient = fidelity.tolerates_residue();
4743        let mut residue = Vec::new();
4744        if self.nodes.is_empty() {
4745            // Older exports and many hand-authored compatibility fixtures do
4746            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4747            // branch information to project in that shape, so preserve the
4748            // historical linear normalization behavior. Native graph-bearing
4749            // transcripts always take the projection below.
4750            return Ok(ClaudeReplaySelection {
4751                lines: self.linear_lines,
4752                residue,
4753            });
4754        }
4755        if lenient {
4756            self.anchor_dangling_parents(&mut residue);
4757        }
4758        // Last resort for a VIEW: a transcript whose graph is unprojectable
4759        // for some OTHER reason (a cycle, an unresolvable compact boundary)
4760        // still renders as the file's own record order. A read-only mirror
4761        // that cannot open a session at all is the defect this mode exists
4762        // to remove, so `Semantic` never returns an error.
4763        let fallback = lenient.then(|| self.linear_lines.clone());
4764        match self.project(lenient, &mut residue) {
4765            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4766            Err(error) => match fallback {
4767                Some(lines) => {
4768                    residue.push(format!(
4769                        "the Claude record graph could not be projected ({error}); \
4770                         every record was stitched in transcript order instead"
4771                    ));
4772                    Ok(ClaudeReplaySelection { lines, residue })
4773                }
4774                None => Err(error),
4775            },
4776        }
4777    }
4778
4779    /// Turn every edge that points outside the transcript into a segment
4780    /// root, naming the dangling uuids as residue.
4781    ///
4782    /// A `fork-context-ref` anchor is already a declared segment boundary,
4783    /// not a break, so it is left alone.
4784    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4785        let mut dangling = Vec::new();
4786        for idx in 0..self.nodes.len() {
4787            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4788                continue;
4789            };
4790            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4791                continue;
4792            }
4793            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4794            self.nodes[idx].parent_uuid = None;
4795        }
4796        if dangling.is_empty() {
4797            return;
4798        }
4799        const NAMED: usize = 8;
4800        let total = dangling.len();
4801        let overflow = total.saturating_sub(NAMED);
4802        dangling.truncate(NAMED);
4803        let mut listed = dangling.join(", ");
4804        if overflow > 0 {
4805            listed.push_str(&format!(", and {overflow} more"));
4806        }
4807        residue.push(format!(
4808            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4809             anchored as segment roots: {listed}"
4810        ));
4811    }
4812
4813    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4814        let mut retained = vec![true; self.nodes.len()];
4815        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4816            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4817                self.nodes
4818                    .iter()
4819                    .map(|node| node.parent_uuid.clone())
4820                    .collect()
4821            });
4822            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4823                let Some(parents) = parents else {
4824                    return Err(error);
4825                };
4826                // The boundary rewrites parents as it goes, so restore the
4827                // graph it half-edited before continuing without it.
4828                for (node, parent) in self.nodes.iter_mut().zip(parents) {
4829                    node.parent_uuid = parent;
4830                }
4831                retained.iter_mut().for_each(|keep| *keep = true);
4832                residue.push(format!(
4833                    "the latest Claude compact boundary could not be projected ({error}); \
4834                     no pre-compaction record was pruned from this view"
4835                ));
4836            }
4837        }
4838        let sidechain_only = self
4839            .nodes
4840            .iter()
4841            .enumerate()
4842            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4843            .all(|(_, node)| node.is_sidechain);
4844
4845        let explicit_leaf = self
4846            .last_prompt
4847            .as_ref()
4848            .filter(|(_, explicit)| *explicit)
4849            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4850            .filter(|idx| retained[*idx]);
4851        let newest_non_sidechain = self
4852            .nodes
4853            .iter()
4854            .enumerate()
4855            .rev()
4856            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4857            .map(|(idx, _)| idx);
4858        // Dedicated Claude subagent transcripts are sidechains by design:
4859        // every record, including their root user prompt, has
4860        // `isSidechain:true`. When there is no main-chain candidate, resume
4861        // the newest retained sidechain leaf instead of rejecting the child.
4862        let newest_sidechain = self
4863            .nodes
4864            .iter()
4865            .enumerate()
4866            .rev()
4867            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4868            .map(|(idx, _)| idx);
4869        let mut active = explicit_leaf
4870            .or(newest_non_sidechain)
4871            .or(newest_sidechain)
4872            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4873
4874        // Metadata descendants such as turn_duration are leaves in the raw
4875        // graph. Claude resumes from their nearest user/assistant ancestor,
4876        // then appends those descendants to the reconstructed chain.
4877        let mut seeking = HashSet::new();
4878        while !self.nodes[active].kind.is_conversation() {
4879            if !seeking.insert(active) {
4880                return Err(claude_replay_error(
4881                    "cycle while resolving active Claude leaf",
4882                ));
4883            }
4884            active = self.parent_index(active, &retained)?;
4885        }
4886
4887        let mut segments =
4888            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4889        if lenient {
4890            for leaf in self.severed_segment_leaves(active, &retained) {
4891                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4892            }
4893            if segments.len() > 1 {
4894                residue.push(format!(
4895                    "{} conversation segments were stitched in transcript order because the \
4896                     Claude record graph is severed",
4897                    segments.len()
4898                ));
4899            }
4900        }
4901        // Each segment keeps its own reconstructed order; the segments
4902        // themselves are spliced by where they start in the file.
4903        segments.retain(|segment| !segment.is_empty());
4904        segments.sort_by_key(|segment| {
4905            segment
4906                .iter()
4907                .map(|idx| self.nodes[*idx].line_index)
4908                .min()
4909                .unwrap_or(usize::MAX)
4910        });
4911        let mut ordered = Vec::new();
4912        let mut placed = HashSet::new();
4913        for idx in segments.into_iter().flatten() {
4914            if placed.insert(idx) {
4915                ordered.push(idx);
4916            }
4917        }
4918
4919        self.recover_parallel_assistant_chunks(ordered, &retained)
4920            .map(|indices| {
4921                indices
4922                    .into_iter()
4923                    .map(|idx| self.nodes[idx].line_index)
4924                    .collect()
4925            })
4926    }
4927
4928    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
4929    /// non-conversation descendants rooted at it.
4930    fn project_segment(
4931        &self,
4932        leaf: usize,
4933        retained: &[bool],
4934        sidechain_only: bool,
4935        lenient: bool,
4936    ) -> Result<Vec<usize>> {
4937        let mut reversed = Vec::new();
4938        let mut seen = HashSet::new();
4939        let mut cursor = Some(leaf);
4940        while let Some(idx) = cursor {
4941            if !seen.insert(idx) {
4942                return Err(claude_replay_error(format!(
4943                    "cycle in active Claude parentUuid chain at `{}`",
4944                    self.nodes[idx].uuid
4945                )));
4946            }
4947            reversed.push(idx);
4948            cursor = match self.nodes[idx].parent_uuid.as_deref() {
4949                Some(parent) => match self.by_uuid.get(parent).copied() {
4950                    Some(parent) => Some(parent),
4951                    None if self.segment_anchors.contains(parent) => None,
4952                    // Claude can resume a background child in-place while
4953                    // retaining only the new segment in that child's JSONL.
4954                    // Its first record then points to a UUID not present in
4955                    // the sidechain file. That external edge is a segment
4956                    // boundary, not corruption; the complete source remains
4957                    // available byte-for-byte in `raw`.
4958                    None if sidechain_only => None,
4959                    None => {
4960                        return Err(claude_replay_error(format!(
4961                            "active Claude record `{}` has missing parentUuid `{parent}`",
4962                            self.nodes[idx].uuid
4963                        )));
4964                    }
4965                },
4966                None => None,
4967            };
4968            if cursor.is_some_and(|parent| !retained[parent]) {
4969                if lenient {
4970                    // A compaction boundary is where this segment ends; the
4971                    // records it pruned stay pruned.
4972                    break;
4973                }
4974                return Err(claude_replay_error(format!(
4975                    "active Claude chain crosses an excluded compaction record from `{}`",
4976                    self.nodes[idx].uuid
4977                )));
4978            }
4979        }
4980        reversed.reverse();
4981
4982        // Include non-conversation descendants rooted at the segment's leaf
4983        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
4984        let mut descendants = Vec::new();
4985        let mut frontier = vec![leaf];
4986        let mut head = 0;
4987        while head < frontier.len() {
4988            let parent = frontier[head];
4989            head += 1;
4990            for (idx, node) in self.nodes.iter().enumerate() {
4991                if !retained[idx]
4992                    || node.kind.is_conversation()
4993                    || seen.contains(&idx)
4994                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
4995                {
4996                    continue;
4997                }
4998                seen.insert(idx);
4999                descendants.push(idx);
5000                frontier.push(idx);
5001            }
5002        }
5003        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5004        reversed.extend(descendants);
5005        Ok(reversed)
5006    }
5007
5008    /// The newest retained conversation record of every component the active
5009    /// leaf's own component cannot reach.
5010    ///
5011    /// Only a severed graph produces any: a healthy transcript is one
5012    /// component, so the abandoned branches a rewind left behind stay
5013    /// abandoned here exactly as they do under strict projection.
5014    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5015        let active_root = self.component_root(active, retained);
5016        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5017        for idx in 0..self.nodes.len() {
5018            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5019                continue;
5020            }
5021            let Some(root) = self.component_root(idx, retained) else {
5022                continue;
5023            };
5024            if Some(root) == active_root {
5025                continue;
5026            }
5027            let newest = newest_by_root.entry(root).or_insert(idx);
5028            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5029                *newest = idx;
5030            }
5031        }
5032        newest_by_root.into_values().collect()
5033    }
5034
5035    /// Walk `idx` up to the record that anchors its component, stopping at a
5036    /// root, an edge that leaves the transcript, or a pruned parent. `None`
5037    /// when the walk cycles.
5038    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5039        let mut cursor = idx;
5040        let mut seen = HashSet::new();
5041        loop {
5042            if !seen.insert(cursor) {
5043                return None;
5044            }
5045            let next = self.nodes[cursor]
5046                .parent_uuid
5047                .as_deref()
5048                .and_then(|parent| self.by_uuid.get(parent).copied())
5049                .filter(|parent| retained[*parent]);
5050            match next {
5051                Some(parent) => cursor = parent,
5052                None => return Some(cursor),
5053            }
5054        }
5055    }
5056
5057    fn apply_latest_compaction(
5058        &mut self,
5059        boundary_index: usize,
5060        retained: &mut [bool],
5061    ) -> Result<()> {
5062        let compact = self.nodes[boundary_index]
5063            .compact
5064            .clone()
5065            .expect("called with compact boundary");
5066        let mut preserved = compact.preserved_uuids;
5067        if preserved.is_empty() {
5068            if let Some((head, tail)) = compact.preserved_segment {
5069                preserved = self.walk_preserved_segment(&head, &tail)?;
5070            }
5071        }
5072
5073        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5074        for uuid in &preserved {
5075            if !self.by_uuid.contains_key(uuid) {
5076                return Err(claude_replay_error(format!(
5077                    "latest compact boundary references missing preserved uuid `{uuid}`"
5078                )));
5079            }
5080        }
5081
5082        let removed_uuids: HashSet<String> = self
5083            .nodes
5084            .iter()
5085            .enumerate()
5086            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5087            .map(|(_, node)| node.uuid.clone())
5088            .collect();
5089        for (idx, node) in self.nodes.iter().enumerate() {
5090            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5091                retained[idx] = false;
5092            }
5093        }
5094
5095        if preserved.is_empty() {
5096            return Ok(());
5097        }
5098        let anchor = compact.anchor_uuid.ok_or_else(|| {
5099            claude_replay_error("preserved compact boundary is missing anchorUuid")
5100        })?;
5101        if !self.by_uuid.contains_key(&anchor) {
5102            return Err(claude_replay_error(format!(
5103                "latest compact boundary references missing anchor uuid `{anchor}`"
5104            )));
5105        }
5106        let tail = preserved.last().cloned().expect("non-empty preserved list");
5107        let mut parent = anchor.clone();
5108        for uuid in &preserved {
5109            let idx = self.by_uuid[uuid];
5110            self.nodes[idx].parent_uuid = Some(parent);
5111            parent = uuid.clone();
5112        }
5113        let first = &preserved[0];
5114        for node in &mut self.nodes {
5115            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5116                node.parent_uuid = Some(tail.clone());
5117            }
5118        }
5119        for node in &mut self.nodes {
5120            if node.kind.is_conversation()
5121                && node
5122                    .parent_uuid
5123                    .as_ref()
5124                    .is_some_and(|parent| removed_uuids.contains(parent))
5125            {
5126                node.parent_uuid = Some(tail.clone());
5127            }
5128        }
5129        Ok(())
5130    }
5131
5132    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5133        let mut reversed = Vec::new();
5134        let mut seen = HashSet::new();
5135        let mut cursor = tail;
5136        loop {
5137            if !seen.insert(cursor.to_string()) {
5138                return Err(claude_replay_error("cycle in compact preservedSegment"));
5139            }
5140            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5141                claude_replay_error(format!(
5142                    "compact preservedSegment references missing uuid `{cursor}`"
5143                ))
5144            })?;
5145            reversed.push(cursor.to_string());
5146            if cursor == head {
5147                reversed.reverse();
5148                return Ok(reversed);
5149            }
5150            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5151                claude_replay_error(format!(
5152                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5153                ))
5154            })?;
5155        }
5156    }
5157
5158    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5159        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5160            claude_replay_error(format!(
5161                "Claude record `{}` has no conversational ancestor",
5162                self.nodes[idx].uuid
5163            ))
5164        })?;
5165        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5166            claude_replay_error(format!(
5167                "Claude record `{}` has missing parentUuid `{parent}`",
5168                self.nodes[idx].uuid
5169            ))
5170        })?;
5171        if !retained[parent_idx] {
5172            return Err(claude_replay_error(format!(
5173                "Claude record `{}` points into compacted-out history",
5174                self.nodes[idx].uuid
5175            )));
5176        }
5177        Ok(parent_idx)
5178    }
5179
5180    fn recover_parallel_assistant_chunks(
5181        &self,
5182        base: Vec<usize>,
5183        retained: &[bool],
5184    ) -> Result<Vec<usize>> {
5185        let selected: HashSet<usize> = base.iter().copied().collect();
5186        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5187        let mut skipped_positions = HashSet::new();
5188        let mut handled_ids = HashSet::new();
5189
5190        for (base_pos, idx) in base.iter().copied().enumerate() {
5191            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5192                continue;
5193            };
5194            if !handled_ids.insert(message_id.to_string()) {
5195                continue;
5196            }
5197            let base_positions: Vec<usize> = base
5198                .iter()
5199                .enumerate()
5200                .filter(|(_, candidate)| {
5201                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5202                })
5203                .map(|(pos, _)| pos)
5204                .collect();
5205            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5206            skipped_positions.extend(base_positions.iter().copied().skip(1));
5207
5208            // A streamed Anthropic response can be stored as sibling records
5209            // rather than a literal parent chain. Reassemble every chunk at
5210            // the first active occurrence and restore raw chunk order before
5211            // the normalizer coalesces their content blocks.
5212            let mut chunks: Vec<usize> = self
5213                .nodes
5214                .iter()
5215                .enumerate()
5216                .filter(|(candidate, node)| {
5217                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5218                })
5219                .map(|(candidate, _)| candidate)
5220                .collect();
5221            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5222
5223            let assistant_uuids: HashSet<&str> = self
5224                .nodes
5225                .iter()
5226                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5227                .map(|node| node.uuid.as_str())
5228                .collect();
5229            let mut results: Vec<usize> = self
5230                .nodes
5231                .iter()
5232                .enumerate()
5233                .filter(|(candidate, node)| {
5234                    retained[*candidate]
5235                        && !selected.contains(candidate)
5236                        && node.is_tool_result
5237                        && node
5238                            .parent_uuid
5239                            .as_deref()
5240                            .is_some_and(|parent| assistant_uuids.contains(parent))
5241                })
5242                .map(|(candidate, _)| candidate)
5243                .collect();
5244            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5245            chunks.extend(results);
5246            replacements.insert(anchor_pos, chunks);
5247        }
5248
5249        let mut out = Vec::with_capacity(selected.len());
5250        for (pos, idx) in base.into_iter().enumerate() {
5251            if let Some(replacement) = replacements.remove(&pos) {
5252                out.extend(replacement);
5253            } else if !skipped_positions.contains(&pos) {
5254                out.push(idx);
5255            }
5256        }
5257        Ok(out)
5258    }
5259}
5260
5261impl ClaudeCompactBoundary {
5262    fn from_value(v: &Value) -> Self {
5263        let metadata = v.get("compactMetadata");
5264        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5265        let anchor_uuid = preserved_messages
5266            .and_then(|p| p.get("anchorUuid"))
5267            .and_then(Value::as_str)
5268            .or_else(|| {
5269                metadata
5270                    .and_then(|m| m.get("preservedSegment"))
5271                    .and_then(|p| p.get("anchorUuid"))
5272                    .and_then(Value::as_str)
5273            })
5274            .map(str::to_string);
5275        let preserved_uuids = preserved_messages
5276            .and_then(|p| p.get("uuids"))
5277            .and_then(Value::as_array)
5278            .map(|uuids| {
5279                uuids
5280                    .iter()
5281                    .filter_map(Value::as_str)
5282                    .map(str::to_string)
5283                    .collect()
5284            })
5285            .unwrap_or_default();
5286        let preserved_segment =
5287            metadata
5288                .and_then(|m| m.get("preservedSegment"))
5289                .and_then(|segment| {
5290                    Some((
5291                        segment.get("headUuid")?.as_str()?.to_string(),
5292                        segment.get("tailUuid")?.as_str()?.to_string(),
5293                    ))
5294                });
5295        Self {
5296            anchor_uuid,
5297            preserved_uuids,
5298            preserved_segment,
5299        }
5300    }
5301}
5302
5303fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5304    crate::Error::Other(format!(
5305        "cannot reconstruct lossless Claude continuation: {}",
5306        message.into()
5307    ))
5308}
5309
5310fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5311    v.get("message")
5312        .and_then(|message| message.get("id"))
5313        .and_then(Value::as_str)
5314}
5315
5316fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5317    let Some(target_message) = target.get_mut("message") else {
5318        return;
5319    };
5320    let Some(chunk_message) = chunk.get("message") else {
5321        return;
5322    };
5323    let mut content = target_message
5324        .get("content")
5325        .and_then(Value::as_array)
5326        .cloned()
5327        .unwrap_or_default();
5328    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5329        content.extend(blocks.iter().cloned());
5330    }
5331    let mut merged_message = chunk_message.clone();
5332    merged_message["content"] = Value::Array(content);
5333    *target_message = merged_message;
5334}
5335
5336fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5337    let Some(v) = pending.take() else {
5338        return;
5339    };
5340    let reasoning_only = claude_assistant_message_id(&v).is_some()
5341        && v.get("message")
5342            .and_then(|message| message.get("content"))
5343            .and_then(Value::as_array)
5344            .is_some_and(|blocks| {
5345                !blocks.is_empty()
5346                    && blocks.iter().all(|block| {
5347                        matches!(
5348                            block.get("type").and_then(Value::as_str),
5349                            Some("thinking" | "redacted_thinking")
5350                        )
5351                    })
5352            });
5353    if reasoning_only {
5354        return;
5355    }
5356    let before = out.len();
5357    push_claude_assistant(&v, out);
5358    capture_claude_record_provenance(&v, &mut out[before..]);
5359    restore_single_grok_message(&v, &mut out[before..]);
5360}
5361
5362/// Attach the record identity, clock, and actual assistant model to every
5363/// canonical message produced from one Claude JSONL record. These fields are
5364/// deliberately per-message: a continued transcript can cross a provider
5365/// boundary, so the session-level source model is not authoritative for its
5366/// appended tail.
5367fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5368    let timestamp = v.get("timestamp").and_then(Value::as_str);
5369    let uuid = v.get("uuid").and_then(Value::as_str);
5370    let model = v
5371        .get("message")
5372        .and_then(|message| message.get("model"))
5373        .and_then(Value::as_str);
5374    for message in messages {
5375        if let Some(timestamp) = timestamp {
5376            message
5377                .metadata
5378                .entry("timestamp".to_string())
5379                .or_insert_with(|| timestamp.to_string());
5380        }
5381        if let Some(uuid) = uuid {
5382            message
5383                .metadata
5384                .entry("claude_uuid".to_string())
5385                .or_insert_with(|| uuid.to_string());
5386        }
5387        if let Some(model) = model {
5388            message
5389                .metadata
5390                .entry("model".to_string())
5391                .or_insert_with(|| model.to_string());
5392        }
5393    }
5394}
5395
5396fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5397    restore_codex_provenance_from_top_level(v, meta)?;
5398    if meta.session_id.is_none() {
5399        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5400            meta.session_id = Some(id.to_string());
5401        }
5402    }
5403    if meta.cwd.is_none() {
5404        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5405            meta.cwd = Some(PathBuf::from(cwd));
5406        }
5407    }
5408    if meta.model.is_none() {
5409        if let Some(model) = v
5410            .get("message")
5411            .and_then(|m| m.get("model"))
5412            .and_then(Value::as_str)
5413        {
5414            meta.model = Some(model.to_string());
5415        }
5416    }
5417    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5418    // real Claude Code record with no confirmed field shape (see
5419    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5420    // named fields and risk silently mis-modeling it, stash the WHOLE raw
5421    // line verbatim under a lineage key. `write_claude_code_records` (below)
5422    // re-emits it byte-for-byte, so the record survives the Claude Code
5423    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5424    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5425    // so a Claude -> Codex -> Claude round trip can still reconstruct it
5426    // (dev/03). A session can only fork from one context, so the first one
5427    // seen wins, matching every other "first wins" field above.
5428    //
5429    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5430    // a RE-SERIALIZATION of the parsed `Value`, not the original source
5431    // text. `serde_json::Value` here has no `preserve_order` feature (see
5432    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5433    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5434    // this very comment was false. Fixed the cheap+honest way: store the
5435    // caller's own already-verbatim source `raw_line` text instead of
5436    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5437    // (key order, spacing, everything) rather than merely
5438    // structurally-equivalent JSON.
5439    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5440        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5441    {
5442        meta.lineage.insert(
5443            "claude_fork_context_ref_raw".to_string(),
5444            raw_line.to_string(),
5445        );
5446    }
5447    Ok(())
5448}
5449
5450fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5451    let content = v.get("message").and_then(|m| m.get("content"));
5452    let provenance = claude_user_provenance(v);
5453    match content {
5454        Some(Value::String(s)) => {
5455            if !s.trim().is_empty() {
5456                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5457            }
5458        }
5459        Some(Value::Array(blocks)) => {
5460            let mut text = String::new();
5461            // IX-5: image blocks alongside/instead of text — collected
5462            // separately (never synthesized on a malformed shape, see
5463            // `claude_image_block_to_part`) so a multimodal user turn
5464            // survives as `content_parts` instead of the image silently
5465            // vanishing.
5466            let mut images: Vec<Value> = Vec::new();
5467            // D5: an `image` block whose `source` isn't base64/url (e.g. a
5468            // Files-API `{"source":{"type":"file","file_id":..}}`
5469            // reference) makes `claude_image_block_to_part` return `None` —
5470            // track that it was SEEN even though it couldn't be converted,
5471            // so an image-ONLY record (no text, no convertible image) isn't
5472            // silently dropped below (the same vanishing-record bug-class
5473            // PARITY-11 fixed for reasoning-only turns).
5474            let mut saw_unconvertible_image = false;
5475            for b in blocks {
5476                match b.get("type").and_then(Value::as_str) {
5477                    Some("text") => push_text(&mut text, b.get("text")),
5478                    Some("tool_result") => {
5479                        let id = b
5480                            .get("tool_use_id")
5481                            .and_then(Value::as_str)
5482                            .unwrap_or_default();
5483                        // PARITY-11 (nested images): `extract_tool_result_content`
5484                        // captures any `image` blocks nested inside this
5485                        // `tool_result` into `content_parts` (via
5486                        // `claude_image_block_to_part`, the same conversion the
5487                        // top-level `image` block path already uses) instead of
5488                        // flattening them to the bare `[image]` marker text the
5489                        // old `extract_tool_result` emitted — the everyday
5490                        // "Read a PNG / screenshot tool output" shape.
5491                        let (result, images) =
5492                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5493                        let mut msg = tool_message(id, result);
5494                        if !images.is_empty() {
5495                            // D-mix (Fable review, must-fix): `content_parts`
5496                            // is a self-contained contract — the pi writer
5497                            // (`pi_content_value`) reads ONLY `content_parts`
5498                            // for a `Role::Tool` message and never falls back
5499                            // to `msg.content`, so on a MIXED text+image
5500                            // tool_result a bare `content_parts: [image]`
5501                            // silently drops the sibling text on `convert
5502                            // --to pi` (a regression vs. the pre-PARITY-11
5503                            // baseline, which at least preserved the text).
5504                            // Prepend the text as part 0, exactly mirroring
5505                            // `pi_content_to_text_and_parts` and
5506                            // `push_opencode_user`'s identical
5507                            // self-contained-parts construction. `msg.content`
5508                            // keeps the text too (unchanged) for the writers
5509                            // that read text from `msg.content` and only scan
5510                            // `content_parts` for `image_url` entries
5511                            // (`claude_tool_result_content_value`,
5512                            // `codex_tool_output_text`, the opencode
5513                            // assistant writer) — those already filter
5514                            // strictly on `image_url`/text-typed lookups, so
5515                            // this text part is never double-counted.
5516                            let mut parts = Vec::new();
5517                            if let Some(t) = &msg.content {
5518                                if !t.is_empty() {
5519                                    parts.push(serde_json::json!({"type": "text", "text": t}));
5520                                }
5521                            }
5522                            parts.extend(images);
5523                            msg.content_parts = Some(parts);
5524                        }
5525                        // The assistant turn that issued this tool call — the
5526                        // tool-pairing graph edge (parallel to parentUuid).
5527                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5528                        {
5529                            msg.metadata
5530                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5531                        }
5532                        // TR-10: preserve the Claude wire `is_error` flag so
5533                        // the reduction layer's success/failure boundary
5534                        // (`ReductionKind::ToolInputElided` must never target
5535                        // an errored call) survives import — `ChatMessage`
5536                        // otherwise has no structural slot for it.
5537                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5538                            crate::mark_tool_error(&mut msg);
5539                        } else {
5540                            restore_tool_outcome_extension(v, &mut msg);
5541                        }
5542                        out.push(msg);
5543                    }
5544                    Some("image") => match claude_image_block_to_part(b) {
5545                        Some(part) => images.push(part),
5546                        None => saw_unconvertible_image = true,
5547                    },
5548                    _ => {} // document / unknown — skip
5549                }
5550            }
5551            // D5: nothing convertible landed in `text`/`images` but an
5552            // image block WAS present — fold in the same short bracketed
5553            // marker convention already used for `[web_search]`/`[model
5554            // fallback: ...]` rather than letting the record vanish.
5555            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5556                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5557            }
5558            let before = out.len();
5559            if !images.is_empty() {
5560                let mut parts = Vec::new();
5561                if !text.trim().is_empty() {
5562                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5563                }
5564                parts.extend(images);
5565                out.push(
5566                    ChatMessage {
5567                        role: Role::User,
5568                        content: None,
5569                        content_parts: Some(parts),
5570                        tool_calls: None,
5571                        tool_call_id: None,
5572                        name: None,
5573                        metadata: Default::default(),
5574                    }
5575                    .with_metas(&provenance),
5576                );
5577            } else if !text.trim().is_empty() {
5578                out.push(ChatMessage::user(text).with_metas(&provenance));
5579            }
5580            if saw_unconvertible_image && out.len() > before {
5581                if let Some(msg) = out.last_mut() {
5582                    msg.metadata
5583                        .insert("image_source_unconvertible".to_string(), "true".to_string());
5584                }
5585            }
5586        }
5587        _ => {}
5588    }
5589}
5590
5591/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5592/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5593/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5594/// else in the record survives either — matches the existing
5595/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5596/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5597const UNCONVERTIBLE_IMAGE_MARKER: &str =
5598    "[image: source not captured — unsupported/unconvertible image reference]";
5599
5600/// Parse a Claude Code user-turn `image` content block
5601/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5602/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5603/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5604/// bare URL for the url form) — the inverse of
5605/// [`claude_user_content_value`]'s emission. Only a well-formed source
5606/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5607/// anything else — including a well-formed but unconvertible source like a
5608/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5609/// residue rather than synthesizing a corrupt/empty part (mirrors the
5610/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5611/// discipline). Callers must not let that turn the record invisible though:
5612/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5613fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5614    let source = b.get("source")?;
5615    match source.get("type").and_then(Value::as_str) {
5616        Some("base64") => {
5617            let mime = source.get("media_type").and_then(Value::as_str)?;
5618            let data = source.get("data").and_then(Value::as_str)?;
5619            if mime.is_empty() || data.is_empty() {
5620                return None;
5621            }
5622            Some(serde_json::json!({
5623                "type": "image_url",
5624                "image_url": {"url": format!("data:{mime};base64,{data}")},
5625            }))
5626        }
5627        Some("url") => {
5628            let url = source.get("url").and_then(Value::as_str)?;
5629            if url.is_empty() {
5630                return None;
5631            }
5632            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5633        }
5634        _ => None,
5635    }
5636}
5637
5638/// Rebuild a Claude Code user-turn `message.content` value from a
5639/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5640/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5641/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5642/// overriding constraint: a text-only message's export stays byte-identical)
5643/// — only a multimodal message (`content_parts` present, e.g. imported from
5644/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5645/// content-array shape, one `text` block (if any non-empty text part) plus
5646/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5647/// any other URL → `source.url`).
5648fn claude_user_content_value(msg: &ChatMessage) -> Value {
5649    match &msg.content_parts {
5650        Some(parts) => {
5651            let mut blocks = Vec::new();
5652            for p in parts {
5653                match p.get("type").and_then(Value::as_str) {
5654                    Some("text") => {
5655                        if let Some(t) = p.get("text").and_then(Value::as_str) {
5656                            if !t.is_empty() {
5657                                blocks.push(serde_json::json!({"type": "text", "text": t}));
5658                            }
5659                        }
5660                    }
5661                    Some("image_url") => {
5662                        if let Some(url) = p
5663                            .get("image_url")
5664                            .and_then(|u| u.get("url"))
5665                            .and_then(Value::as_str)
5666                        {
5667                            blocks.push(match parse_data_uri(url) {
5668                                Some((mime, data)) => serde_json::json!({
5669                                    "type": "image",
5670                                    "source": {"type": "base64", "media_type": mime, "data": data},
5671                                }),
5672                                None => serde_json::json!({
5673                                    "type": "image",
5674                                    "source": {"type": "url", "url": url},
5675                                }),
5676                            });
5677                        }
5678                    }
5679                    _ => {}
5680                }
5681            }
5682            Value::Array(blocks)
5683        }
5684        None => Value::String(msg.content.clone().unwrap_or_default()),
5685    }
5686}
5687
5688/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5689/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5690/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5691/// the historical plain-string `content` exactly (same IX-5-style constraint
5692/// `claude_user_content_value` follows) — only a `tool_result` that actually
5693/// carries a captured nested image gets the Anthropic content-array shape,
5694/// one `text` block (the existing `msg.content`, if any) plus one `image`
5695/// block per `image_url` part (mirrors `claude_user_content_value`'s
5696/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5697fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5698    match &msg.content_parts {
5699        Some(parts) if !parts.is_empty() => {
5700            let mut blocks = Vec::new();
5701            if let Some(t) = &msg.content {
5702                if !t.is_empty() {
5703                    blocks.push(serde_json::json!({"type": "text", "text": t}));
5704                }
5705            }
5706            for p in parts {
5707                if p.get("type").and_then(Value::as_str) == Some("image_url") {
5708                    if let Some(url) = p
5709                        .get("image_url")
5710                        .and_then(|u| u.get("url"))
5711                        .and_then(Value::as_str)
5712                    {
5713                        blocks.push(match parse_data_uri(url) {
5714                            Some((mime, data)) => serde_json::json!({
5715                                "type": "image",
5716                                "source": {"type": "base64", "media_type": mime, "data": data},
5717                            }),
5718                            None => serde_json::json!({
5719                                "type": "image",
5720                                "source": {"type": "url", "url": url},
5721                            }),
5722                        });
5723                    }
5724                }
5725            }
5726            Value::Array(blocks)
5727        }
5728        _ => Value::String(msg.content.clone().unwrap_or_default()),
5729    }
5730}
5731
5732/// Collect the Claude Code user-turn provenance fields that distinguish real
5733/// human input from system-injected turns and record replay-relevant state.
5734fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5735    let mut out = Vec::new();
5736    let mut take_str = |key: &str| {
5737        if let Some(s) = v.get(key).and_then(Value::as_str) {
5738            out.push((key.to_string(), s.to_string()));
5739        }
5740    };
5741    take_str("promptSource"); // typed | queued | system | sdk
5742    take_str("interruptedMessageId");
5743    take_str("sourceToolUseID");
5744    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5745        if v.get(flag).and_then(Value::as_bool) == Some(true) {
5746            out.push((flag.to_string(), "true".to_string()));
5747        }
5748    }
5749    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5750        out.push(("queuePriority".to_string(), n.to_string()));
5751    }
5752    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5753    if let Some(kind) = v
5754        .get("origin")
5755        .and_then(|o| o.get("kind"))
5756        .and_then(Value::as_str)
5757    {
5758        out.push(("origin".to_string(), kind.to_string()));
5759    }
5760    out
5761}
5762
5763/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5764/// `local_command`, `away_summary`) carry real text that's part of the
5765/// interaction; fold them in as system context. Marker/metric subtypes
5766/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5767/// no conversational content and are skipped.
5768fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5769    let keep = matches!(
5770        v.get("subtype").and_then(Value::as_str),
5771        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5772    );
5773    if !keep {
5774        return;
5775    }
5776    if let Some(content) = v.get("content").and_then(Value::as_str) {
5777        if !content.trim().is_empty() {
5778            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5779            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5780        }
5781    }
5782}
5783
5784/// Fold content-bearing Claude Code `attachment` records into the conversation
5785/// as user-role messages. Most attachment subtypes (`task_reminder`,
5786/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5787/// are regenerable system injections and are skipped; only the four that carry
5788/// non-regenerable user/external content are kept.
5789fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5790    let att = match v.get("attachment") {
5791        Some(a) => a,
5792        None => return,
5793    };
5794    let kind = match att.get("type").and_then(Value::as_str) {
5795        Some(kind) => kind,
5796        None => return,
5797    };
5798    let text = match kind {
5799        // A queued prompt. `commandMode` says whose: `prompt` is the person's
5800        // own text, `task-notification` is the runtime reporting a finished
5801        // background task. Kept verbatim below.
5802        "queued_command" => att
5803            .get("prompt")
5804            .and_then(Value::as_str)
5805            .map(str::to_string),
5806        // A file the user attached: header + contents.
5807        "file" => attachment_with_path(att, "attached file", "filename", "content"),
5808        // A user-edited file snippet.
5809        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5810        // Injected project memory (CLAUDE.md), point-in-time.
5811        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5812        _ => None, // regenerable system injection — skip
5813    };
5814    let Some(text) = text else { return };
5815    if text.trim().is_empty() {
5816        return;
5817    }
5818    // An attachment record wears the user's ROLE, but the record itself says
5819    // who actually spoke — and that fact is lost the moment the attachment is
5820    // flattened to `[label: path]` text, so carry it as metadata the way
5821    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5822    //
5823    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
5824    //                     `nested_memory` are envelopes the runtime built
5825    //                     around a file body; a frontend that trusts the role
5826    //                     shows the reader a numbered source listing in a
5827    //                     chat bubble apparently sent by themselves.
5828    //   `commandMode`     present on `queued_command` only, and the whole
5829    //                     story for it. Measured over the local Claude Code
5830    //                     corpus (2,512 `queued_command` attachments): 926
5831    //                     `prompt`, every one of them plain human text, and
5832    //                     1,586 `task-notification`, every one of them a
5833    //                     `<task-notification>` frame — the same text Claude
5834    //                     Code also writes as a `type:"user"` record stamped
5835    //                     `origin.kind = "task-notification"`.
5836    //
5837    // Presentation policy (which of these a frontend hides) belongs to the
5838    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5839    // job is to stop discarding the producer's own answer.
5840    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5841    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5842        message = message.with_meta("commandMode", mode);
5843    }
5844    out.push(message);
5845}
5846
5847/// Format an attachment as `[<label>: <path>]\n<body>`.
5848fn attachment_with_path(
5849    att: &Value,
5850    label: &str,
5851    path_key: &str,
5852    body_key: &str,
5853) -> Option<String> {
5854    let body = att.get(body_key).and_then(Value::as_str)?;
5855    let path = att
5856        .get(path_key)
5857        .or_else(|| att.get("displayPath"))
5858        .and_then(Value::as_str)
5859        .unwrap_or("");
5860    Some(format!("[{label}: {path}]\n{body}"))
5861}
5862
5863fn push_str_field(buf: &mut String, s: &str) {
5864    if !buf.is_empty() {
5865        buf.push('\n');
5866    }
5867    buf.push_str(s);
5868}
5869
5870/// N3: build a synthesized message for reasoning that could not attach to a
5871/// following assistant turn — either interrupted mid-stream by a
5872/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5873/// the three pending buffers (all empty/`false` afterward) so callers don't
5874/// separately have to remember to clear them.
5875fn orphaned_reasoning_message(
5876    reasoning: &mut String,
5877    reasoning_content: &mut String,
5878    encrypted: &mut bool,
5879) -> ChatMessage {
5880    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5881    if !reasoning.is_empty() {
5882        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5883    }
5884    if !reasoning_content.is_empty() {
5885        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5886    }
5887    if *encrypted {
5888        msg = msg.with_meta("reasoning_encrypted", "true");
5889        *encrypted = false;
5890    }
5891    msg
5892}
5893
5894fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5895    let content = v.get("message").and_then(|m| m.get("content"));
5896    let mut text = String::new();
5897    let mut calls: Vec<ToolCall> = Vec::new();
5898    // Legacy singular fields — kept for backward compatibility with every
5899    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5900    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5901    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5902    // message carries MULTIPLE `thinking` blocks, collapsing them down to
5903    // these singular fields silently drops every signature but the last
5904    // one's — a real Anthropic `thinking` block's `signature` cryptographically
5905    // covers ONLY that block's own text, so re-emitting block 1's text under
5906    // block 2's signature (or vice versa) produces a signature that will
5907    // never verify. `thinking_blocks` below is the fix: every block
5908    // preserved SEPARATELY, in order, each with its own (optional)
5909    // signature/data — the writer prefers it over the legacy fields
5910    // whenever present.
5911    let mut thinking = String::new();
5912    let mut signature: Option<String> = None;
5913    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5914    // `image` assistant blocks, and (rarely) a `fallback` model-routing
5915    // marker — none handled before, all silently vanishing (audit's own
5916    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5917    // `fallback` blocks in the reference corpus).
5918    //
5919    // D8: `redacted_thinking` is real data ONLY — never a fabricated
5920    // placeholder. The pre-fix code defaulted a missing `data` field to the
5921    // literal string `"<redacted>"`, which is indistinguishable from an
5922    // actual (if oddly-named) opaque payload on re-emit — a caller reading
5923    // it back has no way to tell "no data was ever captured" from "the
5924    // provider's own opaque blob happens to be the string `<redacted>`".
5925    // `redacted_thinking_seen` tracks block PRESENCE independently of
5926    // whether it had real data, so the reasoning-only-turn rescue below
5927    // still fires even when no block had a `data` field at all.
5928    let mut redacted_thinking: Option<String> = None;
5929    let mut redacted_thinking_seen = false;
5930    let mut images: Vec<Value> = Vec::new();
5931    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
5932    // `thinking` string alongside a real `signature` (the summarized/
5933    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
5934    // would miss those, so track "a thinking block existed at all"
5935    // separately from whether it had visible text.
5936    let mut thinking_block_seen = false;
5937    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
5938    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
5939    // above. Serialized as a single JSON-array metadata string
5940    // (`ChatMessage::metadata` is a flat string map) under
5941    // `"thinking_blocks"`.
5942    let mut thinking_blocks: Vec<Value> = Vec::new();
5943    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
5944    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
5945    // not silently vanish the whole record when nothing else survives.
5946    let mut saw_unconvertible_image = false;
5947
5948    match content {
5949        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
5950        Some(Value::Array(blocks)) => {
5951            for b in blocks {
5952                match b.get("type").and_then(Value::as_str) {
5953                    Some("text") => push_text(&mut text, b.get("text")),
5954                    Some("tool_use") => {
5955                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
5956                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
5957                        let args = b
5958                            .get("input")
5959                            .map(|i| i.to_string())
5960                            .unwrap_or_else(|| "{}".to_string());
5961                        calls.push(function_call(id, name, args));
5962                    }
5963                    // Thinking is not replayed across providers, but retain it in
5964                    // (skip-serialized) metadata so a same-model continuation can
5965                    // re-inject it. See P3.
5966                    Some("thinking") => {
5967                        thinking_block_seen = true;
5968                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
5969                        if !t.is_empty() {
5970                            push_str_field(&mut thinking, t); // legacy concatenated field
5971                        }
5972                        let sig = b.get("signature").and_then(Value::as_str);
5973                        if let Some(s) = sig {
5974                            signature = Some(s.to_string()); // legacy last-wins field
5975                        }
5976                        // D8: this block's OWN text + signature, not folded
5977                        // into the running concatenation above.
5978                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
5979                        if let Some(s) = sig {
5980                            block["signature"] = Value::String(s.to_string());
5981                        }
5982                        thinking_blocks.push(block);
5983                    }
5984                    // Anthropic's redacted reasoning: an opaque, provider-private
5985                    // payload (flagged content the API declines to show in the
5986                    // clear). Like `thinking`, it's not replayable, but the raw
5987                    // `data` is retained in metadata rather than silently
5988                    // vanishing — a same-model continuation can still replay it
5989                    // verbatim even though supercode never renders it.
5990                    Some("redacted_thinking") => {
5991                        redacted_thinking_seen = true;
5992                        let data = b.get("data").and_then(Value::as_str);
5993                        // D8: no fabricated fallback — `data` is only ever
5994                        // the real captured payload, or genuinely absent.
5995                        if let Some(d) = data {
5996                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
5997                        }
5998                        let mut block = serde_json::json!({"type": "redacted_thinking"});
5999                        if let Some(d) = data {
6000                            block["data"] = Value::String(d.to_string());
6001                        }
6002                        thinking_blocks.push(block);
6003                    }
6004                    // An assistant-emitted image block (e.g. a generated
6005                    // image) — collected exactly like `push_claude_user`'s
6006                    // user-turn image handling (`claude_image_block_to_part`
6007                    // is role-general), so it survives as `content_parts`
6008                    // instead of vanishing.
6009                    Some("image") => match claude_image_block_to_part(b) {
6010                        Some(part) => images.push(part),
6011                        None => saw_unconvertible_image = true,
6012                    },
6013                    // A provider-routing note (real shape:
6014                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6015                    // — a mid-generation model swap, e.g. an overloaded model
6016                    // falling back to another). Carries no replayable
6017                    // conversational content, but folding it into `text` as a
6018                    // short bracketed marker — the same convention the Codex
6019                    // loader already uses for `[web_search]`/
6020                    // `[image_generation] ...` — keeps it visible instead of
6021                    // silently vanishing, including the case where it's the
6022                    // ONLY block in the turn (see the reasoning-only-turn fix
6023                    // below: before this, that shape dropped the entire
6024                    // message).
6025                    Some("fallback") => {
6026                        let from = b
6027                            .get("from")
6028                            .and_then(|f| f.get("model"))
6029                            .and_then(Value::as_str)
6030                            .unwrap_or("?");
6031                        let to = b
6032                            .get("to")
6033                            .and_then(|t| t.get("model"))
6034                            .and_then(Value::as_str)
6035                            .unwrap_or("?");
6036                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6037                    }
6038                    _ => {}
6039                }
6040            }
6041        }
6042        _ => {}
6043    }
6044
6045    // D5: nothing convertible landed in `text`/`images` but an image block
6046    // WAS present — fold in the same bracketed-marker convention `fallback`
6047    // uses above, so a genuinely image-only (unconvertible source) turn
6048    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6049    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6050        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6051    }
6052
6053    let before = out.len();
6054    if !images.is_empty() {
6055        let mut parts = Vec::new();
6056        if !text.trim().is_empty() {
6057            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6058        }
6059        parts.extend(images);
6060        out.push(ChatMessage {
6061            role: Role::Assistant,
6062            content: None,
6063            content_parts: Some(parts),
6064            tool_calls: (!calls.is_empty()).then_some(calls),
6065            tool_call_id: None,
6066            name: None,
6067            metadata: Default::default(),
6068        });
6069    } else {
6070        push_assistant(out, text, calls);
6071        // A recognized native assistant record remains transcript state even
6072        // when its content array is empty (for example, an interrupted model
6073        // turn). Force a bare message whenever `push_assistant` had nothing
6074        // to emit. This includes the reasoning-only case and also preserves
6075        // genuinely part-less records instead of silently changing turn
6076        // count/order during translation.
6077        if out.len() == before {
6078            let mut empty = ChatMessage {
6079                role: Role::Assistant,
6080                content: None,
6081                content_parts: None,
6082                tool_calls: None,
6083                tool_call_id: None,
6084                name: None,
6085                metadata: Default::default(),
6086            };
6087            if !thinking_block_seen && !redacted_thinking_seen {
6088                empty
6089                    .metadata
6090                    .insert("empty_assistant_record".to_string(), "true".to_string());
6091            }
6092            out.push(empty);
6093        }
6094    }
6095    // Attach retained reasoning + attribution to the message we just produced.
6096    if out.len() > before {
6097        if let Some(msg) = out.last_mut() {
6098            // Insert "thinking" (even as an empty string) whenever a
6099            // `thinking` block was actually seen, not just when it had
6100            // visible text — a real `thinking` block commonly carries an
6101            // empty `thinking` string alongside a real `signature` (the
6102            // summarized-away-but-still-replayable case), and the writer
6103            // below keys its re-emission decision off this metadata key's
6104            // PRESENCE, not its content.
6105            if thinking_block_seen {
6106                msg.metadata.insert("thinking".to_string(), thinking);
6107            }
6108            if let Some(sig) = signature {
6109                msg.metadata.insert("thinking_signature".to_string(), sig);
6110            }
6111            if let Some(rt) = redacted_thinking {
6112                msg.metadata.insert("redacted_thinking".to_string(), rt);
6113            }
6114            // D8: exact per-block re-emission list — every `thinking`/
6115            // `redacted_thinking` block preserved separately, in order, each
6116            // with its own (optional) signature/data. The writer prefers
6117            // this over the legacy singular fields above whenever present,
6118            // so a multi-block message round-trips losslessly instead of
6119            // collapsing to one block under one (now-unverifiable)
6120            // signature.
6121            if !thinking_blocks.is_empty() {
6122                msg.metadata.insert(
6123                    "thinking_blocks".to_string(),
6124                    Value::Array(thinking_blocks).to_string(),
6125                );
6126            }
6127            // D5: honest signal that this message contained an image block
6128            // whose source this loader couldn't convert — the actual image
6129            // content is NOT captured, only a marker/partial record.
6130            if saw_unconvertible_image {
6131                msg.metadata
6132                    .insert("image_source_unconvertible".to_string(), "true".to_string());
6133            }
6134            // Attribution: which skill / subagent / MCP server+tool produced
6135            // this turn, plus the model `slug`.
6136            for key in [
6137                "attributionSkill",
6138                "attributionAgent",
6139                "attributionMcpServer",
6140                "attributionMcpTool",
6141                "slug",
6142            ] {
6143                if let Some(s) = v.get(key).and_then(Value::as_str) {
6144                    msg.metadata.insert(key.to_string(), s.to_string());
6145                }
6146            }
6147        }
6148    }
6149}
6150
6151// ---- Codex ----------------------------------------------------------------
6152
6153const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6154
6155fn codex_provenance_kind(record: &Value) -> Option<&str> {
6156    match record.get("type").and_then(Value::as_str) {
6157        Some("session_meta") => Some("session_meta"),
6158        Some("turn_context") => Some("turn_context"),
6159        Some("compacted") => Some("compacted"),
6160        Some("event_msg") => match record
6161            .get("payload")
6162            .and_then(|payload| payload.get("type"))
6163            .and_then(Value::as_str)
6164        {
6165            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6166            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6167            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6168            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6169            _ => None,
6170        },
6171        _ => None,
6172    }
6173}
6174
6175fn capture_codex_provenance_record(
6176    meta: &mut SessionMeta,
6177    record_index: usize,
6178    raw_line: &str,
6179    record: &Value,
6180) {
6181    let Some(kind) = codex_provenance_kind(record) else {
6182        return;
6183    };
6184    meta.codex_provenance.push(serde_json::json!({
6185        "record_index": record_index,
6186        "kind": kind,
6187        "raw": raw_line,
6188    }));
6189}
6190
6191fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6192    (!meta.codex_provenance.is_empty()).then(|| {
6193        serde_json::json!({
6194            "version": 1,
6195            "records": &meta.codex_provenance,
6196        })
6197    })
6198}
6199
6200fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6201    if extension.get("version").and_then(Value::as_u64) != Some(1) {
6202        return Err(Error::InvalidSession(
6203            "invalid portable Codex provenance: expected version 1".to_string(),
6204        ));
6205    }
6206    let Some(records) = extension.get("records").and_then(Value::as_array) else {
6207        return Err(Error::InvalidSession(
6208            "invalid portable Codex provenance: `records` must be an array".to_string(),
6209        ));
6210    };
6211    if records.is_empty() {
6212        return Err(Error::InvalidSession(
6213            "invalid portable Codex provenance: `records` must not be empty".to_string(),
6214        ));
6215    }
6216    let mut restored = Vec::with_capacity(records.len());
6217    for entry in records {
6218        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6219            return Err(Error::InvalidSession(
6220                "invalid portable Codex provenance: record_index must be an integer".to_string(),
6221            ));
6222        };
6223        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6224            return Err(Error::InvalidSession(
6225                "invalid portable Codex provenance: kind must be a string".to_string(),
6226            ));
6227        };
6228        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6229            return Err(Error::InvalidSession(
6230                "invalid portable Codex provenance: raw must be a string".to_string(),
6231            ));
6232        };
6233        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6234            return Err(Error::InvalidSession(
6235                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6236            ));
6237        };
6238        if codex_provenance_kind(&record) != Some(kind) {
6239            return Err(Error::InvalidSession(format!(
6240                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6241            )));
6242        }
6243        restored.push(entry.clone());
6244    }
6245    meta.codex_provenance = restored;
6246    meta.codex_headers.clear();
6247    for entry in &meta.codex_provenance {
6248        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6249            continue;
6250        };
6251        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6252            continue;
6253        };
6254        if matches!(
6255            record.get("type").and_then(Value::as_str),
6256            Some("session_meta") | Some("turn_context")
6257        ) {
6258            meta.codex_headers.push(record);
6259        }
6260    }
6261    Ok(true)
6262}
6263
6264fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6265    match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6266        Some(extension) => restore_codex_provenance(extension, meta),
6267        None => Ok(false),
6268    }
6269}
6270
6271fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6272    let Some(line_end) = out.find('\n') else {
6273        return;
6274    };
6275    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6276        return;
6277    };
6278    let Some(object) = record.as_object_mut() else {
6279        return;
6280    };
6281    object.insert(key.to_string(), extension);
6282    out.replace_range(..line_end, &record.to_string());
6283}
6284
6285fn inject_codex_provenance(out: &mut String, extension: Value) {
6286    let Some(line_end) = out.find('\n') else {
6287        return;
6288    };
6289    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6290        return;
6291    };
6292    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6293        return;
6294    }
6295    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6296        return;
6297    };
6298    payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6299    out.replace_range(..line_end, &record.to_string());
6300}
6301
6302/// Remove the last conversational turn from `messages`: everything from the
6303/// last `user` message to the end (the user prompt plus the assistant's
6304/// response and any tool calls/results it triggered).
6305fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6306    if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6307        messages.truncate(idx);
6308    } else {
6309        messages.clear();
6310    }
6311    // IX-6 fix: the new tail exposed by `truncate` may still carry
6312    // `__codex_open_turn` from when it was marked (it was NOT the last
6313    // message at that time — items after it, now removed by the rollback,
6314    // intervened). A bare `function_call` arriving after the rollback is a
6315    // genuinely NEW turn and must get its own message, not merge into this
6316    // stale marked tail — close it out here so `push_codex_item`'s
6317    // adjacency check (`out.last()` + marker) can't be fooled by the
6318    // truncation re-exposing it.
6319    if let Some(last) = messages.last_mut() {
6320        last.metadata.remove("__codex_open_turn");
6321    }
6322}
6323
6324fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6325    truncate_messages_with_anchor(messages, message_limit, None);
6326}
6327
6328fn truncate_messages_with_anchor(
6329    messages: &mut Vec<ChatMessage>,
6330    message_limit: usize,
6331    preceding_user: Option<ChatMessage>,
6332) {
6333    let limit = message_limit.max(1);
6334    if messages.len() <= limit {
6335        return;
6336    }
6337    let tail_start = messages.len() - limit;
6338    if let Some(relative_user) = messages[tail_start..]
6339        .iter()
6340        .position(|message| message.role == Role::User)
6341    {
6342        messages.drain(..tail_start + relative_user);
6343        return;
6344    }
6345    let anchor = messages[..tail_start]
6346        .iter()
6347        .rfind(|message| message.role == Role::User)
6348        .cloned()
6349        .or(preceding_user);
6350    if let Some(anchor) = anchor {
6351        let recent_start = messages.len() - limit.saturating_sub(1);
6352        messages.drain(..recent_start);
6353        messages.insert(0, anchor);
6354    } else {
6355        messages.drain(..tail_start);
6356    }
6357}
6358
6359fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6360    truncate_messages(&mut session.messages, message_limit);
6361}
6362
6363/// The text of a Codex `agent_message` event. `message` is usually a string but
6364/// can be a structured object (e.g. review output) — fall back to its JSON.
6365fn agent_message_text(payload: &Value) -> String {
6366    match payload.get("message") {
6367        Some(Value::String(s)) => s.clone(),
6368        Some(other) => extract_text_content(Some(other)),
6369        None => String::new(),
6370    }
6371}
6372
6373/// Trimmed texts of all assistant messages present as `response_item` — the
6374/// dedup set for recovering collab-only `agent_message` narration.
6375fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6376    let mut set = std::collections::HashSet::new();
6377    for line in non_empty_lines(jsonl) {
6378        let Ok(v) = serde_json::from_str::<Value>(line) else {
6379            continue;
6380        };
6381        if v.get("type").and_then(Value::as_str) != Some("response_item") {
6382            continue;
6383        }
6384        let payload = v.get("payload").unwrap_or(&Value::Null);
6385        if payload.get("type").and_then(Value::as_str) == Some("message")
6386            && payload.get("role").and_then(Value::as_str) == Some("assistant")
6387        {
6388            let text = extract_text_content(payload.get("content"));
6389            if !text.trim().is_empty() {
6390                set.insert(text.trim().to_string());
6391            }
6392        }
6393    }
6394    set
6395}
6396
6397fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6398    if meta.session_id.is_none() {
6399        if let Some(id) = payload.get("id").and_then(Value::as_str) {
6400            meta.session_id = Some(id.to_string());
6401        }
6402    }
6403    if meta.cwd.is_none() {
6404        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6405            meta.cwd = Some(PathBuf::from(cwd));
6406        }
6407    }
6408    if meta.system_prompt.is_none() {
6409        // `base_instructions` may be a string or `{ "text": "..." }`.
6410        let bi = payload.get("base_instructions");
6411        let text = match bi {
6412            Some(Value::String(s)) => Some(s.clone()),
6413            Some(Value::Object(_)) => bi
6414                .and_then(|b| b.get("text"))
6415                .and_then(Value::as_str)
6416                .map(str::to_string),
6417            _ => None,
6418        };
6419        meta.system_prompt = text;
6420    }
6421    if meta.model.is_none() {
6422        if let Some(m) = payload.get("model").and_then(Value::as_str) {
6423            meta.model = Some(m.to_string());
6424        }
6425    }
6426    // Cross-file lineage keys for multi-agent / forked sessions.
6427    let mut put = |key: &str, v: Option<&Value>| {
6428        if let Some(s) = v.and_then(Value::as_str) {
6429            meta.lineage.insert(key.to_string(), s.to_string());
6430        }
6431    };
6432    put("parent_thread_id", payload.get("parent_thread_id"));
6433    put("forked_from_id", payload.get("forked_from_id"));
6434    put("thread_source", payload.get("thread_source"));
6435    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6436    // passthrough — restores a captured Claude `fork-context-ref` so a
6437    // Claude -> Codex -> Claude round trip reconstructs the original record
6438    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6439    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6440        if let Some(v) = payload.get("claude_fork_context_ref") {
6441            meta.lineage
6442                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6443        }
6444    }
6445    if let Some(spawn) = payload
6446        .get("source")
6447        .and_then(|s| s.get("subagent"))
6448        .and_then(|s| s.get("thread_spawn"))
6449    {
6450        // parent_thread_id can also live here (preferred when both present).
6451        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6452            meta.lineage
6453                .insert("parent_thread_id".to_string(), p.to_string());
6454        }
6455        for k in ["agent_role", "agent_nickname"] {
6456            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6457                meta.lineage.insert(k.to_string(), s.to_string());
6458            }
6459        }
6460        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6461            meta.lineage.insert("depth".to_string(), d.to_string());
6462        }
6463    }
6464}
6465
6466/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6467fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6468    let mut d = 0;
6469    let mut guard = 0;
6470    while let Some(p) = parent_of[i] {
6471        if p == i || guard > parent_of.len() {
6472            break;
6473        }
6474        i = p;
6475        d += 1;
6476        guard += 1;
6477    }
6478    d
6479}
6480
6481/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6482fn codex_turn_id(payload: &Value) -> Option<&str> {
6483    payload
6484        .get("metadata")
6485        .and_then(|m| m.get("turn_id"))
6486        .and_then(Value::as_str)
6487}
6488
6489/// N2 (spliced-export hardening): every Codex group id already present in
6490/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6491/// replays ahead of the appended tail it synthesizes via
6492/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6493/// physically lands in the exported `out` string for the prefix: each line
6494/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6495/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6496/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6497/// export) is extracted directly — no re-derivation from `self.messages`
6498/// needed (that would have to reconstruct which ids the ORIGINAL export
6499/// happened to assign, which this sidesteps entirely by reading them back
6500/// out of the bytes themselves). A line that fails to parse, isn't a
6501/// `response_item`, or carries no `turn_id` contributes nothing — headers
6502/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6503/// never carry this field to begin with.
6504fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6505    let mut ids = HashSet::new();
6506    for line in raw_prefix {
6507        if let Ok(v) = serde_json::from_str::<Value>(line) {
6508            if let Some(payload) = v.get("payload") {
6509                if let Some(tid) = codex_turn_id(payload) {
6510                    ids.insert(tid.to_string());
6511                }
6512            }
6513        }
6514    }
6515    ids
6516}
6517
6518/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6519/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6520/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6521/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6522/// message that already carries a more specific timestamp of its own is
6523/// never overwritten (none currently do on the Codex side, but this keeps
6524/// every loader consistent). A no-op when `ts` is `None` (a line with no
6525/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6526fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6527    let Some(ts) = ts else { return };
6528    let Some(slice) = messages.get_mut(from..) else {
6529        return;
6530    };
6531    for m in slice {
6532        m.metadata
6533            .entry("timestamp".to_string())
6534            .or_insert_with(|| ts.to_string());
6535    }
6536}
6537
6538fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6539    match payload.get("type").and_then(Value::as_str) {
6540        Some("message") => {
6541            let role = match payload.get("role").and_then(Value::as_str) {
6542                Some("user") => Role::User,
6543                Some("assistant") => Role::Assistant,
6544                // "developer" and "system" both carry operator instructions.
6545                _ => Role::System,
6546            };
6547            let content = payload.get("content");
6548            let text = extract_text_content(content);
6549            // IX-5: `input_image` blocks alongside/instead of text — see
6550            // `codex_extract_images`. A text-only message (no image blocks)
6551            // takes the historical `content: Some(text)` shape unchanged.
6552            let images = codex_extract_images(content);
6553            let is_empty_assistant =
6554                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6555            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6556                let content_parts = if images.is_empty() {
6557                    None
6558                } else {
6559                    let mut parts = Vec::new();
6560                    if !text.trim().is_empty() {
6561                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6562                    }
6563                    parts.extend(images);
6564                    Some(parts)
6565                };
6566                let mut msg = ChatMessage {
6567                    role,
6568                    content: if content_parts.is_some() || text.is_empty() {
6569                        None
6570                    } else {
6571                        Some(text)
6572                    },
6573                    content_parts,
6574                    tool_calls: None,
6575                    tool_call_id: None,
6576                    name: None,
6577                    metadata: Default::default(),
6578                };
6579                // Preserve the assistant `phase` (commentary vs final_answer) so
6580                // a reloaded transcript can distinguish narration from the answer.
6581                if role == Role::Assistant {
6582                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6583                        msg.metadata.insert("phase".to_string(), phase.to_string());
6584                    }
6585                    // IX-6: mark this as an open, mergeable combined-turn
6586                    // candidate — a `function_call` response_item found
6587                    // immediately after (still `out.last()` when reached,
6588                    // i.e. no other item intervened) merges into this SAME
6589                    // `ChatMessage` instead of splitting into a second one,
6590                    // matching how Claude's parser keeps a text+tool_use
6591                    // turn together. Stripped again before the loaded
6592                    // `Session` is returned (`from_codex_str`), so it never
6593                    // leaks as visible metadata.
6594                    msg.metadata
6595                        .insert("__codex_open_turn".to_string(), "true".to_string());
6596                }
6597                // The per-turn grouping key (Codex batches items by turn_id).
6598                if let Some(tid) = codex_turn_id(payload) {
6599                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
6600                }
6601                // PARITY-6 dev/02: restore the original Claude
6602                // `systemSubtype` for a `developer`/`system` message that
6603                // was itself synthesized FROM a real Claude system record
6604                // (`write_codex_records`'s `Role::System` arm stamps
6605                // `claude_system_subtype`) — the exact inverse, so
6606                // `write_claude_code_records`'s `Role::System` arm can
6607                // re-materialize the real Claude `type: "system"` record
6608                // faithfully on a Codex -> Claude Code hop instead of
6609                // guessing a fallback subtype.
6610                if role == Role::System {
6611                    if let Some(subtype) = payload
6612                        .get("metadata")
6613                        .and_then(|m| m.get("claude_system_subtype"))
6614                        .and_then(Value::as_str)
6615                    {
6616                        msg.metadata
6617                            .insert("systemSubtype".to_string(), subtype.to_string());
6618                    }
6619                }
6620                if is_empty_assistant {
6621                    msg.metadata
6622                        .insert("empty_assistant_record".to_string(), "true".to_string());
6623                }
6624                out.push(msg);
6625            }
6626        }
6627        Some("function_call") => {
6628            let id = payload
6629                .get("call_id")
6630                .and_then(Value::as_str)
6631                .unwrap_or_default();
6632            let raw_name = payload
6633                .get("name")
6634                .and_then(Value::as_str)
6635                .unwrap_or_default();
6636            // Preserve the MCP `namespace` by qualifying the tool name
6637            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6638            // so the tool identity isn't ambiguous on round-trip.
6639            let qualified;
6640            let name = match payload.get("namespace").and_then(Value::as_str) {
6641                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6642                    qualified = format!("{ns}__{raw_name}");
6643                    qualified.as_str()
6644                }
6645                _ => raw_name,
6646            };
6647            let args = payload
6648                .get("arguments")
6649                .map(value_to_arg_string)
6650                .unwrap_or_else(|| "{}".to_string());
6651            let call = function_call(id, name, args);
6652            // IX-6: a `function_call` immediately after an assistant `message`
6653            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6654            // by the "message" arm above, and not yet closed by anything else)
6655            // merges into that ONE `ChatMessage` — text→`content`,
6656            // call→`tool_calls` — instead of splitting into a second message.
6657            // A bare `function_call` with no such preceding turn (the marker
6658            // absent, or `out.last()` not an assistant message) is unaffected:
6659            // it still gets its own synthesized message, exactly as before.
6660            //
6661            // Belt-and-suspenders (PARITY-6/7 tightened): if this
6662            // `function_call` response_item itself carries a `turn_id` (rare
6663            // in observed real-native-Codex corpora — Codex usually only
6664            // stamps it on `message` payloads — but ALWAYS present on OUR
6665            // OWN synthesized export whenever a `ChatMessage`'s own tool
6666            // calls need merge disambiguation, see `write_codex_records`),
6667            // it must match the marked assistant message's recorded
6668            // `turn_id` EXACTLY — including "the marked message has none at
6669            // all" counting as a mismatch. That's exactly the shape of two
6670            // genuinely separate, adjacent `ChatMessage`s (an unrelated
6671            // text-only turn immediately followed by a different,
6672            // tool-call-only turn): the tool-only turn's own `function_call`s
6673            // carry a synthetic id while the unrelated preceding text
6674            // message carries none, so this correctly refuses the merge
6675            // instead of falling through to a permissive default. Only when
6676            // this `function_call` carries NO `turn_id` at all (the ordinary
6677            // real-native-Codex shape) does this fall back to the original
6678            // permissive "adjacency + open marker is enough" rule —
6679            // unchanged from before for the vast majority of real Codex
6680            // data. The truncation/clear strip above is what actually closes
6681            // the marker across rollback/compaction boundaries; this is only
6682            // an extra guard for the case where a stale-but-unstripped
6683            // marker and a turn_id mismatch coincide.
6684            let can_merge = out.last().is_some_and(|last| {
6685                last.role == Role::Assistant
6686                    && last.metadata.contains_key("__codex_open_turn")
6687                    && match codex_turn_id(payload) {
6688                        Some(fc_tid) => {
6689                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6690                        }
6691                        None => true,
6692                    }
6693            });
6694            if can_merge {
6695                out.last_mut()
6696                    .expect("can_merge implies out.last() is Some")
6697                    .tool_calls
6698                    .get_or_insert_with(Vec::new)
6699                    .push(call);
6700            } else {
6701                push_assistant(out, String::new(), vec![call]);
6702                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6703                // in this turn, so nothing set `__codex_open_turn` above) can
6704                // still be the FIRST of several tool calls that all belong to
6705                // the SAME original `ChatMessage` (`write_codex_records`
6706                // stamps every one of a message's own tool calls with the
6707                // identical synthetic `turn_id`). Re-open THIS freshly
6708                // created message — but ONLY when a real `turn_id` is
6709                // present — so the NEXT `function_call` in the same group
6710                // merges into it instead of becoming its own message too.
6711                // Gated on `codex_turn_id(payload).is_some()` (not the bare
6712                // default `true` the belt-and-suspenders check above uses)
6713                // so real native Codex data — which almost never carries
6714                // this field on `function_call` payloads (see the comment
6715                // above) — keeps its existing "every bare tool call is its
6716                // own turn" behavior exactly as before.
6717                if let Some(tid) = codex_turn_id(payload) {
6718                    if let Some(last) = out.last_mut() {
6719                        last.metadata
6720                            .insert("__codex_open_turn".to_string(), "true".to_string());
6721                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6722                    }
6723                }
6724            }
6725        }
6726        Some("function_call_output") => {
6727            let id = payload
6728                .get("call_id")
6729                .and_then(Value::as_str)
6730                .unwrap_or_default();
6731            let result = match payload.get("output") {
6732                Some(Value::String(s)) => s.clone(),
6733                Some(v) => extract_text_content(Some(v)),
6734                None => String::new(),
6735            };
6736            let mut message = tool_message(id, result);
6737            // TR-13: Codex v1 exposes no structured success/error field on
6738            // this record. Free-text output is not a safe classifier, so the
6739            // reduction engine must treat the outcome as explicitly unknown
6740            // and fail closed on both success-only and error-only pruning.
6741            crate::mark_tool_outcome_unknown(&mut message);
6742            out.push(message);
6743        }
6744        // Custom / MCP tool calls are shaped like function calls but carry their
6745        // arguments under `input` (a JSON-encoded string). Normalize them the
6746        // same way so MCP-using sessions don't lose those turns.
6747        Some("custom_tool_call") => {
6748            let id = payload
6749                .get("call_id")
6750                .and_then(Value::as_str)
6751                .unwrap_or_default();
6752            let name = payload
6753                .get("name")
6754                .and_then(Value::as_str)
6755                .unwrap_or_default();
6756            // Unlike `function_call.arguments`, Codex custom tools accept a
6757            // free-form `input` string (apply_patch is the common case).
6758            // Canonical `FunctionCall::arguments` must remain valid JSON, so
6759            // retain the input's JSON type instead of treating a free-form
6760            // string as if it were already a JSON document. This lets every
6761            // target harness carry the value rather than silently replacing
6762            // it with `{}` when `parsed_arguments()` fails.
6763            let args = payload
6764                .get("input")
6765                .map(Value::to_string)
6766                .unwrap_or_else(|| "{}".to_string());
6767            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6768            if let Some(message) = out.last_mut() {
6769                message.metadata.insert(
6770                    "codex_custom_tool_call_ids".to_string(),
6771                    serde_json::json!([id]).to_string(),
6772                );
6773            }
6774        }
6775        Some("custom_tool_call_output") => {
6776            let id = payload
6777                .get("call_id")
6778                .and_then(Value::as_str)
6779                .unwrap_or_default();
6780            let result = match payload.get("output") {
6781                Some(Value::String(s)) => s.clone(),
6782                Some(v) => extract_text_content(Some(v)),
6783                None => String::new(),
6784            };
6785            let mut message = tool_message(id, result);
6786            crate::mark_tool_outcome_unknown(&mut message);
6787            out.push(message);
6788        }
6789        // Tool-search is a clean call/output pair keyed by call_id.
6790        //
6791        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6792        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6793        // its own synthesized `tool_search_call` records (see the PARITY-6/7
6794        // comment there and on `codex_turn_id`/the `function_call` arm
6795        // above). That left the same bug-class the turn_id work fixed for
6796        // `function_call` half-done here: a single Claude assistant message
6797        // containing text + a `tool_search` block reloaded as 2 messages
6798        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6799        // reloaded as 3. Mirror the `function_call` arm's merge check
6800        // exactly so a `tool_search_call` immediately following an open
6801        // assistant turn (or another tool call sharing the same `turn_id`)
6802        // merges into that SAME `ChatMessage` instead of splitting.
6803        Some("tool_search_call") => {
6804            let id = payload
6805                .get("call_id")
6806                .and_then(Value::as_str)
6807                .unwrap_or_default();
6808            let args = payload
6809                .get("arguments")
6810                .map(value_to_arg_string)
6811                .unwrap_or_else(|| "{}".to_string());
6812            let call = function_call(id, "tool_search", args);
6813            let can_merge = out.last().is_some_and(|last| {
6814                last.role == Role::Assistant
6815                    && last.metadata.contains_key("__codex_open_turn")
6816                    && match codex_turn_id(payload) {
6817                        Some(fc_tid) => {
6818                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6819                        }
6820                        None => true,
6821                    }
6822            });
6823            if can_merge {
6824                out.last_mut()
6825                    .expect("can_merge implies out.last() is Some")
6826                    .tool_calls
6827                    .get_or_insert_with(Vec::new)
6828                    .push(call);
6829            } else {
6830                push_assistant(out, String::new(), vec![call]);
6831                // Re-open the freshly created message so a FOLLOWING
6832                // `function_call`/`tool_search_call` sharing this same
6833                // `turn_id` merges into it too — matching the bare
6834                // `function_call` case's own re-open logic above.
6835                if let Some(tid) = codex_turn_id(payload) {
6836                    if let Some(last) = out.last_mut() {
6837                        last.metadata
6838                            .insert("__codex_open_turn".to_string(), "true".to_string());
6839                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6840                    }
6841                }
6842            }
6843        }
6844        Some("tool_search_output") => {
6845            let id = payload
6846                .get("call_id")
6847                .and_then(Value::as_str)
6848                .unwrap_or_default();
6849            let result = payload
6850                .get("tools")
6851                .map(value_to_arg_string)
6852                .unwrap_or_default();
6853            out.push(tool_message(id, result));
6854        }
6855        // Web-search / image-generation response_items carry no paired output
6856        // here (results live in event_msg), so emit an assistant marker rather
6857        // than a dangling unanswered tool call.
6858        Some("web_search_call") => {
6859            push_assistant(out, "[web_search]".to_string(), Vec::new());
6860        }
6861        Some("image_generation_call") => {
6862            let prompt = payload
6863                .get("revised_prompt")
6864                .and_then(Value::as_str)
6865                .unwrap_or("");
6866            push_assistant(
6867                out,
6868                format!("[image_generation] {prompt}").trim().to_string(),
6869                Vec::new(),
6870            );
6871        }
6872        // "reasoning" and anything else — dropped.
6873        _ => {}
6874    }
6875}
6876
6877// ---- Grok -------------------------------------------------------------
6878
6879const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6880
6881fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
6882    value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
6883        "schema": 1,
6884        "role": message.role,
6885        "content": message.content,
6886        "content_parts": message.content_parts,
6887        "tool_calls": message.tool_calls,
6888        "tool_call_id": message.tool_call_id,
6889        "name": message.name,
6890        "metadata": message.metadata,
6891    });
6892}
6893
6894fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
6895    let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
6896        return;
6897    };
6898    if extension.get("schema").and_then(Value::as_u64) != Some(1) {
6899        return;
6900    }
6901    if let Some(role) = extension
6902        .get("role")
6903        .and_then(|value| serde_json::from_value(value.clone()).ok())
6904    {
6905        message.role = role;
6906    }
6907    message.content = extension
6908        .get("content")
6909        .and_then(Value::as_str)
6910        .map(str::to_string);
6911    message.content_parts = extension
6912        .get("content_parts")
6913        .and_then(|value| serde_json::from_value(value.clone()).ok());
6914    message.tool_calls = extension
6915        .get("tool_calls")
6916        .and_then(|value| serde_json::from_value(value.clone()).ok());
6917    message.tool_call_id = extension
6918        .get("tool_call_id")
6919        .and_then(Value::as_str)
6920        .map(str::to_string);
6921    message.name = extension
6922        .get("name")
6923        .and_then(Value::as_str)
6924        .map(str::to_string);
6925    message.metadata.clear();
6926    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
6927        for (key, value) in metadata {
6928            if let Some(value) = value.as_str() {
6929                message.metadata.insert(key.clone(), value.to_string());
6930            }
6931        }
6932    }
6933}
6934
6935fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
6936    for key in keys {
6937        if let Some(value) = value.get(*key) {
6938            message.metadata.insert(
6939                format!("grok_{key}"),
6940                value
6941                    .as_str()
6942                    .map(str::to_string)
6943                    .unwrap_or_else(|| value.to_string()),
6944            );
6945        }
6946    }
6947}
6948
6949fn grok_human_user_text(raw: &str) -> Option<String> {
6950    let text = raw.trim();
6951    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
6952        return None;
6953    }
6954    let unwrapped = text
6955        .strip_prefix("<user_query>")
6956        .and_then(|value| value.strip_suffix("</user_query>"))
6957        .map(str::trim)
6958        .unwrap_or(text);
6959    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
6960}
6961
6962/// Portable extension for messages whose canonical fields cannot be expressed
6963/// by the target's stock schema. It was introduced for Grok and retains that
6964/// on-disk key for compatibility. Gemini has the same need: Claude Code and
6965/// Codex have no native slot for a tool-result name or Gemini-only metadata.
6966/// Their readers tolerate unknown namespaced fields, so forwarding this
6967/// adapter-owned envelope keeps those cross-format hops reversible without
6968/// pretending the stock schemas represent the fields directly.
6969const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
6970
6971/// Namespaced line-level extension carrying the one tool-result outcome state
6972/// Claude cannot represent natively. Keeping this narrower than the full Grok
6973/// portability envelope avoids changing unrelated target-message projection.
6974const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
6975
6976fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
6977    if !crate::is_tool_error(message)
6978        && value
6979            .get(SUPERCODE_TOOL_OUTCOME_KEY)
6980            .and_then(Value::as_str)
6981            == Some("unknown")
6982    {
6983        crate::mark_tool_outcome_unknown(message);
6984    }
6985}
6986
6987fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
6988    let metadata = message
6989        .metadata
6990        .iter()
6991        .filter(|(key, _)| {
6992            key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
6993        })
6994        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
6995        .collect::<serde_json::Map<_, _>>();
6996
6997    // `meta.source` changes after every reload. Keying portability only on
6998    // the immediate source therefore made Grok metadata survive one hop but
6999    // disappear on A -> B -> C translations. Once Grok-owned fields are
7000    // present, keep forwarding them regardless of the current container.
7001    let has_portable_fields =
7002        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7003    (matches!(
7004        source,
7005        SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7006    ) || has_portable_fields
7007        || message.content_parts.is_some())
7008    .then(|| {
7009        serde_json::json!({
7010            "schema": 2,
7011            "role": message.role,
7012            "content": message.content,
7013            "content_parts": message.content_parts,
7014            "tool_calls": message.tool_calls,
7015            "tool_call_id": message.tool_call_id,
7016            "name": message.name,
7017            "metadata": message.metadata,
7018        })
7019    })
7020}
7021
7022fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7023    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7024        "schema": 2,
7025        "role": message.role,
7026        "content": message.content,
7027        "content_parts": message.content_parts,
7028        "tool_calls": message.tool_calls,
7029        "tool_call_id": message.tool_call_id,
7030        "name": message.name,
7031        "metadata": message.metadata,
7032    });
7033}
7034
7035fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7036    if let Some(extension) = grok_message_extension(source, message) {
7037        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7038    }
7039}
7040
7041fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7042    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7043        return;
7044    };
7045    // Codex temporarily marks a text assistant item so immediately-following
7046    // function-call items can merge back into the same canonical turn. The
7047    // portable envelope must not erase that loader-private marker before the
7048    // merge happens; `from_codex_str` removes it before returning.
7049    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7050    let codex_turn_id = message.metadata.get("turn_id").cloned();
7051    let extension_has_turn_id = extension
7052        .get("metadata")
7053        .and_then(Value::as_object)
7054        .is_some_and(|metadata| metadata.contains_key("turn_id"));
7055    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7056        if let Some(role) = extension
7057            .get("role")
7058            .and_then(|value| serde_json::from_value(value.clone()).ok())
7059        {
7060            message.role = role;
7061        }
7062        message.content = extension
7063            .get("content")
7064            .and_then(Value::as_str)
7065            .map(str::to_string);
7066        message.content_parts = extension
7067            .get("content_parts")
7068            .and_then(|value| serde_json::from_value(value.clone()).ok());
7069        // Tool calls are shared native structure in every supported format.
7070        // Keep the loader's reconstruction instead of restoring this copy:
7071        // Codex stores a combined text+tool turn across multiple records, so
7072        // eagerly restoring calls on its text record would duplicate them
7073        // when the following function-call records merge.
7074        message.tool_call_id = extension
7075            .get("tool_call_id")
7076            .and_then(Value::as_str)
7077            .map(str::to_string);
7078        message.name = extension
7079            .get("name")
7080            .and_then(Value::as_str)
7081            .map(str::to_string);
7082        message.metadata.clear();
7083    }
7084    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7085        for (key, value) in metadata {
7086            if let Some(value) = value.as_str() {
7087                message.metadata.insert(key.clone(), value.to_string());
7088            }
7089        }
7090    }
7091    if let Some(name) = extension.get("name").and_then(Value::as_str) {
7092        message.name = Some(name.to_string());
7093    }
7094    if let Some(marker) = codex_open_turn {
7095        message
7096            .metadata
7097            .insert("__codex_open_turn".to_string(), marker);
7098    }
7099    if let Some(turn_id) = codex_turn_id {
7100        message.metadata.insert("turn_id".to_string(), turn_id);
7101        if !extension_has_turn_id {
7102            message.metadata.insert(
7103                "__grok_remove_synthetic_turn_id".to_string(),
7104                "true".to_string(),
7105            );
7106        }
7107    }
7108}
7109
7110fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7111    if let [message] = messages {
7112        restore_grok_message_extension(value, message);
7113    }
7114}
7115
7116fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7117    let role = match native.get("role").and_then(Value::as_str) {
7118        Some("assistant") => Role::Assistant,
7119        _ => Role::User,
7120    };
7121    let created = native.get("created").and_then(Value::as_i64);
7122    let native_id = native.get("id").and_then(Value::as_str);
7123    let mut text = Vec::new();
7124    let mut content_parts = Vec::new();
7125    let mut tool_calls = Vec::new();
7126    let mut tool_results = Vec::new();
7127
7128    for (block_index, block) in native
7129        .get("content")
7130        .and_then(Value::as_array)
7131        .into_iter()
7132        .flatten()
7133        .enumerate()
7134    {
7135        match block.get("type").and_then(Value::as_str) {
7136            Some("text") => {
7137                if let Some(value) = block.get("text").and_then(Value::as_str) {
7138                    text.push(value.to_string());
7139                    content_parts.push(serde_json::json!({"type": "text", "text": value}));
7140                }
7141            }
7142            Some("image") => {
7143                let data = block
7144                    .get("data")
7145                    .and_then(Value::as_str)
7146                    .unwrap_or_default();
7147                let media_type = block
7148                    .get("mimeType")
7149                    .or_else(|| block.get("mime_type"))
7150                    .and_then(Value::as_str)
7151                    .unwrap_or("application/octet-stream");
7152                content_parts.push(serde_json::json!({
7153                    "type": "image_url",
7154                    "image_url": {"url": format!("data:{media_type};base64,{data}")},
7155                }));
7156            }
7157            Some("toolRequest" | "frontendToolRequest") => {
7158                let id = block
7159                    .get("id")
7160                    .and_then(Value::as_str)
7161                    .map(str::to_string)
7162                    .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7163                let call = block
7164                    .get("toolCall")
7165                    .and_then(|call| {
7166                        (call.get("status").and_then(Value::as_str) == Some("success"))
7167                            .then(|| call.get("value"))
7168                            .flatten()
7169                    })
7170                    .or_else(|| block.get("toolCall"));
7171                let Some(call) = call else { continue };
7172                let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7173                let arguments = call
7174                    .get("arguments")
7175                    .map(value_to_arg_string)
7176                    .unwrap_or_else(|| "{}".to_string());
7177                tool_calls.push(function_call(&id, name, arguments));
7178            }
7179            Some("toolResponse") => tool_results.push(block.clone()),
7180            _ => {}
7181        }
7182    }
7183
7184    if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7185        let has_non_text = content_parts
7186            .iter()
7187            .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7188        let mut message = ChatMessage {
7189            role,
7190            content: (!text.is_empty()).then(|| text.join("\n")),
7191            content_parts: has_non_text.then_some(content_parts),
7192            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7193            tool_call_id: None,
7194            name: None,
7195            metadata: Default::default(),
7196        };
7197        capture_goose_message_metadata(native, created, native_id, &mut message);
7198        out.push(message);
7199    }
7200
7201    for (result_index, block) in tool_results.into_iter().enumerate() {
7202        let id = block
7203            .get("id")
7204            .and_then(Value::as_str)
7205            .map(str::to_string)
7206            .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7207        let result = block.get("toolResult").unwrap_or(&Value::Null);
7208        let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7209        let value = result.get("value").unwrap_or(result);
7210        let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7211        let output = if status_error {
7212            result
7213                .get("error")
7214                .and_then(Value::as_str)
7215                .unwrap_or("Goose tool call failed")
7216                .to_string()
7217        } else {
7218            value
7219                .get("content")
7220                .and_then(Value::as_array)
7221                .map(|content| {
7222                    content
7223                        .iter()
7224                        .filter_map(|part| {
7225                            part.get("text")
7226                                .and_then(Value::as_str)
7227                                .map(str::to_string)
7228                                .or_else(|| Some(part.to_string()))
7229                        })
7230                        .collect::<Vec<_>>()
7231                        .join("\n")
7232                })
7233                .unwrap_or_else(|| value.to_string())
7234        };
7235        let mut message = tool_message(&id, output);
7236        if is_error {
7237            crate::mark_tool_error(&mut message);
7238        }
7239        capture_goose_message_metadata(native, created, native_id, &mut message);
7240        out.push(message);
7241    }
7242}
7243
7244fn capture_goose_message_metadata(
7245    native: &Value,
7246    created: Option<i64>,
7247    native_id: Option<&str>,
7248    message: &mut ChatMessage,
7249) {
7250    if let Some(created) = created {
7251        message
7252            .metadata
7253            .insert("goose_created".to_string(), created.to_string());
7254    }
7255    if let Some(native_id) = native_id {
7256        message
7257            .metadata
7258            .insert("goose_message_id".to_string(), native_id.to_string());
7259    }
7260    if let Some(metadata) = native.get("metadata") {
7261        message
7262            .metadata
7263            .insert("goose_metadata".to_string(), metadata.to_string());
7264    }
7265}
7266
7267#[doc(hidden)]
7268pub fn percent_decode_path(encoded: &str) -> Option<String> {
7269    fn hex(byte: u8) -> Option<u8> {
7270        match byte {
7271            b'0'..=b'9' => Some(byte - b'0'),
7272            b'a'..=b'f' => Some(byte - b'a' + 10),
7273            b'A'..=b'F' => Some(byte - b'A' + 10),
7274            _ => None,
7275        }
7276    }
7277
7278    let bytes = encoded.as_bytes();
7279    let mut decoded = Vec::with_capacity(bytes.len());
7280    let mut index = 0usize;
7281    while index < bytes.len() {
7282        if bytes[index] == b'%' {
7283            let high = *bytes.get(index + 1)?;
7284            let low = *bytes.get(index + 2)?;
7285            decoded.push(hex(high)? * 16 + hex(low)?);
7286            index += 3;
7287        } else {
7288            decoded.push(bytes[index]);
7289            index += 1;
7290        }
7291    }
7292    String::from_utf8(decoded).ok()
7293}
7294
7295// ---- Pi ---------------------------------------------------------------
7296
7297fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7298    restore_codex_provenance_from_top_level(v, meta)?;
7299    if let Some(id) = v.get("id").and_then(Value::as_str) {
7300        meta.session_id = Some(id.to_string());
7301    }
7302    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7303        meta.cwd = Some(PathBuf::from(cwd));
7304    }
7305    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7306    let version = v
7307        .get("version")
7308        .and_then(Value::as_u64)
7309        .map(|n| n.to_string())
7310        .unwrap_or_else(|| "1".to_string());
7311    meta.lineage.insert("pi_version".to_string(), version);
7312    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7313        meta.lineage
7314            .insert("created_at".to_string(), ts.to_string());
7315    }
7316    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7317        meta.lineage
7318            .insert("parent_session_path".to_string(), ps.to_string());
7319    }
7320    // D7: the other half of `push_pi_header`'s passthrough — restores a
7321    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7322    // trip reconstructs the original record (mirrors
7323    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7324    // restore for the Codex hop).
7325    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7326        if let Some(v) = v.get("claude_fork_context_ref") {
7327            meta.lineage
7328                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7329        }
7330    }
7331    Ok(())
7332}
7333
7334/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7335/// `(mime, data)` when it looks like a real image payload.
7336///
7337/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7338/// `ai:316-350` for the `ImageContent` content-block union but does not
7339/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7340/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7341/// Anthropic multimodal wire shape) is this loader's best guess, not a
7342/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7343/// against a real pi corpus. Until then this function VALIDATES rather than
7344/// assumes: both fields must be present, non-empty strings, and `data` must
7345/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7346/// else is an unknown/unexpected image shape, and the caller must route the
7347/// whole message to raw-only survival (S6-style fail loud) instead of
7348/// silently synthesizing a corrupt/empty `image_url` part.
7349fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7350    let mime = item.get("mimeType").and_then(Value::as_str)?;
7351    let data = item.get("data").and_then(Value::as_str)?;
7352    if mime.is_empty() || data.is_empty() {
7353        return None;
7354    }
7355    if !data
7356        .bytes()
7357        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7358    {
7359        return None;
7360    }
7361    Some((mime.to_string(), data.to_string()))
7362}
7363
7364/// True if `content` (a pi content value: bare string or
7365/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7366/// that does not match [`pi_image_shape`] — shared by the loader (which
7367/// routes such a message to raw-only survival, never a synthesized-empty
7368/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7369/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7370/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7371#[doc(hidden)]
7372pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7373    let Some(Value::Array(items)) = content else {
7374        return false;
7375    };
7376    items.iter().any(|item| {
7377        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7378    })
7379}
7380
7381/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7382/// into concatenated text plus, when a WELL-FORMED image block is present,
7383/// the full `content_parts` array (leading text block + one `image_url` part
7384/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7385/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7386/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7387///
7388/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7389/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7390/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7391/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7392/// every caller must treat that as raw-only survival for the whole message
7393/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7394/// guessed wrong fails loud instead of silently dropping/corrupting the
7395/// image.
7396fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7397    match content {
7398        Some(Value::String(s)) => (s.clone(), None, false),
7399        Some(Value::Array(items)) => {
7400            let mut text = String::new();
7401            let mut parts: Vec<Value> = Vec::new();
7402            let mut has_image = false;
7403            let mut unknown_image_shape = false;
7404            for item in items {
7405                match item.get("type").and_then(Value::as_str) {
7406                    Some("text") => {
7407                        if let Some(t) = item.get("text").and_then(Value::as_str) {
7408                            push_str_field(&mut text, t);
7409                        }
7410                    }
7411                    Some("image") => {
7412                        has_image = true;
7413                        match pi_image_shape(item) {
7414                            Some((mime, data)) => {
7415                                parts.push(serde_json::json!({
7416                                    "type": "image_url",
7417                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
7418                                }));
7419                            }
7420                            None => unknown_image_shape = true,
7421                        }
7422                    }
7423                    _ => {}
7424                }
7425            }
7426            if unknown_image_shape {
7427                // Never synthesize an empty/corrupt part for a shape we
7428                // don't recognize — raw-only survival for the whole message;
7429                // the coverage guard is what turns this into a visible
7430                // failure (S6-style).
7431                return (String::new(), None, true);
7432            }
7433            if has_image {
7434                if !text.trim().is_empty() {
7435                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7436                }
7437                (text, Some(parts), false)
7438            } else {
7439                (text, None, false)
7440            }
7441        }
7442        _ => (String::new(), None, false),
7443    }
7444}
7445
7446fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7447    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7448    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7449    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7450    // `message/UnknownImageShape` bucket is what turns this into a visible
7451    // coverage failure.
7452    if unknown_image_shape {
7453        return;
7454    }
7455    if text.trim().is_empty() && parts.is_none() {
7456        return;
7457    }
7458    let mut msg = match parts {
7459        Some(parts) => ChatMessage {
7460            role: Role::User,
7461            content: None,
7462            content_parts: Some(parts),
7463            tool_calls: None,
7464            tool_call_id: None,
7465            name: None,
7466            metadata: Default::default(),
7467        },
7468        None => ChatMessage::user(text),
7469    };
7470    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7471    // (`message.timestamp`) is a DISTINCT field from the canonical
7472    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7473    // carry genuinely different values in real corpora (the fixture's are
7474    // ~6 months apart). Preserve it separately so it isn't silently lost for
7475    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7476    // native round-trip consumer) and the INHERENT residue note on
7477    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7478    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7479        msg.metadata
7480            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7481    }
7482    out.push(msg);
7483}
7484
7485fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7486    let mut text = String::new();
7487    let mut calls: Vec<ToolCall> = Vec::new();
7488    let mut thinking = String::new();
7489    let mut thinking_seen = false;
7490    let mut thinking_sig: Option<String> = None;
7491    let mut thinking_redacted = false;
7492    let mut text_sig: Option<String> = None;
7493    let mut thought_sig: Option<String> = None;
7494
7495    if let Some(Value::Array(blocks)) = msg_v.get("content") {
7496        for b in blocks {
7497            match b.get("type").and_then(Value::as_str) {
7498                Some("text") => {
7499                    if let Some(t) = b.get("text").and_then(Value::as_str) {
7500                        push_str_field(&mut text, t);
7501                    }
7502                    if let Some(sig) = b.get("textSignature") {
7503                        text_sig = Some(match sig {
7504                            Value::String(s) => s.clone(),
7505                            other => other.to_string(),
7506                        });
7507                    }
7508                }
7509                Some("thinking") => {
7510                    thinking_seen = true;
7511                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7512                        push_str_field(&mut thinking, t);
7513                    }
7514                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7515                        thinking_sig = Some(sig.to_string());
7516                    }
7517                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7518                        thinking_redacted = true;
7519                    }
7520                }
7521                Some("toolCall") => {
7522                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7523                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7524                    // `arguments` is a JSON OBJECT on pi's wire, not a string
7525                    // (`pi-fields.md` §3b open question 4) — serialize to the
7526                    // string `FunctionCall::arguments` expects.
7527                    let args = b
7528                        .get("arguments")
7529                        .cloned()
7530                        .unwrap_or_else(|| Value::Object(Default::default()));
7531                    calls.push(function_call(id, name, args.to_string()));
7532                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7533                        thought_sig = Some(sig.to_string());
7534                    }
7535                }
7536                _ => {}
7537            }
7538        }
7539    }
7540
7541    let before = out.len();
7542    push_assistant(out, text, calls);
7543    // A recognized native assistant entry remains transcript state even
7544    // when its content array is empty, except Pi's explicit empty error
7545    // response: that record has no replayable content and is established
7546    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7547    // non-error turns and Pi's standalone thinking-block shape.
7548    let is_empty_error =
7549        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7550    if out.len() == before && !is_empty_error {
7551        let mut empty = ChatMessage {
7552            role: Role::Assistant,
7553            content: None,
7554            content_parts: None,
7555            tool_calls: None,
7556            tool_call_id: None,
7557            name: None,
7558            metadata: Default::default(),
7559        };
7560        if !thinking_seen {
7561            empty
7562                .metadata
7563                .insert("empty_assistant_record".to_string(), "true".to_string());
7564        }
7565        out.push(empty);
7566    }
7567    if out.len() > before {
7568        let msg = out.last_mut().expect("just pushed");
7569        if thinking_seen {
7570            msg.metadata.insert("thinking".to_string(), thinking);
7571        }
7572        if let Some(s) = thinking_sig {
7573            msg.metadata.insert("thinking_signature".to_string(), s);
7574        }
7575        if thinking_redacted {
7576            msg.metadata
7577                .insert("pi_thinking_redacted".to_string(), "true".to_string());
7578        }
7579        if let Some(s) = text_sig {
7580            msg.metadata.insert("pi_text_signature".to_string(), s);
7581        }
7582        if let Some(s) = thought_sig {
7583            msg.metadata.insert("pi_thought_signature".to_string(), s);
7584        }
7585        for (key, field) in [
7586            ("pi_api", "api"),
7587            ("pi_provider", "provider"),
7588            ("pi_response_model", "responseModel"),
7589            ("pi_response_id", "responseId"),
7590            ("pi_stop_reason", "stopReason"),
7591            ("pi_error_message", "errorMessage"),
7592        ] {
7593            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7594                msg.metadata.insert(key.to_string(), s.to_string());
7595            }
7596        }
7597        if let Some(diag) = msg_v.get("diagnostics") {
7598            if !diag.is_null() {
7599                msg.metadata
7600                    .insert("pi_diagnostics".to_string(), diag.to_string());
7601            }
7602        }
7603        if let Some(usage) = msg_v.get("usage") {
7604            if !usage.is_null() {
7605                msg.metadata
7606                    .insert("pi_usage".to_string(), usage.to_string());
7607            }
7608        }
7609        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7610        // separately from the canonical entry-level ISO `timestamp` — see
7611        // `push_pi_user`.
7612        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7613            msg.metadata
7614                .insert("pi_msg_timestamp".to_string(), ts.to_string());
7615        }
7616    }
7617}
7618
7619fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7620    let id = msg_v
7621        .get("toolCallId")
7622        .and_then(Value::as_str)
7623        .unwrap_or_default();
7624    let name = msg_v
7625        .get("toolName")
7626        .and_then(Value::as_str)
7627        .unwrap_or_default();
7628    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7629    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7630    // survival, never a synthesized-empty part. Dropping the toolResult
7631    // message here leaves its `toolCallId` unanswered, which
7632    // `ensure_tool_results_paired` already turns into a visible
7633    // "[no tool result recorded — turn interrupted]" placeholder — a loud
7634    // failure mode, not a silent one.
7635    if unknown_image_shape {
7636        return;
7637    }
7638    let mut msg = ChatMessage {
7639        role: Role::Tool,
7640        content: Some(text),
7641        content_parts: parts,
7642        tool_calls: None,
7643        tool_call_id: Some(id.to_string()),
7644        name: Some(name.to_string()),
7645        metadata: Default::default(),
7646    };
7647    if let Some(details) = msg_v.get("details") {
7648        if !details.is_null() {
7649            msg.metadata
7650                .insert("pi_tool_details".to_string(), details.to_string());
7651        }
7652    }
7653    let is_error = msg_v
7654        .get("isError")
7655        .and_then(Value::as_bool)
7656        .unwrap_or(false);
7657    msg.metadata
7658        .insert("pi_is_error".to_string(), is_error.to_string());
7659    if is_error {
7660        crate::mark_tool_error(&mut msg);
7661    }
7662    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7663    // separately from the canonical entry-level ISO `timestamp` — see
7664    // `push_pi_user`.
7665    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7666        msg.metadata
7667            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7668    }
7669    out.push(msg);
7670}
7671
7672/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7673/// pi itself sends the model, mirroring `bashExecutionToText`
7674/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7675/// aren't reproduced in the frozen research doc (only cited by file:line),
7676/// so this is a faithful, clearly-labeled reconstruction — every structured
7677/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7678fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7679    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7680    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7681    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7682    let cancelled = msg_v
7683        .get("cancelled")
7684        .and_then(Value::as_bool)
7685        .unwrap_or(false);
7686    let truncated = msg_v
7687        .get("truncated")
7688        .and_then(Value::as_bool)
7689        .unwrap_or(false);
7690
7691    let mut text = format!("$ {command}\n{output}");
7692    if let Some(code) = exit_code {
7693        if code != 0 {
7694            text.push_str(&format!("\n[exit code: {code}]"));
7695        }
7696    }
7697    if cancelled {
7698        text.push_str("\n[cancelled]");
7699    }
7700    if truncated {
7701        text.push_str("\n[truncated]");
7702    }
7703
7704    let mut msg = ChatMessage::user(text);
7705    msg.metadata
7706        .insert("pi_bash_command".to_string(), command.to_string());
7707    msg.metadata
7708        .insert("pi_bash_output".to_string(), output.to_string());
7709    if let Some(code) = exit_code {
7710        msg.metadata
7711            .insert("pi_bash_exit_code".to_string(), code.to_string());
7712    }
7713    msg.metadata
7714        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7715    msg.metadata
7716        .insert("pi_bash_truncated".to_string(), truncated.to_string());
7717    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7718        msg.metadata
7719            .insert("pi_bash_full_output_path".to_string(), p.to_string());
7720    }
7721    // `!!` — hidden from the model context; honored by `is_replay_excluded`
7722    // on every writer, not just pi's own (§2.2).
7723    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7724        msg.metadata
7725            .insert("pi_exclude_from_context".to_string(), "true".to_string());
7726    }
7727    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7728    // separately from the canonical entry-level ISO `timestamp` — see
7729    // `push_pi_user`.
7730    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7731        msg.metadata
7732            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7733    }
7734    out.push(msg);
7735}
7736
7737/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7738/// stamps on a re-materialized content-bearing Claude `system` record (see
7739/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7740/// never collide with a real pi `CustomMessage.customType` — pi's own
7741/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7742/// migration targets), never this literal string.
7743const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7744
7745/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7746/// `custom_message` entries (§9) — both enter context as a `User` message
7747/// with the same `customType`/`display`/`details` residue.
7748///
7749/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7750/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7751/// actually a re-materialized content-bearing Claude `system` record round-
7752/// tripping through pi, not a genuine pi extension message — restore
7753/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7754/// claude_system_subtype`, falling back to `local_command` — still one of
7755/// `push_claude_system`'s own keep subtypes — exactly like
7756/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7757/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7758/// the exact original role, not just the text.
7759fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7760    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7761        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7762        if content.trim().is_empty() {
7763            return;
7764        }
7765        let subtype = v
7766            .get("details")
7767            .and_then(|d| d.get("claude_system_subtype"))
7768            .and_then(Value::as_str)
7769            .unwrap_or("local_command");
7770        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7771        return;
7772    }
7773    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7774    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7775    // survival, never a synthesized-empty part.
7776    if unknown_image_shape {
7777        return;
7778    }
7779    if text.trim().is_empty() && parts.is_none() {
7780        return;
7781    }
7782    let mut msg = match parts {
7783        Some(parts) => ChatMessage {
7784            role: Role::User,
7785            content: None,
7786            content_parts: Some(parts),
7787            tool_calls: None,
7788            tool_call_id: None,
7789            name: None,
7790            metadata: Default::default(),
7791        },
7792        None => ChatMessage::user(text),
7793    };
7794    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7795        msg.metadata
7796            .insert("pi_custom_type".to_string(), ct.to_string());
7797    }
7798    if let Some(d) = v.get("display").and_then(Value::as_bool) {
7799        msg.metadata.insert("pi_display".to_string(), d.to_string());
7800    }
7801    if let Some(details) = v.get("details") {
7802        if !details.is_null() {
7803            msg.metadata
7804                .insert("pi_details".to_string(), details.to_string());
7805        }
7806    }
7807    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7808    // separately from the canonical entry-level ISO `timestamp` — see
7809    // `push_pi_user`. `v` here is the `message` object for the `role:
7810    // "custom"` case; for the top-level `custom_message` case `v` is the
7811    // entry itself, whose `timestamp` is the entry-level ISO string (not a
7812    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7813    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7814        msg.metadata
7815            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7816    }
7817    out.push(msg);
7818}
7819
7820/// pi's own prefix-wrapped user text for a `compaction` entry summary
7821/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7822/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7823/// reproduced in the frozen research doc; this is a clearly-labeled
7824/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7825fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7826    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7827    if summary.trim().is_empty() {
7828        return;
7829    }
7830    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7831    msg.metadata
7832        .insert("pi_type".to_string(), "compaction".to_string());
7833    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7834        msg.metadata
7835            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7836    }
7837    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7838        msg.metadata
7839            .insert("pi_tokens_before".to_string(), tb.to_string());
7840    }
7841    if let Some(d) = entry_v.get("details") {
7842        if !d.is_null() {
7843            msg.metadata.insert("pi_details".to_string(), d.to_string());
7844        }
7845    }
7846    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7847        msg.metadata
7848            .insert("pi_from_hook".to_string(), "true".to_string());
7849    }
7850    out.push(msg);
7851}
7852
7853/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7854/// rewind-with-summary) — same reconstruction caveat as
7855/// [`push_pi_compaction`].
7856fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7857    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7858    if summary.trim().is_empty() {
7859        return;
7860    }
7861    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
7862    msg.metadata
7863        .insert("pi_type".to_string(), "branch_summary".to_string());
7864    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7865        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7866    }
7867    if let Some(d) = entry_v.get("details") {
7868        if !d.is_null() {
7869            msg.metadata.insert("pi_details".to_string(), d.to_string());
7870        }
7871    }
7872    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7873        msg.metadata
7874            .insert("pi_from_hook".to_string(), "true".to_string());
7875    }
7876    out.push(msg);
7877}
7878
7879// ---- OpenCode ---------------------------------------------------------
7880
7881/// The placeholder opencode's own replay substitutes for a `tool` part's
7882/// output once `state.completed.time.compacted` is set
7883/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
7884/// erased from the record (S1); it survives in `raw` and in this loader's
7885/// `metadata["oc_tool_output_compacted"]`.
7886pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
7887
7888fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
7889    restore_codex_provenance_from_top_level(si, meta)?;
7890    if let Some(id) = si.get("id").and_then(Value::as_str) {
7891        meta.session_id = Some(id.to_string());
7892    }
7893    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
7894        meta.cwd = Some(PathBuf::from(dir));
7895    }
7896    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
7897        meta.agent_id = Some(agent.to_string());
7898    }
7899    if let Some(model) = si.get("model") {
7900        let provider = model.get("providerID").and_then(Value::as_str);
7901        let id = model.get("id").and_then(Value::as_str);
7902        if let (Some(p), Some(i)) = (provider, id) {
7903            meta.model = Some(format!("{p}/{i}"));
7904        }
7905    }
7906    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
7907        meta.lineage
7908            .insert("projectID".to_string(), project_id.to_string());
7909    }
7910    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
7911        meta.lineage.insert("slug".to_string(), slug.to_string());
7912    }
7913    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
7914        meta.lineage
7915            .insert("workspaceID".to_string(), ws.to_string());
7916    }
7917    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
7918        meta.lineage
7919            .insert("parent_session_id".to_string(), parent.to_string());
7920        // Mirrored under the Codex-originated lineage key so the existing
7921        // generic `Session::reconstruct_tree` nests opencode subagent
7922        // sessions too, with no format-specific nesting pass (§2.1: "child
7923        // session's parentID ... → drives reconstruct_tree").
7924        meta.lineage
7925            .insert("parent_thread_id".to_string(), parent.to_string());
7926    }
7927    // D7: the other half of `synthesized_opencode_info`'s passthrough —
7928    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
7929    // -> Claude round trip reconstructs the original record (mirrors
7930    // `capture_codex_session_meta`/`capture_pi_header`'s identical
7931    // `claude_fork_context_ref` restore for the Codex/Pi hops).
7932    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7933        if let Some(v) = si.get("claude_fork_context_ref") {
7934            meta.lineage
7935                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7936        }
7937    }
7938    Ok(())
7939}
7940
7941/// An opencode `User`/`Assistant` `file` part's image data-URI →
7942/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
7943/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
7944/// a bare filesystem path, an `https:` link, or a non-image mime is left as
7945/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
7946/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
7947/// coverage with the SAME test this loader uses to canonicalize it (D5) —
7948/// one definition of "is this file part actually replayed", not two.
7949#[doc(hidden)]
7950pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
7951    let mime = part.get("mime").and_then(Value::as_str)?;
7952    let url = part.get("url").and_then(Value::as_str)?;
7953    if !mime.starts_with("image/") || !url.starts_with("data:") {
7954        return None;
7955    }
7956    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
7957}
7958
7959/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
7960/// `Role::System` arm stamps on the one `synthetic: true` text part of a
7961/// re-materialized content-bearing Claude `system` record (see that arm's
7962/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
7963/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
7964const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
7965
7966/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
7967/// `User` message with EXACTLY one `synthetic: true` text part carrying
7968/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
7969/// opencode data is never misclassified — a genuine opencode `synthetic`
7970/// text part never carries this supercode-namespaced key, and a real
7971/// multi-part user message (text + an attached file, say) never matches
7972/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
7973/// (e.g. `local_command`) on a match.
7974fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
7975    let [part] = parts else { return None };
7976    if part.get("type").and_then(Value::as_str) != Some("text") {
7977        return None;
7978    }
7979    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
7980        return None;
7981    }
7982    part.get("metadata")
7983        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
7984        .and_then(Value::as_str)
7985        .map(str::to_string)
7986}
7987
7988/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
7989/// and `metadata["systemSubtype"]` from the marked text part instead of
7990/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
7991/// OpenCode -> Claude round trip restores the exact original role, not just
7992/// the text. Content is never fabricated — only emitted when non-empty.
7993fn push_opencode_claude_system(
7994    msg_value: &Value,
7995    parts: &[Value],
7996    subtype: String,
7997    out: &mut Vec<ChatMessage>,
7998) {
7999    let Some(text) = parts
8000        .first()
8001        .and_then(|p| p.get("text"))
8002        .and_then(Value::as_str)
8003    else {
8004        return;
8005    };
8006    if text.trim().is_empty() {
8007        return;
8008    }
8009    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8010    set_opencode_msg_timestamp(&mut msg, msg_value);
8011    out.push(msg);
8012}
8013
8014/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8015/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8016/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8017/// the model"); `file` parts with a recognized image shape become
8018/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8019/// `SessionMeta.system_prompt` on the first turn that carries it, and
8020/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8021/// per-user-message, not per-session").
8022/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8023/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8024/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8025/// (opencode's own wire granularity); a `None`/malformed `time.created`
8026/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8027/// `SYNTH_TS`/`SYNTH_TS_MS`.
8028fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8029    if let Some(ms) = msg_value
8030        .get("time")
8031        .and_then(|t| t.get("created"))
8032        .and_then(Value::as_i64)
8033    {
8034        msg.metadata
8035            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8036    }
8037}
8038
8039fn push_opencode_user(
8040    msg_value: &Value,
8041    parts: &[Value],
8042    out: &mut Vec<ChatMessage>,
8043    meta: &mut SessionMeta,
8044    first_system_seen: &mut bool,
8045) {
8046    let mut text = String::new();
8047    let mut image_parts: Vec<Value> = Vec::new();
8048    let mut has_ignored = false;
8049    for p in parts {
8050        match p.get("type").and_then(Value::as_str) {
8051            Some("text") => {
8052                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8053                    has_ignored = true;
8054                    continue; // must never be replayed (§2.2)
8055                }
8056                if let Some(t) = p.get("text").and_then(Value::as_str) {
8057                    push_str_field(&mut text, t);
8058                }
8059            }
8060            Some("file") => {
8061                if let Some(img) = opencode_file_image_part(p) {
8062                    image_parts.push(img);
8063                }
8064            }
8065            // reasoning/tool never appear on a User message; step-start,
8066            // step-finish, snapshot, patch, agent, subtask, retry have no
8067            // clean home (§2.3); compaction is read separately by the
8068            // caller (tail_start_id) and tagged onto the message below.
8069            _ => {}
8070        }
8071    }
8072
8073    let has_images = !image_parts.is_empty();
8074    if text.trim().is_empty() && !has_images {
8075        return;
8076    }
8077    let mut msg = if has_images {
8078        let mut all = Vec::new();
8079        if !text.trim().is_empty() {
8080            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8081        }
8082        all.extend(image_parts);
8083        ChatMessage {
8084            role: Role::User,
8085            content: None,
8086            content_parts: Some(all),
8087            tool_calls: None,
8088            tool_call_id: None,
8089            name: None,
8090            metadata: Default::default(),
8091        }
8092    } else {
8093        ChatMessage::user(text)
8094    };
8095
8096    if has_ignored {
8097        msg.metadata
8098            .insert("oc_has_ignored_part".to_string(), "true".to_string());
8099    }
8100    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8101        msg.metadata
8102            .insert("oc_message_id".to_string(), id.to_string());
8103    }
8104    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8105        msg.metadata.insert("agent".to_string(), agent.to_string());
8106    }
8107    if let Some(model) = msg_value.get("model") {
8108        if !model.is_null() {
8109            msg.metadata.insert("model".to_string(), model.to_string());
8110        }
8111    }
8112    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8113        if !*first_system_seen {
8114            meta.system_prompt = Some(system.to_string());
8115            *first_system_seen = true;
8116        }
8117        msg.metadata
8118            .insert("system".to_string(), system.to_string());
8119    }
8120    for p in parts {
8121        if p.get("type").and_then(Value::as_str) == Some("compaction") {
8122            msg.metadata
8123                .insert("phase".to_string(), "compaction".to_string());
8124            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8125                msg.metadata
8126                    .insert("tail_start_id".to_string(), t.to_string());
8127            }
8128        }
8129    }
8130    set_opencode_msg_timestamp(&mut msg, msg_value);
8131    restore_grok_message_extension(msg_value, &mut msg);
8132    out.push(msg);
8133}
8134
8135/// Map an opencode `Assistant` message + its parts to a canonical
8136/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8137/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8138/// reached `completed`/`error` — the split-by-`callID` opencode's single
8139/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8140/// interrupted turn) synthesize no tool call/result of their own here; the
8141/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8142/// like the other three loaders. A `tool` part whose `state.status` is none
8143/// of the four known values is skipped entirely — raw-only survival, never
8144/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8145fn push_opencode_assistant(
8146    msg_value: &Value,
8147    parts: &[Value],
8148    out: &mut Vec<ChatMessage>,
8149    meta: &mut SessionMeta,
8150) {
8151    let mut text = String::new();
8152    let mut calls: Vec<ToolCall> = Vec::new();
8153    let mut thinking = String::new();
8154    let mut reasoning_seen = false;
8155    let mut thinking_sig: Option<String> = None;
8156    // (call_id, tool_name, the tool part itself) — deferred so the
8157    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8158    // every other loader's message ordering (call, then result).
8159    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8160
8161    for p in parts {
8162        match p.get("type").and_then(Value::as_str) {
8163            Some("text") => {
8164                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8165                    continue;
8166                }
8167                if let Some(t) = p.get("text").and_then(Value::as_str) {
8168                    push_str_field(&mut text, t);
8169                }
8170            }
8171            Some("reasoning") => {
8172                reasoning_seen = true;
8173                if let Some(t) = p.get("text").and_then(Value::as_str) {
8174                    push_str_field(&mut thinking, t);
8175                }
8176                if let Some(sig) = p
8177                    .get("metadata")
8178                    .and_then(|m| m.get("anthropic"))
8179                    .and_then(|a| a.get("signature"))
8180                    .and_then(Value::as_str)
8181                {
8182                    thinking_sig = Some(sig.to_string());
8183                }
8184            }
8185            Some("tool") => {
8186                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8187                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8188                let status = p
8189                    .get("state")
8190                    .and_then(|s| s.get("status"))
8191                    .and_then(Value::as_str);
8192                let known_status = matches!(
8193                    status,
8194                    Some("pending") | Some("running") | Some("completed") | Some("error")
8195                );
8196                if call_id.is_empty() || !known_status {
8197                    // Unknown/unrecognized status, or a malformed part with
8198                    // no callID — raw-only survival, never synthesized.
8199                    continue;
8200                }
8201                let input = p
8202                    .get("state")
8203                    .and_then(|s| s.get("input"))
8204                    .cloned()
8205                    .unwrap_or_else(|| Value::Object(Default::default()));
8206                calls.push(function_call(call_id, tool_name, input.to_string()));
8207                if matches!(status, Some("completed") | Some("error")) {
8208                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8209                }
8210            }
8211            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8212            // — no clean home on an Assistant turn (§2.3).
8213            _ => {}
8214        }
8215    }
8216
8217    let before = out.len();
8218    push_assistant(out, text, calls);
8219    // A native OpenCode assistant record is transcript state even when it
8220    // has no parts. Real stores contain these after an interrupted/empty
8221    // model turn; dropping the record here loses its id, timestamp, model,
8222    // token/cost metadata, and shifts the conversation on every export.
8223    // Keep one empty canonical assistant message so all target writers can
8224    // preserve the turn. This also covers reasoning-only records (whose
8225    // reasoning payload is attached as metadata just below).
8226    if out.len() == before {
8227        let mut empty = ChatMessage {
8228            role: Role::Assistant,
8229            content: None,
8230            content_parts: None,
8231            tool_calls: None,
8232            tool_call_id: None,
8233            name: None,
8234            metadata: Default::default(),
8235        };
8236        if !reasoning_seen {
8237            empty
8238                .metadata
8239                .insert("empty_assistant_record".to_string(), "true".to_string());
8240        }
8241        out.push(empty);
8242    }
8243    if out.len() > before {
8244        let msg = out.last_mut().expect("just pushed");
8245        if reasoning_seen {
8246            msg.metadata.insert("thinking".to_string(), thinking);
8247        }
8248        if let Some(sig) = thinking_sig {
8249            msg.metadata.insert("thinking_signature".to_string(), sig);
8250        }
8251        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8252            msg.metadata
8253                .insert("oc_message_id".to_string(), id.to_string());
8254        }
8255        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8256            msg.metadata.insert("agent".to_string(), agent.to_string());
8257            if meta.agent_id.is_none() {
8258                meta.agent_id = Some(agent.to_string());
8259            }
8260        }
8261        let provider = msg_value.get("providerID").and_then(Value::as_str);
8262        let model_id = msg_value.get("modelID").and_then(Value::as_str);
8263        if let (Some(p), Some(i)) = (provider, model_id) {
8264            let full = format!("{p}/{i}");
8265            msg.metadata.insert("model".to_string(), full.clone());
8266            if meta.model.is_none() {
8267                meta.model = Some(full);
8268            }
8269        }
8270        if let Some(cwd) = msg_value
8271            .get("path")
8272            .and_then(|p| p.get("cwd"))
8273            .and_then(Value::as_str)
8274        {
8275            if meta.cwd.is_none() {
8276                meta.cwd = Some(PathBuf::from(cwd));
8277            }
8278        }
8279        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8280            msg.metadata
8281                .insert("is_summary".to_string(), "true".to_string());
8282        }
8283        for (key, field) in [
8284            ("finish", "finish"),
8285            ("variant", "variant"),
8286            ("mode", "mode"),
8287        ] {
8288            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8289                msg.metadata.insert(key.to_string(), s.to_string());
8290            }
8291        }
8292        for (key, field) in [
8293            ("cost", "cost"),
8294            ("tokens", "tokens"),
8295            ("error", "error"),
8296            ("structured", "structured"),
8297        ] {
8298            if let Some(v) = msg_value.get(field) {
8299                if !v.is_null() {
8300                    msg.metadata.insert(key.to_string(), v.to_string());
8301                }
8302            }
8303        }
8304        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8305        // the spawned child session id — keyed by callID so multiple `task`
8306        // calls in one message never collide.
8307        // `resolve_opencode_parent_tool_use_ids` reads these back once a
8308        // whole session set is loaded.
8309        for p in parts {
8310            if p.get("type").and_then(Value::as_str) == Some("tool")
8311                && p.get("tool").and_then(Value::as_str) == Some("task")
8312            {
8313                if let (Some(call_id), Some(child)) = (
8314                    p.get("callID").and_then(Value::as_str),
8315                    p.get("metadata")
8316                        .and_then(|m| m.get("sessionId"))
8317                        .and_then(Value::as_str),
8318                ) {
8319                    msg.metadata.insert(
8320                        format!("oc_task_child_session_id__{call_id}"),
8321                        child.to_string(),
8322                    );
8323                }
8324            }
8325        }
8326        set_opencode_msg_timestamp(msg, msg_value);
8327        restore_grok_message_extension(msg_value, msg);
8328    }
8329
8330    // Second pass: the paired Tool-role message for each completed/error
8331    // tool part, split by callID (§2.1 — "the SAME part carries call and
8332    // result").
8333    for (call_id, tool_name, part) in tool_results {
8334        let status = part
8335            .get("state")
8336            .and_then(|s| s.get("status"))
8337            .and_then(Value::as_str);
8338        let compacted_at = part
8339            .get("state")
8340            .and_then(|s| s.get("time"))
8341            .and_then(|t| t.get("compacted"))
8342            .and_then(Value::as_i64);
8343        let real_output = part
8344            .get("state")
8345            .and_then(|s| s.get("output"))
8346            .and_then(Value::as_str)
8347            .unwrap_or("")
8348            .to_string();
8349        let (content, is_error) = match status {
8350            Some("completed") => {
8351                if compacted_at.is_some() {
8352                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8353                } else {
8354                    (real_output.clone(), false)
8355                }
8356            }
8357            Some("error") => {
8358                let err = part
8359                    .get("state")
8360                    .and_then(|s| s.get("error"))
8361                    .and_then(Value::as_str)
8362                    .unwrap_or("")
8363                    .to_string();
8364                (err, true)
8365            }
8366            _ => (String::new(), false),
8367        };
8368        let mut tmsg = ChatMessage {
8369            role: Role::Tool,
8370            content: Some(content),
8371            content_parts: None,
8372            tool_calls: None,
8373            tool_call_id: Some(call_id),
8374            name: Some(tool_name),
8375            metadata: Default::default(),
8376        };
8377        if let Some(original_position) = part
8378            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8379            .and_then(Value::as_u64)
8380        {
8381            tmsg.metadata.insert(
8382                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8383                original_position.to_string(),
8384            );
8385        }
8386        if is_error {
8387            crate::mark_tool_error(&mut tmsg);
8388        }
8389        restore_tool_outcome_extension(&part, &mut tmsg);
8390        if let Some(ts) = compacted_at {
8391            // S1: the real output is preserved — reversible, never erased.
8392            tmsg.metadata
8393                .insert("oc_tool_output_compacted".to_string(), real_output);
8394            tmsg.metadata
8395                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8396        }
8397        if status == Some("completed") {
8398            if let Some(atts) = part
8399                .get("state")
8400                .and_then(|s| s.get("attachments"))
8401                .and_then(Value::as_array)
8402            {
8403                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8404                if !images.is_empty() {
8405                    // D-mix consistency fix (Fable-recommended, same
8406                    // pattern as `push_claude_user`'s tool_result arm above):
8407                    // a completed opencode tool part with BOTH `state.output`
8408                    // text and `state.attachments` images is the same
8409                    // non-self-contained hybrid shape — `content_parts` here
8410                    // used to hold images only, so opencode -> pi silently
8411                    // dropped the output text (`pi_content_value` reads
8412                    // `content_parts` exclusively for `Role::Tool`). Prepend
8413                    // the text as part 0 so `content_parts` is
8414                    // self-contained; `tmsg.content` keeps the text too,
8415                    // unchanged, for writers that read it from there and
8416                    // only scan `content_parts` for `image_url` entries.
8417                    let mut parts = Vec::new();
8418                    if let Some(t) = &tmsg.content {
8419                        if !t.is_empty() {
8420                            parts.push(serde_json::json!({"type": "text", "text": t}));
8421                        }
8422                    }
8423                    parts.extend(images);
8424                    tmsg.content_parts = Some(parts);
8425                }
8426            }
8427        }
8428        if let Some(id) = part.get("id").and_then(Value::as_str) {
8429            tmsg.metadata
8430                .insert("oc_part_id".to_string(), id.to_string());
8431        }
8432        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8433        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8434        // cite `state.time.compacted`, but the SAME object also carries
8435        // `start`/`end` on every completed/error call) is this Tool
8436        // message's real source timestamp; prefer `end` (completion, closer
8437        // to when the RESULT — this message's content — was produced) and
8438        // fall back to `start` when only that is present.
8439        let tool_ts = part
8440            .get("state")
8441            .and_then(|s| s.get("time"))
8442            .and_then(|t| t.get("end").or_else(|| t.get("start")))
8443            .and_then(Value::as_i64);
8444        if let Some(ms) = tool_ts {
8445            tmsg.metadata
8446                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8447        }
8448        // OpenCode folds a canonical tool result into the assistant's tool
8449        // part. Restore the portable envelope from that part after native
8450        // fields have been captured so A -> OpenCode -> A retains fields
8451        // OpenCode does not model independently (for example Goose's
8452        // message-level metadata and an intentionally absent tool name).
8453        restore_grok_message_extension(&part, &mut tmsg);
8454        out.push(tmsg);
8455    }
8456}
8457
8458// ---- shared helpers -------------------------------------------------------
8459
8460fn push_text(buf: &mut String, v: Option<&Value>) {
8461    if let Some(Value::String(s)) = v {
8462        if !buf.is_empty() {
8463            buf.push('\n');
8464        }
8465        buf.push_str(s);
8466    }
8467}
8468
8469/// Extract a Claude `tool_result` block's content, preserving non-text items
8470/// instead of silently dropping them:
8471///
8472/// - text blocks are concatenated;
8473/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8474///   PNG / screenshot tool output" shape): `image` blocks are captured into
8475///   the returned `content_parts`-shaped `Vec<Value>` via
8476///   [`claude_image_block_to_part`] — the SAME base64/url conversion the
8477///   top-level `image` content-block path (`push_claude_user`) already uses
8478///   — instead of being flattened to the bare `[image]` marker text that used
8479///   to make the data unrecoverable from every writer. An unconvertible
8480///   source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8481///   etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8482///   vanishing, exactly like the top-level path;
8483/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8484///
8485/// and if the block yields no text/images at all, fall back to the record's
8486/// `toolUseResult` field (string used directly, structured value serialized),
8487/// which is where Claude Code stores the actual result in many cases.
8488///
8489/// Returns `(text, images)`; callers that only need the old text-only
8490/// behavior can ignore the second element — every caller MUST fold non-empty
8491/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8492/// function has no `ChatMessage` to attach to).
8493fn extract_tool_result_content(
8494    content: Option<&Value>,
8495    tool_use_result: Option<&Value>,
8496) -> (String, Vec<Value>) {
8497    let mut parts: Vec<String> = Vec::new();
8498    let mut images: Vec<Value> = Vec::new();
8499    match content {
8500        Some(Value::String(s)) => {
8501            if !s.is_empty() {
8502                parts.push(s.clone());
8503            }
8504        }
8505        Some(Value::Array(items)) => {
8506            for item in items {
8507                match item.get("type").and_then(Value::as_str) {
8508                    Some("text") => {
8509                        if let Some(t) = item.get("text").and_then(Value::as_str) {
8510                            parts.push(t.to_string());
8511                        }
8512                    }
8513                    Some("image") => match claude_image_block_to_part(item) {
8514                        Some(part) => images.push(part),
8515                        None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8516                    },
8517                    Some("tool_reference") => {
8518                        let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8519                        parts.push(format!("[tool_reference: {name}]"));
8520                    }
8521                    _ => {
8522                        if let Some(s) = item.as_str() {
8523                            parts.push(s.to_string());
8524                        }
8525                    }
8526                }
8527            }
8528        }
8529        Some(other) => parts.push(other.to_string()),
8530        None => {}
8531    }
8532
8533    let joined = parts.join("\n");
8534    if !joined.trim().is_empty() || !images.is_empty() {
8535        return (joined, images);
8536    }
8537    // Empty tool_result content — recover from toolUseResult.
8538    match tool_use_result {
8539        Some(Value::String(s)) => (s.clone(), images),
8540        Some(v) => (v.to_string(), images),
8541        None => (joined, images),
8542    }
8543}
8544
8545/// Pull readable text out of a content value that may be a plain string or an
8546/// array of `{ "text": "..." }`-bearing blocks (any block type).
8547fn extract_text_content(v: Option<&Value>) -> String {
8548    match v {
8549        Some(Value::String(s)) => s.clone(),
8550        Some(Value::Array(items)) => {
8551            let mut parts = Vec::new();
8552            for item in items {
8553                if let Some(t) = item.get("text").and_then(Value::as_str) {
8554                    parts.push(t.to_string());
8555                } else if let Some(s) = item.as_str() {
8556                    parts.push(s.to_string());
8557                }
8558            }
8559            parts.join("\n")
8560        }
8561        Some(other) => other.to_string(),
8562        None => String::new(),
8563    }
8564}
8565
8566/// Extract Codex `input_image` content blocks from a `message` response_item's
8567/// `content` value into `content_parts` `image_url` entries — the inverse of
8568/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8569/// block whose `image_url` is a non-empty string is recognized; anything else
8570/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8571/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8572/// the pi/opencode/Claude loaders' image-shape discipline.
8573fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8574    let Some(Value::Array(items)) = content else {
8575        return Vec::new();
8576    };
8577    items
8578        .iter()
8579        .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8580        .filter_map(|item| {
8581            let url = item.get("image_url").and_then(Value::as_str)?;
8582            if url.is_empty() {
8583                return None;
8584            }
8585            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8586        })
8587        .collect()
8588}
8589
8590fn value_to_arg_string(v: &Value) -> String {
8591    match v {
8592        Value::String(s) => s.clone(),
8593        other => other.to_string(),
8594    }
8595}
8596
8597fn push_gemini_user_parts(
8598    messages: &mut Vec<ChatMessage>,
8599    content_parts: Vec<Value>,
8600    timestamp: Option<&str>,
8601    source: &Value,
8602) {
8603    if content_parts.is_empty() {
8604        return;
8605    }
8606    let mut message = ChatMessage {
8607        role: Role::User,
8608        content: None,
8609        content_parts: Some(content_parts),
8610        tool_calls: None,
8611        tool_call_id: None,
8612        name: None,
8613        metadata: Default::default(),
8614    };
8615    if let Some(timestamp) = timestamp {
8616        message
8617            .metadata
8618            .insert("timestamp".into(), timestamp.into());
8619    }
8620    restore_gemini_message_extension(source, &mut message);
8621    messages.push(message);
8622}
8623
8624fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8625    ToolCall {
8626        id: id.to_string(),
8627        kind: "function".to_string(),
8628        function: FunctionCall {
8629            name: name.to_string(),
8630            arguments,
8631        },
8632    }
8633}
8634
8635fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8636    ChatMessage {
8637        role: Role::Tool,
8638        content: Some(content),
8639        content_parts: None,
8640        tool_calls: None,
8641        tool_call_id: Some(tool_call_id.to_string()),
8642        name: None,
8643        metadata: Default::default(),
8644    }
8645}
8646
8647/// Emit a single assistant message combining accumulated text and tool calls.
8648/// A turn with neither (e.g. thinking-only) produces nothing.
8649fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8650    let has_text = !text.trim().is_empty();
8651    if !has_text && calls.is_empty() {
8652        return;
8653    }
8654    out.push(ChatMessage {
8655        role: Role::Assistant,
8656        content: has_text.then_some(text),
8657        content_parts: None,
8658        tool_calls: (!calls.is_empty()).then_some(calls),
8659        tool_call_id: None,
8660        name: None,
8661        metadata: Default::default(),
8662    });
8663}
8664
8665// ---- writers --------------------------------------------------------------
8666
8667/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8668/// fallback (`docs/interop` build brief): every writer now emits a message's
8669/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8670/// field every loader populates) when one is present. `SYNTH_TS` fires only
8671/// for a message with no source timestamp at all — a turn synthesized/
8672/// appended after import (the live agent loop, a splice's appended tail,
8673/// ...), which was never loaded from a real per-message timestamp to begin
8674/// with. Both tools tolerate identical timestamps; callers that need real
8675/// ones for a synthesized turn can post-process.
8676const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8677
8678/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8679/// `time.created`/`time.updated` fields.
8680const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8681
8682/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8683/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8684/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8685/// parse, not just a presence check) so an absent, empty, or malformed
8686/// source value all degrade to the same documented fallback rather than
8687/// propagating garbage verbatim. Used by every writer that emits an
8688/// ISO-8601 timestamp field
8689/// (Claude Code, Codex, pi's entry-level `timestamp`).
8690fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8691    match msg.metadata.get("timestamp") {
8692        Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8693        _ => SYNTH_TS,
8694    }
8695}
8696
8697/// OpenCode reloads an export document by sorting messages on
8698/// `time.created`, so a timestamp-less appended continuation cannot reuse
8699/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8700/// newer. Advance a deterministic cursor for synthesized clocks while still
8701/// preserving every real source timestamp verbatim.
8702fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8703    if let Some(real) = msg
8704        .metadata
8705        .get("timestamp")
8706        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8707    {
8708        // A NativeTurn timestamp is durable provenance minted by supercode,
8709        // not an OpenCode source clock that must be replayed verbatim.
8710        // Multiple turns may be recorded in the same millisecond, while
8711        // OpenCode sorts solely by `time.created`; allocate such turns after
8712        // the existing cursor so their persisted order cannot collapse. This
8713        // also preserves the fail-closed i64::MAX exhaustion behavior.
8714        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8715            *cursor = cursor.checked_add(1).ok_or_else(|| {
8716                crate::Error::Other(
8717                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8718                        .to_string(),
8719                )
8720            })?;
8721            return Ok(*cursor);
8722        }
8723        *cursor = (*cursor).max(real);
8724        return Ok(real);
8725    }
8726    let next = cursor.checked_add(1).ok_or_else(|| {
8727        crate::Error::Other(
8728            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8729        )
8730    })?;
8731    *cursor = next.max(SYNTH_TS_MS);
8732    Ok(*cursor)
8733}
8734
8735/// Largest integer nested under any OpenCode `time` object. Imported
8736/// prefixes carry more clocks than `message.time.created` (assistant
8737/// completion, tool start/end, session updated); a synthesized continuation
8738/// must follow all of them, not merely sort after message creation times.
8739fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8740    fn max_number(value: &Value) -> Option<i64> {
8741        match value {
8742            Value::Number(n) => n.as_i64(),
8743            Value::Array(values) => values.iter().filter_map(max_number).max(),
8744            Value::Object(fields) => fields.values().filter_map(max_number).max(),
8745            _ => None,
8746        }
8747    }
8748
8749    match value {
8750        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8751        Value::Object(fields) => fields
8752            .iter()
8753            .filter_map(|(key, value)| {
8754                if key == "time" {
8755                    max_number(value)
8756                } else {
8757                    opencode_max_timestamp(value)
8758                }
8759            })
8760            .max(),
8761        _ => None,
8762    }
8763}
8764
8765/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8766/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8767/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8768/// reads. The two carry genuinely different values in real pi corpora (a
8769/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8770/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8771/// nested `message.timestamp` field, so a pi -> pi native round-trip
8772/// preserves the source message-level clock value-exact instead of deriving
8773/// it from the (distinct) entry-level timestamp. Falls back to
8774/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8775/// reading (non-pi-sourced, or a synthesized/appended turn).
8776fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8777    msg.metadata
8778        .get("pi_msg_timestamp")
8779        .and_then(|s| s.parse::<i64>().ok())
8780        .unwrap_or(SYNTH_TS_MS)
8781}
8782
8783/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8784fn synth_uuid(n: usize) -> String {
8785    format!("00000000-0000-4000-8000-{n:012x}")
8786}
8787
8788/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8789/// class N2 closed for the Codex spliced path's group ids, see
8790/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8791/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8792/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8793/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8794/// ahead of the tail this counter mints. Without this, re-splicing a
8795/// previously-exported-then-reimported session (export -> reimport -> append
8796/// -> export again) restarts `counter` at 1 with no memory of the prior
8797/// export's tail uuids now sitting in the prefix, so the second tail
8798/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8799/// — a uuid collision across prefix and tail that can mis-link any
8800/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8801/// climbing monotonically even across skips. `used_ids` is also updated for
8802/// each minted or metadata-backed identity, so collisions are prevented both
8803/// against the replayed prefix and within the appended tail.
8804fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8805    loop {
8806        let candidate = synth_uuid(*counter);
8807        *counter += 1;
8808        if used_ids.insert(candidate.clone()) {
8809            return candidate;
8810        }
8811    }
8812}
8813
8814/// Reuse a message's durable native/source UUID when available, falling back
8815/// to the deterministic synthesized sequence only for hand-built or legacy
8816/// messages that never carried identity metadata.
8817fn claude_message_uuid(
8818    msg: &ChatMessage,
8819    counter: &mut usize,
8820    used_ids: &mut HashSet<String>,
8821) -> String {
8822    for key in ["claude_uuid", "supercode_native_uuid"] {
8823        if let Some(candidate) = msg.metadata.get(key) {
8824            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8825                return candidate.clone();
8826            }
8827        }
8828    }
8829    next_claude_uuid(counter, used_ids)
8830}
8831
8832/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8833/// `raw_prefix` — the verbatim RAW lines
8834/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8835/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8836/// the GROUND TRUTH of what physically lands in the exported `out` string
8837/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8838/// the Codex side): each line is parsed as a Claude Code JSONL record and
8839/// its own top-level `uuid` field is read back out of the bytes directly, no
8840/// re-derivation from `self.messages` needed. A line that fails to parse, or
8841/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8842/// record), contributes nothing.
8843fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8844    let mut ids = HashSet::new();
8845    for line in raw_prefix {
8846        if let Ok(v) = serde_json::from_str::<Value>(line) {
8847            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8848                ids.insert(uuid.to_string());
8849            }
8850        }
8851    }
8852    ids
8853}
8854
8855fn push_jsonl(out: &mut String, value: &Value) {
8856    out.push_str(&value.to_string());
8857    out.push('\n');
8858}
8859
8860/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8861/// `new_id` when the line parses as a JSON object carrying that key — used
8862/// by A12's Claude Code splice, where the session id lives at the top level
8863/// of (almost) every record under `key = "sessionId"`. A line that fails to
8864/// parse, or parses but lacks `key`, is copied through byte-for-byte
8865/// (nothing to patch, so nothing is reserialized).
8866fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8867    if let Some(new_id) = new_id {
8868        if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8869            if v.get(key).is_some() {
8870                v[key] = Value::String(new_id.to_string());
8871                out.push_str(&v.to_string());
8872                out.push('\n');
8873                return;
8874            }
8875        }
8876    }
8877    out.push_str(line);
8878    out.push('\n');
8879}
8880
8881impl Session {
8882    fn cwd_string(&self) -> String {
8883        self.meta
8884            .cwd
8885            .as_ref()
8886            .map(|p| p.to_string_lossy().into_owned())
8887            .unwrap_or_else(|| ".".to_string())
8888    }
8889
8890    /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
8891    /// leading `raw` lines / `messages` came from the imported log, as
8892    /// opposed to being appended after import.
8893    ///
8894    /// `imported_message_count` (see its doc comment) pins the message-side
8895    /// boundary directly. The raw-side boundary isn't separately tracked —
8896    /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
8897    /// `raw` line per appended message, so the two lists grow by the same
8898    /// `appended_count` from the same starting point, and
8899    /// `raw.len() - appended_count` recovers it without a second counter.
8900    fn spliced_prefix_lens(&self) -> (usize, usize) {
8901        let message_prefix_len = self
8902            .imported_message_count
8903            .unwrap_or(self.messages.len())
8904            .min(self.messages.len());
8905        let appended_count = self.messages.len() - message_prefix_len;
8906        let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
8907        (raw_prefix_len, message_prefix_len)
8908    }
8909
8910    /// Synthesize a Claude Code transcript.
8911    ///
8912    /// Claude Code transcripts have no slot for the *session-level system
8913    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
8914    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
8915    /// `ChatMessage`s (Claude's own `type: "system"` records with a
8916    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
8917    /// `away_summary` — see `push_claude_system`, the exact inverse of what
8918    /// this writer now does) DO have a first-class slot: the real `type:
8919    /// "system"` record itself. This function used to unconditionally drop
8920    /// every `System` message, silently losing e.g. a real
8921    /// `<local-command-stdout>` record on any format -> Claude Code hop
8922    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
8923    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
8924    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
8925    /// now re-materializes it instead.
8926    fn to_claude_code_jsonl(&self) -> String {
8927        let session_id = self
8928            .meta
8929            .session_id
8930            .clone()
8931            .unwrap_or_else(|| synth_uuid(0));
8932        let cwd = self.cwd_string();
8933        let mut out = String::new();
8934        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
8935        // re-emitted byte-for-byte, ahead of the conversation it applies to —
8936        // this is what makes the record survive the SEMANTIC Claude Code
8937        // writer (the raw-passthrough diagonal in `crates/cli` already
8938        // preserves it by construction; this covers the library `to_jsonl`
8939        // path too, e.g. a `--session-id` override that forces the semantic
8940        // writer).
8941        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
8942            out.push_str(raw);
8943            out.push('\n');
8944        }
8945        // Full synthesis: `out` at this point has no raw prefix ahead of it
8946        // (unlike the A12 splice below), so there are no uuids yet in play
8947        // to seed against — see `next_claude_uuid`'s doc comment.
8948        self.write_claude_code_records(
8949            &mut out,
8950            &self.messages,
8951            &session_id,
8952            &cwd,
8953            None,
8954            1,
8955            &HashSet::new(),
8956        );
8957        if let Some(extension) = codex_provenance_envelope(&self.meta) {
8958            if out.is_empty() {
8959                push_jsonl(
8960                    &mut out,
8961                    &serde_json::json!({
8962                        "type": "file-history-snapshot",
8963                        "messageId": synth_uuid(1),
8964                        "snapshot": {},
8965                        "sessionId": session_id,
8966                        "cwd": cwd,
8967                        "timestamp": SYNTH_TS,
8968                    }),
8969                );
8970            }
8971            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8972        }
8973        out
8974    }
8975
8976    /// Synthesize Claude Code records for `messages` (a full session or an
8977    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
8978    /// the latter), starting the `parentUuid` chain at `parent` and the
8979    /// `synth_uuid` counter at `counter`. Factored out of
8980    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
8981    ///
8982    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
8983    /// every uuid that will ALREADY be present in `out` before this call
8984    /// ever runs — see that function's doc comment for why the A12 splice
8985    /// path needs this and full synthesis doesn't.
8986    // R1: this was already at clippy's `too_many_arguments` threshold (7,
8987    // including `&self`) before the fix; the added `seed_used_ids` param
8988    // pushes it to 8. Every argument here is independently meaningful (two
8989    // record-shape inputs, two id/parent-chain threading values, and now
8990    // the collision seed) — bundling them into a params struct is a larger
8991    // refactor of this already-widely-called private helper than the R1 fix
8992    // warrants, so this is allowed rather than restructured.
8993    #[allow(clippy::too_many_arguments)]
8994    fn write_claude_code_records(
8995        &self,
8996        out: &mut String,
8997        messages: &[ChatMessage],
8998        session_id: &str,
8999        cwd: &str,
9000        mut parent: Option<String>,
9001        mut counter: usize,
9002        seed_used_ids: &HashSet<String>,
9003    ) {
9004        let mut used_ids = seed_used_ids.clone();
9005        for msg in messages {
9006            if is_replay_excluded(msg) {
9007                continue;
9008            }
9009            let blocks: Vec<Value> = match msg.role {
9010                // PARITY-6 dev/02: re-materialize a content-bearing System
9011                // `ChatMessage` as a real Claude Code `type: "system"`
9012                // record — the exact inverse of `push_claude_system`, which
9013                // is what produced it in the first place for a message
9014                // loaded FROM a real Claude Code transcript. `subtype`
9015                // prefers the original `systemSubtype` metadata
9016                // (`push_claude_system`'s `.with_meta`, round-tripped
9017                // through the Codex hop via `write_codex_records`'s
9018                // `claude_system_subtype` metadata channel and restored by
9019                // `push_codex_item`); when that channel didn't carry it
9020                // (e.g. a genuinely native, non-Claude-origin developer
9021                // message), fall back to `local_command` — the observed
9022                // common case, and still one of `push_claude_system`'s own
9023                // `keep` subtypes, so the record survives a *subsequent*
9024                // reload rather than being silently re-dropped. This never
9025                // fabricates content: the real text is always carried
9026                // verbatim, only the subtype label is a best-effort guess
9027                // when the true one wasn't recoverable.
9028                Role::System => {
9029                    let content = msg.content.clone().unwrap_or_default();
9030                    if content.trim().is_empty() {
9031                        continue;
9032                    }
9033                    let subtype = msg
9034                        .metadata
9035                        .get("systemSubtype")
9036                        .cloned()
9037                        .unwrap_or_else(|| "local_command".to_string());
9038                    // R1/B3 union: this mint must ALSO route through
9039                    // `next_claude_uuid` + `seed_used_ids` like the other
9040                    // three arms below — otherwise this System arm (added by
9041                    // B3 after R1 landed) mints a raw `synth_uuid` that can
9042                    // collide with a uuid already sitting in the A12 splice's
9043                    // raw prefix (see `next_claude_uuid`'s doc comment).
9044                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9045                    let mut line = serde_json::json!({
9046                        "parentUuid": parent,
9047                        "type": "system",
9048                        "subtype": subtype,
9049                        "content": content,
9050                        "uuid": uuid,
9051                        "sessionId": session_id,
9052                        "cwd": cwd,
9053                        "timestamp": msg_timestamp_or_synth(msg),
9054                    });
9055                    set_grok_message_extension(&mut line, self.meta.source, msg);
9056                    push_jsonl(out, &line);
9057                    parent = Some(uuid);
9058                    continue;
9059                }
9060                Role::User => {
9061                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9062                    let mut line = serde_json::json!({
9063                        "parentUuid": parent,
9064                        "type": "user",
9065                        "message": {
9066                            "role": "user",
9067                            "content": claude_user_content_value(msg),
9068                        },
9069                        "uuid": uuid,
9070                        "sessionId": session_id,
9071                        "cwd": cwd,
9072                        "timestamp": msg_timestamp_or_synth(msg),
9073                    });
9074                    set_grok_message_extension(&mut line, self.meta.source, msg);
9075                    push_jsonl(out, &line);
9076                    parent = Some(uuid);
9077                    continue;
9078                }
9079                Role::Tool => {
9080                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9081                    let mut line = serde_json::json!({
9082                        "parentUuid": parent,
9083                        "type": "user",
9084                        "message": {
9085                            "role": "user",
9086                            "content": [{
9087                                "type": "tool_result",
9088                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9089                                "content": claude_tool_result_content_value(msg),
9090                            }],
9091                        },
9092                        "uuid": uuid,
9093                        "sessionId": session_id,
9094                        "cwd": cwd,
9095                        "timestamp": msg_timestamp_or_synth(msg),
9096                    });
9097                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9098                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9099                    }
9100                    set_grok_message_extension(&mut line, self.meta.source, msg);
9101                    push_jsonl(out, &line);
9102                    parent = Some(uuid);
9103                    continue;
9104                }
9105                Role::Assistant => {
9106                    let mut blocks = Vec::new();
9107                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
9108                    // dev/01): thinking/redacted_thinking must be re-emitted
9109                    // BEFORE text/tool_use, unconditionally whenever
9110                    // retained metadata is present — not only when `blocks`
9111                    // is otherwise empty. The previous `if blocks.is_empty()`
9112                    // gate (now below, applied unconditionally instead)
9113                    // meant a turn that thinks AND THEN answers/calls a tool
9114                    // in the SAME turn — pi's own default emission shape,
9115                    // and the overwhelmingly common real-world case for any
9116                    // reasoning model, not the rare reasoning-only edge case
9117                    // this gate's comment described — silently dropped its
9118                    // entire `thinking` block on Pi -> Claude Code export. A
9119                    // genuine multi-turn pi session driven through pi's own
9120                    // real Agent loop (faux provider, see
9121                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
9122                    // thinking+text turns lost the thinking block entirely.
9123                    // D8: prefer the exact per-block list when present —
9124                    // every `thinking`/`redacted_thinking` block re-emitted
9125                    // SEPARATELY with its own signature/data, exactly as
9126                    // captured (`push_claude_assistant`), instead of the
9127                    // legacy singular fields' lossy collapse (which drops
9128                    // every signature but the last one's on a multi-block
9129                    // message). Falls back to the legacy fields only for a
9130                    // `Session` that never populated `thinking_blocks` (e.g.
9131                    // hand-constructed in another loader/test, or loaded
9132                    // from a non-Claude-Code source like Pi).
9133                    match msg
9134                        .metadata
9135                        .get("thinking_blocks")
9136                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9137                        .and_then(|v| v.as_array().cloned())
9138                    {
9139                        Some(saved_blocks) => blocks.extend(saved_blocks),
9140                        None => {
9141                            if let Some(t) = msg.metadata.get("thinking") {
9142                                let mut block =
9143                                    serde_json::json!({"type": "thinking", "thinking": t});
9144                                if let Some(sig) = msg.metadata.get("thinking_signature") {
9145                                    block["signature"] = Value::String(sig.clone());
9146                                }
9147                                blocks.push(block);
9148                            }
9149                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
9150                                blocks.push(
9151                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
9152                                );
9153                            }
9154                        }
9155                    }
9156                    if let Some(t) = &msg.content {
9157                        if !t.is_empty() {
9158                            blocks.push(serde_json::json!({"type": "text", "text": t}));
9159                        }
9160                    }
9161                    // PARITY-11: an assistant-emitted image (`content_parts`,
9162                    // e.g. a generated image — `push_claude_assistant`'s
9163                    // load-side counterpart) has no slot in `msg.content`;
9164                    // without this, `blocks` stayed empty for an image-only
9165                    // turn and the whole message vanished on Claude Code
9166                    // semantic export, same failure mode the IX-6 Codex
9167                    // writer fix already closed on that side.
9168                    if let Some(parts) = &msg.content_parts {
9169                        for p in parts {
9170                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
9171                                if let Some(url) = p
9172                                    .get("image_url")
9173                                    .and_then(|u| u.get("url"))
9174                                    .and_then(Value::as_str)
9175                                {
9176                                    blocks.push(match parse_data_uri(url) {
9177                                        Some((mime, data)) => serde_json::json!({
9178                                            "type": "image",
9179                                            "source": {"type": "base64", "media_type": mime, "data": data},
9180                                        }),
9181                                        None => serde_json::json!({
9182                                            "type": "image",
9183                                            "source": {"type": "url", "url": url},
9184                                        }),
9185                                    });
9186                                }
9187                            }
9188                        }
9189                    }
9190                    for tc in msg.tool_calls() {
9191                        let input = tc
9192                            .function
9193                            .parsed_arguments()
9194                            .unwrap_or_else(|_| Value::Object(Default::default()));
9195                        blocks.push(serde_json::json!({
9196                            "type": "tool_use",
9197                            "id": tc.id,
9198                            "name": tc.function.name,
9199                            "input": input,
9200                        }));
9201                    }
9202                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9203                    // (no text, no tool_use, no image) still doesn't vanish
9204                    // — the thinking/redacted_thinking prepend above already
9205                    // ran unconditionally, so `blocks` is non-empty here
9206                    // whenever any of those were present.
9207                    blocks
9208                }
9209            };
9210
9211            // An empty assistant content array is a valid native interrupted
9212            // turn and must remain a record. Every non-assistant arm above
9213            // already `continue`s after writing its own shape, so an empty
9214            // `blocks` value here belongs specifically to that assistant.
9215            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9216            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9217            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9218                message["model"] = Value::String(model.clone());
9219            }
9220            let mut line = serde_json::json!({
9221                "parentUuid": parent,
9222                "type": "assistant",
9223                "message": message,
9224                "uuid": uuid,
9225                "sessionId": session_id,
9226                "cwd": cwd,
9227                "timestamp": msg_timestamp_or_synth(msg),
9228            });
9229            set_grok_message_extension(&mut line, self.meta.source, msg);
9230            push_jsonl(out, &line);
9231            parent = Some(uuid);
9232        }
9233    }
9234
9235    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9236    /// (patching `sessionId` on each line when `session_id` is `Some`), then
9237    /// synthesize records only for the appended tail, via
9238    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9239    /// last original `uuid` found anywhere in the raw prefix (not just its
9240    /// final line: a trailing loader-skipped record, e.g.
9241    /// `file-history-snapshot`, may carry no `uuid` of its own).
9242    fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9243        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9244        let sid = session_id
9245            .map(str::to_string)
9246            .or_else(|| self.meta.session_id.clone())
9247            .unwrap_or_else(|| synth_uuid(0));
9248        let cwd = self.cwd_string();
9249
9250        let mut out = String::new();
9251        let mut parent: Option<String> = None;
9252        for line in &self.raw[..raw_prefix_len] {
9253            push_spliced_line(&mut out, line, session_id, "sessionId");
9254            if let Ok(v) = serde_json::from_str::<Value>(line) {
9255                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9256                    parent = Some(uuid.to_string());
9257                }
9258            }
9259        }
9260
9261        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9262        // the tail's collision guard with every uuid the just-replayed RAW
9263        // prefix already carries, so `write_claude_code_records` never
9264        // fabricates a `synth_uuid` for the appended tail that collides with
9265        // one already sitting in the prefix (see `next_claude_uuid`'s and
9266        // `collect_claude_uuids_from_raw`'s doc comments).
9267        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9268        self.write_claude_code_records(
9269            &mut out,
9270            &self.messages[message_prefix_len..],
9271            &sid,
9272            &cwd,
9273            parent,
9274            1,
9275            &seed_used_ids,
9276        );
9277        out
9278    }
9279
9280    /// Synthesize a Codex rollout.
9281    fn to_codex_jsonl(&self) -> String {
9282        let mut out = String::new();
9283
9284        if self.meta.codex_headers.is_empty() {
9285            self.write_synthesized_codex_header(&mut out);
9286        } else {
9287            // Replay the exact header records the original tool wrote — Codex's
9288            // reader validates the header shape strictly — overriding only the
9289            // session id when the caller changed it.
9290            for header in &self.meta.codex_headers {
9291                let mut header = header.clone();
9292                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9293                    if let Some(id) = &self.meta.session_id {
9294                        if let Some(payload) = header.get_mut("payload") {
9295                            payload["id"] = Value::String(id.clone());
9296                        }
9297                    }
9298                }
9299                push_jsonl(&mut out, &header);
9300            }
9301        }
9302
9303        // Full synthesis: `out` at this point is only the header, so there
9304        // are no group ids yet in play to seed against (see
9305        // `write_codex_records`'s doc comment).
9306        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9307        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9308            inject_codex_provenance(&mut out, extension);
9309        }
9310        out
9311    }
9312
9313    /// Synthesize Codex `response_item` records for `messages` (a full
9314    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9315    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9316    /// the record shape is defined once; `tool_search_call_ids` pairing is
9317    /// scoped to this call's `messages`, matching the header-replay
9318    /// contract that only appended records need synthesizing.
9319    ///
9320    /// `seed_used_ids` primes the N2 collision guard below with every group
9321    /// id that will ALREADY be present in `out` before this call ever runs —
9322    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9323    /// header) passes an empty set, since every group id in that case is
9324    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9325    /// splice) passes the ids already used by the verbatim RAW prefix it
9326    /// replayed into `out` just before calling this for the appended tail —
9327    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9328    /// start blind to the prefix and can fabricate/reuse a group id that
9329    /// COLLIDES with one still "open" at the end of the prefix, letting
9330    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9331    /// an unrelated appended message into a historical one — the same
9332    /// bug-class N2 closed for full synthesis, reopened here because the
9333    /// spliced tail's tracking set used to always start empty regardless of
9334    /// what the replayed prefix already contained.
9335    fn write_codex_records(
9336        &self,
9337        out: &mut String,
9338        messages: &[ChatMessage],
9339        seed_used_ids: &std::collections::HashSet<String>,
9340    ) {
9341        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9342        // the matching tool result below can be emitted as the paired
9343        // `tool_search_output` record rather than a generic
9344        // `function_call_output` — the exact inverse of the importer's
9345        // `tool_search_call`/`tool_search_output` normalization
9346        // (`push_codex_item`, above).
9347        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9348        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9349        // records (e.g. a text-only narration turn immediately followed by a
9350        // bare tool-call turn, no user turn between — a real, common Claude
9351        // Code shape) each become their own Codex `message`/`function_call`
9352        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9353        // opportunistically RE-MERGES an assistant `message` immediately
9354        // followed by a `function_call` back into ONE `ChatMessage`, to match
9355        // how a genuinely single Claude turn (text+tool_use in the SAME
9356        // record) round-trips — but with no distinguishing signal, it can't
9357        // tell that case apart from two originally-separate records that
9358        // just happen to be adjacent, so it wrongly recombines them too,
9359        // silently shrinking the message count on every Claude -> Codex ->
9360        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9361        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9362        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9363        // itself emits. `push_codex_item`'s merge already treats a turn_id
9364        // mismatch as "different turn, do not merge" (the pre-existing
9365        // belt-and-suspenders check); real native Codex data almost never
9366        // carries this field (per that check's own comment), so this is a
9367        // no-op there and only sharpens fidelity for OUR OWN synthesized
9368        // export.
9369        let mut next_group_id: u64 = 0;
9370        // N2 (Fable-5 review, turn_id-collision hardening): every group id
9371        // this export has already assigned — whether REUSED from a real
9372        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9373        // `ChatMessage` never emits one that's already in use. Two concrete
9374        // mis-merge scenarios motivate this:
9375        //
9376        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9377        //     own text+tool_use); reload makes A carry REAL turn_id
9378        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9379        //     its own) is then appended. Re-export: A reuses its real
9380        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
9381        //     from `next_group_id == 0` again (nothing bumped it when A's id
9382        //     was reused rather than fabricated) — also `sc-grp-0`.
9383        //     Collision. If A's call has no output (interrupted session),
9384        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9385        //     adjacent with nothing to break the run and merges all three
9386        //     into ONE message (2 -> 1).
9387        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9388        //     truncation/clear event strips `__codex_open_turn` (closing the
9389        //     turn without changing the id), then `function_call(turn-7)`
9390        //     loads as a SECOND, separate `ChatMessage` that still carries
9391        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
9392        //     restamps it). Full-synthesis export naively reuses `turn-7`
9393        //     verbatim for BOTH messages (they're two different loop
9394        //     iterations, each independently reusing its own `real_turn_id`)
9395        //     and emits them adjacent — reimport's merge check can't tell
9396        //     this apart from a single message's own multi-call turn and
9397        //     recombines them (2 -> 1).
9398        //
9399        // Fix: the fabricated-id counter is advanced (skipped) past any id
9400        // already in `used_group_ids`, AND a real id that's already been
9401        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9402        // — never letting two DIFFERENT `ChatMessage`s in this export share
9403        // one group id, since `push_codex_item`'s merge check treats a
9404        // shared id as "same turn, merge". A single `ChatMessage`'s own
9405        // message record + its own tool call records still share ONE group
9406        // id (computed once per loop iteration below, before insertion), so
9407        // the D1 tool_search merge and ordinary same-turn multi-call
9408        // grouping are unaffected — this only stops REUSE across iterations.
9409        //
9410        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9411        // spliced-export tail is likewise blind-proof against the prefix it
9412        // doesn't itself write.
9413        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9414
9415        for msg in messages {
9416            if is_replay_excluded(msg) {
9417                continue;
9418            }
9419            // D3 (Fable-5 review): a message loaded FROM real native Codex
9420            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9421            // (`push_codex_item`'s "message" arm stamps it whenever the
9422            // source record itself has one). The group-id logic below used
9423            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9424            // silently overwriting/discarding that real id on any
9425            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9426            // when present; only fabricate a synthetic id as a fallback for
9427            // our own merge-disambiguation need (PARITY-6/7) when the
9428            // message has no real one of its own.
9429            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9430            match msg.role {
9431                Role::System => {
9432                    // PARITY-6 dev/02: carry the original Claude
9433                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9434                    // through as `metadata.claude_system_subtype`, so
9435                    // `push_codex_item`'s reverse load can restore it and
9436                    // `write_claude_code_records`'s `Role::System` arm can
9437                    // re-materialize the EXACT original subtype rather than
9438                    // guessing on a Codex -> Claude hop.
9439                    let subtype_meta = msg
9440                        .metadata
9441                        .get("systemSubtype")
9442                        .map(|s| ("claude_system_subtype", s.as_str()));
9443                    self.push_codex_message(
9444                        out,
9445                        "developer",
9446                        "input_text",
9447                        msg,
9448                        real_turn_id,
9449                        subtype_meta,
9450                    )
9451                }
9452                Role::User => {
9453                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9454                }
9455                Role::Assistant => {
9456                    // Emit the message record whenever there is text OR
9457                    // content_parts (IX-6 follow-up): an image-only assistant
9458                    // message has `content: None, content_parts:
9459                    // Some([image])` (the loader's `codex_extract_images` is
9460                    // role-general, so this shape can occur on the assistant
9461                    // side too) — gating on `msg.content` alone silently
9462                    // dropped the whole message, image included. A
9463                    // text-only message (content_parts: None) keeps taking
9464                    // the historical byte-identical path via
9465                    // `codex_message_content_blocks`'s `None` arm. A real
9466                    // empty native assistant record carries the
9467                    // loader's explicit marker and must also be emitted.
9468                    // Reasoning-only cross-provider turns deliberately lack
9469                    // that marker and keep the documented Codex residue.
9470                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9471                    let has_message_record = has_text
9472                        || msg.content_parts.is_some()
9473                        || msg.metadata.contains_key("empty_assistant_record");
9474                    // Only assign a synthetic group id when there's actual
9475                    // merge ambiguity to resolve (a message AND its own tool
9476                    // calls, or 2+ of this message's own tool calls) — a
9477                    // pure-text message with no tool calls, or a lone tool
9478                    // call with nothing else from the same `ChatMessage`,
9479                    // has nothing to disambiguate, so it keeps the exact
9480                    // historical byte shape (no `metadata` key at all).
9481                    let group_id: Option<String> = if let Some(real) = real_turn_id {
9482                        if used_group_ids.contains(real) {
9483                            // N2: this real turn_id was already used by an
9484                            // earlier (now-closed) `ChatMessage` in this same
9485                            // export — reusing it verbatim would let the
9486                            // reimport merge check recombine two originally
9487                            // separate messages (see the doc comment above).
9488                            let mut n = 1u64;
9489                            let mut candidate = format!("{real}~dup{n}");
9490                            while used_group_ids.contains(&candidate) {
9491                                n += 1;
9492                                candidate = format!("{real}~dup{n}");
9493                            }
9494                            Some(candidate)
9495                        } else {
9496                            Some(real.to_string())
9497                        }
9498                    } else if !msg.tool_calls().is_empty() {
9499                        // N2: skip past any id already used (e.g. a REAL
9500                        // turn_id that happens to look like `sc-grp-N`, or an
9501                        // id an earlier reused-real case landed on).
9502                        let mut candidate = format!("sc-grp-{next_group_id}");
9503                        next_group_id += 1;
9504                        while used_group_ids.contains(&candidate) {
9505                            candidate = format!("sc-grp-{next_group_id}");
9506                            next_group_id += 1;
9507                        }
9508                        Some(candidate)
9509                    } else {
9510                        None
9511                    };
9512                    if let Some(g) = &group_id {
9513                        used_group_ids.insert(g.clone());
9514                    }
9515                    if has_message_record {
9516                        self.push_codex_message(
9517                            out,
9518                            "assistant",
9519                            "output_text",
9520                            msg,
9521                            group_id.as_deref(),
9522                            None,
9523                        );
9524                    }
9525                    for tc in msg.tool_calls() {
9526                        let custom_tool_call = msg
9527                            .metadata
9528                            .get("codex_custom_tool_call_ids")
9529                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9530                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9531                        if custom_tool_call {
9532                            let input = tc
9533                                .function
9534                                .parsed_arguments()
9535                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9536                            let mut payload = with_turn_id(
9537                                serde_json::json!({
9538                                    "type": "custom_tool_call",
9539                                    "name": tc.function.name,
9540                                    "input": input,
9541                                    "call_id": tc.id,
9542                                }),
9543                                group_id.as_deref(),
9544                            );
9545                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9546                            push_jsonl(
9547                                out,
9548                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9549                            );
9550                        } else if tc.function.name == "tool_search" {
9551                            tool_search_call_ids.insert(tc.id.clone());
9552                            let mut payload = with_turn_id(
9553                                serde_json::json!({
9554                                    "type": "tool_search_call",
9555                                    "arguments": tc.function.arguments,
9556                                    "call_id": tc.id,
9557                                }),
9558                                group_id.as_deref(),
9559                            );
9560                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9561                            push_jsonl(
9562                                out,
9563                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9564                            );
9565                        } else {
9566                            let mut payload = with_turn_id(
9567                                serde_json::json!({
9568                                    "type": "function_call",
9569                                    "name": tc.function.name,
9570                                    "arguments": tc.function.arguments,
9571                                    "call_id": tc.id,
9572                                }),
9573                                group_id.as_deref(),
9574                            );
9575                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9576                            push_jsonl(
9577                                out,
9578                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9579                            );
9580                        }
9581                    }
9582                    // PARITY-11: a genuinely reasoning-only turn (Claude
9583                    // `thinking`/`redacted_thinking` with no text, tool_use,
9584                    // or image — `push_claude_assistant`'s load-side fix for
9585                    // the ~21% of real assistant records that are exactly
9586                    // this shape) has no message record and no tool calls,
9587                    // so nothing above writes anything for it. This is
9588                    // DELIBERATE, not a residual gap: Codex's `reasoning`
9589                    // response_item is understood on import (see the
9590                    // `response_item`/`"reasoning"` arm above), but its
9591                    // real-native semantics is "the reasoning immediately
9592                    // BEFORE the next turn" — the reader attaches it to
9593                    // whatever response_item comes next, unconditionally.
9594                    // For a genuinely standalone Claude reasoning-only turn
9595                    // (no related turn follows in Codex's export at all),
9596                    // emitting one here would get silently misattributed as
9597                    // belonging to some later, unrelated turn instead —
9598                    // strictly worse than the current honest, accounted-for
9599                    // absence (thinking/redacted_thinking is provider-
9600                    // private and "not replayed across providers" by
9601                    // original design; the audit correctly classifies it
9602                    // `Coverage::Dropped`, not `Unmodeled`). See the
9603                    // PARITY-6/7 corpus test's `is_replayable` filter for
9604                    // why this doesn't count as a message-count regression.
9605                }
9606                Role::Tool
9607                    if msg
9608                        .tool_call_id
9609                        .as_deref()
9610                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9611                {
9612                    let content = msg.content.clone().unwrap_or_default();
9613                    let tools =
9614                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9615                    let mut payload = serde_json::json!({
9616                        "type": "tool_search_output",
9617                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9618                        "tools": tools,
9619                    });
9620                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9621                    push_jsonl(
9622                        out,
9623                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9624                    );
9625                }
9626                Role::Tool => {
9627                    let mut payload = serde_json::json!({
9628                        "type": "function_call_output",
9629                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9630                        "output": codex_tool_output_text(msg),
9631                    });
9632                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9633                    push_jsonl(
9634                        out,
9635                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9636                    );
9637                }
9638            }
9639        }
9640    }
9641
9642    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9643    /// line, not just the `session_meta`/`turn_context` headers
9644    /// [`Self::to_codex_jsonl`] replays — overriding only
9645    /// `session_meta.payload.id` when `session_id` is `Some` (every other
9646    /// line, including `response_item`s the stock synthesis would otherwise
9647    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9648    /// `response_item` records only for the appended tail, via
9649    /// [`Self::write_codex_records`].
9650    fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9651        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9652
9653        let mut out = String::new();
9654        for line in &self.raw[..raw_prefix_len] {
9655            match session_id {
9656                Some(id) => {
9657                    let patched = serde_json::from_str::<Value>(line)
9658                        .ok()
9659                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9660                        .map(|mut v| {
9661                            if let Some(payload) = v.get_mut("payload") {
9662                                payload["id"] = Value::String(id.to_string());
9663                            }
9664                            v.to_string()
9665                        });
9666                    out.push_str(patched.as_deref().unwrap_or(line));
9667                }
9668                None => out.push_str(line),
9669            }
9670            out.push('\n');
9671        }
9672
9673        // N2 (spliced-path hardening): seed the tail's collision guard with
9674        // every group id the just-replayed RAW prefix already carries, so
9675        // `write_codex_records` never fabricates/reuses an id for the
9676        // appended tail that collides with one still open at the end of the
9677        // prefix (see that fn's doc comment, and
9678        // `collect_codex_group_ids_from_raw`'s).
9679        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9680        // Belt-and-suspenders: also union in the prefix `messages`' own
9681        // recorded `turn_id` metadata. In the ordinary case this is already
9682        // a subset of what the raw-line scan above found (the loader stamps
9683        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9684        // field the scan reads) — but scanning `messages` too costs nothing
9685        // and means this stays correct even if some future loader path ever
9686        // derives a message's `turn_id` by some means other than a literal
9687        // `payload.metadata.turn_id` copy.
9688        for msg in &self.messages[..message_prefix_len] {
9689            if let Some(tid) = msg.metadata.get("turn_id") {
9690                seed_used_ids.insert(tid.clone());
9691            }
9692        }
9693        self.write_codex_records(
9694            &mut out,
9695            &self.messages[message_prefix_len..],
9696            &seed_used_ids,
9697        );
9698        out
9699    }
9700
9701    /// Build a Codex header from scratch (used when converting from another
9702    /// format, where no original Codex header exists to replay). Emits the
9703    /// fields Codex requires on `session_meta`.
9704    fn write_synthesized_codex_header(&self, out: &mut String) {
9705        let mut meta_payload = serde_json::json!({
9706            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9707            "timestamp": SYNTH_TS,
9708            "cwd": self.cwd_string(),
9709            "originator": "supercode",
9710            "cli_version": env!("CARGO_PKG_VERSION"),
9711            "source": "exec",
9712            "thread_source": "user",
9713            "model_provider": "openai",
9714        });
9715        if let Some(sp) = &self.meta.system_prompt {
9716            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9717        }
9718        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9719        // `capture_claude_meta`) through the Codex hop under a clearly
9720        // namespaced custom field — real Codex tooling ignores unknown
9721        // `session_meta.payload` keys, and `capture_codex_session_meta`
9722        // reads this same key back on import, so a Claude -> Codex -> Claude
9723        // round trip still reconstructs the original record instead of
9724        // silently losing the lineage note on the cross-format hop.
9725        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9726            meta_payload["claude_fork_context_ref"] =
9727                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9728        }
9729        push_jsonl(
9730            out,
9731            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9732        );
9733        if let Some(model) = &self.meta.model {
9734            push_jsonl(
9735                out,
9736                &serde_json::json!({
9737                    "timestamp": SYNTH_TS,
9738                    "type": "turn_context",
9739                    "payload": {"model": model, "cwd": self.cwd_string()},
9740                }),
9741            );
9742        }
9743    }
9744
9745    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9746    /// [`Self::write_codex_records`] — `Some` when the source message
9747    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9748    /// (assistant only) a synthetic disambiguation id when it owns tool
9749    /// calls needing merge disambiguation and has no real id of its own;
9750    /// `None` reproduces the exact historical shape (no `metadata` key at
9751    /// all).
9752    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9753    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9754    /// `Role::System` case in [`Self::write_codex_records`] to carry
9755    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9756    /// system record's subtype survives the Claude -> Codex -> Claude round
9757    /// trip instead of only its text; `None` for every other caller,
9758    /// preserving the exact historical shape).
9759    fn push_codex_message(
9760        &self,
9761        out: &mut String,
9762        role: &str,
9763        text_type: &str,
9764        msg: &ChatMessage,
9765        turn_id: Option<&str>,
9766        extra_metadata: Option<(&str, &str)>,
9767    ) {
9768        let mut payload = with_turn_id(
9769            serde_json::json!({
9770                "type": "message",
9771                "role": role,
9772                "content": codex_message_content_blocks(text_type, msg),
9773            }),
9774            turn_id,
9775        );
9776        if let Some((k, v)) = extra_metadata {
9777            if payload.get("metadata").is_none() {
9778                payload["metadata"] = serde_json::json!({});
9779            }
9780            payload["metadata"][k] = serde_json::json!(v);
9781        }
9782        set_grok_message_extension(&mut payload, self.meta.source, msg);
9783        push_jsonl(
9784            out,
9785            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9786        );
9787    }
9788
9789    /// Synthesize a fresh pi v3 session from the canonical `messages`
9790    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9791    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9792    /// through `raw` + `to_native_jsonl(_v2)` instead).
9793    fn to_pi_jsonl(&self) -> String {
9794        let session_id = self
9795            .meta
9796            .session_id
9797            .clone()
9798            .unwrap_or_else(|| synth_uuid(0));
9799        let cwd = self.cwd_string();
9800        let mut out = String::new();
9801        push_pi_header(
9802            &mut out,
9803            &session_id,
9804            &cwd,
9805            self.meta
9806                .lineage
9807                .get("parent_session_path")
9808                .map(String::as_str),
9809            self.meta.lineage.get("created_at").map(String::as_str),
9810            // D7: carry a captured Claude `fork-context-ref` (see
9811            // `capture_claude_meta`) through the Pi hop too — mirrors the
9812            // Codex hop's `claude_fork_context_ref` passthrough
9813            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9814            // round trip doesn't silently lose fork lineage just because Pi
9815            // has no native slot for it.
9816            self.meta
9817                .lineage
9818                .get("claude_fork_context_ref_raw")
9819                .map(String::as_str),
9820        );
9821        let mut used_ids: HashSet<String> = HashSet::new();
9822        let mut counter: u64 = 0;
9823        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9824        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9825            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9826        }
9827        out
9828    }
9829
9830    /// Synthesize pi `message` entries for `messages` (a full session, or —
9831    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9832    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9833    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9834    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9835    fn write_pi_entries(
9836        &self,
9837        out: &mut String,
9838        messages: &[ChatMessage],
9839        mut parent: Option<String>,
9840        used_ids: &mut HashSet<String>,
9841        counter: &mut u64,
9842    ) {
9843        // Claude Code and Codex do not repeat the tool name on their native
9844        // tool-result records. Recover that redundant Pi field from the
9845        // paired assistant call when a cross-format round trip therefore
9846        // returns a canonical Tool message with `name == None`.
9847        let mut paired_tool_names = HashMap::<String, String>::new();
9848        for msg in messages {
9849            if is_replay_excluded(msg) {
9850                continue;
9851            }
9852            for call in msg.tool_calls() {
9853                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9854            }
9855            let id = pi_fresh_id(used_ids, counter);
9856            let mut entry = match msg.role {
9857                // B4: pi has no session-level system/developer PROMPT slot
9858                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9859                // content-bearing `Role::System` message loaded from a real
9860                // Claude Code `type: "system"` record (`push_claude_system`'s
9861                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9862                // `away_summary`) is NOT a system prompt — it's a real,
9863                // non-regenerable transcript event. Pi's own `role:"custom"`
9864                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9865                // as a user message") is the closest existing, non-fabricated
9866                // slot pi's own parser already understands, so this
9867                // re-materializes the record there instead of silently
9868                // dropping it — the exact allowance push_claude_system's own
9869                // doc comment describes in reverse. `customType` is a
9870                // supercode-namespaced marker (`push_pi_custom_common`
9871                // recognizes it on reload and restores `Role::System` +
9872                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9873                // produced in the first place); a real pi customType never
9874                // collides with this name. `details.claude_system_subtype`
9875                // carries the original subtype losslessly through the pi leg
9876                // (mirrors `write_codex_records`'s `claude_system_subtype`
9877                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9878                // is never fabricated — only emitted when non-empty.
9879                Role::System => {
9880                    let content = msg.content.clone().unwrap_or_default();
9881                    if content.trim().is_empty() {
9882                        continue;
9883                    }
9884                    let subtype = msg
9885                        .metadata
9886                        .get("systemSubtype")
9887                        .cloned()
9888                        .unwrap_or_else(|| "local_command".to_string());
9889                    serde_json::json!({
9890                        "type": "message",
9891                        "id": id,
9892                        "parentId": parent,
9893                        "timestamp": msg_timestamp_or_synth(msg),
9894                        "message": {
9895                            "role": "custom",
9896                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
9897                            "content": content,
9898                            "display": true,
9899                            "details": {"claude_system_subtype": subtype},
9900                            "timestamp": msg_pi_native_timestamp_ms(msg),
9901                        },
9902                    })
9903                }
9904                Role::User => serde_json::json!({
9905                    "type": "message",
9906                    "id": id,
9907                    "parentId": parent,
9908                    "timestamp": msg_timestamp_or_synth(msg),
9909                    "message": {
9910                        "role": "user",
9911                        "content": pi_content_value(msg),
9912                        "timestamp": msg_pi_native_timestamp_ms(msg),
9913                    },
9914                }),
9915                Role::Assistant => {
9916                    let api = msg
9917                        .metadata
9918                        .get("pi_api")
9919                        .cloned()
9920                        .unwrap_or_else(|| "anthropic-messages".to_string());
9921                    let provider = msg
9922                        .metadata
9923                        .get("pi_provider")
9924                        .cloned()
9925                        .unwrap_or_else(|| "anthropic".to_string());
9926                    let model = self
9927                        .meta
9928                        .model
9929                        .clone()
9930                        .unwrap_or_else(|| "unknown".to_string());
9931                    let usage = msg
9932                        .metadata
9933                        .get("pi_usage")
9934                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9935                        .unwrap_or_else(default_pi_usage);
9936                    let stop_reason = msg
9937                        .metadata
9938                        .get("pi_stop_reason")
9939                        .cloned()
9940                        .unwrap_or_else(|| "stop".to_string());
9941                    serde_json::json!({
9942                        "type": "message",
9943                        "id": id,
9944                        "parentId": parent,
9945                        "timestamp": msg_timestamp_or_synth(msg),
9946                        "message": {
9947                            "role": "assistant",
9948                            "content": pi_assistant_content_value(msg),
9949                            "api": api,
9950                            "provider": provider,
9951                            "model": model,
9952                            "usage": usage,
9953                            "stopReason": stop_reason,
9954                            "timestamp": msg_pi_native_timestamp_ms(msg),
9955                        },
9956                    })
9957                }
9958                Role::Tool => serde_json::json!({
9959                    "type": "message",
9960                    "id": id,
9961                    "parentId": parent,
9962                    "timestamp": msg_timestamp_or_synth(msg),
9963                    "message": {
9964                        "role": "toolResult",
9965                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
9966                        "toolName": msg.name.as_deref().or_else(|| {
9967                            msg.tool_call_id
9968                                .as_deref()
9969                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
9970                        }).unwrap_or_default(),
9971                        "content": pi_content_value(msg),
9972                        "isError": is_tool_error_flag(msg),
9973                        "timestamp": msg_pi_native_timestamp_ms(msg),
9974                    },
9975                }),
9976            };
9977            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9978                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9979            }
9980            set_grok_message_extension(&mut entry, self.meta.source, msg);
9981            push_jsonl(out, &entry);
9982            parent = Some(id);
9983            if msg.role == Role::Tool {
9984                if let Some(call_id) = msg.tool_call_id.as_deref() {
9985                    paired_tool_names.remove(call_id);
9986                }
9987            }
9988        }
9989    }
9990
9991    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
9992    /// **verbatim** — the header line always has its `version` normalized to
9993    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
9994    /// byte-identity, so the writer never re-emits one; this intentionally
9995    /// breaks byte-identity for pre-v3 originals only, the accepted
9996    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
9997    /// other raw line — every entry — is untouched (pi repeats the session
9998    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
9999    /// entries only for the appended tail via [`Self::write_pi_entries`],
10000    /// chaining from the last entry `id` found in the raw prefix.
10001    fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10002        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10003        if raw_prefix_len == 0 {
10004            return Ok(self.to_pi_jsonl());
10005        }
10006
10007        let mut out = String::new();
10008        let mut used_ids: HashSet<String> = HashSet::new();
10009        let mut leaf: Option<String> = None;
10010        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10011            if i == 0 {
10012                if let Ok(v) = serde_json::from_str::<Value>(line) {
10013                    if v.get("type").and_then(Value::as_str) == Some("session") {
10014                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10015                        // Only reparse+reserialize the header when something
10016                        // actually needs to change — this crate doesn't
10017                        // enable serde_json's `preserve_order`, so a no-op
10018                        // round-trip through `Value` would reorder keys
10019                        // alphabetically and silently break the "prefix
10020                        // bytes unchanged" splice guarantee for the (common)
10021                        // already-v3, no-override case.
10022                        if needs_v3 || session_id.is_some() {
10023                            let mut v = v;
10024                            v["version"] = serde_json::json!(3);
10025                            if let Some(new_id) = session_id {
10026                                v["id"] = Value::String(new_id.to_string());
10027                            }
10028                            out.push_str(&v.to_string());
10029                            out.push('\n');
10030                            continue;
10031                        }
10032                    }
10033                }
10034            }
10035            out.push_str(line);
10036            out.push('\n');
10037            if let Ok(v) = serde_json::from_str::<Value>(line) {
10038                if let Some(id) = v.get("id").and_then(Value::as_str) {
10039                    used_ids.insert(id.to_string());
10040                    leaf = Some(id.to_string());
10041                }
10042            }
10043        }
10044
10045        let mut counter: u64 = 0;
10046        self.write_pi_entries(
10047            &mut out,
10048            &self.messages[message_prefix_len..],
10049            leaf,
10050            &mut used_ids,
10051            &mut counter,
10052        );
10053        Ok(out)
10054    }
10055
10056    // ---- Grok writers -----------------------------------------------
10057
10058    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10059    fn to_grok_jsonl(&self) -> String {
10060        let mut out = String::new();
10061        if let Some(prompt) = self
10062            .meta
10063            .system_prompt
10064            .as_deref()
10065            .filter(|prompt| !prompt.is_empty())
10066        {
10067            push_jsonl(
10068                &mut out,
10069                &serde_json::json!({
10070                    "type": "system",
10071                    "content": prompt,
10072                }),
10073            );
10074        }
10075        self.write_grok_records(&mut out, &self.messages);
10076        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10077            if out.is_empty() {
10078                push_jsonl(
10079                    &mut out,
10080                    &serde_json::json!({"type": "system", "content": ""}),
10081                );
10082            }
10083            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10084        }
10085        out
10086    }
10087
10088    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10089        for message in messages {
10090            if is_replay_excluded(message) {
10091                continue;
10092            }
10093            let mut value = match message.role {
10094                Role::System => serde_json::json!({
10095                    "type": "user",
10096                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10097                    "synthetic_reason": "supercode_system_event",
10098                }),
10099                Role::User => {
10100                    let mut value = serde_json::json!({
10101                        "type": "user",
10102                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10103                    });
10104                    if let Some(object) = value.as_object_mut() {
10105                        for (metadata, field) in [
10106                            ("grok_prompt_index", "prompt_index"),
10107                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10108                            ("grok_synthetic_reason", "synthetic_reason"),
10109                        ] {
10110                            if let Some(raw) = message.metadata.get(metadata) {
10111                                object.insert(
10112                                    field.to_string(),
10113                                    serde_json::from_str(raw)
10114                                        .unwrap_or_else(|_| Value::String(raw.clone())),
10115                                );
10116                            }
10117                        }
10118                    }
10119                    value
10120                }
10121                Role::Assistant => {
10122                    let calls = message
10123                        .tool_calls()
10124                        .iter()
10125                        .map(|call| {
10126                            serde_json::json!({
10127                                "id": call.id,
10128                                "name": call.function.name,
10129                                "arguments": call.function.arguments,
10130                            })
10131                        })
10132                        .collect::<Vec<_>>();
10133                    let mut value = serde_json::json!({
10134                        "type": "assistant",
10135                        "content": message.content.clone().unwrap_or_default(),
10136                        "tool_calls": calls,
10137                        "model_id": message.metadata.get("grok_model_id")
10138                            .or(self.meta.model.as_ref())
10139                            .cloned()
10140                            .unwrap_or_else(|| "unknown".to_string()),
10141                    });
10142                    if let Some(object) = value.as_object_mut() {
10143                        for (metadata, field) in [
10144                            ("grok_model_fingerprint", "model_fingerprint"),
10145                            ("grok_reasoning_effort", "reasoning_effort"),
10146                        ] {
10147                            if let Some(raw) = message.metadata.get(metadata) {
10148                                object.insert(field.to_string(), Value::String(raw.clone()));
10149                            }
10150                        }
10151                    }
10152                    value
10153                }
10154                Role::Tool => serde_json::json!({
10155                    "type": "tool_result",
10156                    "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10157                    "content": message.content.clone().unwrap_or_default(),
10158                }),
10159            };
10160            set_grok_target_message_extension(&mut value, message);
10161            push_jsonl(out, &value);
10162        }
10163    }
10164
10165    /// Replay a Grok imported prefix verbatim, then append newly-created
10166    /// canonical turns. Grok stores the session id in the directory name,
10167    /// not in transcript records, so there is no in-file id to rewrite.
10168    fn to_grok_jsonl_spliced(&self) -> String {
10169        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10170        if raw_prefix_len == 0 {
10171            return self.to_grok_jsonl();
10172        }
10173        let mut out = String::new();
10174        for line in &self.raw[..raw_prefix_len] {
10175            out.push_str(line);
10176            out.push('\n');
10177        }
10178        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10179        out
10180    }
10181
10182    // ---- Gemini writers ---------------------------------------------
10183
10184    fn to_gemini_jsonl(&self) -> String {
10185        let mut out = String::new();
10186        self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10187        self.write_gemini_records(&mut out, &self.messages);
10188        push_jsonl(
10189            &mut out,
10190            &serde_json::json!({
10191                "$set": {"lastUpdated": SYNTH_TS}
10192            }),
10193        );
10194        out
10195    }
10196
10197    fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10198        push_jsonl(
10199            out,
10200            &serde_json::json!({
10201                "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10202                "projectHash": self.meta.lineage.get("gemini_project_hash")
10203                    .cloned().unwrap_or_else(|| "supercode".to_string()),
10204                "startTime": self.meta.lineage.get("created_at")
10205                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10206                "lastUpdated": self.meta.lineage.get("updated_at")
10207                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10208                "kind": self.meta.lineage.get("gemini_session_kind")
10209                    .cloned().unwrap_or_else(|| "main".to_string()),
10210            }),
10211        );
10212    }
10213
10214    fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10215        let mut call_names = HashMap::new();
10216        for (index, message) in messages.iter().enumerate() {
10217            if is_replay_excluded(message) {
10218                continue;
10219            }
10220            let timestamp = message
10221                .metadata
10222                .get("timestamp")
10223                .cloned()
10224                .unwrap_or_else(|| SYNTH_TS.to_string());
10225            match message.role {
10226                Role::System | Role::User => {
10227                    let mut parts = Vec::new();
10228                    let text = message.content.clone().or_else(|| {
10229                        message.content_parts.as_ref().and_then(|parts| {
10230                            let text = parts
10231                                .iter()
10232                                .filter_map(|part| part.get("text").and_then(Value::as_str))
10233                                .collect::<Vec<_>>()
10234                                .join(" ");
10235                            (!text.is_empty()).then_some(text)
10236                        })
10237                    });
10238                    if let Some(text) = text {
10239                        let text = if message.role == Role::System {
10240                            format!("[System] {text}")
10241                        } else {
10242                            text
10243                        };
10244                        parts.push(serde_json::json!({"text": text}));
10245                    }
10246                    if let Some(content_parts) = &message.content_parts {
10247                        for part in content_parts {
10248                            let Some(url) = part
10249                                .get("image_url")
10250                                .and_then(|value| value.get("url"))
10251                                .and_then(Value::as_str)
10252                            else {
10253                                continue;
10254                            };
10255                            let Some(rest) = url.strip_prefix("data:") else {
10256                                continue;
10257                            };
10258                            let Some((media_type, data)) = rest.split_once(";base64,") else {
10259                                continue;
10260                            };
10261                            parts.push(serde_json::json!({
10262                                "inlineData": {"mimeType": media_type, "data": data}
10263                            }));
10264                        }
10265                    }
10266                    if !parts.is_empty() {
10267                        let mut value = serde_json::json!({
10268                            "id": format!("supercode-user-{index}"),
10269                            "timestamp": timestamp,
10270                            "type": "user",
10271                            "content": parts,
10272                        });
10273                        set_gemini_message_extension(&mut value, message);
10274                        push_jsonl(out, &value);
10275                    }
10276                }
10277                Role::Assistant => {
10278                    let mut tool_calls = Vec::new();
10279                    for call in message.tool_calls() {
10280                        call_names.insert(call.id.clone(), call.function.name.clone());
10281                        let args = serde_json::from_str::<Value>(&call.function.arguments)
10282                            .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10283                        tool_calls.push(serde_json::json!({
10284                            "id": call.id,
10285                            "name": call.function.name,
10286                            "args": args,
10287                        }));
10288                    }
10289                    let mut value = serde_json::json!({
10290                        "id": format!("supercode-gemini-{index}"),
10291                        "timestamp": timestamp,
10292                        "type": "gemini",
10293                        "content": message.content.clone().unwrap_or_default(),
10294                        "model": message.metadata.get("gemini_model")
10295                            .or(self.meta.model.as_ref())
10296                            .cloned().unwrap_or_else(|| "unknown".to_string()),
10297                    });
10298                    if !tool_calls.is_empty() {
10299                        value["toolCalls"] = Value::Array(tool_calls);
10300                    }
10301                    if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10302                        value["thoughts"] = serde_json::from_str(thoughts)
10303                            .unwrap_or_else(|_| Value::String(thoughts.clone()));
10304                    }
10305                    set_gemini_message_extension(&mut value, message);
10306                    push_jsonl(out, &value);
10307                }
10308                Role::Tool => {
10309                    let id = message.tool_call_id.clone().unwrap_or_default();
10310                    let name = message
10311                        .name
10312                        .clone()
10313                        .or_else(|| call_names.get(&id).cloned())
10314                        .unwrap_or_else(|| "tool".to_string());
10315                    let output = message.content.clone().unwrap_or_else(|| {
10316                        message
10317                            .content_parts
10318                            .as_ref()
10319                            .map(|parts| Value::Array(parts.clone()))
10320                            .map(|value| value.to_string())
10321                            .unwrap_or_default()
10322                    });
10323                    let mut value = serde_json::json!({
10324                        "id": format!("supercode-tool-{index}"),
10325                        "timestamp": timestamp,
10326                        "type": "user",
10327                        "content": [{
10328                            "functionResponse": {
10329                                "id": id,
10330                                "name": name,
10331                                "response": {"output": output}
10332                            }
10333                        }],
10334                    });
10335                    set_gemini_message_extension(&mut value, message);
10336                    push_jsonl(out, &value);
10337                }
10338            }
10339        }
10340    }
10341
10342    fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10343        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10344        if raw_prefix_len == 0 {
10345            let mut out = String::new();
10346            self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10347            self.write_gemini_records(&mut out, &self.messages);
10348            return out;
10349        }
10350        let mut out = String::new();
10351        for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10352            if index == 0 && session_id.is_some() {
10353                if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10354                    if value.get("type").is_none() && value.get("sessionId").is_some() {
10355                        value["sessionId"] =
10356                            Value::String(session_id.unwrap_or_default().to_string());
10357                        push_jsonl(&mut out, &value);
10358                        continue;
10359                    }
10360                }
10361            }
10362            out.push_str(line);
10363            out.push('\n');
10364        }
10365        self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10366        out
10367    }
10368
10369    // ---- Goose writers ----------------------------------------------
10370
10371    fn to_goose_json(&self) -> String {
10372        if self.meta.source == SessionSource::Goose
10373            && !self.raw.is_empty()
10374            && self.imported_message_count == Some(self.messages.len())
10375        {
10376            return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10377        }
10378        self.synthesized_goose_document(None, &self.messages)
10379    }
10380
10381    fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10382        let message_prefix_len = self
10383            .imported_message_count
10384            .unwrap_or(self.messages.len())
10385            .min(self.messages.len());
10386        if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10387            if session_id.is_none() && message_prefix_len == self.messages.len() {
10388                return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10389            }
10390            let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10391            if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10392                if let Some(session_id) = session_id {
10393                    document["id"] = Value::String(session_id.to_string());
10394                }
10395                let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10396                if let Some(conversation) = document
10397                    .get_mut("conversation")
10398                    .and_then(Value::as_array_mut)
10399                {
10400                    conversation.extend(appended);
10401                    document["message_count"] = Value::from(conversation.len());
10402                }
10403                return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10404                    self.synthesized_goose_document(session_id, &self.messages)
10405                });
10406            }
10407        }
10408        self.synthesized_goose_document(session_id, &self.messages)
10409    }
10410
10411    fn synthesized_goose_document(
10412        &self,
10413        session_id: Option<&str>,
10414        messages: &[ChatMessage],
10415    ) -> String {
10416        let mut document = self
10417            .meta
10418            .goose_header
10419            .clone()
10420            .or_else(|| {
10421                self.messages.iter().find_map(|message| {
10422                    message
10423                        .metadata
10424                        .get("goose_session_header")
10425                        .and_then(|value| serde_json::from_str(value).ok())
10426                })
10427            })
10428            .unwrap_or_else(|| {
10429                serde_json::json!({
10430                    "id": "supercode-goose-session",
10431                    "working_dir": self.cwd_string(),
10432                    "name": "supercode export",
10433                    "user_set_name": false,
10434                    "session_type": "user",
10435                    "created_at": SYNTH_TS,
10436                    "updated_at": SYNTH_TS,
10437                    "extension_data": {},
10438                    "usage": {},
10439                    "accumulated_usage": {},
10440                    "accumulated_cost": Value::Null,
10441                    "schedule_id": Value::Null,
10442                    "recipe": Value::Null,
10443                    "user_recipe_values": Value::Null,
10444                    "message_count": 0,
10445                    "last_message_at": Value::Null,
10446                    "provider_name": Value::Null,
10447                    "model_config": Value::Null,
10448                    "goose_mode": "auto",
10449                    "archived_at": Value::Null,
10450                    "project_id": Value::Null,
10451                    "parent_session_id": Value::Null,
10452                    "last_message_snippet": Value::Null,
10453                })
10454            });
10455        document["id"] = Value::String(
10456            session_id
10457                .map(str::to_string)
10458                .or_else(|| self.meta.session_id.clone())
10459                .unwrap_or_else(|| "supercode-goose-session".to_string()),
10460        );
10461        document["working_dir"] = Value::String(self.cwd_string());
10462        let conversation = self.goose_conversation(messages);
10463        document["message_count"] = Value::from(conversation.len());
10464        document["conversation"] = Value::Array(conversation);
10465        serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10466    }
10467
10468    fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10469        let mut out = Vec::new();
10470        let mut last_native_index: Option<String> = None;
10471        let mut tool_names = HashMap::<String, String>::new();
10472        for (index, message) in messages.iter().enumerate() {
10473            if is_replay_excluded(message) {
10474                continue;
10475            }
10476            if let Some(native_index) = message.metadata.get("goose_native_index") {
10477                if last_native_index.as_ref() == Some(native_index) {
10478                    continue;
10479                }
10480                last_native_index = Some(native_index.clone());
10481                if let Some(native) = message
10482                    .metadata
10483                    .get("goose_native_message")
10484                    .and_then(|value| serde_json::from_str::<Value>(value).ok())
10485                {
10486                    out.push(native);
10487                    continue;
10488                }
10489            } else {
10490                last_native_index = None;
10491            }
10492
10493            for call in message.tool_calls() {
10494                tool_names.insert(call.id.clone(), call.function.name.clone());
10495            }
10496            let created = message
10497                .metadata
10498                .get("goose_created")
10499                .and_then(|value| value.parse::<i64>().ok())
10500                .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10501            let role = match message.role {
10502                Role::Assistant => "assistant",
10503                _ => "user",
10504            };
10505            let mut content = Vec::new();
10506            // A Goose tool response carries its output inside
10507            // `toolResult.value.content`; duplicating it as a sibling text
10508            // block makes the loader normalize one Tool message twice.
10509            if message.role != Role::Tool {
10510                if let Some(text) = &message.content {
10511                    let text = if message.role == Role::System {
10512                        format!("[System] {text}")
10513                    } else {
10514                        text.clone()
10515                    };
10516                    content.push(serde_json::json!({"type": "text", "text": text}));
10517                }
10518                if let Some(parts) = &message.content_parts {
10519                    for part in parts {
10520                        if let Some(text) = part.get("text").and_then(Value::as_str) {
10521                            if message.content.is_none() {
10522                                content.push(serde_json::json!({"type": "text", "text": text}));
10523                            }
10524                        }
10525                        let Some(url) = part
10526                            .get("image_url")
10527                            .and_then(|image| image.get("url"))
10528                            .and_then(Value::as_str)
10529                        else {
10530                            continue;
10531                        };
10532                        let Some(data) = url.strip_prefix("data:") else {
10533                            continue;
10534                        };
10535                        let Some((media_type, data)) = data.split_once(";base64,") else {
10536                            continue;
10537                        };
10538                        content.push(serde_json::json!({
10539                            "type": "image",
10540                            "data": data,
10541                            "mimeType": media_type,
10542                        }));
10543                    }
10544                }
10545            }
10546            for call in message.tool_calls() {
10547                let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10548                    .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10549                content.push(serde_json::json!({
10550                    "type": "toolRequest",
10551                    "id": call.id,
10552                    "toolCall": {
10553                        "status": "success",
10554                        "value": {"name": call.function.name, "arguments": arguments}
10555                    }
10556                }));
10557            }
10558            if message.role == Role::Tool {
10559                let id = message.tool_call_id.clone().unwrap_or_default();
10560                let output = message.content.clone().unwrap_or_else(|| {
10561                    message
10562                        .content_parts
10563                        .as_ref()
10564                        .map(|parts| Value::Array(parts.clone()).to_string())
10565                        .unwrap_or_default()
10566                });
10567                let tool_result = if crate::is_tool_error(message) {
10568                    serde_json::json!({"status": "error", "error": output})
10569                } else {
10570                    serde_json::json!({
10571                        "status": "success",
10572                        "value": {
10573                            "content": [{"type": "text", "text": output}],
10574                            "isError": false
10575                        }
10576                    })
10577                };
10578                content.push(serde_json::json!({
10579                    "type": "toolResponse",
10580                    "id": id,
10581                    "toolResult": tool_result,
10582                    "metadata": {
10583                        "toolName": message.name.as_ref()
10584                            .or_else(|| tool_names.get(&id))
10585                    }
10586                }));
10587            }
10588            if content.is_empty() {
10589                continue;
10590            }
10591            let metadata = message
10592                .metadata
10593                .get("goose_metadata")
10594                .and_then(|value| serde_json::from_str::<Value>(value).ok())
10595                .unwrap_or_else(|| {
10596                    serde_json::json!({
10597                        "userVisible": true,
10598                        "agentVisible": true
10599                    })
10600                });
10601            let mut native = serde_json::json!({
10602                "id": message.metadata.get("goose_message_id")
10603                    .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10604                "role": role,
10605                "created": created,
10606                "content": content,
10607                "metadata": metadata,
10608            });
10609            // Goose tolerates unknown top-level fields on a conversation
10610            // message. Always carry the canonical envelope when Goose is
10611            // the TARGET so metadata absent from Goose's stock schema can
10612            // make a later Goose -> source round trip without residue.
10613            set_grok_target_message_extension(&mut native, message);
10614            out.push(native);
10615        }
10616        out
10617    }
10618
10619    // ---- OpenCode writers ---------------------------------------------
10620
10621    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10622    /// `(message value, part values)` list) directly from `self.raw`'s
10623    /// envelope lines — the same classification
10624    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10625    /// rather than canonical `ChatMessage`s. Used by
10626    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10627    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10628    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10629    /// path for excess keys/timestamps/side-records `opencode import`
10630    /// cannot restore).
10631    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10632        let mut session_info: Option<Value> = None;
10633        let mut msg_order: Vec<String> = Vec::new();
10634        let mut msg_values: HashMap<String, Value> = HashMap::new();
10635        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10636        for line in &self.raw {
10637            let Ok(env) = serde_json::from_str::<Value>(line) else {
10638                continue;
10639            };
10640            let Some(key) = env.get("key").and_then(Value::as_array) else {
10641                continue;
10642            };
10643            let value = env.get("value").cloned().unwrap_or(Value::Null);
10644            match key.first().and_then(Value::as_str) {
10645                Some("session") => session_info = Some(value),
10646                Some("message") => {
10647                    if let Some(id) = value.get("id").and_then(Value::as_str) {
10648                        if !msg_values.contains_key(id) {
10649                            msg_order.push(id.to_string());
10650                        }
10651                        msg_values.insert(id.to_string(), value);
10652                    }
10653                }
10654                Some("part") => {
10655                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10656                        msg_parts.entry(mid.to_string()).or_default().push(value);
10657                    }
10658                }
10659                _ => {}
10660            }
10661        }
10662        let mut ordered: Vec<(String, i64)> = msg_order
10663            .iter()
10664            .map(|id| {
10665                let tc = msg_values
10666                    .get(id)
10667                    .and_then(|v| v.get("time"))
10668                    .and_then(|t| t.get("created"))
10669                    .and_then(Value::as_i64)
10670                    .unwrap_or(0);
10671                (id.clone(), tc)
10672            })
10673            .collect();
10674        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10675        let mut out = Vec::new();
10676        for (id, _) in ordered {
10677            let mut parts = msg_parts.remove(&id).unwrap_or_default();
10678            parts.sort_by(|a, b| {
10679                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10680                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10681                ai.cmp(bi)
10682            });
10683            if let Some(v) = msg_values.remove(&id) {
10684                out.push((v, parts));
10685            }
10686        }
10687        (session_info, out)
10688    }
10689
10690    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10691    /// `raw` prefix exists to replay (a fresh/cross-format-converted
10692    /// session). T3 tier: only what `SessionMeta` carries survives.
10693    fn synthesized_opencode_info(&self) -> Value {
10694        let id = self
10695            .meta
10696            .session_id
10697            .clone()
10698            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10699        let mut info = serde_json::json!({
10700            "id": id,
10701            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10702            // OpenCode 1.2.15's import path writes this into a NOT NULL
10703            // SQLite column. Preserve a real source slug when available and
10704            // mint a stable, human-readable fallback for foreign sessions.
10705            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10706            "directory": self.cwd_string(),
10707            "title": "supercode export",
10708            "version": env!("CARGO_PKG_VERSION"),
10709            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10710        });
10711        if let Some(agent) = &self.meta.agent_id {
10712            info["agent"] = Value::String(agent.clone());
10713        }
10714        if let Some(model) = &self.meta.model {
10715            if let Some((provider, mid)) = model.split_once('/') {
10716                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10717            }
10718        }
10719        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10720            info["parentID"] = Value::String(parent.clone());
10721        }
10722        // D7: carry a captured Claude `fork-context-ref` through the
10723        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10724        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10725        // the `session` header) hops already do — namespaced so real
10726        // OpenCode tooling ignores it, and `capture_opencode_session_info`
10727        // reads this same key back on import so a Claude -> OpenCode ->
10728        // Claude round trip doesn't silently lose fork lineage either.
10729        //
10730        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10731        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10732        // this `claude_fork_context_ref` key on `SessionInfo` survives
10733        // supercode's OWN round-trip (write here, read back by
10734        // `capture_opencode_session_info` above) but NOT a real upstream
10735        // `opencode import` ingestion — that path decodes with
10736        // `Schema.decodeUnknownSync`, which strips any key its schema
10737        // doesn't declare. The direct-file/DB fallback (bypassing
10738        // `opencode import` entirely) is the per-spec fidelity path for
10739        // this lineage to actually reach real OpenCode.
10740        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10741            info["claude_fork_context_ref"] =
10742                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10743        }
10744        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10745            info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10746        }
10747        info
10748    }
10749
10750    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10751    /// synthesized continuation message therefore has to advance the
10752    /// session clock along with its own `time.created` value.
10753    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10754        if !info.get("time").is_some_and(Value::is_object) {
10755            info["time"] = serde_json::json!({});
10756        }
10757        info["time"]["updated"] = serde_json::json!(timestamp);
10758    }
10759
10760    /// Synthesize opencode `{info, parts}` message objects for `messages`
10761    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10762    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10763    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10764    /// back into its call's assistant `tool` part (match by
10765    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10766    /// whole slice)
10767    /// — the exact inverse of the loader's call/result split. This is a
10768    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10769    /// immediately following each assistant: two-or-more consecutive
10770    /// assistant-with-tool-call messages before their results (streamed /
10771    /// parallel tool calls) otherwise strand the earlier call's real result
10772    /// behind a later assistant message, silently downgrading it to
10773    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10774    /// messages exactly like every other writer.
10775    fn append_synthesized_opencode_messages(
10776        &self,
10777        out: &mut Vec<Value>,
10778        messages: &[ChatMessage],
10779        session_id: &str,
10780        counter: &mut u64,
10781        timestamp_cursor: &mut i64,
10782    ) -> Result<()> {
10783        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10784        // over the ENTIRE slice being processed, rather than by scanning
10785        // only the contiguous run of `Role::Tool` messages immediately
10786        // following a given assistant message. Two-or-more consecutive
10787        // assistant-with-tool-call messages before their results (streamed
10788        // / parallel tool calls — extremely common in real Claude Code and
10789        // Codex sessions) break the contiguous-run assumption: the first
10790        // assistant's own result(s) land AFTER a second assistant message,
10791        // not immediately after the first, so a contiguous scan starting
10792        // right after the first assistant finds nothing and silently drops
10793        // its real tool output into the `None => "pending"` branch below.
10794        // A single `id -> result` map is still insufficient: long real
10795        // sessions can reuse provider call ids. Last-write-wins then attaches
10796        // the final output to every earlier occurrence. Collect calls and
10797        // results independently and zip their occurrences in transcript
10798        // order, giving every concrete call position its own result.
10799        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10800        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10801        for (message_index, message) in messages.iter().enumerate() {
10802            if message.role == Role::Assistant {
10803                for (tool_index, call) in message.tool_calls().iter().enumerate() {
10804                    calls_by_id
10805                        .entry(call.id.as_str())
10806                        .or_default()
10807                        .push((message_index, tool_index));
10808                }
10809            } else if message.role == Role::Tool {
10810                if let Some(id) = &message.tool_call_id {
10811                    results_by_id
10812                        .entry(id.as_str())
10813                        .or_default()
10814                        .push((message_index, message));
10815                }
10816            }
10817        }
10818        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10819        for (id, calls) in calls_by_id {
10820            let Some(results) = results_by_id.get(id) else {
10821                continue;
10822            };
10823            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10824                paired_results.insert(call_position, result);
10825            }
10826        }
10827        let mut i = 0;
10828        while i < messages.len() {
10829            let msg = &messages[i];
10830            if is_replay_excluded(msg) {
10831                i += 1;
10832                continue;
10833            }
10834            match msg.role {
10835                // B4: opencode V1 has no session-level system-PROMPT slot
10836                // either — `User.system` is a per-turn system-PROMPT
10837                // OVERRIDE (§2.1), a different thing from a content-bearing
10838                // `Role::System` message loaded from a real Claude `type:
10839                // "system"` record (`push_claude_system`'s keep-listed
10840                // subtypes). Stuffing real transcript content into
10841                // `User.system` would be a genuine misuse — it overrides the
10842                // replayed system prompt, not just annotates a turn — so
10843                // this instead reuses opencode's own `text` part `synthetic`
10844                // flag (§3.1: "injected by opencode, not typed by user"),
10845                // which is EXACTLY the right existing, non-fabricated
10846                // semantic for "system-originated content presented as a
10847                // user turn": a dedicated `User` message with one
10848                // `synthetic: true` text part, tagged with a
10849                // supercode-namespaced part-`metadata` key so
10850                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10851                // recognize it on reload and restore `Role::System` +
10852                // `metadata["systemSubtype"]` rather than treating it as a
10853                // real user turn. Content is never fabricated — only
10854                // emitted when non-empty.
10855                Role::System => {
10856                    let content = msg.content.clone().unwrap_or_default();
10857                    if content.trim().is_empty() {
10858                        i += 1;
10859                        continue;
10860                    }
10861                    let subtype = msg
10862                        .metadata
10863                        .get("systemSubtype")
10864                        .cloned()
10865                        .unwrap_or_else(|| "local_command".to_string());
10866                    let msg_id = opencode_fresh_id("msg", counter);
10867                    let part_id = opencode_fresh_id("prt", counter);
10868                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10869                    let mut info = serde_json::json!({
10870                        "id": msg_id,
10871                        "sessionID": session_id,
10872                        "role": "user",
10873                        "time": {"created": timestamp},
10874                    });
10875                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10876                    let parts = vec![serde_json::json!({
10877                        "id": part_id,
10878                        "sessionID": session_id,
10879                        "messageID": msg_id,
10880                        "type": "text",
10881                        "text": content,
10882                        "synthetic": true,
10883                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
10884                    })];
10885                    out.push(serde_json::json!({"info": info, "parts": parts}));
10886                    i += 1;
10887                }
10888                Role::User => {
10889                    let msg_id = opencode_fresh_id("msg", counter);
10890                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
10891                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10892                    let mut info = serde_json::json!({
10893                        "id": msg_id,
10894                        "sessionID": session_id,
10895                        "role": "user",
10896                        "time": {"created": timestamp},
10897                    });
10898                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10899                    opencode_restore_agent_model_fields(
10900                        &mut info, msg, /* is_assistant */ false,
10901                    );
10902                    set_grok_message_extension(&mut info, self.meta.source, msg);
10903                    out.push(serde_json::json!({
10904                        "info": info,
10905                        "parts": parts,
10906                    }));
10907                    i += 1;
10908                }
10909                Role::Assistant => {
10910                    let msg_id = opencode_fresh_id("msg", counter);
10911                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10912                    let mut parts = Vec::new();
10913                    if let Some(thinking) = msg.metadata.get("thinking") {
10914                        let mut part = serde_json::json!({
10915                            "id": opencode_fresh_id("prt", counter),
10916                            "sessionID": session_id,
10917                            "messageID": msg_id,
10918                            "type": "reasoning",
10919                            "text": thinking,
10920                            // Required by OpenCode V1's native reasoning
10921                            // schema. A synthesized part has no distinct
10922                            // stream start/end, so the source message clock
10923                            // is the honest zero-duration span.
10924                            "time": {"start": timestamp, "end": timestamp},
10925                        });
10926                        if let Some(signature) = msg.metadata.get("thinking_signature") {
10927                            part["metadata"] = serde_json::json!({
10928                                "anthropic": {"signature": signature},
10929                            });
10930                        }
10931                        parts.push(part);
10932                    }
10933                    if let Some(t) = &msg.content {
10934                        if !t.is_empty() {
10935                            parts.push(serde_json::json!({
10936                                "id": opencode_fresh_id("prt", counter),
10937                                "sessionID": session_id,
10938                                "messageID": msg_id,
10939                                "type": "text",
10940                                "text": t,
10941                            }));
10942                        }
10943                    }
10944                    // Fold each tool call's result back into ONE `tool`
10945                    // part, matched by tool_call_id via the GLOBAL
10946                    // `all_results` map built above (not a contiguous scan)
10947                    // — a result may be many messages away when other
10948                    // assistant turns with their own pending calls
10949                    // intervene before it appears.
10950                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
10951                        let input = tc
10952                            .function
10953                            .parsed_arguments()
10954                            .unwrap_or_else(|_| Value::Object(Default::default()));
10955                        let paired_result = paired_results.get(&(i, tool_index)).copied();
10956                        let state = match paired_result {
10957                            Some((_, result)) if crate::is_tool_error(result) => {
10958                                let result_timestamp =
10959                                    opencode_message_timestamp(result, timestamp_cursor)?;
10960                                serde_json::json!({
10961                                    "status": "error",
10962                                    "input": input,
10963                                    "error": result.content.clone().unwrap_or_default(),
10964                                    "time": {"end": result_timestamp},
10965                                })
10966                            }
10967                            Some((_, result)) => {
10968                                let result_timestamp =
10969                                    opencode_message_timestamp(result, timestamp_cursor)?;
10970                                let mut s = serde_json::json!({
10971                                    "status": "completed",
10972                                    "input": input,
10973                                    "output": result.content.clone().unwrap_or_default(),
10974                                    "title": tc.function.name,
10975                                    "time": {"end": result_timestamp},
10976                                });
10977                                // PARITY-11 (nested images): the LOADER already
10978                                // reads a completed tool part's
10979                                // `state.attachments` back into `content_parts`
10980                                // (`opencode_file_image_part`, above) — this is
10981                                // the missing WRITE-side inverse. Without it, a
10982                                // Claude `tool_result`'s nested image (now
10983                                // captured into `content_parts` by
10984                                // `extract_tool_result_content`) reached
10985                                // `content_parts` on the canonical `ChatMessage`
10986                                // but was silently dropped again on re-export to
10987                                // OpenCode, because nothing ever read it back
10988                                // out. `mime`/`url` shape matches exactly what
10989                                // `opencode_file_image_part` expects on reload.
10990                                if let Some(cps) = &result.content_parts {
10991                                    let atts: Vec<Value> = cps
10992                                        .iter()
10993                                        .filter(|p| {
10994                                            p.get("type").and_then(Value::as_str)
10995                                                == Some("image_url")
10996                                        })
10997                                        .filter_map(|p| {
10998                                            let url = p
10999                                                .get("image_url")
11000                                                .and_then(|u| u.get("url"))
11001                                                .and_then(Value::as_str)?;
11002                                            let mime = url
11003                                                .strip_prefix("data:")
11004                                                .and_then(|r| r.split_once(','))
11005                                                .map(|(m, _)| m.trim_end_matches(";base64"))
11006                                                .unwrap_or("application/octet-stream");
11007                                            Some(serde_json::json!({
11008                                                "mime": mime,
11009                                                "url": url,
11010                                            }))
11011                                        })
11012                                        .collect();
11013                                    if !atts.is_empty() {
11014                                        s["attachments"] = Value::Array(atts);
11015                                    }
11016                                }
11017                                s
11018                            }
11019                            None => serde_json::json!({"status": "pending", "input": input}),
11020                        };
11021                        let mut part = serde_json::json!({
11022                            "id": opencode_fresh_id("prt", counter),
11023                            "sessionID": session_id,
11024                            "messageID": msg_id,
11025                            "type": "tool",
11026                            "callID": tc.id,
11027                            "tool": tc.function.name,
11028                            "state": state,
11029                        });
11030                        if let Some((result_position, _)) = paired_result {
11031                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11032                                serde_json::json!(result_position);
11033                        }
11034                        if paired_result.is_some_and(|(_, result)| {
11035                            crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11036                        }) {
11037                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11038                        }
11039                        if let Some((_, result)) = paired_result {
11040                            set_grok_message_extension(&mut part, self.meta.source, result);
11041                        }
11042                        parts.push(part);
11043                    }
11044                    let mut info = serde_json::json!({
11045                        "id": msg_id,
11046                        "sessionID": session_id,
11047                        "role": "assistant",
11048                        "time": {"created": timestamp},
11049                    });
11050                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11051                    opencode_restore_agent_model_fields(
11052                        &mut info, msg, /* is_assistant */ true,
11053                    );
11054                    set_grok_message_extension(&mut info, self.meta.source, msg);
11055                    out.push(serde_json::json!({
11056                        "info": info,
11057                        "parts": parts,
11058                    }));
11059                    i += 1;
11060                }
11061                // A Tool message is always folded into its call's assistant
11062                // `tool` part above (via occurrence-aware global pairing, not
11063                // positional adjacency), so it never needs its own entry
11064                // here — just advance past it.
11065                Role::Tool => i += 1,
11066            }
11067        }
11068        Ok(())
11069    }
11070
11071    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11072    /// `messages` (T3 cross-format/full synthesis tier — mirrors
11073    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11074    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11075    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11076    /// (§1.2 — the `opencode export`/`import` interchange shape).
11077    fn to_opencode_jsonl(&self) -> Result<String> {
11078        let mut info = self.synthesized_opencode_info();
11079        let ses_id = info
11080            .get("id")
11081            .and_then(Value::as_str)
11082            .unwrap_or("ses_new")
11083            .to_string();
11084        let mut messages_json: Vec<Value> = Vec::new();
11085        let mut counter: u64 = 0;
11086        let mut timestamp_cursor =
11087            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11088        self.append_synthesized_opencode_messages(
11089            &mut messages_json,
11090            &self.messages,
11091            &ses_id,
11092            &mut counter,
11093            &mut timestamp_cursor,
11094        )?;
11095        if !messages_json.is_empty() {
11096            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11097        }
11098        let doc = serde_json::json!({"info": info, "messages": messages_json});
11099        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11100    }
11101
11102    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11103    /// imported records **value-equal at their position** in the export
11104    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11105    /// via [`Self::opencode_records_from_raw`], never re-derived from the
11106    /// lossy canonical `messages` — then append freshly synthesized
11107    /// `{info, parts}` objects for the tail via
11108    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11109    /// line-oriented formats' splice, `out` here is a single export
11110    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11111    /// assertion accordingly: value-equality at position, not byte
11112    /// equality of a line range).
11113    fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11114        if self.raw.is_empty() {
11115            return self.to_opencode_jsonl();
11116        }
11117        let (session_info, records) = self.opencode_records_from_raw();
11118        let (_, message_prefix_len) = self.spliced_prefix_lens();
11119
11120        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11121        if let Some(id) = session_id {
11122            info["id"] = Value::String(id.to_string());
11123        }
11124        let ses_id_for_new = info
11125            .get("id")
11126            .and_then(Value::as_str)
11127            .unwrap_or("ses_new")
11128            .to_string();
11129
11130        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11131            .chain(records.iter().flat_map(|(msg, parts)| {
11132                std::iter::once(opencode_max_timestamp(msg))
11133                    .chain(parts.iter().map(opencode_max_timestamp))
11134            }))
11135            .flatten()
11136            .max()
11137            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11138
11139        let mut messages_json: Vec<Value> = records
11140            .into_iter()
11141            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11142            .collect();
11143        let imported_len = messages_json.len();
11144
11145        let mut counter: u64 = 0;
11146        self.append_synthesized_opencode_messages(
11147            &mut messages_json,
11148            &self.messages[message_prefix_len..],
11149            &ses_id_for_new,
11150            &mut counter,
11151            &mut timestamp_cursor,
11152        )?;
11153        if messages_json.len() > imported_len {
11154            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11155        }
11156
11157        let doc = serde_json::json!({"info": info, "messages": messages_json});
11158        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11159    }
11160
11161    /// The **required** direct-write fallback (S5): write the imported
11162    /// OpenCode records **verbatim** — excess/unknown keys, part-row
11163    /// timestamps, and `session_diff`/`todo` side-records intact — to a
11164    /// generation-B JSON-file storage tree
11165    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11166    /// `opencode import` cannot provide (S5: import re-decodes through a
11167    /// strict schema and STRIPS excess keys; inserts part rows without
11168    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11169    /// has no ingestion path for `session_diff`/`todo` at all).
11170    ///
11171    /// Writes the JSON-FILE layout rather than a live SQLite write
11172    /// specifically to avoid a new `rusqlite`-class dependency on this
11173    /// build's memory-constrained box (see the build report); `session_diff`
11174    /// itself is still JSON-written by upstream even on SQLite installs
11175    /// (§1.3), so this is a real fidelity path, not a fictional one.
11176    ///
11177    /// Returns the `storage/session/<projectID>/` directory written to.
11178    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11179        let (session_info, mut records) = self.opencode_records_from_raw();
11180        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11181        let ses_id = info
11182            .get("id")
11183            .and_then(Value::as_str)
11184            .unwrap_or("ses_new")
11185            .to_string();
11186        if info.get("id").is_none() {
11187            info["id"] = Value::String(ses_id.clone());
11188        }
11189        let project_id = info
11190            .get("projectID")
11191            .and_then(Value::as_str)
11192            .unwrap_or("global")
11193            .to_string();
11194
11195        // Appended tail (messages produced after import): synthesize fresh
11196        // message/part VALUES via the same T3 synthesis the splice writer
11197        // uses, so continuation turns get files too. Do this BEFORE creating
11198        // any directories: timestamp exhaustion must fail atomically rather
11199        // than leave a partial direct-write tree behind.
11200        let (_, message_prefix_len) = self.spliced_prefix_lens();
11201        let mut counter: u64 = 0;
11202        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11203            .chain(records.iter().flat_map(|(msg, parts)| {
11204                std::iter::once(opencode_max_timestamp(msg))
11205                    .chain(parts.iter().map(opencode_max_timestamp))
11206            }))
11207            .flatten()
11208            .max()
11209            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11210        let mut appended_json: Vec<Value> = Vec::new();
11211        self.append_synthesized_opencode_messages(
11212            &mut appended_json,
11213            &self.messages[message_prefix_len..],
11214            &ses_id,
11215            &mut counter,
11216            &mut timestamp_cursor,
11217        )?;
11218        if !appended_json.is_empty() {
11219            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11220        }
11221        for entry in appended_json {
11222            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11223            let parts = entry
11224                .get("parts")
11225                .and_then(Value::as_array)
11226                .cloned()
11227                .unwrap_or_default();
11228            records.push((msg, parts));
11229        }
11230
11231        let storage = data_root.join("storage");
11232        let session_dir = storage.join("session").join(&project_id);
11233        std::fs::create_dir_all(&session_dir)?;
11234        std::fs::write(
11235            session_dir.join(format!("{ses_id}.json")),
11236            serde_json::to_string_pretty(&info).unwrap_or_default(),
11237        )?;
11238
11239        let message_dir = storage.join("message").join(&ses_id);
11240        let part_dir = storage.join("part");
11241        std::fs::create_dir_all(&message_dir)?;
11242
11243        for (msg, parts) in &records {
11244            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11245                continue;
11246            };
11247            std::fs::write(
11248                message_dir.join(format!("{msg_id}.json")),
11249                serde_json::to_string_pretty(msg).unwrap_or_default(),
11250            )?;
11251            let this_part_dir = part_dir.join(msg_id);
11252            std::fs::create_dir_all(&this_part_dir)?;
11253            for part in parts {
11254                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11255                    continue;
11256                };
11257                std::fs::write(
11258                    this_part_dir.join(format!("{part_id}.json")),
11259                    serde_json::to_string_pretty(part).unwrap_or_default(),
11260                )?;
11261            }
11262        }
11263
11264        // Side-records (S5c): session_diff / todo have NO ingestion path via
11265        // `opencode import` at all — the direct write is their only
11266        // fidelity path.
11267        for header in &self.meta.opencode_headers {
11268            let Some(key) = header.get("key").and_then(Value::as_array) else {
11269                continue;
11270            };
11271            let Some(kind) = key.first().and_then(Value::as_str) else {
11272                continue;
11273            };
11274            let value = header.get("value").cloned().unwrap_or(Value::Null);
11275            if !matches!(kind, "session_diff" | "todo") {
11276                continue;
11277            }
11278            let dir = storage.join(kind);
11279            std::fs::create_dir_all(&dir)?;
11280            std::fs::write(
11281                dir.join(format!("{ses_id}.json")),
11282                serde_json::to_string_pretty(&value).unwrap_or_default(),
11283            )?;
11284        }
11285
11286        Ok(session_dir)
11287    }
11288}
11289
11290fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11291    *counter += 1;
11292    format!("{prefix}_synth{counter:06}")
11293}
11294
11295/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11296/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11297/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11298/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11299/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11300/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11301/// ONLY when its metadata key is present (a synthesized continuation turn, or
11302/// a User message that never carried `agent`, stays clean — no spurious
11303/// null/empty fields).
11304///
11305/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11306///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11307///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11308/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11309///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11310///   inverse must match per-role:
11311///   - User: `push_opencode_user` stores `metadata["model"]` as the
11312///     STRINGIFIED `{providerID, modelID, variant?}` object
11313///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11314///     as that same object under `"model"`.
11315///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11316///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11317///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
11318///     join; a `modelID` containing further `/`s round-trips correctly since
11319///     `split_once` only consumes the first) and re-emitted as the two
11320///     top-level `providerID`/`modelID` fields the loader actually reads.
11321/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11322///   fields exist on opencode's `User` schema) — `is_summary` re-expands
11323///   `"true"` back to the native `summary: true` bool (the loader only ever
11324///   sets the metadata key on `Some(true)`, never on absent/false, so the
11325///   inverse never needs to emit `false`); `finish` is a plain string;
11326///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11327///   `Value` (a number and an object respectively), so they're re-parsed
11328///   from that stringified form and re-emitted as the native JSON value —
11329///   NOT as strings — matching `msg_value.get(field)` shape exactly.
11330fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11331    if let Some(agent) = msg.metadata.get("agent") {
11332        info["agent"] = Value::String(agent.clone());
11333    }
11334    if let Some(model) = msg.metadata.get("model") {
11335        if is_assistant {
11336            if let Some((provider, model_id)) = model.split_once('/') {
11337                info["providerID"] = Value::String(provider.to_string());
11338                info["modelID"] = Value::String(model_id.to_string());
11339            }
11340        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11341            info["model"] = v;
11342        }
11343    }
11344    if !is_assistant {
11345        return;
11346    }
11347    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11348        info["summary"] = Value::Bool(true);
11349    }
11350    if let Some(finish) = msg.metadata.get("finish") {
11351        info["finish"] = Value::String(finish.clone());
11352    }
11353    if let Some(cost) = msg.metadata.get("cost") {
11354        if let Ok(v) = serde_json::from_str::<Value>(cost) {
11355            info["cost"] = v;
11356        }
11357    }
11358    if let Some(tokens) = msg.metadata.get("tokens") {
11359        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11360            info["tokens"] = v;
11361        }
11362    }
11363}
11364
11365fn opencode_user_parts_from_message(
11366    msg: &ChatMessage,
11367    msg_id: &str,
11368    session_id: &str,
11369    counter: &mut u64,
11370) -> Vec<Value> {
11371    let mut parts = Vec::new();
11372    if let Some(cps) = &msg.content_parts {
11373        for p in cps {
11374            match p.get("type").and_then(Value::as_str) {
11375                Some("text") => {
11376                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11377                        parts.push(serde_json::json!({
11378                            "id": opencode_fresh_id("prt", counter),
11379                            "sessionID": session_id,
11380                            "messageID": msg_id,
11381                            "type": "text",
11382                            "text": t,
11383                        }));
11384                    }
11385                }
11386                Some("image_url") => {
11387                    if let Some(url) = p
11388                        .get("image_url")
11389                        .and_then(|u| u.get("url"))
11390                        .and_then(Value::as_str)
11391                    {
11392                        let mime = url
11393                            .strip_prefix("data:")
11394                            .and_then(|r| r.split_once(','))
11395                            .map(|(m, _)| m.trim_end_matches(";base64"))
11396                            .unwrap_or("application/octet-stream");
11397                        parts.push(serde_json::json!({
11398                            "id": opencode_fresh_id("prt", counter),
11399                            "sessionID": session_id,
11400                            "messageID": msg_id,
11401                            "type": "file",
11402                            "mime": mime,
11403                            "url": url,
11404                        }));
11405                    }
11406                }
11407                _ => {}
11408            }
11409        }
11410    } else if let Some(t) = &msg.content {
11411        if !t.is_empty() {
11412            parts.push(serde_json::json!({
11413                "id": opencode_fresh_id("prt", counter),
11414                "sessionID": session_id,
11415                "messageID": msg_id,
11416                "type": "text",
11417                "text": t,
11418            }));
11419        }
11420    }
11421    parts
11422}
11423
11424fn codex_response_item(payload: Value, ts: &str) -> Value {
11425    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11426}
11427
11428/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11429/// see [`Session::write_codex_records`]); a no-op returning `payload`
11430/// untouched when `None`, so the historical byte shape is preserved for
11431/// every record that has no merge ambiguity to disambiguate.
11432fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11433    if let Some(tid) = turn_id {
11434        payload["metadata"] = serde_json::json!({"turn_id": tid});
11435    }
11436    payload
11437}
11438
11439/// Build a Codex `message` response_item's `content` block array from a
11440/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11441/// parse. When `content_parts` is `None` this MUST reproduce the historical
11442/// single-block shape exactly (IX-5's overriding constraint: a text-only
11443/// message's export stays byte-identical) — only a multimodal message gets
11444/// one `{text_type}` block per non-empty text part plus one native Codex
11445/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11446/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11447/// `output_text` blocks already follow the family of) per `image_url` part.
11448fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11449    match &msg.content_parts {
11450        Some(parts) => {
11451            let mut blocks = Vec::new();
11452            for p in parts {
11453                match p.get("type").and_then(Value::as_str) {
11454                    Some("text") => {
11455                        if let Some(t) = p.get("text").and_then(Value::as_str) {
11456                            if !t.is_empty() {
11457                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
11458                            }
11459                        }
11460                    }
11461                    Some("image_url") => {
11462                        if let Some(url) = p
11463                            .get("image_url")
11464                            .and_then(|u| u.get("url"))
11465                            .and_then(Value::as_str)
11466                        {
11467                            blocks.push(serde_json::json!({
11468                                "type": "input_image",
11469                                "image_url": url,
11470                            }));
11471                        }
11472                    }
11473                    _ => {}
11474                }
11475            }
11476            Value::Array(blocks)
11477        }
11478        None => {
11479            let text = msg.content.clone().unwrap_or_default();
11480            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11481        }
11482    }
11483}
11484
11485/// PARITY-11 (nested images, honest-residue side): a Codex
11486/// `function_call_output` response_item's `output` field is a BARE STRING
11487/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11488/// no structured content array, so [`codex_message_content_blocks`]'s
11489/// `input_image` slot genuinely does not apply here). A nested image captured
11490/// off a Claude `tool_result` (`extract_tool_result_content`,
11491/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11492/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11493/// from a real, intentional annotation and impossible to tell apart from
11494/// "the data survived") or dropping it with zero trace, fold in an honest,
11495/// countable disclosure of exactly how many images were dropped and why —
11496/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11497/// on the WRITE side instead of the read side. `content_parts` being `None`
11498/// (every pre-existing call site, and any tool result with no nested image)
11499/// reproduces the historical `msg.content` text byte-for-byte.
11500fn codex_tool_output_text(msg: &ChatMessage) -> String {
11501    let mut text = msg.content.clone().unwrap_or_default();
11502    if let Some(parts) = &msg.content_parts {
11503        let n = parts
11504            .iter()
11505            .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11506            .count();
11507        if n > 0 {
11508            if !text.is_empty() {
11509                text.push('\n');
11510            }
11511            text.push_str(&format!(
11512                "[image: {n} nested image(s) dropped — codex tool output has no \
11513                 structured content slot to carry them]"
11514            ));
11515        }
11516    }
11517    text
11518}
11519
11520// ---- Pi writer helpers -----------------------------------------------------
11521
11522/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11523/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11524/// file in place on first resume (`pi-fields.md` sm:848-850).
11525fn push_pi_header(
11526    out: &mut String,
11527    id: &str,
11528    cwd: &str,
11529    parent_session: Option<&str>,
11530    created_at: Option<&str>,
11531    claude_fork_context_ref: Option<&str>,
11532) {
11533    let mut header = serde_json::json!({
11534        "type": "session",
11535        "version": 3,
11536        "id": id,
11537        "timestamp": created_at.unwrap_or(SYNTH_TS),
11538        "cwd": cwd,
11539    });
11540    if let Some(ps) = parent_session {
11541        header["parentSession"] = Value::String(ps.to_string());
11542    }
11543    // D7: namespaced passthrough field, exactly like the Codex writer's
11544    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11545    // header keys, and `capture_pi_header` reads this same key back on
11546    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11547    // fork-context-ref record instead of silently losing it on this hop.
11548    if let Some(raw) = claude_fork_context_ref {
11549        header["claude_fork_context_ref"] =
11550            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11551    }
11552    push_jsonl(out, &header);
11553}
11554
11555/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11556/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11557/// deterministic here rather than random, which still satisfies "fresh,
11558/// collision-free" without an extra RNG dependency).
11559fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11560    loop {
11561        *counter += 1;
11562        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11563        let id = format!("{:08x}", (h >> 32) as u32);
11564        if used.insert(id.clone()) {
11565            return id;
11566        }
11567    }
11568}
11569
11570/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11571/// inverse of the loader's `data:{mime};base64,{data}` construction.
11572fn parse_data_uri(url: &str) -> Option<(String, String)> {
11573    let rest = url.strip_prefix("data:")?;
11574    let (meta, data) = rest.split_once(',')?;
11575    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11576    Some((mime.to_string(), data.to_string()))
11577}
11578
11579/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11580/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11581/// `toolResult` entries (both use the identical union on the wire).
11582fn pi_content_value(msg: &ChatMessage) -> Value {
11583    if let Some(parts) = &msg.content_parts {
11584        let mut arr = Vec::new();
11585        for p in parts {
11586            match p.get("type").and_then(Value::as_str) {
11587                Some("text") => {
11588                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11589                        arr.push(serde_json::json!({"type": "text", "text": t}));
11590                    }
11591                }
11592                Some("image_url") => {
11593                    if let Some(url) = p
11594                        .get("image_url")
11595                        .and_then(|u| u.get("url"))
11596                        .and_then(Value::as_str)
11597                    {
11598                        if let Some((mime, data)) = parse_data_uri(url) {
11599                            arr.push(
11600                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11601                            );
11602                        }
11603                    }
11604                }
11605                _ => {}
11606            }
11607        }
11608        Value::Array(arr)
11609    } else {
11610        Value::String(msg.content.clone().unwrap_or_default())
11611    }
11612}
11613
11614fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11615    let mut arr = Vec::new();
11616    if let Some(thinking) = msg.metadata.get("thinking") {
11617        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11618        if let Some(sig) = msg.metadata.get("thinking_signature") {
11619            block["thinkingSignature"] = Value::String(sig.clone());
11620        }
11621        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11622            block["redacted"] = Value::Bool(true);
11623        }
11624        arr.push(block);
11625    }
11626    if let Some(text) = &msg.content {
11627        if !text.is_empty() {
11628            let mut block = serde_json::json!({"type": "text", "text": text});
11629            if let Some(sig) = msg.metadata.get("pi_text_signature") {
11630                block["textSignature"] = Value::String(sig.clone());
11631            }
11632            arr.push(block);
11633        }
11634    }
11635    for tc in msg.tool_calls() {
11636        let args = tc
11637            .function
11638            .parsed_arguments()
11639            .unwrap_or_else(|_| Value::Object(Default::default()));
11640        let mut block = serde_json::json!({
11641            "type": "toolCall",
11642            "id": tc.id,
11643            "name": tc.function.name,
11644            "arguments": args,
11645        });
11646        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11647            block["thoughtSignature"] = Value::String(sig.clone());
11648        }
11649        arr.push(block);
11650    }
11651    Value::Array(arr)
11652}
11653
11654fn default_pi_usage() -> Value {
11655    serde_json::json!({
11656        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11657        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11658    })
11659}
11660
11661fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11662    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11663}
11664
11665#[cfg(test)]
11666mod tests {
11667    use super::{opencode_message_timestamp, parent_tool_use_index, Session, SessionFormat};
11668    use crate::message::ChatMessage;
11669
11670    #[test]
11671    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
11672        let base = Session::from_native_messages(Vec::new());
11673        let mut native = base.to_native_jsonl_v2(&[]);
11674        native.push_str("{\"supercode_turn\":1}\n");
11675
11676        let parsed = Session::from_native_str(&native).unwrap();
11677        assert_eq!(parsed.parse_error_lines, 1);
11678        assert!(parsed.messages.is_empty());
11679        assert_eq!(
11680            parsed.raw.last().map(String::as_str),
11681            Some("{\"supercode_turn\":1}")
11682        );
11683    }
11684
11685    #[test]
11686    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
11687        let imported = Session::from_claude_code_str(
11688            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
11689        )
11690        .unwrap();
11691        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
11692        native.push_str("{\"supercode_turn\":1}\n");
11693
11694        let parsed = Session::from_native_str(&native).unwrap();
11695        let error = parsed
11696            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
11697            .unwrap_err();
11698        assert!(error.to_string().contains("parse loss"), "{error}");
11699    }
11700
11701    #[test]
11702    fn sidecar_loader_requires_a_supported_native_header() {
11703        for malformed in [
11704            "",
11705            "not-json\n",
11706            "{}\n",
11707            "{\"supercode_native\":2}\n",
11708            "{\"supercode_native\":99,\"source\":\"native\"}\n",
11709        ] {
11710            let error = Session::from_sidecar_str(malformed).unwrap_err();
11711            assert!(error.to_string().contains("sidecar header"), "{error}");
11712        }
11713    }
11714
11715    #[test]
11716    fn gemini_user_parts_preserve_text_media_and_response_order() {
11717        let session = Session::from_gemini_str(
11718            r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
11719{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
11720{"type":"user","content":[{"text":"before"},{"functionResponse":{"id":"b","name":"read","response":{"output":"B"}}},{"inlineData":{"mimeType":"image/png","data":"YQ=="}},{"functionResponse":{"name":"read","response":{"output":"A"}}},{"text":"after"}]}
11721"#,
11722        )
11723        .unwrap();
11724
11725        assert_eq!(session.messages.len(), 6);
11726        assert_eq!(
11727            session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
11728            "before"
11729        );
11730        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
11731        assert!(
11732            session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
11733                .as_str()
11734                .unwrap()
11735                .starts_with("data:image/png;base64,")
11736        );
11737        assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
11738        assert_eq!(
11739            session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
11740            "after"
11741        );
11742    }
11743
11744    #[test]
11745    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
11746        let msg = ChatMessage::user("continuation");
11747        let mut cursor = i64::MAX - 1;
11748        assert_eq!(
11749            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
11750            i64::MAX
11751        );
11752        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
11753        assert!(err.to_string().contains("after i64::MAX"));
11754        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
11755    }
11756
11757    /// Pin of the single-pass indexer against the relevant Claude tool-result
11758    /// shape (SUP-21). An id absent from the transcript must map to nothing.
11759    #[test]
11760    fn parent_tool_use_index_matches_known_fixture_linkage() {
11761        let main_text = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SYjhg9qRCzUWY2GTa3iazQ","type":"tool_result","content":[{"type":"text","text":"agentId: ad8dc6cf98b49eea6"}]}]},"toolUseResult":{"agentId":"ad8dc6cf98b49eea6"}}"#;
11762
11763        let ids = vec![
11764            "ad8dc6cf98b49eea6".to_string(),
11765            "no-such-agent-id".to_string(),
11766        ];
11767        let index = parent_tool_use_index(main_text, &ids);
11768
11769        assert_eq!(
11770            index.get("ad8dc6cf98b49eea6").map(String::as_str),
11771            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
11772            "known agent id must resolve to the pinned parent tool_use_id"
11773        );
11774        assert_eq!(
11775            index.get("no-such-agent-id"),
11776            None,
11777            "unknown agent id must yield no entry (best-effort None)"
11778        );
11779    }
11780
11781    #[test]
11782    fn parent_tool_use_index_empty_ids_returns_empty_map() {
11783        let index = parent_tool_use_index("irrelevant text", &[]);
11784        assert!(index.is_empty());
11785    }
11786}