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    /// A bounded display-history projection uses this field for the total
287    /// number of normalized messages observed before its in-memory window was
288    /// applied. Such a semantic view is never a continuation source, and all
289    /// splice callers clamp the value to `messages.len()`.
290    pub imported_message_count: Option<usize>,
291    /// Whether `raw` was captured strict-verbatim from real source text
292    /// (`true`) or re-synthesized by this crate (`false`) — the fact
293    /// [`Self::raw_verbatim`]'s callers need to know before claiming a
294    /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
295    /// `true` for every line-oriented loader (`from_claude_code_str`,
296    /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
297    /// surface (`from_opencode_str`'s per-line loop) — each of those splits
298    /// `raw` directly out of the source text via `split_lines_verbatim`, so
299    /// replaying it reproduces the original bytes exactly. `false` for
300    /// OpenCode's EXPORT-DOCUMENT read surface
301    /// (`Session::from_opencode_export_doc`): a pretty-printed
302    /// `{info, messages:[...]}` document has no per-line envelope structure
303    /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
304    /// record — faithful in value, but not the original document's bytes.
305    /// A `Session` assembled programmatically (not through a `from_*_str`
306    /// loader) also defaults to `false` — no real source text was captured
307    /// at all.
308    pub raw_is_verbatim: bool,
309    /// PARITY-15: how many non-empty lines of the source text FAILED to
310    /// deserialize at all (a genuinely malformed/truncated JSON line — not
311    /// a well-formed-but-unmodeled record type, which is a normal,
312    /// intentional "skip", tracked separately by `crate::audit`). Every
313    /// line-oriented loader tolerates a stray corrupt line rather than
314    /// hard-failing the whole load (a single bad line must not make an
315    /// otherwise-healthy multi-thousand-line session unloadable) — but that
316    /// tolerance used to be completely invisible: `Session::load` returned
317    /// `Ok` either way, with no signal that anything was skipped. This
318    /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
319    /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
320    /// and for a `Session` assembled programmatically.
321    pub parse_error_lines: usize,
322    /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
323    /// failing — the same "say exactly what was given up" residue list
324    /// `harness.v1.sessions.export` already reports for artifacts.
325    ///
326    /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
327    /// transcript it cannot reconstruct exactly, which is what keeps
328    /// continuation/transfer/export guarantees intact. A non-empty list means
329    /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
330    /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
331    pub load_residue: Vec<String>,
332}
333
334impl Session {
335    /// The fidelity this reconstruction actually achieved.
336    ///
337    /// Same rule the export path applies to an artifact: named residue means
338    /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
339    /// [`Fidelity::ByteLossless`] and a re-synthesized one is
340    /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
341    /// session's: the whole reconstruction is only as faithful as its least
342    /// faithful part, and each child still reports its own residue where it
343    /// was measured.
344    pub fn load_fidelity(&self) -> Fidelity {
345        let own = if !self.load_residue.is_empty() {
346            Fidelity::Semantic
347        } else if self.raw_is_verbatim {
348            Fidelity::ByteLossless
349        } else {
350            Fidelity::ValueLossless
351        };
352        if own != Fidelity::Semantic
353            && self
354                .subagents
355                .iter()
356                .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
357        {
358            return Fidelity::Semantic;
359        }
360        own
361    }
362
363    /// Assemble a session from supercode's own flat store transcript (one
364    /// [`ChatMessage`] per JSONL line). These files are the native working
365    /// format written by Supercode's native session store, not a foreign
366    /// harness log, so routing them through format auto-detection would
367    /// misclassify them as an empty Claude Code session.
368    pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
369        Session {
370            meta: SessionMeta::new(SessionSource::Native),
371            messages,
372            subagents: Vec::new(),
373            raw: Vec::new(),
374            raw_trailing_newline: true,
375            imported_message_count: None,
376            raw_is_verbatim: false,
377            parse_error_lines: 0,
378            load_residue: Vec::new(),
379        }
380    }
381
382    /// Load a session, auto-detecting whether it's a Claude Code or Codex log
383    /// — or, when `path` looks like a SQLite database, a real OpenCode
384    /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
385    /// UTF-8 text read, so a binary `.db` file is routed to
386    /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
387    /// did not contain valid UTF-8" error (the confirmed footgun these items
388    /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
389    ///
390    /// A DIRECTORY is also accepted directly: `path` is probed with
391    /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
392    /// checks below (both of which assume a file and would otherwise surface
393    /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
394    /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
395    /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
396    /// `audit --format opencode` already does. A resolved `Sqlite` surface
397    /// loads exactly like pointing `load` at that `opencode*.db` file
398    /// directly (most-recently-updated top-level session). The legacy
399    /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
400    /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
401    /// that case returns a clear error naming the `.db` file / `audit` as the
402    /// way in, rather than silently doing nothing or crashing.
403    pub fn load(path: impl AsRef<Path>) -> Result<Session> {
404        Self::load_with_fidelity(path, Fidelity::ByteLossless)
405    }
406
407    /// Load a session at a declared [`Fidelity`].
408    ///
409    /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
410    /// record graph cannot be reconstructed exactly (the everyday case for a
411    /// Claude Code session that has been compacted or resumed across files,
412    /// where a live record's `parentUuid` names a record that was pruned)
413    /// still loads, stitched best-effort in transcript order, and names what
414    /// it gave up in [`Session::load_residue`]. Every stricter level keeps
415    /// the historical behavior — refuse loudly — because a continuation,
416    /// transfer or export built on a guessed graph is exactly the loss
417    /// supercode exists to prevent. Callers that go on to RESUME a session
418    /// must therefore use [`Session::load`].
419    pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
420        Self::load_with_fidelity_and_subagents(path, fidelity, true)
421    }
422
423    /// Load only the selected session's own transcript at a declared fidelity.
424    ///
425    /// This is the read-only frontend path: Claude Code can place hundreds of
426    /// child transcripts beside a parent, but a chat viewport displaying the
427    /// parent must not eagerly parse and transport that entire child tree.
428    /// Translation, continuation, export, and the ordinary [`Self::load`]
429    /// path keep attaching every subagent unchanged.
430    #[doc(hidden)]
431    pub fn load_parent_with_fidelity(
432        path: impl AsRef<Path>,
433        fidelity: Fidelity,
434    ) -> Result<Session> {
435        Self::load_with_fidelity_and_subagents(path, fidelity, false)
436    }
437
438    /// Load a bounded, parent-only transcript for human display.
439    ///
440    /// Unlike the continuation loader, Codex compaction records do not erase
441    /// earlier visible assistant turns here: the native rollout still holds
442    /// those records, and a scrollback view should show what the human saw,
443    /// not only the compacted context the next model call will receive.
444    #[doc(hidden)]
445    pub fn load_display_view(
446        path: impl AsRef<Path>,
447        fidelity: Fidelity,
448        message_limit: usize,
449    ) -> Result<Session> {
450        let path = path.as_ref();
451        if path.is_dir() || looks_like_sqlite(path) {
452            let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
453            truncate_session_messages(&mut session, message_limit);
454            return Ok(session);
455        }
456        let (source, text) = read_display_jsonl(path, message_limit)?;
457        let mut session = match source {
458            Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
459            Some(SessionSource::Gemini) => {
460                let mut session = Self::from_gemini_str(&text)?;
461                session.raw_is_verbatim = false;
462                session.load_residue.push(
463                    "display history is a bounded native-record projection, not a complete Gemini artifact"
464                        .to_string(),
465                );
466                session
467            }
468            Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
469            Some(SessionSource::Grok) => {
470                let mut session = Self::from_grok_str(&text)?;
471                session.capture_grok_path_metadata(path);
472                session
473            }
474            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
475            _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
476        };
477        truncate_session_messages(&mut session, message_limit);
478        Ok(session)
479    }
480
481    fn load_with_fidelity_and_subagents(
482        path: impl AsRef<Path>,
483        fidelity: Fidelity,
484        include_subagents: bool,
485    ) -> Result<Session> {
486        let path = path.as_ref();
487        if path.is_dir() {
488            return match detect_opencode_storage_surface(path) {
489                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
490                    Self::from_opencode_sqlite(&db_path, None)
491                }
492                Some((
493                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
494                    _,
495                )) => Err(crate::Error::Other(format!(
496                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
497                         supercode does not load directly — point `inspect`/`convert`/`resume` \
498                         at the store's `opencode*.db` SQLite file if this install has one, or \
499                         use `audit --format opencode {}` instead",
500                    path.display(),
501                    path.display()
502                ))),
503                None => Err(crate::Error::Other(format!(
504                    "{} is a directory, but no session file or OpenCode store was found in it \
505                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
506                     tree)",
507                    path.display()
508                ))),
509            };
510        }
511        if looks_like_sqlite(path) {
512            return Self::from_opencode_sqlite(path, None);
513        }
514        let text = read_utf8_or_diagnose(path)?;
515        match detect_source(&text) {
516            Some(SessionSource::Codex) => Self::from_codex_str(&text),
517            Some(SessionSource::Pi) => Self::from_pi_str(&text),
518            Some(SessionSource::Grok) => {
519                let mut session = Self::from_grok_str(&text)?;
520                session.capture_grok_path_metadata(path);
521                Ok(session)
522            }
523            Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
524            Some(SessionSource::Goose) => Self::from_goose_str(&text),
525            // IX-3: a detected OpenCode session must route to its own
526            // loader, not the Claude Code fallback below
527            // (`docs/interop/build-followups.md`).
528            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
529            _ => {
530                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
531                if include_subagents {
532                    session.attach_claude_subagents(path, &text, fidelity)?;
533                }
534                Ok(session)
535            }
536        }
537    }
538
539    /// Load a Claude Code transcript from a file, attaching any subagent
540    /// (`Task`) sub-conversations stored alongside it.
541    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
542        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
543    }
544
545    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
546    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
547    pub fn from_claude_code_with_fidelity(
548        path: impl AsRef<Path>,
549        fidelity: Fidelity,
550    ) -> Result<Session> {
551        let text = std::fs::read_to_string(path.as_ref())?;
552        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
553        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
554        Ok(session)
555    }
556
557    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
558    /// Claude Code transcript at `main_path`, linking each back to the parent
559    /// `Task` tool call via the agent id embedded in the parent's tool result.
560    fn attach_claude_subagents(
561        &mut self,
562        main_path: &Path,
563        main_text: &str,
564        fidelity: Fidelity,
565    ) -> Result<()> {
566        let Some(dir) = subagents_dir_for(main_path) else {
567            return Ok(());
568        };
569        let entries = std::fs::read_dir(&dir).map_err(|error| {
570            crate::Error::Other(format!(
571                "failed to enumerate Claude subagents at {}: {error}",
572                dir.display()
573            ))
574        })?;
575        let mut files = Vec::new();
576        for entry in entries {
577            let entry = entry.map_err(|error| {
578                crate::Error::Other(format!(
579                    "failed to enumerate Claude subagents at {}: {error}",
580                    dir.display()
581                ))
582            })?;
583            let path = entry.path();
584            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
585                files.push(path);
586            }
587        }
588        files.sort();
589
590        // Phase 1 — collect each subagent + its recovered agent id, without
591        // touching the main transcript yet.
592        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
593        for file in files {
594            let text = read_utf8_or_diagnose(&file).map_err(|error| {
595                crate::Error::Other(format!(
596                    "failed to read Claude subagent {}: {error}",
597                    file.display()
598                ))
599            })?;
600            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
601                Ok(sub) => sub,
602                // A read-only VIEW keeps the main conversation rather than
603                // losing the whole session to one unreconstructable child;
604                // the skip is named, not silent. Every stricter fidelity
605                // still propagates the child's failure.
606                Err(error) if fidelity.tolerates_residue() => {
607                    self.load_residue.push(format!(
608                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
609                        file.display()
610                    ));
611                    continue;
612                }
613                Err(error) => {
614                    return Err(crate::Error::Other(format!(
615                        "failed to reconstruct Claude subagent {}: {error}",
616                        file.display()
617                    )))
618                }
619            };
620            // agentId: prefer the file's own record, fall back to the filename stem.
621            let agent_id = first_agent_id(&text).or_else(|| {
622                file.file_stem()
623                    .and_then(|s| s.to_str())
624                    .map(|s| s.trim_start_matches("agent-").to_string())
625            });
626            collected.push((sub, agent_id));
627        }
628
629        // Phase 2 — single pass over the main transcript to index every
630        // requested agent id at once, then assign each subagent's parent by
631        // an O(1) lookup.
632        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
633        let index = parent_tool_use_index(main_text, &agent_ids);
634
635        for (mut sub, agent_id) in collected {
636            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
637            sub.meta.agent_id = agent_id;
638            self.subagents.push(sub);
639        }
640        Ok(())
641    }
642
643    /// Load a Codex rollout from a file.
644    pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
645        Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
646    }
647
648    /// Parse a Claude Code transcript from an in-memory JSONL string.
649    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
650        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
651    }
652
653    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
654    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
655    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
656        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
657        let mut messages = Vec::new();
658        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
659        // whitespace all preserved) — separate from the blank-skipping
660        // `non_empty_lines` walk just below, which still parses records only
661        // (a blank line is not a JSON record and must not become one).
662        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
663        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
664        // PARITY-15: a malformed/truncated line is still tolerated (a
665        // single bad line must not make an otherwise-healthy multi-
666        // thousand-line session unloadable) — but it's no longer INVISIBLE.
667        let mut parse_error_lines = 0usize;
668        let mut index = ClaudeReplayIndex::default();
669
670        // Claude transcripts are append-only trees, not linear chat logs.
671        // Build a lightweight graph index first so normalization sees the
672        // same single active, post-compaction branch Claude Code would
673        // resume. `raw` above deliberately remains the complete source.
674        for (line_index, line) in raw_lines.iter().enumerate() {
675            if line.trim().is_empty() {
676                continue;
677            }
678            let v: Value = match serde_json::from_str(line) {
679                Ok(v) => v,
680                Err(_) => {
681                    parse_error_lines += 1; // tolerate stray/corrupt lines
682                    continue;
683                }
684            };
685            capture_claude_meta(&v, &mut meta, line)?;
686            index.observe(line_index, &v)?;
687        }
688
689        let ClaudeReplaySelection {
690            lines: replay_lines,
691            residue: load_residue,
692        } = index.select_lines(fidelity)?;
693        let mut pending_assistant: Option<Value> = None;
694
695        for line_index in replay_lines {
696            let line = raw_lines[line_index];
697            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
698
699            if v.get("type").and_then(Value::as_str) == Some("assistant") {
700                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
701                    flush_claude_assistant(&mut pending_assistant, &mut messages);
702                    continue;
703                }
704                if let Some(pending) = pending_assistant.as_mut() {
705                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
706                        claude_assistant_message_id(&v) == Some(message_id)
707                    }) {
708                        merge_claude_assistant_chunk(pending, &v);
709                        continue;
710                    }
711                    flush_claude_assistant(&mut pending_assistant, &mut messages);
712                }
713                pending_assistant = Some(v);
714                continue;
715            }
716
717            flush_claude_assistant(&mut pending_assistant, &mut messages);
718
719            // WAVE-2 item 1: every Claude Code record carries a real
720            // top-level `timestamp` (ISO-8601) — provenance stamping below
721            // attaches it to every canonical `ChatMessage` this line
722            // produces, together with the record UUID and assistant model.
723            // `entry(...).or_insert_with` preserves any more-precise value a
724            // role-specific loader already supplied.
725            let before = messages.len();
726            match v.get("type").and_then(Value::as_str) {
727                Some("user") => push_claude_user(&v, &mut messages),
728                Some("assistant") => push_claude_assistant(&v, &mut messages),
729                Some("attachment") => push_claude_attachment(&v, &mut messages),
730                Some("system") => push_claude_system(&v, &mut messages),
731                _ => {} // mode, queue-operation, ... — skip
732            }
733            // UUID/model provenance remains meaningful even for legacy
734            // records that predate Claude Code's timestamp field.
735            capture_claude_record_provenance(&v, &mut messages[before..]);
736            restore_single_grok_message(&v, &mut messages[before..]);
737        }
738        flush_claude_assistant(&mut pending_assistant, &mut messages);
739
740        reorder_tool_results_after_calls(&mut messages);
741        ensure_tool_results_paired(&mut messages);
742        let imported_message_count = Some(messages.len());
743        Ok(Session {
744            meta,
745            messages,
746            subagents: Vec::new(),
747            raw,
748            raw_trailing_newline,
749            imported_message_count,
750            // Claude Code is line-oriented: `raw` is split directly out of
751            // the source text (strict-verbatim, IX-1).
752            raw_is_verbatim: true,
753            parse_error_lines,
754            load_residue,
755        })
756    }
757
758    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
759    ///
760    /// Codex stores subagents as separate rollout files linked to their parent
761    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
762    /// collection of sessions, this nests each child into its parent's
763    /// [`Session::subagents`] and returns only the roots. Children whose parent
764    /// isn't in the set are returned as roots themselves (best effort).
765    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
766        use std::collections::HashMap;
767        // Index each session's position by its session_id.
768        let mut idx: HashMap<String, usize> = HashMap::new();
769        for (i, s) in sessions.iter().enumerate() {
770            if let Some(id) = &s.meta.session_id {
771                idx.insert(id.clone(), i);
772            }
773        }
774        // Determine each session's parent (by index), if present in the set.
775        let parent_of: Vec<Option<usize>> = sessions
776            .iter()
777            .map(|s| {
778                s.meta
779                    .lineage
780                    .get("parent_thread_id")
781                    .and_then(|p| idx.get(p).copied())
782            })
783            .collect();
784
785        // Move children into parents, deepest-first so chains nest correctly.
786        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
787        let mut order: Vec<usize> = (0..slots.len()).collect();
788        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
789        for i in order {
790            if let Some(p) = parent_of[i] {
791                if p != i {
792                    if let Some(child) = slots[i].take() {
793                        if let Some(parent) = slots[p].as_mut() {
794                            parent.subagents.push(child);
795                        } else {
796                            slots[i] = Some(child); // parent already moved; keep as root
797                        }
798                    }
799                }
800            }
801        }
802        slots.into_iter().flatten().collect()
803    }
804
805    /// Parse a session of a known format from an in-memory JSONL string.
806    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
807        match format {
808            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
809            SessionFormat::Codex => Self::from_codex_str(jsonl),
810            SessionFormat::Pi => Self::from_pi_str(jsonl),
811            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
812            SessionFormat::Grok => Self::from_grok_str(jsonl),
813            SessionFormat::Gemini => Self::from_gemini_str(jsonl),
814            SessionFormat::Goose => Self::from_goose_str(jsonl),
815        }
816    }
817
818    /// Serialize this session to JSONL in the given format.
819    ///
820    /// The conversation is synthesized from the canonical messages, so this
821    /// works for sessions loaded from *either* tool as well as ones supercode
822    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
823    /// "export": format-specific framing that has no slot in the target may be
824    /// dropped, but the user/assistant/tool conversation is preserved.
825    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
826        match format {
827            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
828            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
829            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
830            SessionFormat::OpenCode => self.to_opencode_jsonl(),
831            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
832            SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
833            SessionFormat::Goose => Ok(self.to_goose_json()),
834        }
835    }
836
837    /// Export back to `format`, replaying the imported `raw` prefix
838    /// **verbatim** — original uuids/ids, real timestamps, and
839    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
840    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
841    /// is the session's own origin (`format.source() == self.meta.source`,
842    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
843    /// Only messages appended *after* import (tracked by
844    /// [`Self::imported_message_count`]) are synthesized, chained onto the
845    /// last original record found in the raw prefix.
846    ///
847    /// `session_id` of `Some(new)` rewrites the session id on every emitted
848    /// line, raw and synthesized alike (`sessionId` for Claude Code,
849    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
850    ///
851    /// Cross-format export (no verbatim prefix exists in the target dialect,
852    /// by definition) and a session with no `raw` lines both fall back
853    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
854    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
855    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
856    /// cross-format stays at the documented semantic tier.
857    pub fn to_jsonl_spliced(
858        &self,
859        format: SessionFormat,
860        session_id: Option<&str>,
861    ) -> Result<String> {
862        if self.parse_error_lines > 0
863            || self
864                .subagents
865                .iter()
866                .any(|subagent| subagent.parse_error_lines > 0)
867        {
868            return Err(Error::InvalidSession(
869                "refusing spliced export because the loaded session contains parse loss"
870                    .to_string(),
871            ));
872        }
873        if self.raw.is_empty() || format.source() != self.meta.source {
874            if let Some(session_id) = session_id {
875                let mut rewritten = self.clone();
876                rewritten.meta.session_id = Some(session_id.to_string());
877                return rewritten.to_jsonl(format);
878            }
879            return self.to_jsonl(format);
880        }
881        match format {
882            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
883            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
884            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
885            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
886            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
887            SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
888            SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
889        }
890    }
891
892    /// Write this session to `path` in the given format.
893    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
894        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
895        Ok(())
896    }
897
898    /// Reconstruct the exact source bytes this `Session` was loaded from,
899    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
900    /// inverse of the strict-verbatim capture those two fields record — see
901    /// `join_lines_verbatim`).
902    ///
903    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
904    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
905    /// original text, so this reproduces the original file byte-for-byte —
906    /// the P008/P009 diagonal-convert fix (`convert <file> --to
907    /// <same-format>` is byte-identical to `<file>`) is built on exactly
908    /// this. The one documented exception is an OpenCode **export-document**
909    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
910    /// is RE-SYNTHESIZED as one envelope line per record (see
911    /// `from_opencode_export_doc`'s contract), so this returns a
912    /// verbatim reproduction of THAT captured representation rather than the
913    /// original pretty-printed document — a known, narrow residue, not a
914    /// silent loss (the same records are all still present).
915    pub fn raw_verbatim(&self) -> String {
916        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
917    }
918
919    /// Serialize to the **supercode-native** lossless format: a header line
920    /// recording the original source, followed by every original JSONL line
921    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
922    /// schema and is necessarily lossy), this preserves *everything* — including
923    /// records with no canonical representation — so [`Self::from_native_str`]
924    /// reconstructs the session with full fidelity.
925    pub fn to_native_jsonl(&self) -> String {
926        let source = match self.meta.source {
927            SessionSource::ClaudeCode => "claude_code",
928            SessionSource::Codex => "codex",
929            SessionSource::Pi => "pi",
930            SessionSource::OpenCode => "opencode",
931            SessionSource::Grok => "grok",
932            SessionSource::Gemini => "gemini",
933            SessionSource::Goose => "goose",
934            // P5-3 safety-hardening fix: a natively-spawned session must
935            // never be written to disk labeled as an imported CC session.
936            SessionSource::Native => "native",
937        };
938        let header = serde_json::json!({
939            "supercode_native": 1,
940            "source": source,
941            // IX-1: carries whether the ORIGINAL imported source text ended
942            // with a trailing newline — `from_native_str` needs this to
943            // reconstruct the exact source bytes (not just the `raw` line
944            // list) when re-parsing the body with the per-source loader.
945            "raw_trailing_newline": self.raw_trailing_newline,
946        })
947        .to_string();
948        let mut out =
949            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
950        out.push_str(&header);
951        out.push('\n');
952        for line in &self.raw {
953            out.push_str(line);
954            out.push('\n');
955        }
956        out
957    }
958
959    /// Serialize to the **supercode-native v2** format: the same imported-body
960    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
961    /// followed by every `Session.raw` line verbatim), plus one
962    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
963    /// produced after import, which have no backing `raw` line of their own.
964    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
965    /// serde), so nothing the live agent loop records is lost to disk.
966    ///
967    /// `appended` is caller-supplied rather than inferred from
968    /// `self.messages`: A1 doesn't track which of `self.messages` came from
969    /// import vs. the live loop — that bookkeeping belongs to the live writer
970    /// built on top of this (A2/A3).
971    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
972        self.to_native_jsonl_v2_with_timestamp(appended, None)
973    }
974
975    pub(crate) fn to_native_jsonl_v2_with_timestamp(
976        &self,
977        appended: &[ChatMessage],
978        fixed_timestamp: Option<&str>,
979    ) -> String {
980        let source = match self.meta.source {
981            SessionSource::ClaudeCode => "claude_code",
982            SessionSource::Codex => "codex",
983            SessionSource::Pi => "pi",
984            SessionSource::OpenCode => "opencode",
985            SessionSource::Grok => "grok",
986            SessionSource::Gemini => "gemini",
987            SessionSource::Goose => "goose",
988            // P5-3 safety-hardening fix: a natively-spawned session must
989            // never be written to disk labeled as an imported CC session.
990            SessionSource::Native => "native",
991        };
992        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
993        // already parses CC sidechains + CX lineage on import"): a
994        // natively-spawned subagent's own `Session` carries its lineage on
995        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
996        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
997        // all, so a native-spawned child's lineage was lost the instant it
998        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
999        // (`skip_serializing_if`-equivalent via manual omission below) so a
1000        // plain top-level session's header is byte-identical to before this
1001        // change.
1002        let mut header_obj = serde_json::json!({
1003            "supercode_native": 2,
1004            "source": source,
1005            "session_id": self.meta.session_id,
1006            "created": fixed_timestamp
1007                .map(ToOwned::to_owned)
1008                .unwrap_or_else(crate::sidecar::now_rfc3339),
1009            // IX-1: see `to_native_jsonl`'s header field of the same name.
1010            "raw_trailing_newline": self.raw_trailing_newline,
1011        });
1012        if let Some(obj) = header_obj.as_object_mut() {
1013            if let Some(agent_id) = &self.meta.agent_id {
1014                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
1015            }
1016            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
1017                obj.insert(
1018                    "parent_tool_use_id".to_string(),
1019                    Value::String(parent_tool_use_id.clone()),
1020                );
1021            }
1022            if !self.meta.lineage.is_empty() {
1023                obj.insert(
1024                    "lineage".to_string(),
1025                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
1026                );
1027            }
1028        }
1029        let header = header_obj.to_string();
1030        let mut out =
1031            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
1032        out.push_str(&header);
1033        out.push('\n');
1034        for line in &self.raw {
1035            out.push_str(line);
1036            out.push('\n');
1037        }
1038        for (turn_index, msg) in appended.iter().enumerate() {
1039            let turn = match fixed_timestamp {
1040                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1041                    msg,
1042                    timestamp.to_string(),
1043                    turn_index as u64,
1044                ),
1045                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1046                    msg,
1047                    crate::sidecar::now_rfc3339(),
1048                    turn_index as u64,
1049                ),
1050            };
1051            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
1052            out.push('\n');
1053        }
1054        out
1055    }
1056
1057    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
1058    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
1059    /// exactly as before. A v2 file's appended `NativeTurn` records —
1060    /// discriminated by the `supercode_turn` key, which never appears in a v1
1061    /// body — are split out before the imported body is handed to the
1062    /// per-source loader, then reattached in file order: to `messages` (via
1063    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
1064    /// so a v2 file round-trips byte-for-byte through
1065    /// [`Self::to_native_jsonl_v2`] again.
1066    pub fn from_native_str(jsonl: &str) -> Result<Session> {
1067        // IX-1: the native WRAPPER's own lines are split verbatim (not via
1068        // the blank-skipping `non_empty_lines`) so that any `raw` line it
1069        // carries — which can itself be blank, CRLF-terminated, or
1070        // whitespace-padded, now that raw-capture is strict-verbatim —
1071        // survives being embedded in (and re-extracted from) this wrapper
1072        // bit-for-bit. The wrapper we ourselves emit never has a blank line
1073        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
1074        // record per line), so this is a behavior-preserving switch for any
1075        // native text this crate produced; it also makes a hand-fed/legacy
1076        // native string tolerated exactly as `non_empty_lines` used to.
1077        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
1078        let mut lines = all_lines.into_iter();
1079        let header = lines.next().unwrap_or("");
1080        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
1081        let source = hv.get("source").and_then(Value::as_str);
1082        // IX-1: whether the ORIGINAL imported source (before it was wrapped
1083        // in this native format) ended with a trailing newline — a property
1084        // of the pre-wrap source, not of this wrapper (which always
1085        // LF-terminates every line it writes, regardless). Missing on a
1086        // native file written before IX-1 (or a hand-built header in an
1087        // older test/sidecar) — default `true`, the historical
1088        // always-newline-terminated assumption.
1089        let raw_trailing_newline = hv
1090            .get("raw_trailing_newline")
1091            .and_then(Value::as_bool)
1092            .unwrap_or(true);
1093
1094        // Split appended NativeTurn records (v2) out of the imported body. A
1095        // v1 body never carries a `supercode_turn` key, so this is a no-op
1096        // there — one code path serves both versions.
1097        let mut body_lines: Vec<String> = Vec::new();
1098        let mut turn_lines: Vec<&str> = Vec::new();
1099        for line in lines {
1100            let is_turn = serde_json::from_str::<Value>(line)
1101                .ok()
1102                .is_some_and(|v| v.get("supercode_turn").is_some());
1103            if is_turn {
1104                turn_lines.push(line);
1105            } else {
1106                body_lines.push(line.to_string());
1107            }
1108        }
1109        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1110        // `body_lines.join("\n")` alone would silently gain a trailing
1111        // newline the original source never had (or lose one it did have).
1112        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1113
1114        // The remaining lines are the original log; re-parse with the right loader.
1115        let mut session = match source {
1116            Some("codex") => Self::from_codex_str(&body)?,
1117            Some("claude_code") => Self::from_claude_code_str(&body)?,
1118            Some("pi") => Self::from_pi_str(&body)?,
1119            Some("opencode") => Self::from_opencode_str(&body)?,
1120            Some("grok") => Self::from_grok_str(&body)?,
1121            Some("gemini") => Self::from_gemini_str(&body)?,
1122            Some("goose") => Self::from_goose_str(&body)?,
1123            // P5-3 safety-hardening fix: a natively-spawned session's body
1124            // is always empty (it never had any foreign-tool prefix to
1125            // begin with — see `SessionSource::Native`'s doc comment), so
1126            // any loader would parse it identically; `from_claude_code_str`
1127            // is reused purely as a blank-skeleton builder (empty
1128            // `raw`/`messages`), then its `meta.source` is corrected to
1129            // `Native` — never left mislabeled as `ClaudeCode`.
1130            Some("native") => {
1131                let mut s = Self::from_claude_code_str(&body)?;
1132                s.meta.source = SessionSource::Native;
1133                s
1134            }
1135            // No/unknown header — auto-detect the body.
1136            _ => match detect_source(&body) {
1137                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1138                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1139                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1140                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1141                Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
1142                Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
1143                _ => Self::from_claude_code_str(&body)?,
1144            },
1145        };
1146
1147        for line in turn_lines {
1148            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1149                Ok(turn) => {
1150                    session.raw.push(line.to_string());
1151                    session.messages.push(turn.into_message());
1152                }
1153                Err(_) => {
1154                    // A valid JSON object carrying the native-turn
1155                    // discriminator belongs to this wrapper, not to the
1156                    // imported body. If its required fields are malformed,
1157                    // count it as parse loss so every fail-loud caller can
1158                    // refuse continuation instead of silently dropping a
1159                    // native history record. Keep the rejected source line
1160                    // in `raw` as well: diagnostics must count it in their
1161                    // denominator, and even corrupt input must not disappear
1162                    // merely because it reached the parser.
1163                    session.raw.push(line.to_string());
1164                    session.parse_error_lines += 1;
1165                }
1166            }
1167        }
1168
1169        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1170        // header block): recover a natively-spawned subagent's own lineage
1171        // from the v2 header, when present. Overlays (rather than merges
1172        // into) whatever the per-source body loader may have already set on
1173        // `session.meta` — these three keys are ONLY ever written by
1174        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1175        // header that carries them is authoritative for a file this crate
1176        // produced.
1177        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1178            session.meta.agent_id = Some(agent_id.to_string());
1179        }
1180        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1181            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1182        }
1183        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1184            for (k, v) in lineage {
1185                if let Some(s) = v.as_str() {
1186                    session.meta.lineage.insert(k.clone(), s.to_string());
1187                }
1188            }
1189        }
1190
1191        Ok(session)
1192    }
1193
1194    /// The full-fidelity [`Session`] a sidecar denotes.
1195    ///
1196    /// The sidecar (native-v2 format, D1) is the imported body plus every
1197    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1198    /// tolerant lower-level native parser, this persisted-store entry point
1199    /// validates its framing header before loading anything: a missing,
1200    /// malformed, or unsupported header must never become a zero-message
1201    /// session that callers could continue as if it were complete.
1202    pub fn from_sidecar_str(s: &str) -> Result<Session> {
1203        let header = s.lines().next().ok_or_else(|| {
1204            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1205        })?;
1206        let value: Value = serde_json::from_str(header).map_err(|error| {
1207            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1208        })?;
1209        let version = value.get("supercode_native").and_then(Value::as_u64);
1210        if !matches!(version, Some(1 | 2)) {
1211            return Err(Error::InvalidSession(
1212                "sidecar header must declare supported `supercode_native` version 1 or 2"
1213                    .to_string(),
1214            ));
1215        }
1216        let source = value.get("source").and_then(Value::as_str);
1217        if !matches!(
1218            source,
1219            Some(
1220                "native"
1221                    | "claude_code"
1222                    | "codex"
1223                    | "gemini"
1224                    | "goose"
1225                    | "opencode"
1226                    | "pi"
1227                    | "grok"
1228            )
1229        ) {
1230            return Err(Error::InvalidSession(
1231                "sidecar header must declare a supported `source`".to_string(),
1232            ));
1233        }
1234        Self::from_native_str(s)
1235    }
1236
1237    /// Parse a Codex rollout from an in-memory JSONL string.
1238    pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1239        let mut meta = SessionMeta::new(SessionSource::Codex);
1240        let mut messages = Vec::new();
1241
1242        // First pass: collect the text of every assistant message that exists as
1243        // a canonical `response_item`. In normal sessions the streamed
1244        // `event_msg/agent_message` events duplicate these and are safely
1245        // skipped; in collab/multi-agent sessions the assistant narration lives
1246        // ONLY as `agent_message` events, so we recover the ones with no
1247        // response_item counterpart (deduping by exact text).
1248        let assistant_texts = collect_codex_assistant_texts(jsonl);
1249        // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1250        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1251        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1252        let mut pending_reasoning = String::new();
1253        let mut pending_reasoning_content = String::new();
1254        let mut pending_reasoning_encrypted = false;
1255        // PARITY-15: see `from_claude_code_str`'s identical counter.
1256        let mut parse_error_lines = 0usize;
1257        let mut restored_embedded_codex_provenance = false;
1258
1259        for (record_index, raw_line) in raw_lines.iter().enumerate() {
1260            let line = raw_line.trim();
1261            if line.is_empty() {
1262                continue;
1263            }
1264            let v: Value = match serde_json::from_str(line) {
1265                Ok(v) => v,
1266                Err(_) => {
1267                    parse_error_lines += 1;
1268                    continue;
1269                }
1270            };
1271            let payload = v.get("payload").unwrap_or(&Value::Null);
1272            if !restored_embedded_codex_provenance
1273                && v.get("type").and_then(Value::as_str) == Some("session_meta")
1274                && payload
1275                    .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1276                    .map(|extension| restore_codex_provenance(extension, &mut meta))
1277                    .transpose()?
1278                    .unwrap_or(false)
1279            {
1280                restored_embedded_codex_provenance = true;
1281            }
1282            if !restored_embedded_codex_provenance {
1283                capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1284            }
1285            // WAVE-2 item 1: every Codex record carries a real top-level
1286            // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1287            // line produces via `stamp_new_codex_messages` below, at each
1288            // arm that pushes messages.
1289            let line_ts = v.get("timestamp").and_then(Value::as_str);
1290
1291            match v.get("type").and_then(Value::as_str) {
1292                Some("session_meta") => {
1293                    capture_codex_session_meta(payload, &mut meta);
1294                    if !restored_embedded_codex_provenance {
1295                        meta.codex_headers.push(v.clone());
1296                    }
1297                }
1298                Some("turn_context") => {
1299                    if meta.model.is_none() {
1300                        meta.model = payload
1301                            .get("model")
1302                            .and_then(Value::as_str)
1303                            .map(str::to_string);
1304                    }
1305                    if !restored_embedded_codex_provenance {
1306                        meta.codex_headers.push(v.clone());
1307                    }
1308                }
1309                Some("response_item")
1310                    if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1311                {
1312                    // Retain reasoning (P3): summary text if any, the raw
1313                    // `content` chain-of-thought text if any (N2 — this used
1314                    // to be dropped despite `Coverage::Retained` claiming the
1315                    // whole item survived; see `crate::audit`'s doc comment),
1316                    // plus a flag for the opaque encrypted_content a
1317                    // same-model continuation can replay. Stashed onto the
1318                    // next assistant message below.
1319                    let summary = extract_text_content(payload.get("summary"));
1320                    if !summary.trim().is_empty() {
1321                        push_str_field(&mut pending_reasoning, &summary);
1322                    }
1323                    // N2: `content` is `null` on the vast majority of real
1324                    // turns (raw reasoning text is only ever populated for
1325                    // certain reasoning-transcript configurations) — guard
1326                    // on non-null BEFORE calling `extract_text_content`,
1327                    // since `Some(&Value::Null)` would otherwise fall into
1328                    // its `Some(other) => other.to_string()` arm and
1329                    // stringify to the literal text `"null"`.
1330                    if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1331                        let text = extract_text_content(Some(raw_content));
1332                        if !text.trim().is_empty() {
1333                            push_str_field(&mut pending_reasoning_content, &text);
1334                        }
1335                    }
1336                    // N1: `serde_json` returns `Some(&Value::Null)` for a
1337                    // present-but-null `encrypted_content` key — which is
1338                    // what EVERY real rollout's reasoning item carries
1339                    // (upstream always serializes the field, never
1340                    // `skip_serializing_if`, `codex-rs/protocol/src/
1341                    // models.rs:970-983`). The old `.is_some()` check
1342                    // false-flagged every single reasoning item as
1343                    // "encrypted" on real data; only a genuinely non-null
1344                    // value means the model actually returned an opaque
1345                    // blob that a same-model continuation could replay.
1346                    if payload
1347                        .get("encrypted_content")
1348                        .is_some_and(|v| !v.is_null())
1349                    {
1350                        pending_reasoning_encrypted = true;
1351                    }
1352                }
1353                Some("response_item") => {
1354                    let before = messages.len();
1355                    push_codex_item(payload, &mut messages);
1356                    // Attach any pending reasoning to a newly produced assistant turn.
1357                    if messages.len() > before
1358                        && (!pending_reasoning.is_empty()
1359                            || !pending_reasoning_content.is_empty()
1360                            || pending_reasoning_encrypted)
1361                    {
1362                        let is_assistant = messages
1363                            .last()
1364                            .map(|m| m.role == Role::Assistant)
1365                            .unwrap_or(false);
1366                        if is_assistant {
1367                            let last = messages.last_mut().expect("checked above");
1368                            if !pending_reasoning.is_empty() {
1369                                last.metadata.insert(
1370                                    "reasoning".to_string(),
1371                                    std::mem::take(&mut pending_reasoning),
1372                                );
1373                            }
1374                            if !pending_reasoning_content.is_empty() {
1375                                last.metadata.insert(
1376                                    "reasoning_content".to_string(),
1377                                    std::mem::take(&mut pending_reasoning_content),
1378                                );
1379                            }
1380                            if pending_reasoning_encrypted {
1381                                last.metadata
1382                                    .insert("reasoning_encrypted".to_string(), "true".to_string());
1383                                pending_reasoning_encrypted = false;
1384                            }
1385                        } else {
1386                            // N3: the item that just landed is NOT the
1387                            // assistant turn the pending reasoning was for
1388                            // (e.g. an aborted turn's reasoning directly
1389                            // followed by a user message) — the old code
1390                            // unconditionally cleared the pending state
1391                            // here, silently discarding it. Flush it as its
1392                            // own message instead, inserted just before the
1393                            // interrupting item so replay order stays
1394                            // chronological, keeping `Coverage::Retained`
1395                            // honest for this shape too.
1396                            let orphan = orphaned_reasoning_message(
1397                                &mut pending_reasoning,
1398                                &mut pending_reasoning_content,
1399                                &mut pending_reasoning_encrypted,
1400                            );
1401                            messages.insert(before, orphan);
1402                        }
1403                    }
1404                    stamp_new_codex_messages(&mut messages, before, line_ts);
1405                    restore_single_grok_message(payload, &mut messages[before..]);
1406                }
1407                // A compaction record replaces all prior turns with its
1408                // summarized `replacement_history` — exactly how Codex itself
1409                // resumes a compacted session.
1410                Some("compacted") => {
1411                    messages.clear();
1412                    if let Some(Value::Array(history)) = payload.get("replacement_history") {
1413                        for item in history {
1414                            push_codex_item(item, &mut messages);
1415                        }
1416                    }
1417                    // `replacement_history` items carry no per-item
1418                    // timestamp of their own (observed corpora) — the
1419                    // `compacted` record's own timestamp (when it happened)
1420                    // is the best-effort real source for every message it
1421                    // synthesizes, so it stamps the whole rebuilt vec (index
1422                    // 0, since `clear()` reset it above).
1423                    stamp_new_codex_messages(&mut messages, 0, line_ts);
1424                    // IX-6 fix: replaying `replacement_history` through
1425                    // `push_codex_item` can leave the LAST replayed message
1426                    // marked `__codex_open_turn` (if it's an assistant
1427                    // `message`, per the combined-turn merge below). That
1428                    // marker must not survive past the compaction boundary —
1429                    // a live `function_call` arriving after this record is a
1430                    // NEW turn, not a continuation of the compaction
1431                    // summary's synthetic turn, so it must not merge into it.
1432                    if let Some(last) = messages.last_mut() {
1433                        last.metadata.remove("__codex_open_turn");
1434                    }
1435                }
1436                Some("event_msg")
1437                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1438                {
1439                    let before = messages.len();
1440                    let text = agent_message_text(payload);
1441                    if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1442                        push_assistant(&mut messages, text, Vec::new());
1443                        if let Some(last) = messages.last_mut() {
1444                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1445                                last.metadata.insert("phase".to_string(), phase.to_string());
1446                            }
1447                        }
1448                    }
1449                    stamp_new_codex_messages(&mut messages, before, line_ts);
1450                }
1451                // The user rolled back (undid) the last N turns — replay must
1452                // drop them so the reloaded conversation matches what the user
1453                // actually kept.
1454                Some("event_msg")
1455                    if payload.get("type").and_then(Value::as_str)
1456                        == Some("thread_rolled_back") =>
1457                {
1458                    let n = payload
1459                        .get("num_turns")
1460                        .and_then(Value::as_u64)
1461                        .unwrap_or(1);
1462                    for _ in 0..n {
1463                        remove_last_turn(&mut messages);
1464                    }
1465                }
1466                // The natural-language goal assigned to this thread (sometimes
1467                // the only place the objective text is recorded).
1468                Some("event_msg")
1469                    if payload.get("type").and_then(Value::as_str)
1470                        == Some("thread_goal_updated") =>
1471                {
1472                    let before = messages.len();
1473                    let goal = payload.get("goal");
1474                    if let Some(obj) = goal
1475                        .and_then(|g| g.get("objective"))
1476                        .and_then(Value::as_str)
1477                    {
1478                        if !obj.trim().is_empty() {
1479                            messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1480                            // D4: `goal.objective` alone used to be the ONLY
1481                            // captured field, but the audit labeled this
1482                            // `Retained` as if the whole record survived.
1483                            // `goal.status`/`goal.tokenBudget` (real
1484                            // `ThreadGoal` wire fields, camelCase) are
1485                            // captured too so that label is honest — see
1486                            // `crate::audit::event_msg_coverage`'s doc
1487                            // comment.
1488                            if let Some(last) = messages.last_mut() {
1489                                if let Some(status) =
1490                                    goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1491                                {
1492                                    last.metadata
1493                                        .insert("goal_status".to_string(), status.to_string());
1494                                }
1495                                if let Some(budget) = goal
1496                                    .and_then(|g| g.get("tokenBudget"))
1497                                    .and_then(Value::as_i64)
1498                                {
1499                                    last.metadata.insert(
1500                                        "goal_token_budget".to_string(),
1501                                        budget.to_string(),
1502                                    );
1503                                }
1504                            }
1505                        }
1506                    }
1507                    stamp_new_codex_messages(&mut messages, before, line_ts);
1508                }
1509                // Code-review output — unique assistant-generated content with no
1510                // `message` counterpart.
1511                Some("event_msg")
1512                    if payload.get("type").and_then(Value::as_str)
1513                        == Some("exited_review_mode") =>
1514                {
1515                    let before = messages.len();
1516                    if let Some(review) = payload.get("review_output") {
1517                        let text = review
1518                            .get("overall_explanation")
1519                            .and_then(Value::as_str)
1520                            .map(str::to_string)
1521                            .unwrap_or_else(|| review.to_string());
1522                        push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1523                        // D4: `overall_explanation` alone used to be the ONLY
1524                        // captured field, but the audit labeled this
1525                        // `Retained` as if `review_output.findings` survived
1526                        // too. Capture `findings` verbatim (as JSON, onto
1527                        // metadata) so that label is honest — this is the
1528                        // only place review-mode findings (title/body/
1529                        // confidence_score/priority/code_location) live.
1530                        if let Some(findings) = review.get("findings") {
1531                            if findings.as_array().is_some_and(|a| !a.is_empty()) {
1532                                if let Some(last) = messages.last_mut() {
1533                                    if let Ok(s) = serde_json::to_string(findings) {
1534                                        last.metadata.insert("review_findings".to_string(), s);
1535                                    }
1536                                }
1537                            }
1538                        }
1539                        // N4: `overall_correctness`/`overall_confidence_score`
1540                        // are the review's actual verdict — distinct from the
1541                        // findings list and the explanation prose already
1542                        // captured above — and were neither captured nor
1543                        // disclosed as residue while the audit doc stayed
1544                        // silent about them. Capture both onto the same
1545                        // message's metadata, same pattern as `findings`.
1546                        if let Some(last) = messages.last_mut() {
1547                            if let Some(correctness) =
1548                                review.get("overall_correctness").and_then(Value::as_str)
1549                            {
1550                                last.metadata.insert(
1551                                    "review_overall_correctness".to_string(),
1552                                    correctness.to_string(),
1553                                );
1554                            }
1555                            if let Some(score) = review
1556                                .get("overall_confidence_score")
1557                                .and_then(Value::as_f64)
1558                            {
1559                                last.metadata.insert(
1560                                    "review_overall_confidence_score".to_string(),
1561                                    score.to_string(),
1562                                );
1563                            }
1564                        }
1565                    }
1566                    stamp_new_codex_messages(&mut messages, before, line_ts);
1567                }
1568                _ => {} // other event_msg, token_count, ... — UI events, skip
1569            }
1570        }
1571
1572        // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1573        // shape a real rollout can leave behind (the process was
1574        // interrupted mid-turn, after the model reasoned but before it
1575        // replied — end of file, or a rollback/compaction boundary that
1576        // clears the pending state some other way) — the old code silently
1577        // dropped it here (nothing ever consumed the pending buffers once
1578        // the loop ended). Flush it as its own trailing message instead, so
1579        // `Coverage::Retained` holds for this shape too. Superset of the
1580        // independently-discovered PARITY-11 fix: also folds in
1581        // `pending_reasoning_content` (the raw chain-of-thought, distinct
1582        // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1583        // message` helper, which the interrupted-by-a-user-message shape
1584        // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1585        // relies on — a trailing-EOF-only flush here would miss that case.
1586        if !pending_reasoning.is_empty()
1587            || !pending_reasoning_content.is_empty()
1588            || pending_reasoning_encrypted
1589        {
1590            let orphan = orphaned_reasoning_message(
1591                &mut pending_reasoning,
1592                &mut pending_reasoning_content,
1593                &mut pending_reasoning_encrypted,
1594            );
1595            messages.push(orphan);
1596        }
1597
1598        ensure_tool_results_paired(&mut messages);
1599        // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1600        // combined-turn merge above — strip it so it never leaks out as
1601        // visible `ChatMessage` metadata.
1602        for m in &mut messages {
1603            m.metadata.remove("__codex_open_turn");
1604            if m.metadata
1605                .remove("__grok_remove_synthetic_turn_id")
1606                .is_some()
1607            {
1608                m.metadata.remove("turn_id");
1609            }
1610        }
1611        let imported_message_count = Some(messages.len());
1612        Ok(Session {
1613            meta,
1614            messages,
1615            subagents: Vec::new(),
1616            raw,
1617            raw_trailing_newline,
1618            imported_message_count,
1619            // Codex is line-oriented: `raw` is split directly out of the
1620            // source text (strict-verbatim, IX-1).
1621            raw_is_verbatim: true,
1622            parse_error_lines,
1623            load_residue: Vec::new(),
1624        })
1625    }
1626
1627    /// Parse a Codex rollout as bounded human-visible history rather than as
1628    /// resumable model context. This deliberately ignores outer `compacted`
1629    /// replacement semantics: the original `response_item` records remain in
1630    /// the rollout and are the authoritative UI history.
1631    fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
1632        let mut meta = SessionMeta::new(SessionSource::Codex);
1633        let mut messages: Vec<ChatMessage> = Vec::new();
1634        let mut preceding_user = None;
1635        let mut parse_error_lines = 0usize;
1636        let mut record_count = 0usize;
1637        let mut total_message_count = 0usize;
1638        let retain = message_limit.max(1).saturating_add(64);
1639        let mut canonical_assistant_texts = HashSet::new();
1640
1641        for raw_line in non_empty_lines(jsonl) {
1642            record_count += 1;
1643            let value: Value = match serde_json::from_str(raw_line) {
1644                Ok(value) => value,
1645                Err(_) => {
1646                    parse_error_lines += 1;
1647                    continue;
1648                }
1649            };
1650            let payload = value.get("payload").unwrap_or(&Value::Null);
1651            let line_ts = value.get("timestamp").and_then(Value::as_str);
1652            match value.get("type").and_then(Value::as_str) {
1653                Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
1654                Some("turn_context") if meta.model.is_none() => {
1655                    meta.model = payload
1656                        .get("model")
1657                        .and_then(Value::as_str)
1658                        .map(str::to_string);
1659                }
1660                Some("response_item")
1661                    if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
1662                {
1663                    let assistant_text = (payload.get("type").and_then(Value::as_str)
1664                        == Some("message")
1665                        && payload.get("role").and_then(Value::as_str) == Some("assistant"))
1666                    .then(|| extract_text_content(payload.get("content")))
1667                    .filter(|text| !text.trim().is_empty());
1668                    if let Some(text) = assistant_text.as_deref() {
1669                        if let Some(index) = messages.iter().rposition(|message| {
1670                            message.metadata.contains_key("codex_event_message")
1671                                && message.content.as_deref() == Some(text)
1672                        }) {
1673                            messages.remove(index);
1674                            total_message_count = total_message_count.saturating_sub(1);
1675                        }
1676                        canonical_assistant_texts.insert(text.trim().to_string());
1677                    }
1678                    let before = messages.len();
1679                    push_codex_item(payload, &mut messages);
1680                    total_message_count += messages.len().saturating_sub(before);
1681                    stamp_new_codex_messages(&mut messages, before, line_ts);
1682                    restore_single_grok_message(payload, &mut messages[before..]);
1683                }
1684                Some("event_msg")
1685                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1686                {
1687                    let text = agent_message_text(payload);
1688                    if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
1689                        let before = messages.len();
1690                        push_assistant(&mut messages, text, Vec::new());
1691                        total_message_count += 1;
1692                        if let Some(last) = messages.last_mut() {
1693                            last.metadata
1694                                .insert("codex_event_message".to_string(), "true".to_string());
1695                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1696                                last.metadata.insert("phase".to_string(), phase.to_string());
1697                            }
1698                        }
1699                        stamp_new_codex_messages(&mut messages, before, line_ts);
1700                    }
1701                }
1702                // `compacted` changes continuation context, not what was
1703                // already visible in scrollback. Other event records are UI
1704                // lifecycle noise or duplicate canonical response items.
1705                _ => {}
1706            }
1707            if messages.len() > retain {
1708                let remove = messages.len() - retain;
1709                for message in messages.drain(..remove) {
1710                    if message.role == Role::User {
1711                        preceding_user = Some(message);
1712                    }
1713                }
1714            }
1715        }
1716
1717        for message in &mut messages {
1718            message.metadata.remove("__codex_open_turn");
1719            message.metadata.remove("codex_event_message");
1720            if message
1721                .metadata
1722                .remove("__grok_remove_synthetic_turn_id")
1723                .is_some()
1724            {
1725                message.metadata.remove("turn_id");
1726            }
1727        }
1728        truncate_messages_with_anchor(&mut messages, message_limit, preceding_user);
1729        let imported_message_count = Some(total_message_count);
1730        Ok(Session {
1731            meta,
1732            messages,
1733            subagents: Vec::new(),
1734            // Preserve the cheap count without retaining hundreds of
1735            // megabytes of source lines in a display-only value.
1736            raw: vec![String::new(); record_count],
1737            raw_trailing_newline: jsonl.ends_with('\n'),
1738            imported_message_count,
1739            raw_is_verbatim: false,
1740            parse_error_lines,
1741            load_residue: vec![
1742                "display history is a bounded native-record projection, not resumable model context"
1743                    .to_string(),
1744            ],
1745        })
1746    }
1747
1748    /// Load a Pi session from a file.
1749    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1750        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1751    }
1752
1753    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1754    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1755    ///
1756    /// Line 1 is the `session` header; every other line is one `SessionEntry`
1757    /// in a tree keyed by `id`/`parentId` — file order is append order, not
1758    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1759    /// exactly like Claude Code/Codex). `messages` is the **active path
1760    /// only**: pi's own leaf rule is "the last entry in file order"
1761    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1762    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1763    /// state records (`thinking_level_change`/`model_change`/`custom`/
1764    /// `session_info`) are never visited by that walk — they survive in
1765    /// `raw` only, pi's defining residue (§1.1).
1766    ///
1767    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1768    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1769    /// `custom`) produces no canonical message — raw-only survival, never a
1770    /// panic — and the Pi corpus audit turns that into a
1771    /// visible coverage failure rather than a silent drop.
1772    ///
1773    /// Same fail-loud discipline applies to `ImageContent` blocks
1774    /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1775    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1776    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1777    /// cites the containing union) — a follow-up TR tracks confirming it
1778    /// against a real corpus. Until then, an image block that doesn't match
1779    /// that shape never gets silently synthesized as an empty/corrupt
1780    /// `image_url` part; the containing message survives in `raw` only and
1781    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1782    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1783        let mut meta = SessionMeta::new(SessionSource::Pi);
1784        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1785        // blank-skipping PARSE walk (`lines_v`) below, which must keep
1786        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1787        // records (a blank line is never a record, on either view).
1788        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1789        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1790        let non_empty_line_count = non_empty_lines(jsonl).count();
1791        let lines_v: Vec<Value> = non_empty_lines(jsonl)
1792            .filter_map(|l| serde_json::from_str(l).ok())
1793            .collect();
1794        // PARITY-15: every line that failed to even deserialize as JSON at
1795        // all (never mind whether it then parsed as a recognized
1796        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1797        // counter.
1798        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1799
1800        if let Some(header) = lines_v.first() {
1801            capture_pi_header(header, &mut meta)?;
1802        }
1803
1804        // Every non-header entry that parses as an object carrying an `id`.
1805        // (A line that fails to parse, or a header re-parsed as an entry,
1806        // simply never enters `by_id` — it survives in `raw` only, exactly
1807        // like a malformed/non-conversational line in the other loaders.)
1808        struct PiEntry {
1809            id: String,
1810            parent_id: Option<String>,
1811            value: Value,
1812        }
1813        let mut entries: Vec<PiEntry> = Vec::new();
1814        let mut by_id: HashMap<String, usize> = HashMap::new();
1815        for v in lines_v.iter().skip(1) {
1816            let Some(id) = v.get("id").and_then(Value::as_str) else {
1817                continue;
1818            };
1819            let parent_id = v
1820                .get("parentId")
1821                .and_then(Value::as_str)
1822                .map(str::to_string);
1823            by_id.insert(id.to_string(), entries.len());
1824            entries.push(PiEntry {
1825                id: id.to_string(),
1826                parent_id,
1827                value: v.clone(),
1828            });
1829        }
1830
1831        if entries.is_empty() {
1832            return Ok(Session {
1833                meta,
1834                messages: Vec::new(),
1835                subagents: Vec::new(),
1836                raw,
1837                raw_trailing_newline,
1838                imported_message_count: Some(0),
1839                // Pi is line-oriented: `raw` is split directly out of the
1840                // source text (strict-verbatim, IX-1), even for this
1841                // no-entries early return.
1842                raw_is_verbatim: true,
1843                parse_error_lines,
1844                load_residue: Vec::new(),
1845            });
1846        }
1847
1848        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1849        // necessarily a `message` entry — a trailing `label`/`session_info`
1850        // still anchors the walk correctly since the walk just follows
1851        // `parentId` regardless of the leaf's own type.
1852        let leaf_idx = entries.len() - 1;
1853        let mut chain_rev: Vec<usize> = Vec::new();
1854        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1855        let mut guard = 0usize;
1856        while let Some(id) = cur {
1857            let Some(&idx) = by_id.get(&id) else { break };
1858            chain_rev.push(idx);
1859            cur = entries[idx].parent_id.clone();
1860            guard += 1;
1861            if guard > entries.len() + 1 {
1862                break; // cycle guard — malformed parentId chain
1863            }
1864        }
1865        chain_rev.reverse();
1866        let active = chain_rev; // indices into `entries`, root..leaf order
1867
1868        let pos_in_active: HashMap<&str, usize> = active
1869            .iter()
1870            .enumerate()
1871            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1872            .collect();
1873
1874        // First pass: compaction discipline (§2.1 S3) — every message from an
1875        // entry before the LATEST `firstKeptEntryId` on the active path is
1876        // excluded from replay (`compacted_out`), mirroring pi's own
1877        // `buildContextEntries` slice (`sm:414-450`).
1878        let mut kept_from_pos = 0usize;
1879        for &idx in &active {
1880            let e = &entries[idx];
1881            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1882                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1883                    if let Some(&p) = pos_in_active.get(fk) {
1884                        kept_from_pos = kept_from_pos.max(p);
1885                    }
1886                }
1887            }
1888        }
1889
1890        let mut messages = Vec::new();
1891        let mut current_model: Option<String> = None;
1892        for (pos, &idx) in active.iter().enumerate() {
1893            let e = &entries[idx];
1894            let v = &e.value;
1895            let entry_ts = v
1896                .get("timestamp")
1897                .and_then(Value::as_str)
1898                .map(str::to_string);
1899            let before = messages.len();
1900            match v.get("type").and_then(Value::as_str) {
1901                Some("message") => {
1902                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1903                    match msg_v.get("role").and_then(Value::as_str) {
1904                        Some("user") => push_pi_user(&msg_v, &mut messages),
1905                        Some("assistant") => {
1906                            push_pi_assistant(&msg_v, &mut messages);
1907                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1908                                current_model = Some(m.to_string());
1909                            }
1910                        }
1911                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1912                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1913                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1914                        // OPEN UNION (S6): any other role — raw-only survival.
1915                        _ => {}
1916                    }
1917                }
1918                Some("custom_message") => push_pi_custom_common(v, &mut messages),
1919                Some("compaction") => push_pi_compaction(v, &mut messages),
1920                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1921                Some("model_change") => {
1922                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1923                        current_model = Some(m.to_string());
1924                    }
1925                }
1926                Some("session_info") => {
1927                    if let Some(name) = v.get("name").and_then(Value::as_str) {
1928                        if !name.is_empty() {
1929                            meta.lineage
1930                                .insert("session_name".to_string(), name.to_string());
1931                        }
1932                    }
1933                }
1934                // thinking_level_change, custom (entry-level state), label —
1935                // no clean home, raw-only (§2.3).
1936                _ => {}
1937            }
1938            let is_summary = matches!(
1939                v.get("type").and_then(Value::as_str),
1940                Some("compaction") | Some("branch_summary")
1941            );
1942            for m in &mut messages[before..] {
1943                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1944                if let Some(p) = &e.parent_id {
1945                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
1946                }
1947                if let Some(ts) = &entry_ts {
1948                    m.metadata
1949                        .entry("timestamp".to_string())
1950                        .or_insert_with(|| ts.clone());
1951                }
1952                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1953                // is pi's authoritative, always-monotonic-in-file-order
1954                // wall-clock (mandatory on every entry) and wins whenever
1955                // present. The nested `message.timestamp` (unix-ms) is only
1956                // reached here — via `entry(...).or_insert_with`, so it
1957                // never overwrites the entry-level value — in the rare case
1958                // an entry lacks its own `timestamp`. This intentionally
1959                // does NOT prefer the msg-level field even though it LOOKS
1960                // more precise: unlike the entry-level timestamp, it is not
1961                // guaranteed monotonic with this loader's root->leaf
1962                // linearization (e.g. a rewound-branch entry can carry an
1963                // earlier msg-level clock reading than its file-order
1964                // neighbors), and OpenCode's own loader re-sorts messages by
1965                // this canonical timestamp — a non-monotonic source would
1966                // silently scramble replay order on a pi->opencode hop.
1967                if let Some(ms) = v
1968                    .get("message")
1969                    .and_then(|mm| mm.get("timestamp"))
1970                    .and_then(Value::as_u64)
1971                {
1972                    m.metadata
1973                        .entry("timestamp".to_string())
1974                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
1975                }
1976                // A compaction/branch-summary message IS the retained marker
1977                // — never mark it excluded, regardless of its own position.
1978                if !is_summary && pos < kept_from_pos {
1979                    m.metadata
1980                        .insert("compacted_out".to_string(), "true".to_string());
1981                }
1982            }
1983            restore_single_grok_message(v, &mut messages[before..]);
1984            for message in &mut messages[before..] {
1985                restore_tool_outcome_extension(v, message);
1986            }
1987        }
1988
1989        meta.model = current_model;
1990        ensure_tool_results_paired(&mut messages);
1991        let imported_message_count = Some(messages.len());
1992        Ok(Session {
1993            meta,
1994            messages,
1995            subagents: Vec::new(),
1996            raw,
1997            raw_trailing_newline,
1998            imported_message_count,
1999            // Pi is line-oriented: `raw` is split directly out of the
2000            // source text (strict-verbatim, IX-1).
2001            raw_is_verbatim: true,
2002            parse_error_lines,
2003            load_residue: Vec::new(),
2004        })
2005    }
2006
2007    /// Load Grok's resumable `chat_history.jsonl` transcript.
2008    ///
2009    /// The surrounding session directory carries the session id, workspace,
2010    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
2011    /// itself while this path-aware entry point overlays that directory
2012    /// metadata.
2013    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
2014        let path = path.as_ref();
2015        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
2016        session.capture_grok_path_metadata(path);
2017        Ok(session)
2018    }
2019
2020    /// Parse Grok's line-oriented `chat_history.jsonl` format.
2021    ///
2022    /// Conversational records are `user`, `assistant`, and `tool_result`.
2023    /// `system` is the regenerated base prompt and is retained in
2024    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2025    /// state remain byte-exact in [`Session::raw`] but are intentionally not
2026    /// replayed as chat turns.
2027    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2028        let mut meta = SessionMeta::new(SessionSource::Grok);
2029        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2030        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2031        let mut messages = Vec::new();
2032        let mut parse_error_lines = 0usize;
2033        let mut tool_names: HashMap<String, String> = HashMap::new();
2034
2035        for line in non_empty_lines(jsonl) {
2036            let value: Value = match serde_json::from_str(line) {
2037                Ok(value) => value,
2038                Err(_) => {
2039                    parse_error_lines += 1;
2040                    continue;
2041                }
2042            };
2043            restore_codex_provenance_from_top_level(&value, &mut meta)?;
2044            match value.get("type").and_then(Value::as_str) {
2045                Some("system") => {
2046                    if meta.system_prompt.is_none() {
2047                        meta.system_prompt = value
2048                            .get("content")
2049                            .and_then(Value::as_str)
2050                            .map(str::to_string);
2051                    }
2052                }
2053                Some("user") => {
2054                    let content = extract_text_content(value.get("content"));
2055                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
2056                        == Some("supercode_system_event")
2057                    {
2058                        Role::System
2059                    } else {
2060                        Role::User
2061                    };
2062                    let content = if role == Role::User {
2063                        match grok_human_user_text(&content) {
2064                            Some(content) => content,
2065                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2066                                String::new()
2067                            }
2068                            None => continue,
2069                        }
2070                    } else {
2071                        content
2072                    };
2073                    let mut message = ChatMessage {
2074                        role,
2075                        content: Some(content),
2076                        content_parts: None,
2077                        tool_calls: None,
2078                        tool_call_id: None,
2079                        name: None,
2080                        metadata: Default::default(),
2081                    };
2082                    capture_grok_scalar_metadata(
2083                        &value,
2084                        &mut message,
2085                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2086                    );
2087                    restore_grok_message_extension(&value, &mut message);
2088                    messages.push(message);
2089                }
2090                Some("assistant") => {
2091                    let calls: Vec<ToolCall> = value
2092                        .get("tool_calls")
2093                        .and_then(Value::as_array)
2094                        .into_iter()
2095                        .flatten()
2096                        .filter_map(|call| {
2097                            let id = call.get("id")?.as_str()?.to_string();
2098                            let name = call.get("name")?.as_str()?.to_string();
2099                            let arguments = call
2100                                .get("arguments")
2101                                .map(value_to_arg_string)
2102                                .unwrap_or_else(|| "{}".to_string());
2103                            tool_names.insert(id.clone(), name.clone());
2104                            Some(function_call(&id, &name, arguments))
2105                        })
2106                        .collect();
2107                    let content = value
2108                        .get("content")
2109                        .and_then(Value::as_str)
2110                        .filter(|content| !content.is_empty())
2111                        .map(str::to_string);
2112                    let mut message = ChatMessage {
2113                        role: Role::Assistant,
2114                        content,
2115                        content_parts: None,
2116                        tool_calls: (!calls.is_empty()).then_some(calls),
2117                        tool_call_id: None,
2118                        name: None,
2119                        metadata: Default::default(),
2120                    };
2121                    capture_grok_scalar_metadata(
2122                        &value,
2123                        &mut message,
2124                        &["model_id", "model_fingerprint", "reasoning_effort"],
2125                    );
2126                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2127                        meta.model = Some(model.to_string());
2128                    }
2129                    restore_grok_message_extension(&value, &mut message);
2130                    messages.push(message);
2131                }
2132                Some("tool_result") => {
2133                    let id = value
2134                        .get("tool_call_id")
2135                        .and_then(Value::as_str)
2136                        .unwrap_or_default();
2137                    let content = value
2138                        .get("content")
2139                        .map(|value| match value {
2140                            Value::String(text) => text.clone(),
2141                            other => extract_text_content(Some(other)),
2142                        })
2143                        .unwrap_or_default();
2144                    let mut message = tool_message(id, content);
2145                    message.name = tool_names.get(id).cloned();
2146                    restore_grok_message_extension(&value, &mut message);
2147                    messages.push(message);
2148                }
2149                // `reasoning` contains encrypted chain-of-thought and
2150                // `backend_tool_call` is execution bookkeeping. Both survive
2151                // verbatim in raw without being replayed to another model.
2152                _ => {}
2153            }
2154        }
2155
2156        ensure_tool_results_paired(&mut messages);
2157        let imported_message_count = Some(messages.len());
2158        Ok(Session {
2159            meta,
2160            messages,
2161            subagents: Vec::new(),
2162            raw,
2163            raw_trailing_newline,
2164            imported_message_count,
2165            raw_is_verbatim: true,
2166            parse_error_lines,
2167            load_residue: Vec::new(),
2168        })
2169    }
2170
2171    fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2172        let Some(session_dir) = transcript.parent() else {
2173            return;
2174        };
2175        self.meta.session_id = session_dir
2176            .file_name()
2177            .and_then(|name| name.to_str())
2178            .map(str::to_string);
2179        self.meta.cwd = session_dir
2180            .parent()
2181            .and_then(Path::file_name)
2182            .and_then(|name| name.to_str())
2183            .and_then(percent_decode_path)
2184            .map(PathBuf::from);
2185
2186        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2187            return;
2188        };
2189        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2190            return;
2191        };
2192        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2193            self.meta.model = Some(model.to_string());
2194        }
2195        for (source, target) in [
2196            ("generated_title", "session_name"),
2197            ("created_at", "created_at"),
2198            ("updated_at", "updated_at"),
2199            ("chat_format_version", "grok_chat_format_version"),
2200        ] {
2201            if let Some(value) = summary.get(source) {
2202                self.meta.lineage.insert(
2203                    target.to_string(),
2204                    value
2205                        .as_str()
2206                        .map(str::to_string)
2207                        .unwrap_or_else(|| value.to_string()),
2208                );
2209            }
2210        }
2211    }
2212
2213    /// Load a Gemini CLI transcript from disk.
2214    pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2215        Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2216    }
2217
2218    /// Parse Gemini CLI's line-oriented session format.
2219    ///
2220    /// Gemini stores a header without a `type`, followed by `user` and
2221    /// `gemini` records. Function calls are embedded in assistant content
2222    /// parts and function responses in user content parts. Unknown records
2223    /// remain byte-exact in [`Session::raw`] instead of silently entering the
2224    /// replay conversation.
2225    pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2226        let mut meta = SessionMeta::new(SessionSource::Gemini);
2227        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2228        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2229        let mut messages = Vec::new();
2230        let mut parse_error_lines = 0usize;
2231        let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2232
2233        for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2234            let value: Value = match serde_json::from_str(line) {
2235                Ok(value) => value,
2236                Err(_) => {
2237                    parse_error_lines += 1;
2238                    continue;
2239                }
2240            };
2241            let kind = value.get("type").and_then(Value::as_str);
2242            if kind.is_none() {
2243                if meta.session_id.is_none() {
2244                    meta.session_id = value
2245                        .get("sessionId")
2246                        .and_then(Value::as_str)
2247                        .map(str::to_string);
2248                }
2249                for (source, target) in [
2250                    ("projectHash", "gemini_project_hash"),
2251                    ("startTime", "created_at"),
2252                    ("lastUpdated", "updated_at"),
2253                    ("kind", "gemini_session_kind"),
2254                ] {
2255                    if let Some(raw) = value.get(source) {
2256                        meta.lineage.insert(
2257                            target.to_string(),
2258                            raw.as_str()
2259                                .map(str::to_string)
2260                                .unwrap_or_else(|| raw.to_string()),
2261                        );
2262                    }
2263                }
2264                continue;
2265            }
2266            if kind != Some("user") && kind != Some("gemini") {
2267                continue;
2268            }
2269
2270            let timestamp = value.get("timestamp").and_then(Value::as_str);
2271            let model = value.get("model").and_then(Value::as_str);
2272            if let Some(model) = model {
2273                meta.model = Some(model.to_string());
2274            }
2275            let content = value.get("content").unwrap_or(&Value::Null);
2276            let parts = content.as_array();
2277            let text = match content {
2278                Value::String(text) => text.clone(),
2279                Value::Array(parts) => parts
2280                    .iter()
2281                    .filter_map(|part| part.get("text").and_then(Value::as_str))
2282                    .collect::<Vec<_>>()
2283                    .join(" ")
2284                    .trim()
2285                    .to_string(),
2286                _ => String::new(),
2287            };
2288
2289            if kind == Some("gemini") {
2290                let legacy_calls = parts
2291                    .into_iter()
2292                    .flatten()
2293                    .filter_map(|part| part.get("functionCall"));
2294                let native_calls = value
2295                    .get("toolCalls")
2296                    .and_then(Value::as_array)
2297                    .into_iter()
2298                    .flatten();
2299                let calls = native_calls
2300                    .chain(legacy_calls)
2301                    .enumerate()
2302                    .filter_map(|(call_index, call)| {
2303                        let name = call.get("name")?.as_str()?.to_string();
2304                        let id = call
2305                            .get("id")
2306                            .and_then(Value::as_str)
2307                            .map(str::to_string)
2308                            .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2309                        pending_by_name
2310                            .entry(name.clone())
2311                            .or_default()
2312                            .push(id.clone());
2313                        let arguments = call
2314                            .get("args")
2315                            .map(value_to_arg_string)
2316                            .unwrap_or_else(|| "{}".to_string());
2317                        Some(function_call(&id, &name, arguments))
2318                    })
2319                    .collect::<Vec<_>>();
2320                let mut message = ChatMessage {
2321                    role: Role::Assistant,
2322                    content: (!text.is_empty()).then_some(text),
2323                    content_parts: None,
2324                    tool_calls: (!calls.is_empty()).then_some(calls),
2325                    tool_call_id: None,
2326                    name: None,
2327                    metadata: Default::default(),
2328                };
2329                if let Some(timestamp) = timestamp {
2330                    message
2331                        .metadata
2332                        .insert("timestamp".into(), timestamp.into());
2333                }
2334                if let Some(model) = model {
2335                    message.metadata.insert("gemini_model".into(), model.into());
2336                }
2337                if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2338                    message
2339                        .metadata
2340                        .insert("gemini_thoughts".into(), thoughts.to_string());
2341                }
2342                restore_gemini_message_extension(&value, &mut message);
2343                if message.content.is_some() || message.tool_calls.is_some() {
2344                    messages.push(message);
2345                }
2346                continue;
2347            }
2348
2349            let mut user_parts = Vec::new();
2350            if let Some(parts) = parts {
2351                for part in parts {
2352                    if let Some(response) = part.get("functionResponse") {
2353                        push_gemini_user_parts(
2354                            &mut messages,
2355                            std::mem::take(&mut user_parts),
2356                            timestamp,
2357                            &value,
2358                        );
2359                        let name = response
2360                            .get("name")
2361                            .and_then(Value::as_str)
2362                            .unwrap_or("tool")
2363                            .to_string();
2364                        let explicit_id = response
2365                            .get("id")
2366                            .and_then(Value::as_str)
2367                            .map(str::to_string);
2368                        if let Some(id) = explicit_id.as_deref() {
2369                            if let Some(ids) = pending_by_name.get_mut(&name) {
2370                                if let Some(position) = ids.iter().position(|pending| pending == id)
2371                                {
2372                                    ids.remove(position);
2373                                }
2374                            }
2375                        }
2376                        let id = explicit_id
2377                            .or_else(|| {
2378                                pending_by_name
2379                                    .get_mut(&name)
2380                                    .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2381                            })
2382                            .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2383                        let output = response
2384                            .get("response")
2385                            .and_then(|response| response.get("output"))
2386                            .map(|output| {
2387                                output
2388                                    .as_str()
2389                                    .map(str::to_string)
2390                                    .unwrap_or_else(|| output.to_string())
2391                            })
2392                            .or_else(|| response.get("response").map(Value::to_string))
2393                            .unwrap_or_default();
2394                        let mut message = tool_message(&id, output);
2395                        message.name = Some(name);
2396                        if let Some(timestamp) = timestamp {
2397                            message
2398                                .metadata
2399                                .insert("timestamp".into(), timestamp.into());
2400                        }
2401                        restore_gemini_message_extension(&value, &mut message);
2402                        messages.push(message);
2403                        continue;
2404                    }
2405                    if let Some(text) = part.get("text").and_then(Value::as_str) {
2406                        user_parts.push(serde_json::json!({"type": "text", "text": text}));
2407                        continue;
2408                    }
2409                    if let Some(inline) = part.get("inlineData") {
2410                        let Some(data) = inline.get("data").and_then(Value::as_str) else {
2411                            continue;
2412                        };
2413                        let media_type = inline
2414                            .get("mimeType")
2415                            .and_then(Value::as_str)
2416                            .unwrap_or("application/octet-stream");
2417                        user_parts.push(serde_json::json!({
2418                            "type": "image_url",
2419                            "image_url": {"url": format!("data:{media_type};base64,{data}")},
2420                        }));
2421                    }
2422                }
2423            } else if !text.is_empty() {
2424                user_parts.push(serde_json::json!({"type": "text", "text": text}));
2425            }
2426            push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2427        }
2428
2429        ensure_tool_results_paired(&mut messages);
2430        let imported_message_count = Some(messages.len());
2431        Ok(Session {
2432            meta,
2433            messages,
2434            subagents: Vec::new(),
2435            raw,
2436            raw_trailing_newline,
2437            imported_message_count,
2438            raw_is_verbatim: true,
2439            parse_error_lines,
2440            load_residue: Vec::new(),
2441        })
2442    }
2443
2444    /// Load a Goose session-export JSON document from disk.
2445    pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2446        Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2447    }
2448
2449    /// Parse Goose's official native import/export document.
2450    ///
2451    /// Goose's durable store is SQLite, but its own
2452    /// `_goose/unstable/session/export` and `/session/import` boundary is one
2453    /// JSON object containing a `conversation` array. Unknown native content
2454    /// blocks are retained on the first canonical message in a namespaced
2455    /// portability envelope; unchanged same-format exports replay the exact
2456    /// source bytes.
2457    pub fn from_goose_str(json: &str) -> Result<Session> {
2458        let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2459        let object = document.as_object().ok_or_else(|| {
2460            Error::InvalidSession("Goose session export must be a JSON object".to_string())
2461        })?;
2462        let conversation = object
2463            .get("conversation")
2464            .and_then(Value::as_array)
2465            .ok_or_else(|| {
2466                Error::InvalidSession(
2467                    "Goose session export must contain a conversation array".to_string(),
2468                )
2469            })?;
2470
2471        let mut meta = SessionMeta::new(SessionSource::Goose);
2472        meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2473        meta.cwd = object
2474            .get("working_dir")
2475            .or_else(|| object.get("workingDir"))
2476            .and_then(Value::as_str)
2477            .map(PathBuf::from);
2478        meta.model = object
2479            .get("model_config")
2480            .or_else(|| object.get("modelConfig"))
2481            .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2482            .and_then(Value::as_str)
2483            .map(str::to_string);
2484        for (source, target) in [
2485            ("name", "session_name"),
2486            ("created_at", "created_at"),
2487            ("updated_at", "updated_at"),
2488            ("session_type", "goose_session_type"),
2489            ("goose_mode", "goose_mode"),
2490            ("provider_name", "goose_provider_name"),
2491            ("parent_session_id", "parent_session_id"),
2492        ] {
2493            if let Some(value) = object.get(source) {
2494                meta.lineage.insert(
2495                    target.to_string(),
2496                    value
2497                        .as_str()
2498                        .map(str::to_string)
2499                        .unwrap_or_else(|| value.to_string()),
2500                );
2501            }
2502        }
2503        let mut header = document.clone();
2504        if let Some(header) = header.as_object_mut() {
2505            header.remove("conversation");
2506        }
2507        meta.goose_header = Some(header.clone());
2508
2509        let mut messages = Vec::new();
2510        for (native_index, native) in conversation.iter().enumerate() {
2511            let before = messages.len();
2512            normalize_goose_message(native, native_index, &mut messages);
2513            if let Some(first) = messages.get_mut(before) {
2514                first
2515                    .metadata
2516                    .insert("goose_native_message".to_string(), native.to_string());
2517                first
2518                    .metadata
2519                    .insert("goose_native_index".to_string(), native_index.to_string());
2520                if native_index == 0 {
2521                    first
2522                        .metadata
2523                        .insert("goose_session_header".to_string(), header.to_string());
2524                }
2525                restore_grok_message_extension(native, first);
2526            }
2527            for message in messages.iter_mut().skip(before + 1) {
2528                message
2529                    .metadata
2530                    .insert("goose_native_index".to_string(), native_index.to_string());
2531            }
2532        }
2533        ensure_tool_results_paired(&mut messages);
2534
2535        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2536        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2537        let imported_message_count = Some(messages.len());
2538        Ok(Session {
2539            meta,
2540            messages,
2541            subagents: Vec::new(),
2542            raw,
2543            raw_trailing_newline,
2544            imported_message_count,
2545            raw_is_verbatim: true,
2546            parse_error_lines: 0,
2547            load_residue: Vec::new(),
2548        })
2549    }
2550
2551    /// Load one Goose session directly from its native SQLite store.
2552    ///
2553    /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2554    /// uses Goose's own public export shape, so the ordinary Goose codec is
2555    /// the single normalization boundary for both files and the live store.
2556    pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2557        Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2558    }
2559
2560    /// Bounded Goose store read for transcript UI surfaces. The inner query
2561    /// selects only the newest native rows; the outer query restores their
2562    /// chronological order. Export/continue callers deliberately use the
2563    /// unbounded public loader above.
2564    #[doc(hidden)]
2565    pub fn from_goose_sqlite_display(
2566        db_path: &Path,
2567        session_id: &str,
2568        message_limit: usize,
2569    ) -> Result<Session> {
2570        Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2571    }
2572
2573    fn from_goose_sqlite_with_limit(
2574        db_path: &Path,
2575        session_id: &str,
2576        message_limit: Option<usize>,
2577    ) -> Result<Session> {
2578        let connection = Connection::open_with_flags(
2579            db_path,
2580            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2581        )
2582        .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2583        let mut statement = connection
2584            .prepare(
2585                "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2586                    extension_data, goose_mode, provider_name, model_config_json \
2587             FROM sessions WHERE id = ?1",
2588            )
2589            .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2590        let mut document = statement
2591            .query_row([session_id], |row| {
2592                let extension_data: Option<String> = row.get(6)?;
2593                let model_config: Option<String> = row.get(9)?;
2594                Ok(serde_json::json!({
2595                    "id": row.get::<_, String>(0)?,
2596                    "working_dir": row.get::<_, String>(2)?,
2597                    "name": row.get::<_, String>(1)?,
2598                    "user_set_name": false,
2599                    "session_type": row.get::<_, String>(5)?,
2600                    "created_at": row.get::<_, String>(3)?,
2601                    "updated_at": row.get::<_, String>(4)?,
2602                    "extension_data": extension_data
2603                        .as_deref()
2604                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2605                        .unwrap_or_else(|| serde_json::json!({})),
2606                    "usage": {},
2607                    "accumulated_usage": {},
2608                    "accumulated_cost": Value::Null,
2609                    "schedule_id": Value::Null,
2610                    "recipe": Value::Null,
2611                    "user_recipe_values": Value::Null,
2612                    "conversation": [],
2613                    "message_count": 0,
2614                    "last_message_at": Value::Null,
2615                    "provider_name": row.get::<_, Option<String>>(8)?,
2616                    "model_config": model_config
2617                        .as_deref()
2618                        .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2619                    "goose_mode": row.get::<_, String>(7)?,
2620                    "archived_at": Value::Null,
2621                    "project_id": Value::Null,
2622                    "parent_session_id": Value::Null,
2623                    "last_message_snippet": Value::Null,
2624                }))
2625            })
2626            .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2627
2628        let message_query = message_limit.map_or_else(
2629            || {
2630                "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2631                 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2632                    .to_string()
2633            },
2634            |limit| {
2635                format!(
2636                    "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2637                     FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2638                                  created_timestamp, metadata_json \
2639                           FROM messages WHERE session_id = ?1 \
2640                           ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2641                     ORDER BY created_timestamp, native_row_id"
2642                )
2643            },
2644        );
2645        let mut message_statement = connection
2646            .prepare(&message_query)
2647            .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2648        let rows = message_statement
2649            .query_map([session_id], |row| {
2650                let content: String = row.get(2)?;
2651                let metadata: Option<String> = row.get(4)?;
2652                Ok(serde_json::json!({
2653                    "id": row.get::<_, Option<String>>(0)?,
2654                    "role": row.get::<_, String>(1)?,
2655                    "created": row.get::<_, i64>(3)?,
2656                    "content": serde_json::from_str::<Value>(&content)
2657                        .unwrap_or_else(|_| Value::Array(Vec::new())),
2658                    "metadata": metadata
2659                        .as_deref()
2660                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2661                        .unwrap_or_else(|| serde_json::json!({
2662                            "userVisible": true,
2663                            "agentVisible": true
2664                        })),
2665                }))
2666            })
2667            .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2668        let conversation = rows
2669            .collect::<std::result::Result<Vec<_>, _>>()
2670            .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2671        document["message_count"] = Value::from(conversation.len());
2672        document["conversation"] = Value::Array(conversation);
2673        let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2674        let mut session = Self::from_goose_str(&json)?;
2675        // SQLite was reconstructed through values, not captured byte-for-byte.
2676        session.raw_is_verbatim = false;
2677        Ok(session)
2678    }
2679
2680    /// Load an OpenCode session from a file — either read surface, see
2681    /// [`Self::from_opencode_str`].
2682    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2683        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2684    }
2685
2686    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2687    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2688    /// most-recently-updated top-level session, see
2689    /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2690    /// envelope form [`Self::from_opencode_str`] already parses for the
2691    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2692    /// discipline, S1 tool-output masking, …) is shared code, not
2693    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2694    /// for the envelope-construction rules this follows (all-columns rule,
2695    /// raw `revert` column carried verbatim).
2696    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2697        let conn = opencode_sqlite_open(db_path)?;
2698        let id = match session_id {
2699            Some(id) => id.to_string(),
2700            None => opencode_sqlite_primary_session_id(&conn)?,
2701        };
2702        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2703        let mut text = lines.join("\n");
2704        text.push('\n');
2705        let mut session = Self::from_opencode_str(&text)?;
2706        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2707        // not the original source bytes (a binary `.db` file has no
2708        // "verbatim" line-oriented form to begin with). `from_opencode_str`
2709        // defaults `raw_is_verbatim` to `true` because for its OTHER two
2710        // callers (an actual envelope-form file's own text, an actual
2711        // export-document's text) that really is the source. It is NEVER
2712        // true for this diagonal — mirrors the export-document fix just
2713        // above for the same reason (`from_opencode_export_doc`, `false`).
2714        // `convert opencode.db --to opencode` must not claim byte-identical.
2715        session.raw_is_verbatim = false;
2716        Ok(session)
2717    }
2718
2719    /// Parse an OpenCode session from either of its two frozen **read
2720    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2721    /// `opencode-fields.md`):
2722    ///
2723    /// - the **envelope form**: each line is
2724    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
2725    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
2726    ///   generations;
2727    /// - the **export-document form**: a single pretty-printed JSON document
2728    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2729    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
2730    ///   what the OpenCode writer emits.
2731    ///
2732    /// Both forms are parsed into the same `(session_info, side_records,
2733    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2734    /// `opencode_session_from_records` — so the same underlying records
2735    /// produce identical `messages` regardless of which surface carried
2736    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2737    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2738    /// exercises): previously this function parsed the envelope form only
2739    /// and silently returned an empty-but-`Ok` `Session` for an export
2740    /// document — the confirmed footgun this now closes.
2741    ///
2742    /// Record classification (envelope form) is driven by the envelope
2743    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2744    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2745    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2746    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2747    /// every column, `data` and non-`data` alike — e.g. the `session` row's
2748    /// `revert` column under the V2 `Revert.State` schema, whose extra
2749    /// `files` field the CLI's own row→V1 reconstruction drops; the
2750    /// envelope's `raw` capture keeps that raw column value regardless of
2751    /// what this loader's canonicalization understands).
2752    ///
2753    /// Mapping to canonical `messages` (§2.1, shared by both forms via
2754    /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2755    /// text parts → `content`; a `User` `file` part whose `mime` is an image
2756    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2757    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2758    /// `ToolCall`, and the SAME part's `state.completed.output` /
2759    /// `state.error.error` → a paired `Tool` message split by `callID`
2760    /// (opencode keeps call+result on one record; this loader splits it
2761    /// into the two OpenAI-shape messages the other loaders already
2762    /// produce).
2763    ///
2764    /// **S1 (`time.compacted`):** when a `tool` part's
2765    /// `state.completed.time.compacted` is set, the emitted `Tool`
2766    /// message's `content` is the placeholder
2767    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2768    /// own `toModelMessage` replays — while the REAL output survives in
2769    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2770    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2771    /// it is reversible, never actually lost.
2772    ///
2773    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2774    /// every message strictly before that message id
2775    /// `metadata["compacted_out"]="true"` (honored uniformly by
2776    /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2777    /// message, which opencode itself hoists in FRONT of the retained tail
2778    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2779    /// regardless of its position, mirroring pi's identical exemption for
2780    /// its own compaction/branch-summary entries.
2781    ///
2782    /// **Unknown part `type` or unknown `tool.state.status`:** never
2783    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2784    /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2785    /// is what turns that into a visible coverage failure rather than a
2786    /// silent drop.
2787    ///
2788    /// **Export-document `raw`:** an export document is a single
2789    /// pretty-printed JSON value with no per-line envelope structure of its
2790    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2791    /// envelope line per `session`/`message`/`part` record found in the
2792    /// document, in the exact `{"key":[...],"value":...}` shape the native
2793    /// envelope form uses — so every native/T1-value-tier path
2794    /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2795    /// splice/direct-write writers) stays consistent regardless of which
2796    /// read surface produced this `Session`.
2797    ///
2798    /// **Malformed input:** input that reaches this function non-empty but
2799    /// yields zero session/message/part records under EITHER form returns a
2800    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2801    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2802    /// input must not silently succeed with an empty session). A
2803    /// legitimately-empty session — a real `session` record with zero
2804    /// messages, or a valid export document with an empty `messages` array
2805    /// — is not an error.
2806    pub fn from_opencode_str(text: &str) -> Result<Session> {
2807        let trimmed = text.trim();
2808
2809        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2810        // own precedence: try the whole-text parse before the per-line
2811        // envelope loop below, since a pretty-printed multi-line document
2812        // has no individually-valid-JSON lines for that loop to match.
2813        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2814            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2815            {
2816                return Self::from_opencode_export_doc(&doc);
2817            }
2818        }
2819
2820        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2821        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2822        // blank-skipping PARSE walk just below, which keeps skipping
2823        // blank/whitespace-only lines when it looks for envelope records.
2824        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2825        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2826        let mut session_info: Option<Value> = None;
2827        let mut side_records: Vec<Value> = Vec::new();
2828        let mut msgs: Vec<OcMsg> = Vec::new();
2829        let mut msg_index: HashMap<String, usize> = HashMap::new();
2830        // PARITY-15: see `from_claude_code_str`'s identical counter — only
2831        // a genuinely malformed line (fails to deserialize as JSON at all),
2832        // not a well-formed envelope this loader simply doesn't recognize.
2833        let mut parse_error_lines = 0usize;
2834
2835        for line in non_empty_lines(text) {
2836            let Ok(env) = serde_json::from_str::<Value>(line) else {
2837                parse_error_lines += 1;
2838                continue; // malformed line — raw-only, exactly like the other loaders
2839            };
2840            let Some(key) = env.get("key").and_then(Value::as_array) else {
2841                continue; // not an envelope record — raw-only
2842            };
2843            let value = env.get("value").cloned().unwrap_or(Value::Null);
2844            match key.first().and_then(Value::as_str) {
2845                Some("session") => session_info = Some(value),
2846                Some("message") => {
2847                    let Some(id) = value.get("id").and_then(Value::as_str) else {
2848                        continue;
2849                    };
2850                    let time_created = value
2851                        .get("time")
2852                        .and_then(|t| t.get("created"))
2853                        .and_then(Value::as_i64)
2854                        .unwrap_or(0);
2855                    msg_index.insert(id.to_string(), msgs.len());
2856                    msgs.push(OcMsg {
2857                        id: id.to_string(),
2858                        time_created,
2859                        value,
2860                        parts: Vec::new(),
2861                    });
2862                }
2863                Some("part") => {
2864                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2865                        if let Some(&idx) = msg_index.get(msg_id) {
2866                            msgs[idx].parts.push(value);
2867                        }
2868                        // A part whose message wasn't captured (out-of-order
2869                        // envelope) — still fully present in `raw`, just not
2870                        // attached to a canonical message.
2871                    }
2872                }
2873                Some("session_diff") | Some("todo") => {
2874                    side_records.push(serde_json::json!({"key": key, "value": value}));
2875                }
2876                _ => {} // unrecognized top-level key — raw-only
2877            }
2878        }
2879
2880        opencode_guard_against_silent_empty(
2881            !trimmed.is_empty(),
2882            &session_info,
2883            &msgs,
2884            &side_records,
2885        )?;
2886        opencode_session_from_records(
2887            session_info,
2888            side_records,
2889            msgs,
2890            raw,
2891            raw_trailing_newline,
2892            // Envelope form: `raw` is split directly out of the source text
2893            // (strict-verbatim, IX-1) — genuinely reproduces the original
2894            // bytes on replay.
2895            true,
2896            parse_error_lines,
2897        )
2898    }
2899
2900    /// The **export-document** read surface of [`Self::from_opencode_str`]
2901    /// — see that function's doc comment for the shared canonicalization
2902    /// and the `raw` re-synthesis this performs. `doc` is already known to
2903    /// have the `{info, messages:[...]}` shape (the caller checks this,
2904    /// matching `detect_source`'s own S9a check) before calling this.
2905    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2906        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2907        let messages_arr = doc
2908            .get("messages")
2909            .and_then(Value::as_array)
2910            .cloned()
2911            .unwrap_or_default();
2912
2913        let session_id = session_info
2914            .as_ref()
2915            .and_then(|si| si.get("id"))
2916            .and_then(Value::as_str)
2917            .unwrap_or("ses_unknown")
2918            .to_string();
2919        let project_id = session_info
2920            .as_ref()
2921            .and_then(|si| si.get("projectID"))
2922            .and_then(Value::as_str)
2923            .unwrap_or("global")
2924            .to_string();
2925
2926        // Re-synthesize one envelope line per record — see the doc comment
2927        // on `from_opencode_str` ("Export-document `raw`").
2928        let mut raw: Vec<String> = Vec::new();
2929        if let Some(si) = &session_info {
2930            raw.push(
2931                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2932                    .to_string(),
2933            );
2934        }
2935
2936        let mut msgs: Vec<OcMsg> = Vec::new();
2937        for entry in &messages_arr {
2938            let Some(info) = entry.get("info") else {
2939                continue; // malformed message entry — no clean home, raw-only
2940            };
2941            let Some(id) = info.get("id").and_then(Value::as_str) else {
2942                continue;
2943            };
2944            let time_created = info
2945                .get("time")
2946                .and_then(|t| t.get("created"))
2947                .and_then(Value::as_i64)
2948                .unwrap_or(0);
2949            let parts: Vec<Value> = entry
2950                .get("parts")
2951                .and_then(Value::as_array)
2952                .cloned()
2953                .unwrap_or_default();
2954
2955            raw.push(
2956                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2957            );
2958            for p in &parts {
2959                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
2960                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
2961            }
2962
2963            msgs.push(OcMsg {
2964                id: id.to_string(),
2965                time_created,
2966                value: info.clone(),
2967                parts,
2968            });
2969        }
2970
2971        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
2972        opencode_session_from_records(
2973            session_info,
2974            Vec::new(),
2975            msgs,
2976            raw,
2977            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
2978            // line re-derived per record, no real per-line source bytes to
2979            // measure) — matches the historical always-newline-terminated
2980            // behavior; see `Session::raw_trailing_newline`'s doc comment.
2981            true,
2982            // Export-document form: `raw` above is RE-SYNTHESIZED, one
2983            // envelope line derived per record — not the original document's
2984            // bytes (see this function's doc comment). `convert`'s
2985            // byte-identical claim must not fire on this diagonal.
2986            false,
2987            // PARITY-15: a pretty-printed export document is parsed WHOLE
2988            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
2989            // there's no per-line parse-loss concept here; a malformed
2990            // document fails that top-level parse and never reaches this
2991            // function at all.
2992            0,
2993        )
2994    }
2995
2996    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
2997    /// core.session(tree-addressable transcript)"): materialize this
2998    /// session's linear [`Self::messages`] into a native in-place
2999    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
3000    /// FIRST time it wants to run a tree operation (rewind/branch/label)
3001    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
3002    /// synthesized node (see
3003    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
3004    /// why a single timestamp is used: the source linear messages carry no
3005    /// per-turn timestamp of their own here).
3006    ///
3007    /// This does not mutate `self` or persist anything — see
3008    /// the composition layer's session-store tree writer for persistence, and
3009    /// [`Self::apply_session_tree`] for the inverse bridge.
3010    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
3011        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
3012    }
3013
3014    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
3015    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
3016    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3017    /// existing linear consumer — the agent loop, exporters — working
3018    /// unchanged after a tree operation runs). Nothing else on `self`
3019    /// (`meta`, `raw`, ...) is touched.
3020    ///
3021    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3022    /// `Err` rather than applying anything — a structurally-corrupt tree
3023    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3024    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3025    /// `self` is left untouched on `Err` (the assignment only happens after
3026    /// the projection has already succeeded).
3027    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3028        self.messages = tree.linear_projection()?;
3029        Ok(())
3030    }
3031}
3032
3033/// One opencode `message` record plus its `part` children, gathered from
3034/// EITHER read surface (envelope-form records or export-document
3035/// `{info, parts}` entries) before the shared per-record canonicalization
3036/// in [`opencode_session_from_records`].
3037struct OcMsg {
3038    id: String,
3039    time_created: i64,
3040    value: Value,
3041    parts: Vec<Value>,
3042}
3043
3044const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3045const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3046const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3047
3048/// Guard against the confirmed footgun: input that reached
3049/// [`Session::from_opencode_str`] non-empty but produced no
3050/// session/message/part record under either read surface returns `Err`
3051/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3052/// (a real session record with zero messages, or a valid empty `messages`
3053/// array) is not an error — only genuinely unparseable content is.
3054fn opencode_guard_against_silent_empty(
3055    non_empty_input: bool,
3056    session_info: &Option<Value>,
3057    msgs: &[OcMsg],
3058    side_records: &[Value],
3059) -> Result<()> {
3060    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3061        || !msgs.is_empty()
3062        || !side_records.is_empty();
3063    if non_empty_input && !has_any_record {
3064        return Err(crate::Error::Other(
3065            "opencode input was recognized as an OpenCode source (envelope or \
3066             export-document form) but no session/message/part record could be parsed from \
3067             it — refusing to silently return an empty session"
3068                .to_string(),
3069        ));
3070    }
3071    Ok(())
3072}
3073
3074/// The shared per-record canonicalization for BOTH of
3075/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3076/// export-document form): frozen ordering, `SessionMeta` capture, the
3077/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3078/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3079/// same underlying `(session_info, side_records, msgs)` regardless of which
3080/// surface produced them, this produces byte-for-byte identical `messages`
3081/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3082fn opencode_session_from_records(
3083    session_info: Option<Value>,
3084    side_records: Vec<Value>,
3085    mut msgs: Vec<OcMsg>,
3086    raw: Vec<String>,
3087    raw_trailing_newline: bool,
3088    raw_is_verbatim: bool,
3089    parse_error_lines: usize,
3090) -> Result<Session> {
3091    let mut meta = SessionMeta::new(SessionSource::OpenCode);
3092
3093    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3094    // each message id to its PRE-sort position — used only to resolve a
3095    // `tail_start_id` reference in the compaction-boundary pass further
3096    // down. In every real opencode session (either surface) records
3097    // already arrive/are listed in creation order, so pre- and post-sort
3098    // positions coincide; this mirrors the original envelope-only
3099    // implementation's behavior exactly (not a new invariant introduced by
3100    // sharing this code across both surfaces).
3101    let msg_index: HashMap<String, usize> = msgs
3102        .iter()
3103        .enumerate()
3104        .map(|(i, m)| (m.id.clone(), i))
3105        .collect();
3106
3107    // Frozen order (§1.2): messages by (time.created, id); each
3108    // message's parts by id.
3109    msgs.sort_by(|a, b| {
3110        a.time_created
3111            .cmp(&b.time_created)
3112            .then_with(|| a.id.cmp(&b.id))
3113    });
3114    for m in &mut msgs {
3115        m.parts.sort_by(|a, b| {
3116            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3117            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3118            ai.cmp(bi)
3119        });
3120    }
3121
3122    meta.opencode_headers
3123        .push(session_info.clone().unwrap_or(Value::Null));
3124    meta.opencode_headers.extend(side_records);
3125    if let Some(si) = &session_info {
3126        capture_opencode_session_info(si, &mut meta)?;
3127    }
3128
3129    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3130    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3131    // active path in opencode's own linear message list, so no branch
3132    // walk is needed the way pi's tree requires).
3133    let mut tail_start_pos: Option<usize> = None;
3134    for m in &msgs {
3135        for p in &m.parts {
3136            if p.get("type").and_then(Value::as_str) == Some("compaction") {
3137                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3138                    if let Some(&tp) = msg_index.get(t) {
3139                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3140                    }
3141                }
3142            }
3143        }
3144    }
3145
3146    let mut messages = Vec::new();
3147    let mut first_system_seen = false;
3148    for (pos, m) in msgs.iter().enumerate() {
3149        let before = messages.len();
3150        match m.value.get("role").and_then(Value::as_str) {
3151            // B4: a `User` message that's actually
3152            // `append_synthesized_opencode_messages`'s own re-materialized
3153            // Claude `system` record (one `synthetic: true` text part
3154            // carrying the supercode marker key — see
3155            // `opencode_claude_system_subtype`'s doc comment) restores
3156            // `Role::System`, not a genuine user turn.
3157            Some("user") => match opencode_claude_system_subtype(&m.parts) {
3158                Some(subtype) => {
3159                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3160                }
3161                None => push_opencode_user(
3162                    &m.value,
3163                    &m.parts,
3164                    &mut messages,
3165                    &mut meta,
3166                    &mut first_system_seen,
3167                ),
3168            },
3169            Some("assistant") => {
3170                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3171            }
3172            // Unrecognized/missing role — raw-only survival;
3173            // `audit::Corpus::OpenCode` scores this as Unmodeled.
3174            _ => {}
3175        }
3176        if let Some(original_position) = m
3177            .value
3178            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3179            .and_then(Value::as_u64)
3180        {
3181            if let Some(message) = messages[before..]
3182                .iter_mut()
3183                .find(|message| message.role != Role::Tool)
3184            {
3185                message.metadata.insert(
3186                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3187                    original_position.to_string(),
3188                );
3189            }
3190        }
3191        for msg in &mut messages[before..] {
3192            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3193            if !is_summary {
3194                if let Some(tsp) = tail_start_pos {
3195                    if pos < tsp {
3196                        msg.metadata
3197                            .insert("compacted_out".to_string(), "true".to_string());
3198                    }
3199                }
3200            }
3201        }
3202    }
3203
3204    let marked_slots = messages
3205        .iter()
3206        .enumerate()
3207        .filter_map(|(index, message)| {
3208            message
3209                .metadata
3210                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3211                .then_some(index)
3212        })
3213        .collect::<Vec<_>>();
3214    if !marked_slots.is_empty() {
3215        // A spliced OpenCode export can contain an unmarked native prefix
3216        // followed by a marked synthesized tail. Reorder only among the
3217        // marked slots so the tail never jumps in front of its raw prefix.
3218        let mut marked_messages = marked_slots
3219            .iter()
3220            .map(|index| messages[*index].clone())
3221            .collect::<Vec<_>>();
3222        marked_messages.sort_by_key(|message| {
3223            message
3224                .metadata
3225                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3226                .and_then(|position| position.parse::<usize>().ok())
3227                .unwrap_or(usize::MAX)
3228        });
3229        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3230            messages[slot] = message;
3231        }
3232        for message in &mut messages {
3233            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3234        }
3235    }
3236    ensure_tool_results_paired(&mut messages);
3237    let imported_message_count = Some(messages.len());
3238    Ok(Session {
3239        meta,
3240        messages,
3241        subagents: Vec::new(),
3242        raw,
3243        raw_trailing_newline,
3244        imported_message_count,
3245        raw_is_verbatim,
3246        parse_error_lines,
3247        load_residue: Vec::new(),
3248    })
3249}
3250
3251/// Resolve each opencode subagent (`task`) child session's
3252/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3253/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3254/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3255/// `opencode-fields.md` `task.ts:145,171-176`).
3256///
3257/// Nesting itself needs no opencode-specific pass:
3258/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3259/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3260/// so the existing generic [`Session::reconstruct_tree`] nests these
3261/// sessions correctly on its own. Call this FIRST — it only reads
3262/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3263/// the same `Vec` to `reconstruct_tree`.
3264pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3265    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3266    for i in 0..sessions.len() {
3267        let child_id = sessions[i].meta.session_id.clone();
3268        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3269        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3270            continue;
3271        };
3272        let Some(parent_idx) = ids
3273            .iter()
3274            .position(|id| id.as_deref() == Some(parent_id.as_str()))
3275        else {
3276            continue;
3277        };
3278        for m in &sessions[parent_idx].messages {
3279            for (k, v) in &m.metadata {
3280                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3281                    if v == &child_id {
3282                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3283                    }
3284                }
3285            }
3286        }
3287    }
3288}
3289
3290/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3291///
3292/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3293/// structure but are NOT guaranteed to be well-formed in raw file order: async
3294/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3295/// line BEFORE the assistant `tool_use` line that owns it, even though the
3296/// parent/child tree itself is fine. The active-branch projection restores
3297/// parent-before-child order, but a result can still trail a later assistant
3298/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3299/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3300/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3301///
3302/// This reorders `messages` so every OWNED `Role::Tool` result (its
3303/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3304/// message anywhere in the list) sits immediately after the `Role::Assistant`
3305/// message that owns it, while leaving every other message's relative order
3306/// untouched. Orphan tool results — no matching call anywhere in the list —
3307/// are left in their ORIGINAL position, untouched; they are never moved. It
3308/// is a pure reorder: same message count, same multiset of messages, in/out.
3309///
3310/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3311/// — each appears exactly once as a call and once as its result — so a
3312/// simple id -> owning-assistant map is sufficient; no special-casing is
3313/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3314/// already pushes those inline with their own distinct ids.
3315///
3316/// Results whose matching call is missing entirely (no owner found) are left
3317/// in place untouched — `ensure_tool_results_paired` (which runs right after
3318/// this) is responsible for synthesizing a placeholder result for any call
3319/// that ends up unanswered; this pass never drops or fabricates anything.
3320fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3321    // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3322    //    some assistant message anywhere in the list — and the position of
3323    //    that owning assistant. Owned as `String` (not borrowed) so this map
3324    //    can outlive the later `messages.drain(..)`.
3325    let mut owner_positions: HashMap<String, usize> = HashMap::new();
3326    for (index, m) in messages.iter().enumerate() {
3327        if m.role == Role::Assistant {
3328            for c in m.tool_calls() {
3329                if !c.id.is_empty() {
3330                    owner_positions.entry(c.id.clone()).or_insert(index);
3331                }
3332            }
3333        }
3334    }
3335
3336    // Fast, cheap detection of "nothing to do": every owned result must be
3337    // in the contiguous tool-result block immediately following its owning
3338    // assistant. Checking only result-before-owner inversions is insufficient
3339    // after Claude's active-branch projection: that projection can put the
3340    // owner first while leaving its result behind a later assistant turn.
3341    // Mere orphans never set this flag. A canonical session returns with
3342    // `messages` byte-for-byte unchanged, mirroring
3343    // `ensure_tool_results_paired`'s own no-op guard.
3344    let mut contiguous_owner = None;
3345    let needs_reorder =
3346        messages
3347            .iter()
3348            .enumerate()
3349            .any(|(message_index, message)| match message.role {
3350                Role::Assistant => {
3351                    contiguous_owner = Some(message_index);
3352                    false
3353                }
3354                Role::Tool => match message
3355                    .tool_call_id
3356                    .as_deref()
3357                    .and_then(|id| owner_positions.get(id))
3358                    .copied()
3359                {
3360                    Some(owner) => Some(owner) != contiguous_owner,
3361                    None => {
3362                        // An orphan or unlinked tool message interrupts the
3363                        // owner's contiguous result block but never moves by
3364                        // itself.
3365                        contiguous_owner = None;
3366                        false
3367                    }
3368                },
3369                _ => {
3370                    contiguous_owner = None;
3371                    false
3372                }
3373            });
3374    if !needs_reorder {
3375        return;
3376    }
3377
3378    // 1. Second pass: route messages into the "spine" (everything that stays
3379    //    at its own position — non-tool messages AND orphan tool results)
3380    //    versus owned tool results (pulled out, to be reattached right after
3381    //    their owner). Record, for each spine index that's an assistant, the
3382    //    set of tool_call_ids it owns.
3383    let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3384    let mut call_owner: HashMap<String, usize> = HashMap::new();
3385    // Buffer of (original_position, message) for every OWNED tool result,
3386    // built alongside the spine; a result can reference a call emitted later
3387    // in file order, so owner spine-index is resolved in a later step once
3388    // `call_owner` is complete.
3389    let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3390
3391    let drained: Vec<ChatMessage> = std::mem::take(messages);
3392    for (orig_pos, msg) in drained.into_iter().enumerate() {
3393        if msg.role == Role::Tool {
3394            let is_owned = msg
3395                .tool_call_id
3396                .as_deref()
3397                .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3398                .unwrap_or(false);
3399            if is_owned {
3400                owned_results.push((orig_pos, msg));
3401                continue;
3402            }
3403            // Orphan: no matching call anywhere. Treat exactly like a
3404            // non-tool message for placement — it joins the spine at its
3405            // current position and is never moved.
3406            spine.push(msg);
3407            continue;
3408        }
3409        if msg.role == Role::Assistant {
3410            let spine_idx = spine.len();
3411            for c in msg.tool_calls() {
3412                if !c.id.is_empty() {
3413                    call_owner.entry(c.id.clone()).or_insert(spine_idx);
3414                }
3415            }
3416        }
3417        spine.push(msg);
3418    }
3419
3420    // 2. Resolve each owned result's owner spine-index now that `call_owner`
3421    //    is complete, then bucket results by owner spine-index. Every result
3422    //    here was routed as "owned" because its id was found in `owned_ids`,
3423    //    which was built from the exact same `tool_calls()` scan that
3424    //    populates `call_owner` below, so the lookup is guaranteed to hit.
3425    let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3426    for (orig_pos, msg) in owned_results.into_iter() {
3427        let id = msg
3428            .tool_call_id
3429            .as_deref()
3430            .filter(|id| !id.is_empty())
3431            .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3432        let idx = *call_owner
3433            .get(id)
3434            .expect("owned id must have an owning assistant in call_owner");
3435        buckets.entry(idx).or_default().push((orig_pos, msg));
3436    }
3437    // Keep each bucket's results in their original relative file order.
3438    for v in buckets.values_mut() {
3439        v.sort_by_key(|(pos, _)| *pos);
3440    }
3441
3442    // 3. Rebuild: emit each spine message (which now includes orphans at
3443    //    their original position, untouched) in order; immediately after
3444    //    emitting an assistant message that owns one or more tool results,
3445    //    emit its owned results, in original relative order.
3446    let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3447    for (idx, msg) in spine.into_iter().enumerate() {
3448        out.push(msg);
3449        if let Some(results) = buckets.remove(&idx) {
3450            for (_, r) in results {
3451                out.push(r);
3452            }
3453        }
3454    }
3455    *messages = out;
3456}
3457
3458/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3459/// result. Interrupted/aborted turns leave a tool call with no result, which
3460/// many chat-completions endpoints reject when the conversation is replayed.
3461/// We insert a synthetic placeholder result immediately after the assistant
3462/// turn so the transcript stays valid for continuation. (Orphan results — a
3463/// tool message with no preceding call — do not occur in practice and are left
3464/// untouched.)
3465fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3466    let answered: HashSet<String> = messages
3467        .iter()
3468        .filter(|m| m.role == Role::Tool)
3469        .filter_map(|m| m.tool_call_id.clone())
3470        .collect();
3471
3472    // Nothing missing? Leave the vector byte-for-byte unchanged.
3473    let any_missing = messages.iter().any(|m| {
3474        m.tool_calls()
3475            .iter()
3476            .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3477    });
3478    if !any_missing {
3479        return;
3480    }
3481
3482    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3483    for msg in messages.drain(..) {
3484        let synth: Vec<ChatMessage> = msg
3485            .tool_calls()
3486            .iter()
3487            .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3488            .map(|c| {
3489                let mut m = ChatMessage::tool_result(
3490                    c.id.clone(),
3491                    c.function.name.clone(),
3492                    "[no tool result recorded — turn interrupted]".to_string(),
3493                );
3494                // TR-10: an interrupted call never executed to completion —
3495                // never a candidate for `ReductionKind::ToolInputElided`
3496                // (the "still-pending calls are never input-elided"
3497                // boundary).
3498                crate::mark_tool_error(&mut m);
3499                m
3500            })
3501            .collect();
3502        out.push(msg);
3503        out.extend(synth);
3504    }
3505    *messages = out;
3506}
3507
3508/// Whether `msg` is excluded from every replay/export path — the frozen
3509/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3510/// a message marked `compacted_out` (pre-compaction history a source harness
3511/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3512/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3513/// format, not just the one that produced the marker — so a translated
3514/// compacted session replays the same sliced context the source harness
3515/// would, instead of double-including history plus its own summary.
3516fn is_replay_excluded(msg: &ChatMessage) -> bool {
3517    msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3518        || msg
3519            .metadata
3520            .get("pi_exclude_from_context")
3521            .map(String::as_str)
3522            == Some("true")
3523}
3524
3525// ---- detection ------------------------------------------------------------
3526
3527fn detect_source(text: &str) -> Option<SessionSource> {
3528    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3529    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3530    // pretty-printed, MULTI-LINE JSON document, unlike every other format
3531    // this crate reads. It cannot be recognized by the per-line loop below
3532    // (no individual line of a pretty-printed document is itself valid
3533    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3534    // attempt: a real JSONL file (many newline-separated objects) fails this
3535    // parse immediately (trailing-data error) and falls through unaffected.
3536    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3537        if v.get("conversation").and_then(Value::as_array).is_some()
3538            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3539        {
3540            return Some(SessionSource::Goose);
3541        }
3542        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3543            return Some(SessionSource::OpenCode);
3544        }
3545    }
3546    for line in non_empty_lines(text) {
3547        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3548        // than abandoning detection — the loaders themselves skip bad lines, so
3549        // bailing here would silently misroute an otherwise-valid Codex file.
3550        let Ok(v) = serde_json::from_str::<Value>(line) else {
3551            continue;
3552        };
3553        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3554        // one record per line — the synthesized raw-capture unit for the
3555        // JSON-tree/SQLite generations alike. No other format's lines carry
3556        // both a top-level `key` ARRAY and a `value` field, so this is
3557        // unambiguous against Codex/Pi/Claude Code.
3558        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3559            return Some(SessionSource::OpenCode);
3560        }
3561        // Codex envelopes always carry a `payload`; Claude Code lines never do.
3562        if v.get("payload").is_some() {
3563            return Some(SessionSource::Codex);
3564        }
3565        // Gemini CLI starts with an untyped session header. Its project hash
3566        // and timestamps distinguish it from Claude Code records that also
3567        // carry `sessionId`.
3568        if v.get("sessionId").and_then(Value::as_str).is_some()
3569            && (v.get("projectHash").is_some()
3570                || v.get("startTime").is_some()
3571                || v.get("lastUpdated").is_some())
3572            && v.get("type").is_none()
3573        {
3574            return Some(SessionSource::Gemini);
3575        }
3576        // Grok's resumable `chat_history.jsonl` stores the role/type and
3577        // content directly on each record. Claude Code uses a nested
3578        // `message` envelope for the overlapping `user`/`assistant` tags.
3579        let tag = v.get("type").and_then(Value::as_str);
3580        if tag == Some("gemini") && v.get("content").is_some() {
3581            return Some(SessionSource::Gemini);
3582        }
3583        if v.get("message").is_none()
3584            && v.get("uuid").is_none()
3585            && v.get("sessionId").is_none()
3586            && matches!(
3587                tag,
3588                Some(
3589                    "system"
3590                        | "user"
3591                        | "assistant"
3592                        | "tool_result"
3593                        | "reasoning"
3594                        | "backend_tool_call"
3595                )
3596            )
3597            && (v.get("content").is_some()
3598                || v.get("tool_calls").is_some()
3599                || v.get("tool_call_id").is_some()
3600                || v.get("encrypted_content").is_some()
3601                || v.get("kind").is_some())
3602        {
3603            return Some(SessionSource::Grok);
3604        }
3605        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3606        // (the session id) with no `message`/`uuid` — Claude Code's own
3607        // `type`-bearing lines always carry one or the other, never a
3608        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3609        // §1).
3610        if v.get("type").and_then(Value::as_str) == Some("session")
3611            && v.get("id").and_then(Value::as_str).is_some()
3612            && v.get("message").is_none()
3613            && v.get("uuid").is_none()
3614        {
3615            return Some(SessionSource::Pi);
3616        }
3617        if v.get("type").is_some() || v.get("message").is_some() {
3618            return Some(SessionSource::ClaudeCode);
3619        }
3620    }
3621    None
3622}
3623
3624/// Which on-disk OpenCode storage surface is present under a data root
3625/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3626/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3627/// generation A. This is a **filesystem classifier only** — it answers
3628/// "which generation is this?" for a corpus-discovery tool; it does not
3629/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3630/// for the envelope form any of these three surfaces synthesizes into, and
3631/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3632/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3633/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3634/// round-trips via the JSON store per upstream's own behavior even on a
3635/// SQLite install, so nothing is silently lost by not reading the legacy
3636/// trees directly).
3637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3638pub enum OpenCodeStorageSurface {
3639    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3640    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3641    /// [`opencode_sqlite_corpus_envelope_text`].
3642    Sqlite,
3643    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3644    /// marker file `storage/migration`.
3645    JsonTreeB,
3646    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3647    JsonTreeA,
3648}
3649
3650/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3651/// storage surface present, per the discovery rules frozen in
3652/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3653/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3654/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3655/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3656/// `project/` subtree. Returns `None` if nothing is found.
3657pub fn detect_opencode_storage_surface(
3658    data_root: &Path,
3659) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3660    if let Ok(p) = std::env::var("OPENCODE_DB") {
3661        let pb = PathBuf::from(p);
3662        if pb.is_file() {
3663            return Some((OpenCodeStorageSurface::Sqlite, pb));
3664        }
3665    }
3666    if let Ok(entries) = std::fs::read_dir(data_root) {
3667        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3668        // NOT deterministic — a store with both a default-channel
3669        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3670        // are legal, e.g. after switching install channels) previously
3671        // returned "whichever the OS happened to list first", which could
3672        // differ between two `inspect`/`audit`/`convert` runs against the
3673        // exact same directory. Collect every `opencode*.db` candidate and
3674        // pick deterministically: the exact `opencode.db` name wins if
3675        // present (the default/most-common channel); otherwise the
3676        // lexicographically-smallest match, so repeated runs always agree.
3677        let mut candidates: Vec<PathBuf> = entries
3678            .flatten()
3679            .map(|entry| entry.path())
3680            .filter(|p| {
3681                p.file_name()
3682                    .and_then(|n| n.to_str())
3683                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3684            })
3685            .collect();
3686        candidates.sort();
3687        if let Some(exact) = candidates
3688            .iter()
3689            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3690        {
3691            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3692        }
3693        if let Some(first) = candidates.into_iter().next() {
3694            return Some((OpenCodeStorageSurface::Sqlite, first));
3695        }
3696    }
3697    let storage = data_root.join("storage");
3698    if storage.join("migration").is_file() {
3699        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3700    }
3701    let project_dir = data_root.join("project");
3702    if project_dir.is_dir() {
3703        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3704    }
3705    None
3706}
3707
3708/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3709/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3710///
3711/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3712/// `\r` survives as part of the returned line's own content; blank lines and
3713/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3714/// trimmed. This is what makes `Session.raw` — populated from this at every
3715/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3716/// just well-formed LF JSONL with no blank lines.
3717///
3718/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3719/// distinguish a source that ended with a trailing newline from one that
3720/// didn't (both split into the same line list), so `ends_with_newline`
3721/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3722/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3723/// source has zero lines, not one blank line.
3724fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3725    if text.is_empty() {
3726        return (Vec::new(), false);
3727    }
3728    let ends_with_newline = text.ends_with('\n');
3729    let body = if ends_with_newline {
3730        &text[..text.len() - 1]
3731    } else {
3732        text
3733    };
3734    (body.split('\n').collect(), ends_with_newline)
3735}
3736
3737/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3738/// source bytes from its verbatim lines plus the trailing-newline flag.
3739fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3740    let mut out = lines.join("\n");
3741    if ends_with_newline {
3742        out.push('\n');
3743    }
3744    out
3745}
3746
3747// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3748//
3749// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3750// SQLite — no system library dependency) and reconstructs the SAME envelope
3751// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3752// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3753// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3754// `session.ts` `fromRow` (session table: columnar fields recombined into the
3755// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3756// carried as the RAW column value, not upstream's own `fromRow`
3757// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3758// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3759// `message`/`part` rows are simpler: their `data` column is already the V1
3760// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3761// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3762// `id`/`sessionID`(/`messageID`).
3763
3764/// First 16 bytes of every SQLite database file — the format's own magic,
3765/// independent of file extension.
3766const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3767
3768/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3769/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3770/// the SQLite magic, OR its extension is `.db` — the latter so a
3771/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3772/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3773/// A non-existent path is NOT considered SQLite here — the missing-file
3774/// diagnostic in that case comes from the normal load path (`with_context`
3775/// at the CLI call sites), which already names the path clearly.
3776pub fn looks_like_sqlite(path: &Path) -> bool {
3777    if !path.is_file() {
3778        return false;
3779    }
3780    if path.extension().and_then(|e| e.to_str()) == Some("db") {
3781        return true;
3782    }
3783    use std::io::Read;
3784    let Ok(mut f) = std::fs::File::open(path) else {
3785        return false;
3786    };
3787    let mut buf = [0u8; 16];
3788    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3789}
3790
3791/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3792/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3793/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3794/// accept there. Binary SQLite input never reaches this function: callers
3795/// check [`looks_like_sqlite`] first and route to
3796/// [`Session::from_opencode_sqlite`] instead.
3797fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3798    let bytes = std::fs::read(path)?;
3799    String::from_utf8(bytes).map_err(|_| {
3800        crate::Error::Other(format!(
3801            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3802             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3803             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3804            path.display()
3805        ))
3806    })
3807}
3808
3809/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3810///
3811/// The first record carries durable session metadata (especially for Codex),
3812/// while the trailing window carries the messages the viewport will render.
3813/// Full lossless loaders intentionally continue to read every byte.
3814fn read_display_jsonl(
3815    path: &Path,
3816    message_limit: usize,
3817) -> Result<(Option<SessionSource>, String)> {
3818    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3819    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3820    const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3821
3822    let mut first = String::new();
3823    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3824    let source = detect_source(&first);
3825    if !matches!(
3826        source,
3827        Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
3828    ) {
3829        let text = read_utf8_or_diagnose(path)?;
3830        return Ok((detect_source(&text), text));
3831    }
3832
3833    let mut file = std::fs::File::open(path)?;
3834    let file_len = file.metadata()?.len();
3835    let requested = (message_limit.max(1) as u64)
3836        .saturating_mul(BYTES_PER_MESSAGE)
3837        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3838    if file_len <= requested {
3839        let text = read_utf8_or_diagnose(path)?;
3840        return Ok((source, text));
3841    }
3842
3843    let start = file_len - requested;
3844    file.seek(SeekFrom::Start(start))?;
3845    let mut bytes = Vec::with_capacity(requested as usize);
3846    file.read_to_end(&mut bytes)?;
3847    // The window normally starts in the middle of a JSON record. Discard that
3848    // partial prefix so every line passed to the existing parsers is valid.
3849    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3850        bytes.drain(..=newline);
3851    }
3852    let mut tail = String::from_utf8(bytes).map_err(|_| {
3853        crate::Error::Other(format!(
3854            "{} contains non-UTF-8 data in its display window",
3855            path.display()
3856        ))
3857    })?;
3858    if !tail
3859        .lines()
3860        .any(|line| native_display_human_line(line, source))
3861    {
3862        // A single tool-heavy turn can exceed the ordinary byte window. Search backward through a
3863        // separately bounded native slice for only its nearest human record, then prepend that one
3864        // line to the cheap tail. The skipped megabytes are never normalized or sent over RPC.
3865        let search_bytes = file_len.min(requested.saturating_mul(2).min(MAX_TAIL_BYTES));
3866        let search_start = file_len - search_bytes;
3867        file.seek(SeekFrom::Start(search_start))?;
3868        let mut search = Vec::with_capacity(search_bytes as usize);
3869        file.read_to_end(&mut search)?;
3870        if search_start > 0 {
3871            if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3872                search.drain(..=newline);
3873            }
3874        }
3875        if let Ok(search) = std::str::from_utf8(&search) {
3876            if let Some(anchor) = search
3877                .lines()
3878                .rev()
3879                .find(|line| native_display_human_line(line, source))
3880            {
3881                tail = format!("{anchor}\n{tail}");
3882            }
3883        }
3884    }
3885    let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
3886        format!("{first}{tail}")
3887    } else {
3888        tail
3889    };
3890    Ok((source, text))
3891}
3892
3893fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3894    if !line
3895        .as_bytes()
3896        .windows(6)
3897        .any(|window| window == b"\"user\"")
3898    {
3899        return false;
3900    }
3901    let Ok(value) = serde_json::from_str::<Value>(line) else {
3902        return false;
3903    };
3904    match source {
3905        Some(SessionSource::Codex) => {
3906            value.get("type").and_then(Value::as_str) == Some("response_item")
3907                && value
3908                    .get("payload")
3909                    .and_then(|payload| payload.get("type"))
3910                    .and_then(Value::as_str)
3911                    == Some("message")
3912                && value
3913                    .get("payload")
3914                    .and_then(|payload| payload.get("role"))
3915                    .and_then(Value::as_str)
3916                    == Some("user")
3917        }
3918        Some(SessionSource::ClaudeCode) => {
3919            value.get("type").and_then(Value::as_str) == Some("user")
3920                && value
3921                    .get("message")
3922                    .and_then(|message| message.get("content"))
3923                    .is_some_and(|content| match content {
3924                        Value::String(text) => !text.trim().is_empty(),
3925                        Value::Array(parts) => parts.iter().any(|part| {
3926                            part.get("type").and_then(Value::as_str) == Some("text")
3927                                && part
3928                                    .get("text")
3929                                    .and_then(Value::as_str)
3930                                    .is_some_and(|text| !text.trim().is_empty())
3931                        }),
3932                        _ => false,
3933                    })
3934        }
3935        Some(SessionSource::Gemini) => {
3936            value.get("type").and_then(Value::as_str) == Some("user")
3937                && value.get("content").is_some_and(|content| match content {
3938                    Value::String(text) => !text.trim().is_empty(),
3939                    Value::Array(parts) => parts.iter().any(|part| {
3940                        part.get("text")
3941                            .and_then(Value::as_str)
3942                            .is_some_and(|text| !text.trim().is_empty())
3943                    }),
3944                    _ => false,
3945                })
3946        }
3947        _ => false,
3948    }
3949}
3950
3951fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3952    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3953}
3954
3955/// Open `db_path` read-only and confirm it carries the expected V1 schema
3956/// (a `session` table) — the shared entry point for every SQLite read below,
3957/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
3958/// path, not-a-database, and wrong/unsupported schema are each named
3959/// distinctly rather than surfacing later as "zero sessions" or a generic
3960/// parse failure.
3961fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
3962    if !db_path.is_file() {
3963        return Err(crate::Error::Other(format!(
3964            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
3965             (see `docs/interop/opencode-pi-spec.md` §1.2)",
3966            db_path.display()
3967        )));
3968    }
3969    let conn = Connection::open_with_flags(
3970        db_path,
3971        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3972    )
3973    .map_err(|e| {
3974        crate::Error::Other(format!(
3975            "{} does not look like a valid OpenCode SQLite database: {e}",
3976            db_path.display()
3977        ))
3978    })?;
3979    let has_session_table: i64 = conn
3980        .query_row(
3981            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
3982            [],
3983            |r| r.get(0),
3984        )
3985        .map_err(|e| {
3986            crate::Error::Other(format!(
3987                "failed to read the OpenCode SQLite schema at {}: {e}",
3988                db_path.display()
3989            ))
3990        })?;
3991    if has_session_table == 0 {
3992        return Err(crate::Error::Other(format!(
3993            "{} is a SQLite database but has no `session` table — not a recognized \
3994             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
3995            db_path.display()
3996        )));
3997    }
3998    Ok(conn)
3999}
4000
4001/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
4002/// …). D7: an unparseable non-empty column previously degraded to
4003/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
4004/// absent/NULL column, so a corrupt `data`/`metadata` value silently
4005/// vanished (e.g. a message whose `data` fails to parse loses its entire
4006/// canonical content with no trace). A `tracing::warn!` now surfaces the
4007/// column name and context (session/record id) whenever this happens, so
4008/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
4009/// (still the least-wrong placeholder for a broken column; changing it to a
4010/// sentinel would risk misleading every legitimate `.is_null()` check
4011/// elsewhere) but the frontend/log now knows it happened.
4012fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
4013    match s.as_deref() {
4014        None => Value::Null,
4015        Some(t) => match serde_json::from_str::<Value>(t) {
4016            Ok(v) => v,
4017            Err(e) => {
4018                tracing::warn!(
4019                    column = col,
4020                    context,
4021                    error = %e,
4022                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
4023                );
4024                Value::Null
4025            }
4026        },
4027    }
4028}
4029
4030/// Columns the `session` table has in a GIVEN store, read once per session
4031/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4032/// `opencode` generation may lack columns the newest schema added, e.g.
4033/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4034/// "Invalid column name" on an absent column, so callers must check
4035/// membership before reading a not-guaranteed column instead of reading it
4036/// unconditionally).
4037fn opencode_session_columns(
4038    conn: &Connection,
4039) -> rusqlite::Result<std::collections::HashSet<String>> {
4040    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4041    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4042    names.collect()
4043}
4044
4045/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4046/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4047/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4048/// `revert` carries the raw column value verbatim rather than upstream's
4049/// field-selecting reconstruction (spec S9c: that reconstruction silently
4050/// drops the V2 `Revert.State` schema's extra `files` field).
4051///
4052/// D3: not every column this loader would like to read is guaranteed to
4053/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4054/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4055/// `agent`/`model` entirely. Those are read defensively (guarded by
4056/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4057/// generation this loader has ever targeted are still read unconditionally.
4058fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4059    let cols = opencode_session_columns(conn)
4060        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4061    let has = |name: &str| cols.contains(name);
4062
4063    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4064        let id: String = r.get("id")?;
4065        let project_id: String = r.get("project_id")?;
4066        let workspace_id: Option<String> = if has("workspace_id") {
4067            r.get("workspace_id")?
4068        } else {
4069            None
4070        };
4071        let parent_id: Option<String> = r.get("parent_id")?;
4072        let slug: String = r.get("slug")?;
4073        let directory: String = r.get("directory")?;
4074        let path: Option<String> = if has("path") { r.get("path")? } else { None };
4075        let title: String = r.get("title")?;
4076        let version: String = r.get("version")?;
4077        let share_url: Option<String> = r.get("share_url")?;
4078        let summary_additions: Option<i64> = r.get("summary_additions")?;
4079        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4080        let summary_files: Option<i64> = r.get("summary_files")?;
4081        let summary_diffs: Option<String> = r.get("summary_diffs")?;
4082        let metadata: Option<String> = if has("metadata") {
4083            r.get("metadata")?
4084        } else {
4085            None
4086        };
4087        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4088        let tokens_input: i64 = if has("tokens_input") {
4089            r.get("tokens_input")?
4090        } else {
4091            0
4092        };
4093        let tokens_output: i64 = if has("tokens_output") {
4094            r.get("tokens_output")?
4095        } else {
4096            0
4097        };
4098        let tokens_reasoning: i64 = if has("tokens_reasoning") {
4099            r.get("tokens_reasoning")?
4100        } else {
4101            0
4102        };
4103        let tokens_cache_read: i64 = if has("tokens_cache_read") {
4104            r.get("tokens_cache_read")?
4105        } else {
4106            0
4107        };
4108        let tokens_cache_write: i64 = if has("tokens_cache_write") {
4109            r.get("tokens_cache_write")?
4110        } else {
4111            0
4112        };
4113        let revert: Option<String> = r.get("revert")?;
4114        let permission: Option<String> = if has("permission") {
4115            r.get("permission")?
4116        } else {
4117            None
4118        };
4119        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4120        let model: Option<String> = if has("model") { r.get("model")? } else { None };
4121        let time_created: i64 = r.get("time_created")?;
4122        let time_updated: i64 = r.get("time_updated")?;
4123        let time_compacting: Option<i64> = if has("time_compacting") {
4124            r.get("time_compacting")?
4125        } else {
4126            None
4127        };
4128        let time_archived: Option<i64> = if has("time_archived") {
4129            r.get("time_archived")?
4130        } else {
4131            None
4132        };
4133
4134        let summary =
4135            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4136                .then(|| {
4137                    serde_json::json!({
4138                        "additions": summary_additions.unwrap_or(0),
4139                        "deletions": summary_deletions.unwrap_or(0),
4140                        "files": summary_files.unwrap_or(0),
4141                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4142                    })
4143                });
4144        let share = share_url.map(|u| serde_json::json!({"url": u}));
4145
4146        Ok(serde_json::json!({
4147            "id": id,
4148            "slug": slug,
4149            "projectID": project_id,
4150            "workspaceID": workspace_id,
4151            "directory": directory,
4152            "path": path,
4153            "parentID": parent_id,
4154            "summary": summary,
4155            "cost": cost,
4156            "tokens": {
4157                "input": tokens_input,
4158                "output": tokens_output,
4159                "reasoning": tokens_reasoning,
4160                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4161            },
4162            "share": share,
4163            "title": title,
4164            "agent": agent,
4165            "model": opencode_json_col(model, "model", session_id),
4166            "version": version,
4167            "metadata": opencode_json_col(metadata, "metadata", session_id),
4168            "time": {
4169                "created": time_created,
4170                "updated": time_updated,
4171                "compacting": time_compacting,
4172                "archived": time_archived,
4173            },
4174            "permission": opencode_json_col(permission, "permission", session_id),
4175            // S9c: raw column value, not a field-selecting reconstruction —
4176            // see this function's doc comment.
4177            "revert": opencode_json_col(revert, "revert", session_id),
4178        }))
4179    })
4180    .map_err(|e| match e {
4181        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4182            "OpenCode session `{session_id}` not found in this SQLite store"
4183        )),
4184        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4185    })
4186}
4187
4188/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4189/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4190/// re-inject them, matching what a JSON-tree file (or the export document)
4191/// carries at this same key. Also re-injects the row's own `time_created`/
4192/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4193/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4194/// in the envelope so `raw` is value-complete and re-writable without
4195/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4196/// which is a different, in-schema field with different semantics).
4197fn opencode_row_message_value(
4198    id: &str,
4199    session_id: &str,
4200    data_json: &str,
4201    time_created: i64,
4202    time_updated: i64,
4203) -> Value {
4204    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4205    if let Value::Object(map) = &mut v {
4206        map.insert("id".to_string(), Value::String(id.to_string()));
4207        map.insert(
4208            "sessionID".to_string(),
4209            Value::String(session_id.to_string()),
4210        );
4211        map.insert("time_created".to_string(), Value::from(time_created));
4212        map.insert("time_updated".to_string(), Value::from(time_updated));
4213    }
4214    v
4215}
4216
4217fn opencode_row_part_value(
4218    id: &str,
4219    session_id: &str,
4220    message_id: &str,
4221    data_json: &str,
4222    time_created: i64,
4223    time_updated: i64,
4224) -> Value {
4225    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4226    if let Value::Object(map) = &mut v {
4227        map.insert("id".to_string(), Value::String(id.to_string()));
4228        map.insert(
4229            "sessionID".to_string(),
4230            Value::String(session_id.to_string()),
4231        );
4232        map.insert(
4233            "messageID".to_string(),
4234            Value::String(message_id.to_string()),
4235        );
4236        map.insert("time_created".to_string(), Value::from(time_created));
4237        map.insert("time_updated".to_string(), Value::from(time_updated));
4238    }
4239    v
4240}
4241
4242/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4243/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4244/// info first, then each message (by `time_created, id`) immediately
4245/// followed by its own parts (by `id`) — parts MUST directly follow their
4246/// owning message line, since `Session::from_opencode_str`'s envelope parser
4247/// attaches a `part` line to whichever message id is already in its index
4248/// and silently leaves an out-of-order part `raw`-only otherwise — then
4249/// `todo` side-records, then a `session_diff` side-record if the JSON
4250/// sidecar file for this session exists (order-independent).
4251///
4252/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4253/// it "is still JSON-written even on SQLite installs" — verified against
4254/// `packages/opencode/src/session/revert.ts:76` /
4255/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4256/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4257/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4258/// separate from the `session.revert` DB column this loader already
4259/// captures. Without this, revert diffs vanish from `raw` and audit
4260/// under-counts `session_diff` records for real reverted sessions.
4261fn opencode_sqlite_session_envelope_lines(
4262    conn: &Connection,
4263    db_path: &Path,
4264    session_id: &str,
4265) -> Result<Vec<String>> {
4266    let mut lines = Vec::new();
4267
4268    let session_info = opencode_row_session_info(conn, session_id)?;
4269    let project_id = session_info
4270        .get("projectID")
4271        .and_then(Value::as_str)
4272        .unwrap_or("global")
4273        .to_string();
4274    lines.push(
4275        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4276            .to_string(),
4277    );
4278
4279    let mut msg_stmt = conn
4280        .prepare(
4281            "SELECT id, data, time_created, time_updated FROM message \
4282             WHERE session_id = ?1 ORDER BY time_created, id",
4283        )
4284        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4285    let msg_rows = msg_stmt
4286        .query_map([session_id], |r| {
4287            let id: String = r.get("id")?;
4288            let data: String = r.get("data")?;
4289            let time_created: i64 = r.get("time_created")?;
4290            let time_updated: i64 = r.get("time_updated")?;
4291            Ok((id, data, time_created, time_updated))
4292        })
4293        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4294
4295    let mut part_stmt = conn
4296        .prepare(
4297            "SELECT id, data, time_created, time_updated FROM part \
4298             WHERE message_id = ?1 ORDER BY id",
4299        )
4300        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4301
4302    for row in msg_rows {
4303        let (msg_id, data, msg_time_created, msg_time_updated) =
4304            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4305        let msg_value = opencode_row_message_value(
4306            &msg_id,
4307            session_id,
4308            &data,
4309            msg_time_created,
4310            msg_time_updated,
4311        );
4312        lines.push(
4313            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4314                .to_string(),
4315        );
4316
4317        let part_rows = part_stmt
4318            .query_map([&msg_id], |r| {
4319                let id: String = r.get("id")?;
4320                let data: String = r.get("data")?;
4321                let time_created: i64 = r.get("time_created")?;
4322                let time_updated: i64 = r.get("time_updated")?;
4323                Ok((id, data, time_created, time_updated))
4324            })
4325            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4326        for prow in part_rows {
4327            let (part_id, pdata, part_time_created, part_time_updated) =
4328                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4329            let part_value = opencode_row_part_value(
4330                &part_id,
4331                session_id,
4332                &msg_id,
4333                &pdata,
4334                part_time_created,
4335                part_time_updated,
4336            );
4337            lines.push(
4338                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4339                    .to_string(),
4340            );
4341        }
4342    }
4343
4344    let mut todo_stmt = conn
4345        .prepare(
4346            "SELECT content, status, priority, position, time_created, time_updated \
4347             FROM todo WHERE session_id = ?1 ORDER BY position",
4348        )
4349        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4350    let todo_rows = todo_stmt
4351        .query_map([session_id], |r| {
4352            let content: String = r.get("content")?;
4353            let status: String = r.get("status")?;
4354            let priority: String = r.get("priority")?;
4355            let position: i64 = r.get("position")?;
4356            let time_created: i64 = r.get("time_created")?;
4357            let time_updated: i64 = r.get("time_updated")?;
4358            Ok(serde_json::json!({
4359                "sessionID": session_id,
4360                "content": content,
4361                "status": status,
4362                "priority": priority,
4363                "position": position,
4364                "time": {"created": time_created, "updated": time_updated},
4365            }))
4366        })
4367        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4368    for trow in todo_rows {
4369        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4370        let position = tv.get("position").cloned().unwrap_or(Value::Null);
4371        lines.push(
4372            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4373        );
4374    }
4375
4376    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4377        lines.push(
4378            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4379                .to_string(),
4380        );
4381    }
4382
4383    Ok(lines)
4384}
4385
4386/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4387/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4388/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4389/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4390/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4391/// case (most sessions never revert) and is not an error; an existing-but-
4392/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4393/// vanishing.
4394fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4395    let dir = db_path.parent()?;
4396    let sidecar = dir
4397        .join("storage")
4398        .join("session_diff")
4399        .join(format!("{session_id}.json"));
4400    let text = std::fs::read_to_string(&sidecar).ok()?;
4401    match serde_json::from_str::<Value>(&text) {
4402        Ok(v) => Some(v),
4403        Err(e) => {
4404            tracing::warn!(
4405                path = %sidecar.display(),
4406                error = %e,
4407                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4408            );
4409            None
4410        }
4411    }
4412}
4413
4414/// Pick the "primary" session for a bare `.db` path with no explicit session
4415/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4416/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4417/// descending) — a subagent/task child session is never picked over an
4418/// available root session, mirroring `most_recent_session`'s "latest wins"
4419/// convention used elsewhere in this crate for supercode's own store.
4420fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4421    conn.query_row(
4422        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4423        [],
4424        |r| r.get::<_, String>(0),
4425    )
4426    .map_err(|e| match e {
4427        rusqlite::Error::QueryReturnedNoRows => {
4428            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4429        }
4430        e => opencode_sql_err(e, "selecting the primary session"),
4431    })
4432}
4433
4434fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4435    let mut stmt = conn
4436        .prepare("SELECT id FROM session ORDER BY time_created, id")
4437        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4438    let rows = stmt
4439        .query_map([], |r| r.get::<_, String>(0))
4440        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4441    let mut ids = Vec::new();
4442    for row in rows {
4443        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4444        if limit.is_some_and(|n| ids.len() >= n) {
4445            break;
4446        }
4447    }
4448    Ok(ids)
4449}
4450
4451/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4452/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4453/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4454/// silently picks just the primary one. Previously nothing surfaced this:
4455/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4456/// and no way to name a different one.
4457pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4458    let conn = opencode_sqlite_open(db_path)?;
4459    opencode_sqlite_all_session_ids(&conn, None)
4460}
4461
4462/// D6: the same "most-recently-updated top-level session" selection
4463/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4464/// no explicit session id is given — exposed so a CLI-level warning can name
4465/// which one was chosen.
4466pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4467    let conn = opencode_sqlite_open(db_path)?;
4468    opencode_sqlite_primary_session_id(&conn)
4469}
4470
4471/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4472/// `inspect`'s "reports the audited real store's sessions, messages, and
4473/// parts" summary (PARITY-3 AC01).
4474#[derive(Debug, Clone, Copy, Default)]
4475#[non_exhaustive]
4476pub struct OpenCodeSqliteStoreStats {
4477    /// Row count of the `session` table.
4478    pub sessions: u64,
4479    /// Row count of the `message` table.
4480    pub messages: u64,
4481    /// Row count of the `part` table.
4482    pub parts: u64,
4483    /// Row count of the `todo` table.
4484    pub todos: u64,
4485}
4486
4487/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4488/// without loading any of them (PARITY-3 AC01).
4489pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4490    let conn = opencode_sqlite_open(db_path)?;
4491    let count = |table: &str| -> Result<u64> {
4492        let sql = format!("SELECT count(*) FROM {table}");
4493        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4494            .map(|n| n.max(0) as u64)
4495            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4496    };
4497    Ok(OpenCodeSqliteStoreStats {
4498        sessions: count("session")?,
4499        messages: count("message")?,
4500        parts: count("part")?,
4501        todos: count("todo")?,
4502    })
4503}
4504
4505/// Combined envelope text spanning every session in `db_path` (or up to
4506/// `limit_sessions`) — for corpus-style scanning
4507/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4508/// Safe to concatenate multiple sessions' records into one text even though
4509/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4510/// (single-session semantics) — the audit line-classifier
4511/// (`audit_opencode_line`) scores each line independently and doesn't care
4512/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4513/// one session as a real [`Session`].
4514pub fn opencode_sqlite_corpus_envelope_text(
4515    db_path: &Path,
4516    limit_sessions: Option<usize>,
4517) -> Result<String> {
4518    let conn = opencode_sqlite_open(db_path)?;
4519    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4520    let mut out = String::new();
4521    for id in ids {
4522        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4523            out.push_str(&line);
4524            out.push('\n');
4525        }
4526    }
4527    Ok(out)
4528}
4529
4530/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4531/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4532/// everywhere a loader walks lines looking for JSON *records*, where a blank
4533/// line is simply not a record and must not become a spurious parse
4534/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4535/// (IX-1) — see [`split_lines_verbatim`] for that.
4536fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4537    text.lines().map(str::trim).filter(|l| !l.is_empty())
4538}
4539
4540// ---- Claude Code ----------------------------------------------------------
4541
4542/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4543/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4544fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4545    let dir = main_path.parent()?;
4546    let stem = main_path.file_stem()?.to_str()?;
4547    let candidate = dir.join(stem).join("subagents");
4548    candidate.is_dir().then_some(candidate)
4549}
4550
4551/// The first `agentId` recorded in a subagent transcript.
4552fn first_agent_id(jsonl: &str) -> Option<String> {
4553    for line in non_empty_lines(jsonl) {
4554        if let Ok(v) = serde_json::from_str::<Value>(line) {
4555            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4556                return Some(id.to_string());
4557            }
4558        }
4559    }
4560    None
4561}
4562
4563/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4564/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4565/// serialized content mentions the agent id. Best effort: an id with no
4566/// qualifying match is simply absent from the returned map.
4567///
4568/// Single pass over `main_text` — each line is parsed at most once,
4569/// regardless of how many agent ids are being sought — with each id's result
4570/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4571/// return: the first line (in file order) whose raw text contains the id and
4572/// which — the first qualifying `tool_result` block in that line, in block
4573/// order — has a string `tool_use_id` and a serialized form that also
4574/// contains the id. A `tool_result` block matching on raw-line/serialized
4575/// containment but lacking a `tool_use_id` yields nothing for that id and
4576/// does not shadow a later match.
4577fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4578    let mut index: HashMap<String, String> = HashMap::new();
4579    if agent_ids.is_empty() {
4580        return index;
4581    }
4582
4583    for line in non_empty_lines(main_text) {
4584        if index.len() == agent_ids.len() {
4585            break;
4586        }
4587        // Cheap prefilter: every match this function can ever return comes
4588        // from a block whose raw line carries the literal JSON string value
4589        // `tool_result` (no JSON-escape variants of that ASCII literal).
4590        if !line.contains("tool_result") {
4591            continue;
4592        }
4593        let still_unmapped: Vec<&String> = agent_ids
4594            .iter()
4595            .filter(|id| !index.contains_key(id.as_str()))
4596            .collect();
4597        if still_unmapped.is_empty() {
4598            break;
4599        }
4600        let Ok(v) = serde_json::from_str::<Value>(line) else {
4601            continue;
4602        };
4603        let content = v.get("message").and_then(|m| m.get("content"));
4604        let Some(Value::Array(blocks)) = content else {
4605            continue;
4606        };
4607        for b in blocks {
4608            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4609                continue;
4610            }
4611            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4612                continue;
4613            };
4614            let block_str = b.to_string();
4615            for id in &still_unmapped {
4616                if index.contains_key(id.as_str()) {
4617                    continue;
4618                }
4619                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4620                    index.insert((*id).clone(), tool_use_id.to_string());
4621                }
4622            }
4623        }
4624    }
4625
4626    index
4627}
4628
4629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4630enum ClaudeReplayKind {
4631    User,
4632    Assistant,
4633    Attachment,
4634    System,
4635}
4636
4637impl ClaudeReplayKind {
4638    fn is_conversation(self) -> bool {
4639        matches!(self, Self::User | Self::Assistant)
4640    }
4641}
4642
4643#[derive(Debug, Clone)]
4644struct ClaudeReplayNode {
4645    line_index: usize,
4646    uuid: String,
4647    parent_uuid: Option<String>,
4648    kind: ClaudeReplayKind,
4649    is_sidechain: bool,
4650    assistant_message_id: Option<String>,
4651    is_tool_result: bool,
4652    compact: Option<ClaudeCompactBoundary>,
4653}
4654
4655#[derive(Debug, Clone)]
4656struct ClaudeCompactBoundary {
4657    anchor_uuid: Option<String>,
4658    preserved_uuids: Vec<String>,
4659    preserved_segment: Option<(String, String)>,
4660}
4661
4662/// One projection of a Claude transcript graph: the source lines to replay,
4663/// plus whatever the projection had to give up to produce them (always empty
4664/// below [`Fidelity::Semantic`], which is the only level that degrades
4665/// instead of failing).
4666#[derive(Debug, Default)]
4667struct ClaudeReplaySelection {
4668    lines: Vec<usize>,
4669    residue: Vec<String>,
4670}
4671
4672#[derive(Debug, Default)]
4673struct ClaudeReplayIndex {
4674    nodes: Vec<ClaudeReplayNode>,
4675    by_uuid: HashMap<String, usize>,
4676    segment_anchors: HashSet<String>,
4677    last_prompt: Option<(String, bool)>,
4678    linear_lines: Vec<usize>,
4679}
4680
4681impl ClaudeReplayIndex {
4682    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4683        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4684            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4685                self.last_prompt = Some((
4686                    leaf.to_string(),
4687                    v.get("explicit").and_then(Value::as_bool) == Some(true),
4688                ));
4689            }
4690            return Ok(());
4691        }
4692
4693        // A fork-context-ref is a real Claude graph anchor, but not a replay
4694        // message. Its child is the first conversational record in the
4695        // exported fork, so reaching this UUID terminates the locally
4696        // replayable segment rather than indicating a broken parent edge.
4697        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4698            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4699                self.segment_anchors.insert(uuid.to_string());
4700            }
4701            return Ok(());
4702        }
4703
4704        let kind = match v.get("type").and_then(Value::as_str) {
4705            Some("user") => ClaudeReplayKind::User,
4706            Some("assistant") => ClaudeReplayKind::Assistant,
4707            Some("attachment") => ClaudeReplayKind::Attachment,
4708            Some("system") => ClaudeReplayKind::System,
4709            _ => return Ok(()),
4710        };
4711        self.linear_lines.push(line_index);
4712        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4713            return Ok(());
4714        };
4715        if self.by_uuid.contains_key(uuid) {
4716            return Err(claude_replay_error(format!(
4717                "duplicate uuid `{uuid}` in Claude transcript"
4718            )));
4719        }
4720
4721        let compact = (kind == ClaudeReplayKind::System
4722            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4723        .then(|| ClaudeCompactBoundary::from_value(v));
4724        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4725            .then(|| claude_assistant_message_id(v).map(str::to_string))
4726            .flatten();
4727        let is_tool_result = kind == ClaudeReplayKind::User
4728            && v.get("message")
4729                .and_then(|m| m.get("content"))
4730                .and_then(Value::as_array)
4731                .is_some_and(|blocks| {
4732                    blocks
4733                        .iter()
4734                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4735                });
4736        let node = ClaudeReplayNode {
4737            line_index,
4738            uuid: uuid.to_string(),
4739            parent_uuid: v
4740                .get("parentUuid")
4741                .and_then(Value::as_str)
4742                .map(str::to_string),
4743            kind,
4744            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4745            assistant_message_id,
4746            is_tool_result,
4747            compact,
4748        };
4749        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4750        self.nodes.push(node);
4751        Ok(())
4752    }
4753
4754    /// Project the transcript at `fidelity`.
4755    ///
4756    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4757    /// continuation, transfer and export path depends on: reconstruct
4758    /// Claude's own single active post-compaction branch, or fail naming what
4759    /// could not be reconstructed.
4760    ///
4761    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4762    /// that has been compacted, summarized, or resumed across files routinely
4763    /// contains a live record whose `parentUuid` names a record that is no
4764    /// longer on disk. Strict projection rightly refuses — a continuation
4765    /// built on a guessed graph is silent loss — but a VIEW does not need a
4766    /// continuation, so this mode anchors each dangling edge as a segment
4767    /// root, projects every severed segment exactly as the active branch is
4768    /// projected, splices them back together in transcript order, and names
4769    /// every degradation in the returned residue instead of erroring.
4770    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4771        let lenient = fidelity.tolerates_residue();
4772        let mut residue = Vec::new();
4773        if self.nodes.is_empty() {
4774            // Older exports and many hand-authored compatibility fixtures do
4775            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4776            // branch information to project in that shape, so preserve the
4777            // historical linear normalization behavior. Native graph-bearing
4778            // transcripts always take the projection below.
4779            return Ok(ClaudeReplaySelection {
4780                lines: self.linear_lines,
4781                residue,
4782            });
4783        }
4784        if lenient {
4785            self.anchor_dangling_parents(&mut residue);
4786        }
4787        // Last resort for a VIEW: a transcript whose graph is unprojectable
4788        // for some OTHER reason (a cycle, an unresolvable compact boundary)
4789        // still renders as the file's own record order. A read-only mirror
4790        // that cannot open a session at all is the defect this mode exists
4791        // to remove, so `Semantic` never returns an error.
4792        let fallback = lenient.then(|| self.linear_lines.clone());
4793        match self.project(lenient, &mut residue) {
4794            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4795            Err(error) => match fallback {
4796                Some(lines) => {
4797                    residue.push(format!(
4798                        "the Claude record graph could not be projected ({error}); \
4799                         every record was stitched in transcript order instead"
4800                    ));
4801                    Ok(ClaudeReplaySelection { lines, residue })
4802                }
4803                None => Err(error),
4804            },
4805        }
4806    }
4807
4808    /// Turn every edge that points outside the transcript into a segment
4809    /// root, naming the dangling uuids as residue.
4810    ///
4811    /// A `fork-context-ref` anchor is already a declared segment boundary,
4812    /// not a break, so it is left alone.
4813    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4814        let mut dangling = Vec::new();
4815        for idx in 0..self.nodes.len() {
4816            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4817                continue;
4818            };
4819            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4820                continue;
4821            }
4822            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4823            self.nodes[idx].parent_uuid = None;
4824        }
4825        if dangling.is_empty() {
4826            return;
4827        }
4828        const NAMED: usize = 8;
4829        let total = dangling.len();
4830        let overflow = total.saturating_sub(NAMED);
4831        dangling.truncate(NAMED);
4832        let mut listed = dangling.join(", ");
4833        if overflow > 0 {
4834            listed.push_str(&format!(", and {overflow} more"));
4835        }
4836        residue.push(format!(
4837            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4838             anchored as segment roots: {listed}"
4839        ));
4840    }
4841
4842    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4843        let mut retained = vec![true; self.nodes.len()];
4844        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4845            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4846                self.nodes
4847                    .iter()
4848                    .map(|node| node.parent_uuid.clone())
4849                    .collect()
4850            });
4851            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4852                let Some(parents) = parents else {
4853                    return Err(error);
4854                };
4855                // The boundary rewrites parents as it goes, so restore the
4856                // graph it half-edited before continuing without it.
4857                for (node, parent) in self.nodes.iter_mut().zip(parents) {
4858                    node.parent_uuid = parent;
4859                }
4860                retained.iter_mut().for_each(|keep| *keep = true);
4861                residue.push(format!(
4862                    "the latest Claude compact boundary could not be projected ({error}); \
4863                     no pre-compaction record was pruned from this view"
4864                ));
4865            }
4866        }
4867        let sidechain_only = self
4868            .nodes
4869            .iter()
4870            .enumerate()
4871            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4872            .all(|(_, node)| node.is_sidechain);
4873
4874        let explicit_leaf = self
4875            .last_prompt
4876            .as_ref()
4877            .filter(|(_, explicit)| *explicit)
4878            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4879            .filter(|idx| retained[*idx]);
4880        let newest_non_sidechain = self
4881            .nodes
4882            .iter()
4883            .enumerate()
4884            .rev()
4885            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4886            .map(|(idx, _)| idx);
4887        // Dedicated Claude subagent transcripts are sidechains by design:
4888        // every record, including their root user prompt, has
4889        // `isSidechain:true`. When there is no main-chain candidate, resume
4890        // the newest retained sidechain leaf instead of rejecting the child.
4891        let newest_sidechain = self
4892            .nodes
4893            .iter()
4894            .enumerate()
4895            .rev()
4896            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4897            .map(|(idx, _)| idx);
4898        let mut active = explicit_leaf
4899            .or(newest_non_sidechain)
4900            .or(newest_sidechain)
4901            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4902
4903        // Metadata descendants such as turn_duration are leaves in the raw
4904        // graph. Claude resumes from their nearest user/assistant ancestor,
4905        // then appends those descendants to the reconstructed chain.
4906        let mut seeking = HashSet::new();
4907        while !self.nodes[active].kind.is_conversation() {
4908            if !seeking.insert(active) {
4909                return Err(claude_replay_error(
4910                    "cycle while resolving active Claude leaf",
4911                ));
4912            }
4913            active = self.parent_index(active, &retained)?;
4914        }
4915
4916        let mut segments =
4917            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4918        if lenient {
4919            for leaf in self.severed_segment_leaves(active, &retained) {
4920                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4921            }
4922            if segments.len() > 1 {
4923                residue.push(format!(
4924                    "{} conversation segments were stitched in transcript order because the \
4925                     Claude record graph is severed",
4926                    segments.len()
4927                ));
4928            }
4929        }
4930        // Each segment keeps its own reconstructed order; the segments
4931        // themselves are spliced by where they start in the file.
4932        segments.retain(|segment| !segment.is_empty());
4933        segments.sort_by_key(|segment| {
4934            segment
4935                .iter()
4936                .map(|idx| self.nodes[*idx].line_index)
4937                .min()
4938                .unwrap_or(usize::MAX)
4939        });
4940        let mut ordered = Vec::new();
4941        let mut placed = HashSet::new();
4942        for idx in segments.into_iter().flatten() {
4943            if placed.insert(idx) {
4944                ordered.push(idx);
4945            }
4946        }
4947
4948        self.recover_parallel_assistant_chunks(ordered, &retained)
4949            .map(|indices| {
4950                indices
4951                    .into_iter()
4952                    .map(|idx| self.nodes[idx].line_index)
4953                    .collect()
4954            })
4955    }
4956
4957    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
4958    /// non-conversation descendants rooted at it.
4959    fn project_segment(
4960        &self,
4961        leaf: usize,
4962        retained: &[bool],
4963        sidechain_only: bool,
4964        lenient: bool,
4965    ) -> Result<Vec<usize>> {
4966        let mut reversed = Vec::new();
4967        let mut seen = HashSet::new();
4968        let mut cursor = Some(leaf);
4969        while let Some(idx) = cursor {
4970            if !seen.insert(idx) {
4971                return Err(claude_replay_error(format!(
4972                    "cycle in active Claude parentUuid chain at `{}`",
4973                    self.nodes[idx].uuid
4974                )));
4975            }
4976            reversed.push(idx);
4977            cursor = match self.nodes[idx].parent_uuid.as_deref() {
4978                Some(parent) => match self.by_uuid.get(parent).copied() {
4979                    Some(parent) => Some(parent),
4980                    None if self.segment_anchors.contains(parent) => None,
4981                    // Claude can resume a background child in-place while
4982                    // retaining only the new segment in that child's JSONL.
4983                    // Its first record then points to a UUID not present in
4984                    // the sidechain file. That external edge is a segment
4985                    // boundary, not corruption; the complete source remains
4986                    // available byte-for-byte in `raw`.
4987                    None if sidechain_only => None,
4988                    None => {
4989                        return Err(claude_replay_error(format!(
4990                            "active Claude record `{}` has missing parentUuid `{parent}`",
4991                            self.nodes[idx].uuid
4992                        )));
4993                    }
4994                },
4995                None => None,
4996            };
4997            if cursor.is_some_and(|parent| !retained[parent]) {
4998                if lenient {
4999                    // A compaction boundary is where this segment ends; the
5000                    // records it pruned stay pruned.
5001                    break;
5002                }
5003                return Err(claude_replay_error(format!(
5004                    "active Claude chain crosses an excluded compaction record from `{}`",
5005                    self.nodes[idx].uuid
5006                )));
5007            }
5008        }
5009        reversed.reverse();
5010
5011        // Include non-conversation descendants rooted at the segment's leaf
5012        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
5013        let mut descendants = Vec::new();
5014        let mut frontier = vec![leaf];
5015        let mut head = 0;
5016        while head < frontier.len() {
5017            let parent = frontier[head];
5018            head += 1;
5019            for (idx, node) in self.nodes.iter().enumerate() {
5020                if !retained[idx]
5021                    || node.kind.is_conversation()
5022                    || seen.contains(&idx)
5023                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
5024                {
5025                    continue;
5026                }
5027                seen.insert(idx);
5028                descendants.push(idx);
5029                frontier.push(idx);
5030            }
5031        }
5032        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5033        reversed.extend(descendants);
5034        Ok(reversed)
5035    }
5036
5037    /// The newest retained conversation record of every component the active
5038    /// leaf's own component cannot reach.
5039    ///
5040    /// Only a severed graph produces any: a healthy transcript is one
5041    /// component, so the abandoned branches a rewind left behind stay
5042    /// abandoned here exactly as they do under strict projection.
5043    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5044        let active_root = self.component_root(active, retained);
5045        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5046        for idx in 0..self.nodes.len() {
5047            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5048                continue;
5049            }
5050            let Some(root) = self.component_root(idx, retained) else {
5051                continue;
5052            };
5053            if Some(root) == active_root {
5054                continue;
5055            }
5056            let newest = newest_by_root.entry(root).or_insert(idx);
5057            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5058                *newest = idx;
5059            }
5060        }
5061        newest_by_root.into_values().collect()
5062    }
5063
5064    /// Walk `idx` up to the record that anchors its component, stopping at a
5065    /// root, an edge that leaves the transcript, or a pruned parent. `None`
5066    /// when the walk cycles.
5067    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5068        let mut cursor = idx;
5069        let mut seen = HashSet::new();
5070        loop {
5071            if !seen.insert(cursor) {
5072                return None;
5073            }
5074            let next = self.nodes[cursor]
5075                .parent_uuid
5076                .as_deref()
5077                .and_then(|parent| self.by_uuid.get(parent).copied())
5078                .filter(|parent| retained[*parent]);
5079            match next {
5080                Some(parent) => cursor = parent,
5081                None => return Some(cursor),
5082            }
5083        }
5084    }
5085
5086    fn apply_latest_compaction(
5087        &mut self,
5088        boundary_index: usize,
5089        retained: &mut [bool],
5090    ) -> Result<()> {
5091        let compact = self.nodes[boundary_index]
5092            .compact
5093            .clone()
5094            .expect("called with compact boundary");
5095        let mut preserved = compact.preserved_uuids;
5096        if preserved.is_empty() {
5097            if let Some((head, tail)) = compact.preserved_segment {
5098                preserved = self.walk_preserved_segment(&head, &tail)?;
5099            }
5100        }
5101
5102        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5103        for uuid in &preserved {
5104            if !self.by_uuid.contains_key(uuid) {
5105                return Err(claude_replay_error(format!(
5106                    "latest compact boundary references missing preserved uuid `{uuid}`"
5107                )));
5108            }
5109        }
5110
5111        let removed_uuids: HashSet<String> = self
5112            .nodes
5113            .iter()
5114            .enumerate()
5115            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5116            .map(|(_, node)| node.uuid.clone())
5117            .collect();
5118        for (idx, node) in self.nodes.iter().enumerate() {
5119            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5120                retained[idx] = false;
5121            }
5122        }
5123
5124        if preserved.is_empty() {
5125            return Ok(());
5126        }
5127        let anchor = compact.anchor_uuid.ok_or_else(|| {
5128            claude_replay_error("preserved compact boundary is missing anchorUuid")
5129        })?;
5130        if !self.by_uuid.contains_key(&anchor) {
5131            return Err(claude_replay_error(format!(
5132                "latest compact boundary references missing anchor uuid `{anchor}`"
5133            )));
5134        }
5135        let tail = preserved.last().cloned().expect("non-empty preserved list");
5136        let mut parent = anchor.clone();
5137        for uuid in &preserved {
5138            let idx = self.by_uuid[uuid];
5139            self.nodes[idx].parent_uuid = Some(parent);
5140            parent = uuid.clone();
5141        }
5142        let first = &preserved[0];
5143        for node in &mut self.nodes {
5144            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5145                node.parent_uuid = Some(tail.clone());
5146            }
5147        }
5148        for node in &mut self.nodes {
5149            if node.kind.is_conversation()
5150                && node
5151                    .parent_uuid
5152                    .as_ref()
5153                    .is_some_and(|parent| removed_uuids.contains(parent))
5154            {
5155                node.parent_uuid = Some(tail.clone());
5156            }
5157        }
5158        Ok(())
5159    }
5160
5161    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5162        let mut reversed = Vec::new();
5163        let mut seen = HashSet::new();
5164        let mut cursor = tail;
5165        loop {
5166            if !seen.insert(cursor.to_string()) {
5167                return Err(claude_replay_error("cycle in compact preservedSegment"));
5168            }
5169            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5170                claude_replay_error(format!(
5171                    "compact preservedSegment references missing uuid `{cursor}`"
5172                ))
5173            })?;
5174            reversed.push(cursor.to_string());
5175            if cursor == head {
5176                reversed.reverse();
5177                return Ok(reversed);
5178            }
5179            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5180                claude_replay_error(format!(
5181                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5182                ))
5183            })?;
5184        }
5185    }
5186
5187    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5188        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5189            claude_replay_error(format!(
5190                "Claude record `{}` has no conversational ancestor",
5191                self.nodes[idx].uuid
5192            ))
5193        })?;
5194        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5195            claude_replay_error(format!(
5196                "Claude record `{}` has missing parentUuid `{parent}`",
5197                self.nodes[idx].uuid
5198            ))
5199        })?;
5200        if !retained[parent_idx] {
5201            return Err(claude_replay_error(format!(
5202                "Claude record `{}` points into compacted-out history",
5203                self.nodes[idx].uuid
5204            )));
5205        }
5206        Ok(parent_idx)
5207    }
5208
5209    fn recover_parallel_assistant_chunks(
5210        &self,
5211        base: Vec<usize>,
5212        retained: &[bool],
5213    ) -> Result<Vec<usize>> {
5214        let selected: HashSet<usize> = base.iter().copied().collect();
5215        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5216        let mut skipped_positions = HashSet::new();
5217        let mut handled_ids = HashSet::new();
5218
5219        for (base_pos, idx) in base.iter().copied().enumerate() {
5220            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5221                continue;
5222            };
5223            if !handled_ids.insert(message_id.to_string()) {
5224                continue;
5225            }
5226            let base_positions: Vec<usize> = base
5227                .iter()
5228                .enumerate()
5229                .filter(|(_, candidate)| {
5230                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5231                })
5232                .map(|(pos, _)| pos)
5233                .collect();
5234            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5235            skipped_positions.extend(base_positions.iter().copied().skip(1));
5236
5237            // A streamed Anthropic response can be stored as sibling records
5238            // rather than a literal parent chain. Reassemble every chunk at
5239            // the first active occurrence and restore raw chunk order before
5240            // the normalizer coalesces their content blocks.
5241            let mut chunks: Vec<usize> = self
5242                .nodes
5243                .iter()
5244                .enumerate()
5245                .filter(|(candidate, node)| {
5246                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5247                })
5248                .map(|(candidate, _)| candidate)
5249                .collect();
5250            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5251
5252            let assistant_uuids: HashSet<&str> = self
5253                .nodes
5254                .iter()
5255                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5256                .map(|node| node.uuid.as_str())
5257                .collect();
5258            let mut results: Vec<usize> = self
5259                .nodes
5260                .iter()
5261                .enumerate()
5262                .filter(|(candidate, node)| {
5263                    retained[*candidate]
5264                        && !selected.contains(candidate)
5265                        && node.is_tool_result
5266                        && node
5267                            .parent_uuid
5268                            .as_deref()
5269                            .is_some_and(|parent| assistant_uuids.contains(parent))
5270                })
5271                .map(|(candidate, _)| candidate)
5272                .collect();
5273            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5274            chunks.extend(results);
5275            replacements.insert(anchor_pos, chunks);
5276        }
5277
5278        let mut out = Vec::with_capacity(selected.len());
5279        for (pos, idx) in base.into_iter().enumerate() {
5280            if let Some(replacement) = replacements.remove(&pos) {
5281                out.extend(replacement);
5282            } else if !skipped_positions.contains(&pos) {
5283                out.push(idx);
5284            }
5285        }
5286        Ok(out)
5287    }
5288}
5289
5290impl ClaudeCompactBoundary {
5291    fn from_value(v: &Value) -> Self {
5292        let metadata = v.get("compactMetadata");
5293        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5294        let anchor_uuid = preserved_messages
5295            .and_then(|p| p.get("anchorUuid"))
5296            .and_then(Value::as_str)
5297            .or_else(|| {
5298                metadata
5299                    .and_then(|m| m.get("preservedSegment"))
5300                    .and_then(|p| p.get("anchorUuid"))
5301                    .and_then(Value::as_str)
5302            })
5303            .map(str::to_string);
5304        let preserved_uuids = preserved_messages
5305            .and_then(|p| p.get("uuids"))
5306            .and_then(Value::as_array)
5307            .map(|uuids| {
5308                uuids
5309                    .iter()
5310                    .filter_map(Value::as_str)
5311                    .map(str::to_string)
5312                    .collect()
5313            })
5314            .unwrap_or_default();
5315        let preserved_segment =
5316            metadata
5317                .and_then(|m| m.get("preservedSegment"))
5318                .and_then(|segment| {
5319                    Some((
5320                        segment.get("headUuid")?.as_str()?.to_string(),
5321                        segment.get("tailUuid")?.as_str()?.to_string(),
5322                    ))
5323                });
5324        Self {
5325            anchor_uuid,
5326            preserved_uuids,
5327            preserved_segment,
5328        }
5329    }
5330}
5331
5332fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5333    crate::Error::Other(format!(
5334        "cannot reconstruct lossless Claude continuation: {}",
5335        message.into()
5336    ))
5337}
5338
5339fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5340    v.get("message")
5341        .and_then(|message| message.get("id"))
5342        .and_then(Value::as_str)
5343}
5344
5345fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5346    let Some(target_message) = target.get_mut("message") else {
5347        return;
5348    };
5349    let Some(chunk_message) = chunk.get("message") else {
5350        return;
5351    };
5352    let mut content = target_message
5353        .get("content")
5354        .and_then(Value::as_array)
5355        .cloned()
5356        .unwrap_or_default();
5357    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5358        content.extend(blocks.iter().cloned());
5359    }
5360    let mut merged_message = chunk_message.clone();
5361    merged_message["content"] = Value::Array(content);
5362    *target_message = merged_message;
5363}
5364
5365fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5366    let Some(v) = pending.take() else {
5367        return;
5368    };
5369    let reasoning_only = claude_assistant_message_id(&v).is_some()
5370        && v.get("message")
5371            .and_then(|message| message.get("content"))
5372            .and_then(Value::as_array)
5373            .is_some_and(|blocks| {
5374                !blocks.is_empty()
5375                    && blocks.iter().all(|block| {
5376                        matches!(
5377                            block.get("type").and_then(Value::as_str),
5378                            Some("thinking" | "redacted_thinking")
5379                        )
5380                    })
5381            });
5382    if reasoning_only {
5383        return;
5384    }
5385    let before = out.len();
5386    push_claude_assistant(&v, out);
5387    capture_claude_record_provenance(&v, &mut out[before..]);
5388    restore_single_grok_message(&v, &mut out[before..]);
5389}
5390
5391/// Attach the record identity, clock, and actual assistant model to every
5392/// canonical message produced from one Claude JSONL record. These fields are
5393/// deliberately per-message: a continued transcript can cross a provider
5394/// boundary, so the session-level source model is not authoritative for its
5395/// appended tail.
5396fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5397    let timestamp = v.get("timestamp").and_then(Value::as_str);
5398    let uuid = v.get("uuid").and_then(Value::as_str);
5399    let model = v
5400        .get("message")
5401        .and_then(|message| message.get("model"))
5402        .and_then(Value::as_str);
5403    for message in messages {
5404        if let Some(timestamp) = timestamp {
5405            message
5406                .metadata
5407                .entry("timestamp".to_string())
5408                .or_insert_with(|| timestamp.to_string());
5409        }
5410        if let Some(uuid) = uuid {
5411            message
5412                .metadata
5413                .entry("claude_uuid".to_string())
5414                .or_insert_with(|| uuid.to_string());
5415        }
5416        if let Some(model) = model {
5417            message
5418                .metadata
5419                .entry("model".to_string())
5420                .or_insert_with(|| model.to_string());
5421        }
5422    }
5423}
5424
5425fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5426    restore_codex_provenance_from_top_level(v, meta)?;
5427    if meta.session_id.is_none() {
5428        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5429            meta.session_id = Some(id.to_string());
5430        }
5431    }
5432    if meta.cwd.is_none() {
5433        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5434            meta.cwd = Some(PathBuf::from(cwd));
5435        }
5436    }
5437    if meta.model.is_none() {
5438        if let Some(model) = v
5439            .get("message")
5440            .and_then(|m| m.get("model"))
5441            .and_then(Value::as_str)
5442        {
5443            meta.model = Some(model.to_string());
5444        }
5445    }
5446    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5447    // real Claude Code record with no confirmed field shape (see
5448    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5449    // named fields and risk silently mis-modeling it, stash the WHOLE raw
5450    // line verbatim under a lineage key. `write_claude_code_records` (below)
5451    // re-emits it byte-for-byte, so the record survives the Claude Code
5452    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5453    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5454    // so a Claude -> Codex -> Claude round trip can still reconstruct it
5455    // (dev/03). A session can only fork from one context, so the first one
5456    // seen wins, matching every other "first wins" field above.
5457    //
5458    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5459    // a RE-SERIALIZATION of the parsed `Value`, not the original source
5460    // text. `serde_json::Value` here has no `preserve_order` feature (see
5461    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5462    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5463    // this very comment was false. Fixed the cheap+honest way: store the
5464    // caller's own already-verbatim source `raw_line` text instead of
5465    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5466    // (key order, spacing, everything) rather than merely
5467    // structurally-equivalent JSON.
5468    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5469        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5470    {
5471        meta.lineage.insert(
5472            "claude_fork_context_ref_raw".to_string(),
5473            raw_line.to_string(),
5474        );
5475    }
5476    Ok(())
5477}
5478
5479fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5480    let content = v.get("message").and_then(|m| m.get("content"));
5481    let provenance = claude_user_provenance(v);
5482    match content {
5483        Some(Value::String(s)) => {
5484            if !s.trim().is_empty() {
5485                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5486            }
5487        }
5488        Some(Value::Array(blocks)) => {
5489            let mut text = String::new();
5490            // IX-5: image blocks alongside/instead of text — collected
5491            // separately (never synthesized on a malformed shape, see
5492            // `claude_image_block_to_part`) so a multimodal user turn
5493            // survives as `content_parts` instead of the image silently
5494            // vanishing.
5495            let mut images: Vec<Value> = Vec::new();
5496            // D5: an `image` block whose `source` isn't base64/url (e.g. a
5497            // Files-API `{"source":{"type":"file","file_id":..}}`
5498            // reference) makes `claude_image_block_to_part` return `None` —
5499            // track that it was SEEN even though it couldn't be converted,
5500            // so an image-ONLY record (no text, no convertible image) isn't
5501            // silently dropped below (the same vanishing-record bug-class
5502            // PARITY-11 fixed for reasoning-only turns).
5503            let mut saw_unconvertible_image = false;
5504            for b in blocks {
5505                match b.get("type").and_then(Value::as_str) {
5506                    Some("text") => push_text(&mut text, b.get("text")),
5507                    Some("tool_result") => {
5508                        let id = b
5509                            .get("tool_use_id")
5510                            .and_then(Value::as_str)
5511                            .unwrap_or_default();
5512                        // PARITY-11 (nested images): `extract_tool_result_content`
5513                        // captures any `image` blocks nested inside this
5514                        // `tool_result` into `content_parts` (via
5515                        // `claude_image_block_to_part`, the same conversion the
5516                        // top-level `image` block path already uses) instead of
5517                        // flattening them to the bare `[image]` marker text the
5518                        // old `extract_tool_result` emitted — the everyday
5519                        // "Read a PNG / screenshot tool output" shape.
5520                        let (result, images) =
5521                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5522                        let mut msg = tool_message(id, result);
5523                        if !images.is_empty() {
5524                            // D-mix (Fable review, must-fix): `content_parts`
5525                            // is a self-contained contract — the pi writer
5526                            // (`pi_content_value`) reads ONLY `content_parts`
5527                            // for a `Role::Tool` message and never falls back
5528                            // to `msg.content`, so on a MIXED text+image
5529                            // tool_result a bare `content_parts: [image]`
5530                            // silently drops the sibling text on `convert
5531                            // --to pi` (a regression vs. the pre-PARITY-11
5532                            // baseline, which at least preserved the text).
5533                            // Prepend the text as part 0, exactly mirroring
5534                            // `pi_content_to_text_and_parts` and
5535                            // `push_opencode_user`'s identical
5536                            // self-contained-parts construction. `msg.content`
5537                            // keeps the text too (unchanged) for the writers
5538                            // that read text from `msg.content` and only scan
5539                            // `content_parts` for `image_url` entries
5540                            // (`claude_tool_result_content_value`,
5541                            // `codex_tool_output_text`, the opencode
5542                            // assistant writer) — those already filter
5543                            // strictly on `image_url`/text-typed lookups, so
5544                            // this text part is never double-counted.
5545                            let mut parts = Vec::new();
5546                            if let Some(t) = &msg.content {
5547                                if !t.is_empty() {
5548                                    parts.push(serde_json::json!({"type": "text", "text": t}));
5549                                }
5550                            }
5551                            parts.extend(images);
5552                            msg.content_parts = Some(parts);
5553                        }
5554                        // The assistant turn that issued this tool call — the
5555                        // tool-pairing graph edge (parallel to parentUuid).
5556                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5557                        {
5558                            msg.metadata
5559                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5560                        }
5561                        // TR-10: preserve the Claude wire `is_error` flag so
5562                        // the reduction layer's success/failure boundary
5563                        // (`ReductionKind::ToolInputElided` must never target
5564                        // an errored call) survives import — `ChatMessage`
5565                        // otherwise has no structural slot for it.
5566                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5567                            crate::mark_tool_error(&mut msg);
5568                        } else {
5569                            restore_tool_outcome_extension(v, &mut msg);
5570                        }
5571                        out.push(msg);
5572                    }
5573                    Some("image") => match claude_image_block_to_part(b) {
5574                        Some(part) => images.push(part),
5575                        None => saw_unconvertible_image = true,
5576                    },
5577                    _ => {} // document / unknown — skip
5578                }
5579            }
5580            // D5: nothing convertible landed in `text`/`images` but an
5581            // image block WAS present — fold in the same short bracketed
5582            // marker convention already used for `[web_search]`/`[model
5583            // fallback: ...]` rather than letting the record vanish.
5584            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5585                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5586            }
5587            let before = out.len();
5588            if !images.is_empty() {
5589                let mut parts = Vec::new();
5590                if !text.trim().is_empty() {
5591                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5592                }
5593                parts.extend(images);
5594                out.push(
5595                    ChatMessage {
5596                        role: Role::User,
5597                        content: None,
5598                        content_parts: Some(parts),
5599                        tool_calls: None,
5600                        tool_call_id: None,
5601                        name: None,
5602                        metadata: Default::default(),
5603                    }
5604                    .with_metas(&provenance),
5605                );
5606            } else if !text.trim().is_empty() {
5607                out.push(ChatMessage::user(text).with_metas(&provenance));
5608            }
5609            if saw_unconvertible_image && out.len() > before {
5610                if let Some(msg) = out.last_mut() {
5611                    msg.metadata
5612                        .insert("image_source_unconvertible".to_string(), "true".to_string());
5613                }
5614            }
5615        }
5616        _ => {}
5617    }
5618}
5619
5620/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5621/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5622/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5623/// else in the record survives either — matches the existing
5624/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5625/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5626const UNCONVERTIBLE_IMAGE_MARKER: &str =
5627    "[image: source not captured — unsupported/unconvertible image reference]";
5628
5629/// Parse a Claude Code user-turn `image` content block
5630/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5631/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5632/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5633/// bare URL for the url form) — the inverse of
5634/// [`claude_user_content_value`]'s emission. Only a well-formed source
5635/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5636/// anything else — including a well-formed but unconvertible source like a
5637/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5638/// residue rather than synthesizing a corrupt/empty part (mirrors the
5639/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5640/// discipline). Callers must not let that turn the record invisible though:
5641/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5642fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5643    let source = b.get("source")?;
5644    match source.get("type").and_then(Value::as_str) {
5645        Some("base64") => {
5646            let mime = source.get("media_type").and_then(Value::as_str)?;
5647            let data = source.get("data").and_then(Value::as_str)?;
5648            if mime.is_empty() || data.is_empty() {
5649                return None;
5650            }
5651            Some(serde_json::json!({
5652                "type": "image_url",
5653                "image_url": {"url": format!("data:{mime};base64,{data}")},
5654            }))
5655        }
5656        Some("url") => {
5657            let url = source.get("url").and_then(Value::as_str)?;
5658            if url.is_empty() {
5659                return None;
5660            }
5661            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5662        }
5663        _ => None,
5664    }
5665}
5666
5667/// Rebuild a Claude Code user-turn `message.content` value from a
5668/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5669/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5670/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5671/// overriding constraint: a text-only message's export stays byte-identical)
5672/// — only a multimodal message (`content_parts` present, e.g. imported from
5673/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5674/// content-array shape, one `text` block (if any non-empty text part) plus
5675/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5676/// any other URL → `source.url`).
5677fn claude_user_content_value(msg: &ChatMessage) -> Value {
5678    match &msg.content_parts {
5679        Some(parts) => {
5680            let mut blocks = Vec::new();
5681            for p in parts {
5682                match p.get("type").and_then(Value::as_str) {
5683                    Some("text") => {
5684                        if let Some(t) = p.get("text").and_then(Value::as_str) {
5685                            if !t.is_empty() {
5686                                blocks.push(serde_json::json!({"type": "text", "text": t}));
5687                            }
5688                        }
5689                    }
5690                    Some("image_url") => {
5691                        if let Some(url) = p
5692                            .get("image_url")
5693                            .and_then(|u| u.get("url"))
5694                            .and_then(Value::as_str)
5695                        {
5696                            blocks.push(match parse_data_uri(url) {
5697                                Some((mime, data)) => serde_json::json!({
5698                                    "type": "image",
5699                                    "source": {"type": "base64", "media_type": mime, "data": data},
5700                                }),
5701                                None => serde_json::json!({
5702                                    "type": "image",
5703                                    "source": {"type": "url", "url": url},
5704                                }),
5705                            });
5706                        }
5707                    }
5708                    _ => {}
5709                }
5710            }
5711            Value::Array(blocks)
5712        }
5713        None => Value::String(msg.content.clone().unwrap_or_default()),
5714    }
5715}
5716
5717/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5718/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5719/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5720/// the historical plain-string `content` exactly (same IX-5-style constraint
5721/// `claude_user_content_value` follows) — only a `tool_result` that actually
5722/// carries a captured nested image gets the Anthropic content-array shape,
5723/// one `text` block (the existing `msg.content`, if any) plus one `image`
5724/// block per `image_url` part (mirrors `claude_user_content_value`'s
5725/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5726fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5727    match &msg.content_parts {
5728        Some(parts) if !parts.is_empty() => {
5729            let mut blocks = Vec::new();
5730            if let Some(t) = &msg.content {
5731                if !t.is_empty() {
5732                    blocks.push(serde_json::json!({"type": "text", "text": t}));
5733                }
5734            }
5735            for p in parts {
5736                if p.get("type").and_then(Value::as_str) == Some("image_url") {
5737                    if let Some(url) = p
5738                        .get("image_url")
5739                        .and_then(|u| u.get("url"))
5740                        .and_then(Value::as_str)
5741                    {
5742                        blocks.push(match parse_data_uri(url) {
5743                            Some((mime, data)) => serde_json::json!({
5744                                "type": "image",
5745                                "source": {"type": "base64", "media_type": mime, "data": data},
5746                            }),
5747                            None => serde_json::json!({
5748                                "type": "image",
5749                                "source": {"type": "url", "url": url},
5750                            }),
5751                        });
5752                    }
5753                }
5754            }
5755            Value::Array(blocks)
5756        }
5757        _ => Value::String(msg.content.clone().unwrap_or_default()),
5758    }
5759}
5760
5761/// Collect the Claude Code user-turn provenance fields that distinguish real
5762/// human input from system-injected turns and record replay-relevant state.
5763pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5764    let mut out = Vec::new();
5765    let mut take_str = |key: &str| {
5766        if let Some(s) = v.get(key).and_then(Value::as_str) {
5767            out.push((key.to_string(), s.to_string()));
5768        }
5769    };
5770    take_str("promptSource"); // typed | queued | system | sdk
5771    take_str("interruptedMessageId");
5772    take_str("sourceToolUseID");
5773    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5774        if v.get(flag).and_then(Value::as_bool) == Some(true) {
5775            out.push((flag.to_string(), "true".to_string()));
5776        }
5777    }
5778    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5779        out.push(("queuePriority".to_string(), n.to_string()));
5780    }
5781    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5782    if let Some(kind) = v
5783        .get("origin")
5784        .and_then(|o| o.get("kind"))
5785        .and_then(Value::as_str)
5786    {
5787        out.push(("origin".to_string(), kind.to_string()));
5788    }
5789    out
5790}
5791
5792/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5793/// `local_command`, `away_summary`) carry real text that's part of the
5794/// interaction; fold them in as system context. Marker/metric subtypes
5795/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5796/// no conversational content and are skipped.
5797fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5798    let keep = matches!(
5799        v.get("subtype").and_then(Value::as_str),
5800        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5801    );
5802    if !keep {
5803        return;
5804    }
5805    if let Some(content) = v.get("content").and_then(Value::as_str) {
5806        if !content.trim().is_empty() {
5807            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5808            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5809        }
5810    }
5811}
5812
5813/// Fold content-bearing Claude Code `attachment` records into the conversation
5814/// as user-role messages. Most attachment subtypes (`task_reminder`,
5815/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5816/// are regenerable system injections and are skipped; only the four that carry
5817/// non-regenerable user/external content are kept.
5818fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5819    let att = match v.get("attachment") {
5820        Some(a) => a,
5821        None => return,
5822    };
5823    let kind = match att.get("type").and_then(Value::as_str) {
5824        Some(kind) => kind,
5825        None => return,
5826    };
5827    let text = match kind {
5828        // A queued prompt. `commandMode` says whose: `prompt` is the person's
5829        // own text, `task-notification` is the runtime reporting a finished
5830        // background task. Kept verbatim below.
5831        "queued_command" => att
5832            .get("prompt")
5833            .and_then(Value::as_str)
5834            .map(str::to_string),
5835        // A file the user attached: header + contents.
5836        "file" => attachment_with_path(att, "attached file", "filename", "content"),
5837        // A user-edited file snippet.
5838        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5839        // Injected project memory (CLAUDE.md), point-in-time.
5840        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5841        _ => None, // regenerable system injection — skip
5842    };
5843    let Some(text) = text else { return };
5844    if text.trim().is_empty() {
5845        return;
5846    }
5847    // An attachment record wears the user's ROLE, but the record itself says
5848    // who actually spoke — and that fact is lost the moment the attachment is
5849    // flattened to `[label: path]` text, so carry it as metadata the way
5850    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5851    //
5852    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
5853    //                     `nested_memory` are envelopes the runtime built
5854    //                     around a file body; a frontend that trusts the role
5855    //                     shows the reader a numbered source listing in a
5856    //                     chat bubble apparently sent by themselves.
5857    //   `commandMode`     present on `queued_command` only, and the whole
5858    //                     story for it. Measured over the local Claude Code
5859    //                     corpus (2,512 `queued_command` attachments): 926
5860    //                     `prompt`, every one of them plain human text, and
5861    //                     1,586 `task-notification`, every one of them a
5862    //                     `<task-notification>` frame — the same text Claude
5863    //                     Code also writes as a `type:"user"` record stamped
5864    //                     `origin.kind = "task-notification"`.
5865    //
5866    // Presentation policy (which of these a frontend hides) belongs to the
5867    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5868    // job is to stop discarding the producer's own answer.
5869    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5870    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5871        message = message.with_meta("commandMode", mode);
5872    }
5873    out.push(message);
5874}
5875
5876/// Format an attachment as `[<label>: <path>]\n<body>`.
5877fn attachment_with_path(
5878    att: &Value,
5879    label: &str,
5880    path_key: &str,
5881    body_key: &str,
5882) -> Option<String> {
5883    let body = att.get(body_key).and_then(Value::as_str)?;
5884    let path = att
5885        .get(path_key)
5886        .or_else(|| att.get("displayPath"))
5887        .and_then(Value::as_str)
5888        .unwrap_or("");
5889    Some(format!("[{label}: {path}]\n{body}"))
5890}
5891
5892fn push_str_field(buf: &mut String, s: &str) {
5893    if !buf.is_empty() {
5894        buf.push('\n');
5895    }
5896    buf.push_str(s);
5897}
5898
5899/// N3: build a synthesized message for reasoning that could not attach to a
5900/// following assistant turn — either interrupted mid-stream by a
5901/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5902/// the three pending buffers (all empty/`false` afterward) so callers don't
5903/// separately have to remember to clear them.
5904fn orphaned_reasoning_message(
5905    reasoning: &mut String,
5906    reasoning_content: &mut String,
5907    encrypted: &mut bool,
5908) -> ChatMessage {
5909    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5910    if !reasoning.is_empty() {
5911        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5912    }
5913    if !reasoning_content.is_empty() {
5914        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5915    }
5916    if *encrypted {
5917        msg = msg.with_meta("reasoning_encrypted", "true");
5918        *encrypted = false;
5919    }
5920    msg
5921}
5922
5923fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5924    let content = v.get("message").and_then(|m| m.get("content"));
5925    let mut text = String::new();
5926    let mut calls: Vec<ToolCall> = Vec::new();
5927    // Legacy singular fields — kept for backward compatibility with every
5928    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5929    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5930    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5931    // message carries MULTIPLE `thinking` blocks, collapsing them down to
5932    // these singular fields silently drops every signature but the last
5933    // one's — a real Anthropic `thinking` block's `signature` cryptographically
5934    // covers ONLY that block's own text, so re-emitting block 1's text under
5935    // block 2's signature (or vice versa) produces a signature that will
5936    // never verify. `thinking_blocks` below is the fix: every block
5937    // preserved SEPARATELY, in order, each with its own (optional)
5938    // signature/data — the writer prefers it over the legacy fields
5939    // whenever present.
5940    let mut thinking = String::new();
5941    let mut signature: Option<String> = None;
5942    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5943    // `image` assistant blocks, and (rarely) a `fallback` model-routing
5944    // marker — none handled before, all silently vanishing (audit's own
5945    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5946    // `fallback` blocks in the reference corpus).
5947    //
5948    // D8: `redacted_thinking` is real data ONLY — never a fabricated
5949    // placeholder. The pre-fix code defaulted a missing `data` field to the
5950    // literal string `"<redacted>"`, which is indistinguishable from an
5951    // actual (if oddly-named) opaque payload on re-emit — a caller reading
5952    // it back has no way to tell "no data was ever captured" from "the
5953    // provider's own opaque blob happens to be the string `<redacted>`".
5954    // `redacted_thinking_seen` tracks block PRESENCE independently of
5955    // whether it had real data, so the reasoning-only-turn rescue below
5956    // still fires even when no block had a `data` field at all.
5957    let mut redacted_thinking: Option<String> = None;
5958    let mut redacted_thinking_seen = false;
5959    let mut images: Vec<Value> = Vec::new();
5960    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
5961    // `thinking` string alongside a real `signature` (the summarized/
5962    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
5963    // would miss those, so track "a thinking block existed at all"
5964    // separately from whether it had visible text.
5965    let mut thinking_block_seen = false;
5966    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
5967    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
5968    // above. Serialized as a single JSON-array metadata string
5969    // (`ChatMessage::metadata` is a flat string map) under
5970    // `"thinking_blocks"`.
5971    let mut thinking_blocks: Vec<Value> = Vec::new();
5972    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
5973    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
5974    // not silently vanish the whole record when nothing else survives.
5975    let mut saw_unconvertible_image = false;
5976
5977    match content {
5978        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
5979        Some(Value::Array(blocks)) => {
5980            for b in blocks {
5981                match b.get("type").and_then(Value::as_str) {
5982                    Some("text") => push_text(&mut text, b.get("text")),
5983                    Some("tool_use") => {
5984                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
5985                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
5986                        let args = b
5987                            .get("input")
5988                            .map(|i| i.to_string())
5989                            .unwrap_or_else(|| "{}".to_string());
5990                        calls.push(function_call(id, name, args));
5991                    }
5992                    // Thinking is not replayed across providers, but retain it in
5993                    // (skip-serialized) metadata so a same-model continuation can
5994                    // re-inject it. See P3.
5995                    Some("thinking") => {
5996                        thinking_block_seen = true;
5997                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
5998                        if !t.is_empty() {
5999                            push_str_field(&mut thinking, t); // legacy concatenated field
6000                        }
6001                        let sig = b.get("signature").and_then(Value::as_str);
6002                        if let Some(s) = sig {
6003                            signature = Some(s.to_string()); // legacy last-wins field
6004                        }
6005                        // D8: this block's OWN text + signature, not folded
6006                        // into the running concatenation above.
6007                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
6008                        if let Some(s) = sig {
6009                            block["signature"] = Value::String(s.to_string());
6010                        }
6011                        thinking_blocks.push(block);
6012                    }
6013                    // Anthropic's redacted reasoning: an opaque, provider-private
6014                    // payload (flagged content the API declines to show in the
6015                    // clear). Like `thinking`, it's not replayable, but the raw
6016                    // `data` is retained in metadata rather than silently
6017                    // vanishing — a same-model continuation can still replay it
6018                    // verbatim even though supercode never renders it.
6019                    Some("redacted_thinking") => {
6020                        redacted_thinking_seen = true;
6021                        let data = b.get("data").and_then(Value::as_str);
6022                        // D8: no fabricated fallback — `data` is only ever
6023                        // the real captured payload, or genuinely absent.
6024                        if let Some(d) = data {
6025                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
6026                        }
6027                        let mut block = serde_json::json!({"type": "redacted_thinking"});
6028                        if let Some(d) = data {
6029                            block["data"] = Value::String(d.to_string());
6030                        }
6031                        thinking_blocks.push(block);
6032                    }
6033                    // An assistant-emitted image block (e.g. a generated
6034                    // image) — collected exactly like `push_claude_user`'s
6035                    // user-turn image handling (`claude_image_block_to_part`
6036                    // is role-general), so it survives as `content_parts`
6037                    // instead of vanishing.
6038                    Some("image") => match claude_image_block_to_part(b) {
6039                        Some(part) => images.push(part),
6040                        None => saw_unconvertible_image = true,
6041                    },
6042                    // A provider-routing note (real shape:
6043                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6044                    // — a mid-generation model swap, e.g. an overloaded model
6045                    // falling back to another). Carries no replayable
6046                    // conversational content, but folding it into `text` as a
6047                    // short bracketed marker — the same convention the Codex
6048                    // loader already uses for `[web_search]`/
6049                    // `[image_generation] ...` — keeps it visible instead of
6050                    // silently vanishing, including the case where it's the
6051                    // ONLY block in the turn (see the reasoning-only-turn fix
6052                    // below: before this, that shape dropped the entire
6053                    // message).
6054                    Some("fallback") => {
6055                        let from = b
6056                            .get("from")
6057                            .and_then(|f| f.get("model"))
6058                            .and_then(Value::as_str)
6059                            .unwrap_or("?");
6060                        let to = b
6061                            .get("to")
6062                            .and_then(|t| t.get("model"))
6063                            .and_then(Value::as_str)
6064                            .unwrap_or("?");
6065                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6066                    }
6067                    _ => {}
6068                }
6069            }
6070        }
6071        _ => {}
6072    }
6073
6074    // D5: nothing convertible landed in `text`/`images` but an image block
6075    // WAS present — fold in the same bracketed-marker convention `fallback`
6076    // uses above, so a genuinely image-only (unconvertible source) turn
6077    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6078    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6079        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6080    }
6081
6082    let before = out.len();
6083    if !images.is_empty() {
6084        let mut parts = Vec::new();
6085        if !text.trim().is_empty() {
6086            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6087        }
6088        parts.extend(images);
6089        out.push(ChatMessage {
6090            role: Role::Assistant,
6091            content: None,
6092            content_parts: Some(parts),
6093            tool_calls: (!calls.is_empty()).then_some(calls),
6094            tool_call_id: None,
6095            name: None,
6096            metadata: Default::default(),
6097        });
6098    } else {
6099        push_assistant(out, text, calls);
6100        // A recognized native assistant record remains transcript state even
6101        // when its content array is empty (for example, an interrupted model
6102        // turn). Force a bare message whenever `push_assistant` had nothing
6103        // to emit. This includes the reasoning-only case and also preserves
6104        // genuinely part-less records instead of silently changing turn
6105        // count/order during translation.
6106        if out.len() == before {
6107            let mut empty = ChatMessage {
6108                role: Role::Assistant,
6109                content: None,
6110                content_parts: None,
6111                tool_calls: None,
6112                tool_call_id: None,
6113                name: None,
6114                metadata: Default::default(),
6115            };
6116            if !thinking_block_seen && !redacted_thinking_seen {
6117                empty
6118                    .metadata
6119                    .insert("empty_assistant_record".to_string(), "true".to_string());
6120            }
6121            out.push(empty);
6122        }
6123    }
6124    // Attach retained reasoning + attribution to the message we just produced.
6125    if out.len() > before {
6126        if let Some(msg) = out.last_mut() {
6127            // Insert "thinking" (even as an empty string) whenever a
6128            // `thinking` block was actually seen, not just when it had
6129            // visible text — a real `thinking` block commonly carries an
6130            // empty `thinking` string alongside a real `signature` (the
6131            // summarized-away-but-still-replayable case), and the writer
6132            // below keys its re-emission decision off this metadata key's
6133            // PRESENCE, not its content.
6134            if thinking_block_seen {
6135                msg.metadata.insert("thinking".to_string(), thinking);
6136            }
6137            if let Some(sig) = signature {
6138                msg.metadata.insert("thinking_signature".to_string(), sig);
6139            }
6140            if let Some(rt) = redacted_thinking {
6141                msg.metadata.insert("redacted_thinking".to_string(), rt);
6142            }
6143            // D8: exact per-block re-emission list — every `thinking`/
6144            // `redacted_thinking` block preserved separately, in order, each
6145            // with its own (optional) signature/data. The writer prefers
6146            // this over the legacy singular fields above whenever present,
6147            // so a multi-block message round-trips losslessly instead of
6148            // collapsing to one block under one (now-unverifiable)
6149            // signature.
6150            if !thinking_blocks.is_empty() {
6151                msg.metadata.insert(
6152                    "thinking_blocks".to_string(),
6153                    Value::Array(thinking_blocks).to_string(),
6154                );
6155            }
6156            // D5: honest signal that this message contained an image block
6157            // whose source this loader couldn't convert — the actual image
6158            // content is NOT captured, only a marker/partial record.
6159            if saw_unconvertible_image {
6160                msg.metadata
6161                    .insert("image_source_unconvertible".to_string(), "true".to_string());
6162            }
6163            // Attribution: which skill / subagent / MCP server+tool produced
6164            // this turn, plus the model `slug`.
6165            for key in [
6166                "attributionSkill",
6167                "attributionAgent",
6168                "attributionMcpServer",
6169                "attributionMcpTool",
6170                "slug",
6171            ] {
6172                if let Some(s) = v.get(key).and_then(Value::as_str) {
6173                    msg.metadata.insert(key.to_string(), s.to_string());
6174                }
6175            }
6176        }
6177    }
6178}
6179
6180// ---- Codex ----------------------------------------------------------------
6181
6182const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6183
6184fn codex_provenance_kind(record: &Value) -> Option<&str> {
6185    match record.get("type").and_then(Value::as_str) {
6186        Some("session_meta") => Some("session_meta"),
6187        Some("turn_context") => Some("turn_context"),
6188        Some("compacted") => Some("compacted"),
6189        Some("event_msg") => match record
6190            .get("payload")
6191            .and_then(|payload| payload.get("type"))
6192            .and_then(Value::as_str)
6193        {
6194            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6195            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6196            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6197            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6198            _ => None,
6199        },
6200        _ => None,
6201    }
6202}
6203
6204fn capture_codex_provenance_record(
6205    meta: &mut SessionMeta,
6206    record_index: usize,
6207    raw_line: &str,
6208    record: &Value,
6209) {
6210    let Some(kind) = codex_provenance_kind(record) else {
6211        return;
6212    };
6213    meta.codex_provenance.push(serde_json::json!({
6214        "record_index": record_index,
6215        "kind": kind,
6216        "raw": raw_line,
6217    }));
6218}
6219
6220fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6221    (!meta.codex_provenance.is_empty()).then(|| {
6222        serde_json::json!({
6223            "version": 1,
6224            "records": &meta.codex_provenance,
6225        })
6226    })
6227}
6228
6229fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6230    if extension.get("version").and_then(Value::as_u64) != Some(1) {
6231        return Err(Error::InvalidSession(
6232            "invalid portable Codex provenance: expected version 1".to_string(),
6233        ));
6234    }
6235    let Some(records) = extension.get("records").and_then(Value::as_array) else {
6236        return Err(Error::InvalidSession(
6237            "invalid portable Codex provenance: `records` must be an array".to_string(),
6238        ));
6239    };
6240    if records.is_empty() {
6241        return Err(Error::InvalidSession(
6242            "invalid portable Codex provenance: `records` must not be empty".to_string(),
6243        ));
6244    }
6245    let mut restored = Vec::with_capacity(records.len());
6246    for entry in records {
6247        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6248            return Err(Error::InvalidSession(
6249                "invalid portable Codex provenance: record_index must be an integer".to_string(),
6250            ));
6251        };
6252        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6253            return Err(Error::InvalidSession(
6254                "invalid portable Codex provenance: kind must be a string".to_string(),
6255            ));
6256        };
6257        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6258            return Err(Error::InvalidSession(
6259                "invalid portable Codex provenance: raw must be a string".to_string(),
6260            ));
6261        };
6262        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6263            return Err(Error::InvalidSession(
6264                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6265            ));
6266        };
6267        if codex_provenance_kind(&record) != Some(kind) {
6268            return Err(Error::InvalidSession(format!(
6269                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6270            )));
6271        }
6272        restored.push(entry.clone());
6273    }
6274    meta.codex_provenance = restored;
6275    meta.codex_headers.clear();
6276    for entry in &meta.codex_provenance {
6277        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6278            continue;
6279        };
6280        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6281            continue;
6282        };
6283        if matches!(
6284            record.get("type").and_then(Value::as_str),
6285            Some("session_meta") | Some("turn_context")
6286        ) {
6287            meta.codex_headers.push(record);
6288        }
6289    }
6290    Ok(true)
6291}
6292
6293fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6294    match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6295        Some(extension) => restore_codex_provenance(extension, meta),
6296        None => Ok(false),
6297    }
6298}
6299
6300fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6301    let Some(line_end) = out.find('\n') else {
6302        return;
6303    };
6304    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6305        return;
6306    };
6307    let Some(object) = record.as_object_mut() else {
6308        return;
6309    };
6310    object.insert(key.to_string(), extension);
6311    out.replace_range(..line_end, &record.to_string());
6312}
6313
6314fn inject_codex_provenance(out: &mut String, extension: Value) {
6315    let Some(line_end) = out.find('\n') else {
6316        return;
6317    };
6318    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6319        return;
6320    };
6321    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6322        return;
6323    }
6324    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6325        return;
6326    };
6327    payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6328    out.replace_range(..line_end, &record.to_string());
6329}
6330
6331/// Remove the last conversational turn from `messages`: everything from the
6332/// last `user` message to the end (the user prompt plus the assistant's
6333/// response and any tool calls/results it triggered).
6334fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6335    if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6336        messages.truncate(idx);
6337    } else {
6338        messages.clear();
6339    }
6340    // IX-6 fix: the new tail exposed by `truncate` may still carry
6341    // `__codex_open_turn` from when it was marked (it was NOT the last
6342    // message at that time — items after it, now removed by the rollback,
6343    // intervened). A bare `function_call` arriving after the rollback is a
6344    // genuinely NEW turn and must get its own message, not merge into this
6345    // stale marked tail — close it out here so `push_codex_item`'s
6346    // adjacency check (`out.last()` + marker) can't be fooled by the
6347    // truncation re-exposing it.
6348    if let Some(last) = messages.last_mut() {
6349        last.metadata.remove("__codex_open_turn");
6350    }
6351}
6352
6353fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6354    truncate_messages_with_anchor(messages, message_limit, None);
6355}
6356
6357fn truncate_messages_with_anchor(
6358    messages: &mut Vec<ChatMessage>,
6359    message_limit: usize,
6360    preceding_user: Option<ChatMessage>,
6361) {
6362    let limit = message_limit.max(1);
6363    if messages.len() <= limit {
6364        return;
6365    }
6366    let tail_start = messages.len() - limit;
6367    // A bounded display view must slide monotonically. Anchoring on the first
6368    // user *inside* the tail lets one newly appended prompt erase every
6369    // previously visible turn in a tool-heavy session. Retain the latest user
6370    // at or before the boundary and fill the remainder with the newest rows.
6371    let anchor = messages[..tail_start]
6372        .iter()
6373        .rfind(|message| message.role == Role::User)
6374        .cloned()
6375        .or(preceding_user);
6376    if let Some(anchor) = anchor {
6377        let recent_start = messages.len() - limit.saturating_sub(1);
6378        messages.drain(..recent_start);
6379        messages.insert(0, anchor);
6380    } else {
6381        messages.drain(..tail_start);
6382    }
6383}
6384
6385fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6386    truncate_messages(&mut session.messages, message_limit);
6387}
6388
6389/// The text of a Codex `agent_message` event. `message` is usually a string but
6390/// can be a structured object (e.g. review output) — fall back to its JSON.
6391fn agent_message_text(payload: &Value) -> String {
6392    match payload.get("message") {
6393        Some(Value::String(s)) => s.clone(),
6394        Some(other) => extract_text_content(Some(other)),
6395        None => String::new(),
6396    }
6397}
6398
6399/// Trimmed texts of all assistant messages present as `response_item` — the
6400/// dedup set for recovering collab-only `agent_message` narration.
6401fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6402    let mut set = std::collections::HashSet::new();
6403    for line in non_empty_lines(jsonl) {
6404        let Ok(v) = serde_json::from_str::<Value>(line) else {
6405            continue;
6406        };
6407        if v.get("type").and_then(Value::as_str) != Some("response_item") {
6408            continue;
6409        }
6410        let payload = v.get("payload").unwrap_or(&Value::Null);
6411        if payload.get("type").and_then(Value::as_str) == Some("message")
6412            && payload.get("role").and_then(Value::as_str) == Some("assistant")
6413        {
6414            let text = extract_text_content(payload.get("content"));
6415            if !text.trim().is_empty() {
6416                set.insert(text.trim().to_string());
6417            }
6418        }
6419    }
6420    set
6421}
6422
6423fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6424    if meta.session_id.is_none() {
6425        if let Some(id) = payload.get("id").and_then(Value::as_str) {
6426            meta.session_id = Some(id.to_string());
6427        }
6428    }
6429    if meta.cwd.is_none() {
6430        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6431            meta.cwd = Some(PathBuf::from(cwd));
6432        }
6433    }
6434    if meta.system_prompt.is_none() {
6435        // `base_instructions` may be a string or `{ "text": "..." }`.
6436        let bi = payload.get("base_instructions");
6437        let text = match bi {
6438            Some(Value::String(s)) => Some(s.clone()),
6439            Some(Value::Object(_)) => bi
6440                .and_then(|b| b.get("text"))
6441                .and_then(Value::as_str)
6442                .map(str::to_string),
6443            _ => None,
6444        };
6445        meta.system_prompt = text;
6446    }
6447    if meta.model.is_none() {
6448        if let Some(m) = payload.get("model").and_then(Value::as_str) {
6449            meta.model = Some(m.to_string());
6450        }
6451    }
6452    // Cross-file lineage keys for multi-agent / forked sessions.
6453    let mut put = |key: &str, v: Option<&Value>| {
6454        if let Some(s) = v.and_then(Value::as_str) {
6455            meta.lineage.insert(key.to_string(), s.to_string());
6456        }
6457    };
6458    put("parent_thread_id", payload.get("parent_thread_id"));
6459    put("forked_from_id", payload.get("forked_from_id"));
6460    put("thread_source", payload.get("thread_source"));
6461    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6462    // passthrough — restores a captured Claude `fork-context-ref` so a
6463    // Claude -> Codex -> Claude round trip reconstructs the original record
6464    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6465    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6466        if let Some(v) = payload.get("claude_fork_context_ref") {
6467            meta.lineage
6468                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6469        }
6470    }
6471    if let Some(spawn) = payload
6472        .get("source")
6473        .and_then(|s| s.get("subagent"))
6474        .and_then(|s| s.get("thread_spawn"))
6475    {
6476        // parent_thread_id can also live here (preferred when both present).
6477        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6478            meta.lineage
6479                .insert("parent_thread_id".to_string(), p.to_string());
6480        }
6481        for k in ["agent_role", "agent_nickname"] {
6482            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6483                meta.lineage.insert(k.to_string(), s.to_string());
6484            }
6485        }
6486        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6487            meta.lineage.insert("depth".to_string(), d.to_string());
6488        }
6489    }
6490}
6491
6492/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6493fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6494    let mut d = 0;
6495    let mut guard = 0;
6496    while let Some(p) = parent_of[i] {
6497        if p == i || guard > parent_of.len() {
6498            break;
6499        }
6500        i = p;
6501        d += 1;
6502        guard += 1;
6503    }
6504    d
6505}
6506
6507/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6508fn codex_turn_id(payload: &Value) -> Option<&str> {
6509    payload
6510        .get("metadata")
6511        .and_then(|m| m.get("turn_id"))
6512        .and_then(Value::as_str)
6513}
6514
6515/// N2 (spliced-export hardening): every Codex group id already present in
6516/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6517/// replays ahead of the appended tail it synthesizes via
6518/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6519/// physically lands in the exported `out` string for the prefix: each line
6520/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6521/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6522/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6523/// export) is extracted directly — no re-derivation from `self.messages`
6524/// needed (that would have to reconstruct which ids the ORIGINAL export
6525/// happened to assign, which this sidesteps entirely by reading them back
6526/// out of the bytes themselves). A line that fails to parse, isn't a
6527/// `response_item`, or carries no `turn_id` contributes nothing — headers
6528/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6529/// never carry this field to begin with.
6530fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6531    let mut ids = HashSet::new();
6532    for line in raw_prefix {
6533        if let Ok(v) = serde_json::from_str::<Value>(line) {
6534            if let Some(payload) = v.get("payload") {
6535                if let Some(tid) = codex_turn_id(payload) {
6536                    ids.insert(tid.to_string());
6537                }
6538            }
6539        }
6540    }
6541    ids
6542}
6543
6544/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6545/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6546/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6547/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6548/// message that already carries a more specific timestamp of its own is
6549/// never overwritten (none currently do on the Codex side, but this keeps
6550/// every loader consistent). A no-op when `ts` is `None` (a line with no
6551/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6552fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6553    let Some(ts) = ts else { return };
6554    let Some(slice) = messages.get_mut(from..) else {
6555        return;
6556    };
6557    for m in slice {
6558        m.metadata
6559            .entry("timestamp".to_string())
6560            .or_insert_with(|| ts.to_string());
6561    }
6562}
6563
6564fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6565    match payload.get("type").and_then(Value::as_str) {
6566        Some("message") => {
6567            let role = match payload.get("role").and_then(Value::as_str) {
6568                Some("user") => Role::User,
6569                Some("assistant") => Role::Assistant,
6570                // "developer" and "system" both carry operator instructions.
6571                _ => Role::System,
6572            };
6573            let content = payload.get("content");
6574            let text = extract_text_content(content);
6575            // IX-5: `input_image` blocks alongside/instead of text — see
6576            // `codex_extract_images`. A text-only message (no image blocks)
6577            // takes the historical `content: Some(text)` shape unchanged.
6578            let images = codex_extract_images(content);
6579            let is_empty_assistant =
6580                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6581            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6582                let content_parts = if images.is_empty() {
6583                    None
6584                } else {
6585                    let mut parts = Vec::new();
6586                    if !text.trim().is_empty() {
6587                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6588                    }
6589                    parts.extend(images);
6590                    Some(parts)
6591                };
6592                let mut msg = ChatMessage {
6593                    role,
6594                    content: if content_parts.is_some() || text.is_empty() {
6595                        None
6596                    } else {
6597                        Some(text)
6598                    },
6599                    content_parts,
6600                    tool_calls: None,
6601                    tool_call_id: None,
6602                    name: None,
6603                    metadata: Default::default(),
6604                };
6605                // Preserve the assistant `phase` (commentary vs final_answer) so
6606                // a reloaded transcript can distinguish narration from the answer.
6607                if role == Role::Assistant {
6608                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6609                        msg.metadata.insert("phase".to_string(), phase.to_string());
6610                    }
6611                    // IX-6: mark this as an open, mergeable combined-turn
6612                    // candidate — a `function_call` response_item found
6613                    // immediately after (still `out.last()` when reached,
6614                    // i.e. no other item intervened) merges into this SAME
6615                    // `ChatMessage` instead of splitting into a second one,
6616                    // matching how Claude's parser keeps a text+tool_use
6617                    // turn together. Stripped again before the loaded
6618                    // `Session` is returned (`from_codex_str`), so it never
6619                    // leaks as visible metadata.
6620                    msg.metadata
6621                        .insert("__codex_open_turn".to_string(), "true".to_string());
6622                }
6623                // The per-turn grouping key (Codex batches items by turn_id).
6624                if let Some(tid) = codex_turn_id(payload) {
6625                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
6626                }
6627                // PARITY-6 dev/02: restore the original Claude
6628                // `systemSubtype` for a `developer`/`system` message that
6629                // was itself synthesized FROM a real Claude system record
6630                // (`write_codex_records`'s `Role::System` arm stamps
6631                // `claude_system_subtype`) — the exact inverse, so
6632                // `write_claude_code_records`'s `Role::System` arm can
6633                // re-materialize the real Claude `type: "system"` record
6634                // faithfully on a Codex -> Claude Code hop instead of
6635                // guessing a fallback subtype.
6636                if role == Role::System {
6637                    if let Some(subtype) = payload
6638                        .get("metadata")
6639                        .and_then(|m| m.get("claude_system_subtype"))
6640                        .and_then(Value::as_str)
6641                    {
6642                        msg.metadata
6643                            .insert("systemSubtype".to_string(), subtype.to_string());
6644                    }
6645                }
6646                if is_empty_assistant {
6647                    msg.metadata
6648                        .insert("empty_assistant_record".to_string(), "true".to_string());
6649                }
6650                out.push(msg);
6651            }
6652        }
6653        Some("function_call") => {
6654            let id = payload
6655                .get("call_id")
6656                .and_then(Value::as_str)
6657                .unwrap_or_default();
6658            let raw_name = payload
6659                .get("name")
6660                .and_then(Value::as_str)
6661                .unwrap_or_default();
6662            // Preserve the MCP `namespace` by qualifying the tool name
6663            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6664            // so the tool identity isn't ambiguous on round-trip.
6665            let qualified;
6666            let name = match payload.get("namespace").and_then(Value::as_str) {
6667                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6668                    qualified = format!("{ns}__{raw_name}");
6669                    qualified.as_str()
6670                }
6671                _ => raw_name,
6672            };
6673            let args = payload
6674                .get("arguments")
6675                .map(value_to_arg_string)
6676                .unwrap_or_else(|| "{}".to_string());
6677            let call = function_call(id, name, args);
6678            // IX-6: a `function_call` immediately after an assistant `message`
6679            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6680            // by the "message" arm above, and not yet closed by anything else)
6681            // merges into that ONE `ChatMessage` — text→`content`,
6682            // call→`tool_calls` — instead of splitting into a second message.
6683            // A bare `function_call` with no such preceding turn (the marker
6684            // absent, or `out.last()` not an assistant message) is unaffected:
6685            // it still gets its own synthesized message, exactly as before.
6686            //
6687            // Belt-and-suspenders (PARITY-6/7 tightened): if this
6688            // `function_call` response_item itself carries a `turn_id` (rare
6689            // in observed real-native-Codex corpora — Codex usually only
6690            // stamps it on `message` payloads — but ALWAYS present on OUR
6691            // OWN synthesized export whenever a `ChatMessage`'s own tool
6692            // calls need merge disambiguation, see `write_codex_records`),
6693            // it must match the marked assistant message's recorded
6694            // `turn_id` EXACTLY — including "the marked message has none at
6695            // all" counting as a mismatch. That's exactly the shape of two
6696            // genuinely separate, adjacent `ChatMessage`s (an unrelated
6697            // text-only turn immediately followed by a different,
6698            // tool-call-only turn): the tool-only turn's own `function_call`s
6699            // carry a synthetic id while the unrelated preceding text
6700            // message carries none, so this correctly refuses the merge
6701            // instead of falling through to a permissive default. Only when
6702            // this `function_call` carries NO `turn_id` at all (the ordinary
6703            // real-native-Codex shape) does this fall back to the original
6704            // permissive "adjacency + open marker is enough" rule —
6705            // unchanged from before for the vast majority of real Codex
6706            // data. The truncation/clear strip above is what actually closes
6707            // the marker across rollback/compaction boundaries; this is only
6708            // an extra guard for the case where a stale-but-unstripped
6709            // marker and a turn_id mismatch coincide.
6710            let can_merge = out.last().is_some_and(|last| {
6711                last.role == Role::Assistant
6712                    && last.metadata.contains_key("__codex_open_turn")
6713                    && match codex_turn_id(payload) {
6714                        Some(fc_tid) => {
6715                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6716                        }
6717                        None => true,
6718                    }
6719            });
6720            if can_merge {
6721                out.last_mut()
6722                    .expect("can_merge implies out.last() is Some")
6723                    .tool_calls
6724                    .get_or_insert_with(Vec::new)
6725                    .push(call);
6726            } else {
6727                push_assistant(out, String::new(), vec![call]);
6728                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6729                // in this turn, so nothing set `__codex_open_turn` above) can
6730                // still be the FIRST of several tool calls that all belong to
6731                // the SAME original `ChatMessage` (`write_codex_records`
6732                // stamps every one of a message's own tool calls with the
6733                // identical synthetic `turn_id`). Re-open THIS freshly
6734                // created message — but ONLY when a real `turn_id` is
6735                // present — so the NEXT `function_call` in the same group
6736                // merges into it instead of becoming its own message too.
6737                // Gated on `codex_turn_id(payload).is_some()` (not the bare
6738                // default `true` the belt-and-suspenders check above uses)
6739                // so real native Codex data — which almost never carries
6740                // this field on `function_call` payloads (see the comment
6741                // above) — keeps its existing "every bare tool call is its
6742                // own turn" behavior exactly as before.
6743                if let Some(tid) = codex_turn_id(payload) {
6744                    if let Some(last) = out.last_mut() {
6745                        last.metadata
6746                            .insert("__codex_open_turn".to_string(), "true".to_string());
6747                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6748                    }
6749                }
6750            }
6751        }
6752        Some("function_call_output") => {
6753            let id = payload
6754                .get("call_id")
6755                .and_then(Value::as_str)
6756                .unwrap_or_default();
6757            let result = match payload.get("output") {
6758                Some(Value::String(s)) => s.clone(),
6759                Some(v) => extract_text_content(Some(v)),
6760                None => String::new(),
6761            };
6762            let mut message = tool_message(id, result);
6763            // TR-13: Codex v1 exposes no structured success/error field on
6764            // this record. Free-text output is not a safe classifier, so the
6765            // reduction engine must treat the outcome as explicitly unknown
6766            // and fail closed on both success-only and error-only pruning.
6767            crate::mark_tool_outcome_unknown(&mut message);
6768            out.push(message);
6769        }
6770        // Custom / MCP tool calls are shaped like function calls but carry their
6771        // arguments under `input` (a JSON-encoded string). Normalize them the
6772        // same way so MCP-using sessions don't lose those turns.
6773        Some("custom_tool_call") => {
6774            let id = payload
6775                .get("call_id")
6776                .and_then(Value::as_str)
6777                .unwrap_or_default();
6778            let name = payload
6779                .get("name")
6780                .and_then(Value::as_str)
6781                .unwrap_or_default();
6782            // Unlike `function_call.arguments`, Codex custom tools accept a
6783            // free-form `input` string (apply_patch is the common case).
6784            // Canonical `FunctionCall::arguments` must remain valid JSON, so
6785            // retain the input's JSON type instead of treating a free-form
6786            // string as if it were already a JSON document. This lets every
6787            // target harness carry the value rather than silently replacing
6788            // it with `{}` when `parsed_arguments()` fails.
6789            let args = payload
6790                .get("input")
6791                .map(Value::to_string)
6792                .unwrap_or_else(|| "{}".to_string());
6793            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6794            if let Some(message) = out.last_mut() {
6795                message.metadata.insert(
6796                    "codex_custom_tool_call_ids".to_string(),
6797                    serde_json::json!([id]).to_string(),
6798                );
6799            }
6800        }
6801        Some("custom_tool_call_output") => {
6802            let id = payload
6803                .get("call_id")
6804                .and_then(Value::as_str)
6805                .unwrap_or_default();
6806            let result = match payload.get("output") {
6807                Some(Value::String(s)) => s.clone(),
6808                Some(v) => extract_text_content(Some(v)),
6809                None => String::new(),
6810            };
6811            let mut message = tool_message(id, result);
6812            crate::mark_tool_outcome_unknown(&mut message);
6813            out.push(message);
6814        }
6815        // Tool-search is a clean call/output pair keyed by call_id.
6816        //
6817        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6818        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6819        // its own synthesized `tool_search_call` records (see the PARITY-6/7
6820        // comment there and on `codex_turn_id`/the `function_call` arm
6821        // above). That left the same bug-class the turn_id work fixed for
6822        // `function_call` half-done here: a single Claude assistant message
6823        // containing text + a `tool_search` block reloaded as 2 messages
6824        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6825        // reloaded as 3. Mirror the `function_call` arm's merge check
6826        // exactly so a `tool_search_call` immediately following an open
6827        // assistant turn (or another tool call sharing the same `turn_id`)
6828        // merges into that SAME `ChatMessage` instead of splitting.
6829        Some("tool_search_call") => {
6830            let id = payload
6831                .get("call_id")
6832                .and_then(Value::as_str)
6833                .unwrap_or_default();
6834            let args = payload
6835                .get("arguments")
6836                .map(value_to_arg_string)
6837                .unwrap_or_else(|| "{}".to_string());
6838            let call = function_call(id, "tool_search", args);
6839            let can_merge = out.last().is_some_and(|last| {
6840                last.role == Role::Assistant
6841                    && last.metadata.contains_key("__codex_open_turn")
6842                    && match codex_turn_id(payload) {
6843                        Some(fc_tid) => {
6844                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6845                        }
6846                        None => true,
6847                    }
6848            });
6849            if can_merge {
6850                out.last_mut()
6851                    .expect("can_merge implies out.last() is Some")
6852                    .tool_calls
6853                    .get_or_insert_with(Vec::new)
6854                    .push(call);
6855            } else {
6856                push_assistant(out, String::new(), vec![call]);
6857                // Re-open the freshly created message so a FOLLOWING
6858                // `function_call`/`tool_search_call` sharing this same
6859                // `turn_id` merges into it too — matching the bare
6860                // `function_call` case's own re-open logic above.
6861                if let Some(tid) = codex_turn_id(payload) {
6862                    if let Some(last) = out.last_mut() {
6863                        last.metadata
6864                            .insert("__codex_open_turn".to_string(), "true".to_string());
6865                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6866                    }
6867                }
6868            }
6869        }
6870        Some("tool_search_output") => {
6871            let id = payload
6872                .get("call_id")
6873                .and_then(Value::as_str)
6874                .unwrap_or_default();
6875            let result = payload
6876                .get("tools")
6877                .map(value_to_arg_string)
6878                .unwrap_or_default();
6879            out.push(tool_message(id, result));
6880        }
6881        // Web-search / image-generation response_items carry no paired output
6882        // here (results live in event_msg), so emit an assistant marker rather
6883        // than a dangling unanswered tool call.
6884        Some("web_search_call") => {
6885            push_assistant(out, "[web_search]".to_string(), Vec::new());
6886        }
6887        Some("image_generation_call") => {
6888            let prompt = payload
6889                .get("revised_prompt")
6890                .and_then(Value::as_str)
6891                .unwrap_or("");
6892            push_assistant(
6893                out,
6894                format!("[image_generation] {prompt}").trim().to_string(),
6895                Vec::new(),
6896            );
6897        }
6898        // "reasoning" and anything else — dropped.
6899        _ => {}
6900    }
6901}
6902
6903// ---- Grok -------------------------------------------------------------
6904
6905const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6906
6907fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
6908    value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
6909        "schema": 1,
6910        "role": message.role,
6911        "content": message.content,
6912        "content_parts": message.content_parts,
6913        "tool_calls": message.tool_calls,
6914        "tool_call_id": message.tool_call_id,
6915        "name": message.name,
6916        "metadata": message.metadata,
6917    });
6918}
6919
6920fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
6921    let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
6922        return;
6923    };
6924    if extension.get("schema").and_then(Value::as_u64) != Some(1) {
6925        return;
6926    }
6927    if let Some(role) = extension
6928        .get("role")
6929        .and_then(|value| serde_json::from_value(value.clone()).ok())
6930    {
6931        message.role = role;
6932    }
6933    message.content = extension
6934        .get("content")
6935        .and_then(Value::as_str)
6936        .map(str::to_string);
6937    message.content_parts = extension
6938        .get("content_parts")
6939        .and_then(|value| serde_json::from_value(value.clone()).ok());
6940    message.tool_calls = extension
6941        .get("tool_calls")
6942        .and_then(|value| serde_json::from_value(value.clone()).ok());
6943    message.tool_call_id = extension
6944        .get("tool_call_id")
6945        .and_then(Value::as_str)
6946        .map(str::to_string);
6947    message.name = extension
6948        .get("name")
6949        .and_then(Value::as_str)
6950        .map(str::to_string);
6951    message.metadata.clear();
6952    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
6953        for (key, value) in metadata {
6954            if let Some(value) = value.as_str() {
6955                message.metadata.insert(key.clone(), value.to_string());
6956            }
6957        }
6958    }
6959}
6960
6961fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
6962    for key in keys {
6963        if let Some(value) = value.get(*key) {
6964            message.metadata.insert(
6965                format!("grok_{key}"),
6966                value
6967                    .as_str()
6968                    .map(str::to_string)
6969                    .unwrap_or_else(|| value.to_string()),
6970            );
6971        }
6972    }
6973}
6974
6975fn grok_human_user_text(raw: &str) -> Option<String> {
6976    let text = raw.trim();
6977    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
6978        return None;
6979    }
6980    let unwrapped = text
6981        .strip_prefix("<user_query>")
6982        .and_then(|value| value.strip_suffix("</user_query>"))
6983        .map(str::trim)
6984        .unwrap_or(text);
6985    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
6986}
6987
6988/// Portable extension for messages whose canonical fields cannot be expressed
6989/// by the target's stock schema. It was introduced for Grok and retains that
6990/// on-disk key for compatibility. Gemini has the same need: Claude Code and
6991/// Codex have no native slot for a tool-result name or Gemini-only metadata.
6992/// Their readers tolerate unknown namespaced fields, so forwarding this
6993/// adapter-owned envelope keeps those cross-format hops reversible without
6994/// pretending the stock schemas represent the fields directly.
6995const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
6996
6997/// Namespaced line-level extension carrying the one tool-result outcome state
6998/// Claude cannot represent natively. Keeping this narrower than the full Grok
6999/// portability envelope avoids changing unrelated target-message projection.
7000const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
7001
7002fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
7003    if !crate::is_tool_error(message)
7004        && value
7005            .get(SUPERCODE_TOOL_OUTCOME_KEY)
7006            .and_then(Value::as_str)
7007            == Some("unknown")
7008    {
7009        crate::mark_tool_outcome_unknown(message);
7010    }
7011}
7012
7013fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
7014    let metadata = message
7015        .metadata
7016        .iter()
7017        .filter(|(key, _)| {
7018            key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
7019        })
7020        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
7021        .collect::<serde_json::Map<_, _>>();
7022
7023    // `meta.source` changes after every reload. Keying portability only on
7024    // the immediate source therefore made Grok metadata survive one hop but
7025    // disappear on A -> B -> C translations. Once Grok-owned fields are
7026    // present, keep forwarding them regardless of the current container.
7027    let has_portable_fields =
7028        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7029    (matches!(
7030        source,
7031        SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7032    ) || has_portable_fields
7033        || message.content_parts.is_some())
7034    .then(|| {
7035        serde_json::json!({
7036            "schema": 2,
7037            "role": message.role,
7038            "content": message.content,
7039            "content_parts": message.content_parts,
7040            "tool_calls": message.tool_calls,
7041            "tool_call_id": message.tool_call_id,
7042            "name": message.name,
7043            "metadata": message.metadata,
7044        })
7045    })
7046}
7047
7048fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7049    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7050        "schema": 2,
7051        "role": message.role,
7052        "content": message.content,
7053        "content_parts": message.content_parts,
7054        "tool_calls": message.tool_calls,
7055        "tool_call_id": message.tool_call_id,
7056        "name": message.name,
7057        "metadata": message.metadata,
7058    });
7059}
7060
7061fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7062    if let Some(extension) = grok_message_extension(source, message) {
7063        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7064    }
7065}
7066
7067fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7068    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7069        return;
7070    };
7071    // Codex temporarily marks a text assistant item so immediately-following
7072    // function-call items can merge back into the same canonical turn. The
7073    // portable envelope must not erase that loader-private marker before the
7074    // merge happens; `from_codex_str` removes it before returning.
7075    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7076    let codex_turn_id = message.metadata.get("turn_id").cloned();
7077    let extension_has_turn_id = extension
7078        .get("metadata")
7079        .and_then(Value::as_object)
7080        .is_some_and(|metadata| metadata.contains_key("turn_id"));
7081    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7082        if let Some(role) = extension
7083            .get("role")
7084            .and_then(|value| serde_json::from_value(value.clone()).ok())
7085        {
7086            message.role = role;
7087        }
7088        message.content = extension
7089            .get("content")
7090            .and_then(Value::as_str)
7091            .map(str::to_string);
7092        message.content_parts = extension
7093            .get("content_parts")
7094            .and_then(|value| serde_json::from_value(value.clone()).ok());
7095        // Tool calls are shared native structure in every supported format.
7096        // Keep the loader's reconstruction instead of restoring this copy:
7097        // Codex stores a combined text+tool turn across multiple records, so
7098        // eagerly restoring calls on its text record would duplicate them
7099        // when the following function-call records merge.
7100        message.tool_call_id = extension
7101            .get("tool_call_id")
7102            .and_then(Value::as_str)
7103            .map(str::to_string);
7104        message.name = extension
7105            .get("name")
7106            .and_then(Value::as_str)
7107            .map(str::to_string);
7108        message.metadata.clear();
7109    }
7110    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7111        for (key, value) in metadata {
7112            if let Some(value) = value.as_str() {
7113                message.metadata.insert(key.clone(), value.to_string());
7114            }
7115        }
7116    }
7117    if let Some(name) = extension.get("name").and_then(Value::as_str) {
7118        message.name = Some(name.to_string());
7119    }
7120    if let Some(marker) = codex_open_turn {
7121        message
7122            .metadata
7123            .insert("__codex_open_turn".to_string(), marker);
7124    }
7125    if let Some(turn_id) = codex_turn_id {
7126        message.metadata.insert("turn_id".to_string(), turn_id);
7127        if !extension_has_turn_id {
7128            message.metadata.insert(
7129                "__grok_remove_synthetic_turn_id".to_string(),
7130                "true".to_string(),
7131            );
7132        }
7133    }
7134}
7135
7136fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7137    if let [message] = messages {
7138        restore_grok_message_extension(value, message);
7139    }
7140}
7141
7142fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7143    let role = match native.get("role").and_then(Value::as_str) {
7144        Some("assistant") => Role::Assistant,
7145        _ => Role::User,
7146    };
7147    let created = native.get("created").and_then(Value::as_i64);
7148    let native_id = native.get("id").and_then(Value::as_str);
7149    let mut text = Vec::new();
7150    let mut content_parts = Vec::new();
7151    let mut tool_calls = Vec::new();
7152    let mut tool_results = Vec::new();
7153
7154    for (block_index, block) in native
7155        .get("content")
7156        .and_then(Value::as_array)
7157        .into_iter()
7158        .flatten()
7159        .enumerate()
7160    {
7161        match block.get("type").and_then(Value::as_str) {
7162            Some("text") => {
7163                if let Some(value) = block.get("text").and_then(Value::as_str) {
7164                    text.push(value.to_string());
7165                    content_parts.push(serde_json::json!({"type": "text", "text": value}));
7166                }
7167            }
7168            Some("image") => {
7169                let data = block
7170                    .get("data")
7171                    .and_then(Value::as_str)
7172                    .unwrap_or_default();
7173                let media_type = block
7174                    .get("mimeType")
7175                    .or_else(|| block.get("mime_type"))
7176                    .and_then(Value::as_str)
7177                    .unwrap_or("application/octet-stream");
7178                content_parts.push(serde_json::json!({
7179                    "type": "image_url",
7180                    "image_url": {"url": format!("data:{media_type};base64,{data}")},
7181                }));
7182            }
7183            Some("toolRequest" | "frontendToolRequest") => {
7184                let id = block
7185                    .get("id")
7186                    .and_then(Value::as_str)
7187                    .map(str::to_string)
7188                    .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7189                let call = block
7190                    .get("toolCall")
7191                    .and_then(|call| {
7192                        (call.get("status").and_then(Value::as_str) == Some("success"))
7193                            .then(|| call.get("value"))
7194                            .flatten()
7195                    })
7196                    .or_else(|| block.get("toolCall"));
7197                let Some(call) = call else { continue };
7198                let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7199                let arguments = call
7200                    .get("arguments")
7201                    .map(value_to_arg_string)
7202                    .unwrap_or_else(|| "{}".to_string());
7203                tool_calls.push(function_call(&id, name, arguments));
7204            }
7205            Some("toolResponse") => tool_results.push(block.clone()),
7206            _ => {}
7207        }
7208    }
7209
7210    if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7211        let has_non_text = content_parts
7212            .iter()
7213            .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7214        let mut message = ChatMessage {
7215            role,
7216            content: (!text.is_empty()).then(|| text.join("\n")),
7217            content_parts: has_non_text.then_some(content_parts),
7218            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7219            tool_call_id: None,
7220            name: None,
7221            metadata: Default::default(),
7222        };
7223        capture_goose_message_metadata(native, created, native_id, &mut message);
7224        out.push(message);
7225    }
7226
7227    for (result_index, block) in tool_results.into_iter().enumerate() {
7228        let id = block
7229            .get("id")
7230            .and_then(Value::as_str)
7231            .map(str::to_string)
7232            .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7233        let result = block.get("toolResult").unwrap_or(&Value::Null);
7234        let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7235        let value = result.get("value").unwrap_or(result);
7236        let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7237        let output = if status_error {
7238            result
7239                .get("error")
7240                .and_then(Value::as_str)
7241                .unwrap_or("Goose tool call failed")
7242                .to_string()
7243        } else {
7244            value
7245                .get("content")
7246                .and_then(Value::as_array)
7247                .map(|content| {
7248                    content
7249                        .iter()
7250                        .filter_map(|part| {
7251                            part.get("text")
7252                                .and_then(Value::as_str)
7253                                .map(str::to_string)
7254                                .or_else(|| Some(part.to_string()))
7255                        })
7256                        .collect::<Vec<_>>()
7257                        .join("\n")
7258                })
7259                .unwrap_or_else(|| value.to_string())
7260        };
7261        let mut message = tool_message(&id, output);
7262        if is_error {
7263            crate::mark_tool_error(&mut message);
7264        }
7265        capture_goose_message_metadata(native, created, native_id, &mut message);
7266        out.push(message);
7267    }
7268}
7269
7270fn capture_goose_message_metadata(
7271    native: &Value,
7272    created: Option<i64>,
7273    native_id: Option<&str>,
7274    message: &mut ChatMessage,
7275) {
7276    if let Some(created) = created {
7277        message
7278            .metadata
7279            .insert("goose_created".to_string(), created.to_string());
7280    }
7281    if let Some(native_id) = native_id {
7282        message
7283            .metadata
7284            .insert("goose_message_id".to_string(), native_id.to_string());
7285    }
7286    if let Some(metadata) = native.get("metadata") {
7287        message
7288            .metadata
7289            .insert("goose_metadata".to_string(), metadata.to_string());
7290    }
7291}
7292
7293#[doc(hidden)]
7294pub fn percent_decode_path(encoded: &str) -> Option<String> {
7295    fn hex(byte: u8) -> Option<u8> {
7296        match byte {
7297            b'0'..=b'9' => Some(byte - b'0'),
7298            b'a'..=b'f' => Some(byte - b'a' + 10),
7299            b'A'..=b'F' => Some(byte - b'A' + 10),
7300            _ => None,
7301        }
7302    }
7303
7304    let bytes = encoded.as_bytes();
7305    let mut decoded = Vec::with_capacity(bytes.len());
7306    let mut index = 0usize;
7307    while index < bytes.len() {
7308        if bytes[index] == b'%' {
7309            let high = *bytes.get(index + 1)?;
7310            let low = *bytes.get(index + 2)?;
7311            decoded.push(hex(high)? * 16 + hex(low)?);
7312            index += 3;
7313        } else {
7314            decoded.push(bytes[index]);
7315            index += 1;
7316        }
7317    }
7318    String::from_utf8(decoded).ok()
7319}
7320
7321// ---- Pi ---------------------------------------------------------------
7322
7323fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7324    restore_codex_provenance_from_top_level(v, meta)?;
7325    if let Some(id) = v.get("id").and_then(Value::as_str) {
7326        meta.session_id = Some(id.to_string());
7327    }
7328    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7329        meta.cwd = Some(PathBuf::from(cwd));
7330    }
7331    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7332    let version = v
7333        .get("version")
7334        .and_then(Value::as_u64)
7335        .map(|n| n.to_string())
7336        .unwrap_or_else(|| "1".to_string());
7337    meta.lineage.insert("pi_version".to_string(), version);
7338    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7339        meta.lineage
7340            .insert("created_at".to_string(), ts.to_string());
7341    }
7342    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7343        meta.lineage
7344            .insert("parent_session_path".to_string(), ps.to_string());
7345    }
7346    // D7: the other half of `push_pi_header`'s passthrough — restores a
7347    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7348    // trip reconstructs the original record (mirrors
7349    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7350    // restore for the Codex hop).
7351    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7352        if let Some(v) = v.get("claude_fork_context_ref") {
7353            meta.lineage
7354                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7355        }
7356    }
7357    Ok(())
7358}
7359
7360/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7361/// `(mime, data)` when it looks like a real image payload.
7362///
7363/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7364/// `ai:316-350` for the `ImageContent` content-block union but does not
7365/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7366/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7367/// Anthropic multimodal wire shape) is this loader's best guess, not a
7368/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7369/// against a real pi corpus. Until then this function VALIDATES rather than
7370/// assumes: both fields must be present, non-empty strings, and `data` must
7371/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7372/// else is an unknown/unexpected image shape, and the caller must route the
7373/// whole message to raw-only survival (S6-style fail loud) instead of
7374/// silently synthesizing a corrupt/empty `image_url` part.
7375fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7376    let mime = item.get("mimeType").and_then(Value::as_str)?;
7377    let data = item.get("data").and_then(Value::as_str)?;
7378    if mime.is_empty() || data.is_empty() {
7379        return None;
7380    }
7381    if !data
7382        .bytes()
7383        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7384    {
7385        return None;
7386    }
7387    Some((mime.to_string(), data.to_string()))
7388}
7389
7390/// True if `content` (a pi content value: bare string or
7391/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7392/// that does not match [`pi_image_shape`] — shared by the loader (which
7393/// routes such a message to raw-only survival, never a synthesized-empty
7394/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7395/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7396/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7397#[doc(hidden)]
7398pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7399    let Some(Value::Array(items)) = content else {
7400        return false;
7401    };
7402    items.iter().any(|item| {
7403        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7404    })
7405}
7406
7407/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7408/// into concatenated text plus, when a WELL-FORMED image block is present,
7409/// the full `content_parts` array (leading text block + one `image_url` part
7410/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7411/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7412/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7413///
7414/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7415/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7416/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7417/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7418/// every caller must treat that as raw-only survival for the whole message
7419/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7420/// guessed wrong fails loud instead of silently dropping/corrupting the
7421/// image.
7422fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7423    match content {
7424        Some(Value::String(s)) => (s.clone(), None, false),
7425        Some(Value::Array(items)) => {
7426            let mut text = String::new();
7427            let mut parts: Vec<Value> = Vec::new();
7428            let mut has_image = false;
7429            let mut unknown_image_shape = false;
7430            for item in items {
7431                match item.get("type").and_then(Value::as_str) {
7432                    Some("text") => {
7433                        if let Some(t) = item.get("text").and_then(Value::as_str) {
7434                            push_str_field(&mut text, t);
7435                        }
7436                    }
7437                    Some("image") => {
7438                        has_image = true;
7439                        match pi_image_shape(item) {
7440                            Some((mime, data)) => {
7441                                parts.push(serde_json::json!({
7442                                    "type": "image_url",
7443                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
7444                                }));
7445                            }
7446                            None => unknown_image_shape = true,
7447                        }
7448                    }
7449                    _ => {}
7450                }
7451            }
7452            if unknown_image_shape {
7453                // Never synthesize an empty/corrupt part for a shape we
7454                // don't recognize — raw-only survival for the whole message;
7455                // the coverage guard is what turns this into a visible
7456                // failure (S6-style).
7457                return (String::new(), None, true);
7458            }
7459            if has_image {
7460                if !text.trim().is_empty() {
7461                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7462                }
7463                (text, Some(parts), false)
7464            } else {
7465                (text, None, false)
7466            }
7467        }
7468        _ => (String::new(), None, false),
7469    }
7470}
7471
7472fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7473    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7474    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7475    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7476    // `message/UnknownImageShape` bucket is what turns this into a visible
7477    // coverage failure.
7478    if unknown_image_shape {
7479        return;
7480    }
7481    if text.trim().is_empty() && parts.is_none() {
7482        return;
7483    }
7484    let mut msg = match parts {
7485        Some(parts) => ChatMessage {
7486            role: Role::User,
7487            content: None,
7488            content_parts: Some(parts),
7489            tool_calls: None,
7490            tool_call_id: None,
7491            name: None,
7492            metadata: Default::default(),
7493        },
7494        None => ChatMessage::user(text),
7495    };
7496    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7497    // (`message.timestamp`) is a DISTINCT field from the canonical
7498    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7499    // carry genuinely different values in real corpora (the fixture's are
7500    // ~6 months apart). Preserve it separately so it isn't silently lost for
7501    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7502    // native round-trip consumer) and the INHERENT residue note on
7503    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7504    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7505        msg.metadata
7506            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7507    }
7508    out.push(msg);
7509}
7510
7511fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7512    let mut text = String::new();
7513    let mut calls: Vec<ToolCall> = Vec::new();
7514    let mut thinking = String::new();
7515    let mut thinking_seen = false;
7516    let mut thinking_sig: Option<String> = None;
7517    let mut thinking_redacted = false;
7518    let mut text_sig: Option<String> = None;
7519    let mut thought_sig: Option<String> = None;
7520
7521    if let Some(Value::Array(blocks)) = msg_v.get("content") {
7522        for b in blocks {
7523            match b.get("type").and_then(Value::as_str) {
7524                Some("text") => {
7525                    if let Some(t) = b.get("text").and_then(Value::as_str) {
7526                        push_str_field(&mut text, t);
7527                    }
7528                    if let Some(sig) = b.get("textSignature") {
7529                        text_sig = Some(match sig {
7530                            Value::String(s) => s.clone(),
7531                            other => other.to_string(),
7532                        });
7533                    }
7534                }
7535                Some("thinking") => {
7536                    thinking_seen = true;
7537                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7538                        push_str_field(&mut thinking, t);
7539                    }
7540                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7541                        thinking_sig = Some(sig.to_string());
7542                    }
7543                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7544                        thinking_redacted = true;
7545                    }
7546                }
7547                Some("toolCall") => {
7548                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7549                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7550                    // `arguments` is a JSON OBJECT on pi's wire, not a string
7551                    // (`pi-fields.md` §3b open question 4) — serialize to the
7552                    // string `FunctionCall::arguments` expects.
7553                    let args = b
7554                        .get("arguments")
7555                        .cloned()
7556                        .unwrap_or_else(|| Value::Object(Default::default()));
7557                    calls.push(function_call(id, name, args.to_string()));
7558                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7559                        thought_sig = Some(sig.to_string());
7560                    }
7561                }
7562                _ => {}
7563            }
7564        }
7565    }
7566
7567    let before = out.len();
7568    push_assistant(out, text, calls);
7569    // A recognized native assistant entry remains transcript state even
7570    // when its content array is empty, except Pi's explicit empty error
7571    // response: that record has no replayable content and is established
7572    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7573    // non-error turns and Pi's standalone thinking-block shape.
7574    let is_empty_error =
7575        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7576    if out.len() == before && !is_empty_error {
7577        let mut empty = ChatMessage {
7578            role: Role::Assistant,
7579            content: None,
7580            content_parts: None,
7581            tool_calls: None,
7582            tool_call_id: None,
7583            name: None,
7584            metadata: Default::default(),
7585        };
7586        if !thinking_seen {
7587            empty
7588                .metadata
7589                .insert("empty_assistant_record".to_string(), "true".to_string());
7590        }
7591        out.push(empty);
7592    }
7593    if out.len() > before {
7594        let msg = out.last_mut().expect("just pushed");
7595        if thinking_seen {
7596            msg.metadata.insert("thinking".to_string(), thinking);
7597        }
7598        if let Some(s) = thinking_sig {
7599            msg.metadata.insert("thinking_signature".to_string(), s);
7600        }
7601        if thinking_redacted {
7602            msg.metadata
7603                .insert("pi_thinking_redacted".to_string(), "true".to_string());
7604        }
7605        if let Some(s) = text_sig {
7606            msg.metadata.insert("pi_text_signature".to_string(), s);
7607        }
7608        if let Some(s) = thought_sig {
7609            msg.metadata.insert("pi_thought_signature".to_string(), s);
7610        }
7611        for (key, field) in [
7612            ("pi_api", "api"),
7613            ("pi_provider", "provider"),
7614            ("pi_response_model", "responseModel"),
7615            ("pi_response_id", "responseId"),
7616            ("pi_stop_reason", "stopReason"),
7617            ("pi_error_message", "errorMessage"),
7618        ] {
7619            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7620                msg.metadata.insert(key.to_string(), s.to_string());
7621            }
7622        }
7623        if let Some(diag) = msg_v.get("diagnostics") {
7624            if !diag.is_null() {
7625                msg.metadata
7626                    .insert("pi_diagnostics".to_string(), diag.to_string());
7627            }
7628        }
7629        if let Some(usage) = msg_v.get("usage") {
7630            if !usage.is_null() {
7631                msg.metadata
7632                    .insert("pi_usage".to_string(), usage.to_string());
7633            }
7634        }
7635        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7636        // separately from the canonical entry-level ISO `timestamp` — see
7637        // `push_pi_user`.
7638        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7639            msg.metadata
7640                .insert("pi_msg_timestamp".to_string(), ts.to_string());
7641        }
7642    }
7643}
7644
7645fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7646    let id = msg_v
7647        .get("toolCallId")
7648        .and_then(Value::as_str)
7649        .unwrap_or_default();
7650    let name = msg_v
7651        .get("toolName")
7652        .and_then(Value::as_str)
7653        .unwrap_or_default();
7654    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7655    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7656    // survival, never a synthesized-empty part. Dropping the toolResult
7657    // message here leaves its `toolCallId` unanswered, which
7658    // `ensure_tool_results_paired` already turns into a visible
7659    // "[no tool result recorded — turn interrupted]" placeholder — a loud
7660    // failure mode, not a silent one.
7661    if unknown_image_shape {
7662        return;
7663    }
7664    let mut msg = ChatMessage {
7665        role: Role::Tool,
7666        content: Some(text),
7667        content_parts: parts,
7668        tool_calls: None,
7669        tool_call_id: Some(id.to_string()),
7670        name: Some(name.to_string()),
7671        metadata: Default::default(),
7672    };
7673    if let Some(details) = msg_v.get("details") {
7674        if !details.is_null() {
7675            msg.metadata
7676                .insert("pi_tool_details".to_string(), details.to_string());
7677        }
7678    }
7679    let is_error = msg_v
7680        .get("isError")
7681        .and_then(Value::as_bool)
7682        .unwrap_or(false);
7683    msg.metadata
7684        .insert("pi_is_error".to_string(), is_error.to_string());
7685    if is_error {
7686        crate::mark_tool_error(&mut msg);
7687    }
7688    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7689    // separately from the canonical entry-level ISO `timestamp` — see
7690    // `push_pi_user`.
7691    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7692        msg.metadata
7693            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7694    }
7695    out.push(msg);
7696}
7697
7698/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7699/// pi itself sends the model, mirroring `bashExecutionToText`
7700/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7701/// aren't reproduced in the frozen research doc (only cited by file:line),
7702/// so this is a faithful, clearly-labeled reconstruction — every structured
7703/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7704fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7705    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7706    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7707    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7708    let cancelled = msg_v
7709        .get("cancelled")
7710        .and_then(Value::as_bool)
7711        .unwrap_or(false);
7712    let truncated = msg_v
7713        .get("truncated")
7714        .and_then(Value::as_bool)
7715        .unwrap_or(false);
7716
7717    let mut text = format!("$ {command}\n{output}");
7718    if let Some(code) = exit_code {
7719        if code != 0 {
7720            text.push_str(&format!("\n[exit code: {code}]"));
7721        }
7722    }
7723    if cancelled {
7724        text.push_str("\n[cancelled]");
7725    }
7726    if truncated {
7727        text.push_str("\n[truncated]");
7728    }
7729
7730    let mut msg = ChatMessage::user(text);
7731    msg.metadata
7732        .insert("pi_bash_command".to_string(), command.to_string());
7733    msg.metadata
7734        .insert("pi_bash_output".to_string(), output.to_string());
7735    if let Some(code) = exit_code {
7736        msg.metadata
7737            .insert("pi_bash_exit_code".to_string(), code.to_string());
7738    }
7739    msg.metadata
7740        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7741    msg.metadata
7742        .insert("pi_bash_truncated".to_string(), truncated.to_string());
7743    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7744        msg.metadata
7745            .insert("pi_bash_full_output_path".to_string(), p.to_string());
7746    }
7747    // `!!` — hidden from the model context; honored by `is_replay_excluded`
7748    // on every writer, not just pi's own (§2.2).
7749    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7750        msg.metadata
7751            .insert("pi_exclude_from_context".to_string(), "true".to_string());
7752    }
7753    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7754    // separately from the canonical entry-level ISO `timestamp` — see
7755    // `push_pi_user`.
7756    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7757        msg.metadata
7758            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7759    }
7760    out.push(msg);
7761}
7762
7763/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7764/// stamps on a re-materialized content-bearing Claude `system` record (see
7765/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7766/// never collide with a real pi `CustomMessage.customType` — pi's own
7767/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7768/// migration targets), never this literal string.
7769const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7770
7771/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7772/// `custom_message` entries (§9) — both enter context as a `User` message
7773/// with the same `customType`/`display`/`details` residue.
7774///
7775/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7776/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7777/// actually a re-materialized content-bearing Claude `system` record round-
7778/// tripping through pi, not a genuine pi extension message — restore
7779/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7780/// claude_system_subtype`, falling back to `local_command` — still one of
7781/// `push_claude_system`'s own keep subtypes — exactly like
7782/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7783/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7784/// the exact original role, not just the text.
7785fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7786    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7787        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7788        if content.trim().is_empty() {
7789            return;
7790        }
7791        let subtype = v
7792            .get("details")
7793            .and_then(|d| d.get("claude_system_subtype"))
7794            .and_then(Value::as_str)
7795            .unwrap_or("local_command");
7796        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7797        return;
7798    }
7799    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7800    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7801    // survival, never a synthesized-empty part.
7802    if unknown_image_shape {
7803        return;
7804    }
7805    if text.trim().is_empty() && parts.is_none() {
7806        return;
7807    }
7808    let mut msg = match parts {
7809        Some(parts) => ChatMessage {
7810            role: Role::User,
7811            content: None,
7812            content_parts: Some(parts),
7813            tool_calls: None,
7814            tool_call_id: None,
7815            name: None,
7816            metadata: Default::default(),
7817        },
7818        None => ChatMessage::user(text),
7819    };
7820    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7821        msg.metadata
7822            .insert("pi_custom_type".to_string(), ct.to_string());
7823    }
7824    if let Some(d) = v.get("display").and_then(Value::as_bool) {
7825        msg.metadata.insert("pi_display".to_string(), d.to_string());
7826    }
7827    if let Some(details) = v.get("details") {
7828        if !details.is_null() {
7829            msg.metadata
7830                .insert("pi_details".to_string(), details.to_string());
7831        }
7832    }
7833    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7834    // separately from the canonical entry-level ISO `timestamp` — see
7835    // `push_pi_user`. `v` here is the `message` object for the `role:
7836    // "custom"` case; for the top-level `custom_message` case `v` is the
7837    // entry itself, whose `timestamp` is the entry-level ISO string (not a
7838    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7839    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7840        msg.metadata
7841            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7842    }
7843    out.push(msg);
7844}
7845
7846/// pi's own prefix-wrapped user text for a `compaction` entry summary
7847/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7848/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7849/// reproduced in the frozen research doc; this is a clearly-labeled
7850/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7851fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7852    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7853    if summary.trim().is_empty() {
7854        return;
7855    }
7856    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7857    msg.metadata
7858        .insert("pi_type".to_string(), "compaction".to_string());
7859    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7860        msg.metadata
7861            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7862    }
7863    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7864        msg.metadata
7865            .insert("pi_tokens_before".to_string(), tb.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/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7880/// rewind-with-summary) — same reconstruction caveat as
7881/// [`push_pi_compaction`].
7882fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7883    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7884    if summary.trim().is_empty() {
7885        return;
7886    }
7887    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
7888    msg.metadata
7889        .insert("pi_type".to_string(), "branch_summary".to_string());
7890    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7891        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7892    }
7893    if let Some(d) = entry_v.get("details") {
7894        if !d.is_null() {
7895            msg.metadata.insert("pi_details".to_string(), d.to_string());
7896        }
7897    }
7898    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7899        msg.metadata
7900            .insert("pi_from_hook".to_string(), "true".to_string());
7901    }
7902    out.push(msg);
7903}
7904
7905// ---- OpenCode ---------------------------------------------------------
7906
7907/// The placeholder opencode's own replay substitutes for a `tool` part's
7908/// output once `state.completed.time.compacted` is set
7909/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
7910/// erased from the record (S1); it survives in `raw` and in this loader's
7911/// `metadata["oc_tool_output_compacted"]`.
7912pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
7913
7914fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
7915    restore_codex_provenance_from_top_level(si, meta)?;
7916    if let Some(id) = si.get("id").and_then(Value::as_str) {
7917        meta.session_id = Some(id.to_string());
7918    }
7919    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
7920        meta.cwd = Some(PathBuf::from(dir));
7921    }
7922    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
7923        meta.agent_id = Some(agent.to_string());
7924    }
7925    if let Some(model) = si.get("model") {
7926        let provider = model.get("providerID").and_then(Value::as_str);
7927        let id = model.get("id").and_then(Value::as_str);
7928        if let (Some(p), Some(i)) = (provider, id) {
7929            meta.model = Some(format!("{p}/{i}"));
7930        }
7931    }
7932    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
7933        meta.lineage
7934            .insert("projectID".to_string(), project_id.to_string());
7935    }
7936    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
7937        meta.lineage.insert("slug".to_string(), slug.to_string());
7938    }
7939    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
7940        meta.lineage
7941            .insert("workspaceID".to_string(), ws.to_string());
7942    }
7943    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
7944        meta.lineage
7945            .insert("parent_session_id".to_string(), parent.to_string());
7946        // Mirrored under the Codex-originated lineage key so the existing
7947        // generic `Session::reconstruct_tree` nests opencode subagent
7948        // sessions too, with no format-specific nesting pass (§2.1: "child
7949        // session's parentID ... → drives reconstruct_tree").
7950        meta.lineage
7951            .insert("parent_thread_id".to_string(), parent.to_string());
7952    }
7953    // D7: the other half of `synthesized_opencode_info`'s passthrough —
7954    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
7955    // -> Claude round trip reconstructs the original record (mirrors
7956    // `capture_codex_session_meta`/`capture_pi_header`'s identical
7957    // `claude_fork_context_ref` restore for the Codex/Pi hops).
7958    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7959        if let Some(v) = si.get("claude_fork_context_ref") {
7960            meta.lineage
7961                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7962        }
7963    }
7964    Ok(())
7965}
7966
7967/// An opencode `User`/`Assistant` `file` part's image data-URI →
7968/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
7969/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
7970/// a bare filesystem path, an `https:` link, or a non-image mime is left as
7971/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
7972/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
7973/// coverage with the SAME test this loader uses to canonicalize it (D5) —
7974/// one definition of "is this file part actually replayed", not two.
7975#[doc(hidden)]
7976pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
7977    let mime = part.get("mime").and_then(Value::as_str)?;
7978    let url = part.get("url").and_then(Value::as_str)?;
7979    if !mime.starts_with("image/") || !url.starts_with("data:") {
7980        return None;
7981    }
7982    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
7983}
7984
7985/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
7986/// `Role::System` arm stamps on the one `synthetic: true` text part of a
7987/// re-materialized content-bearing Claude `system` record (see that arm's
7988/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
7989/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
7990const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
7991
7992/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
7993/// `User` message with EXACTLY one `synthetic: true` text part carrying
7994/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
7995/// opencode data is never misclassified — a genuine opencode `synthetic`
7996/// text part never carries this supercode-namespaced key, and a real
7997/// multi-part user message (text + an attached file, say) never matches
7998/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
7999/// (e.g. `local_command`) on a match.
8000fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
8001    let [part] = parts else { return None };
8002    if part.get("type").and_then(Value::as_str) != Some("text") {
8003        return None;
8004    }
8005    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
8006        return None;
8007    }
8008    part.get("metadata")
8009        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
8010        .and_then(Value::as_str)
8011        .map(str::to_string)
8012}
8013
8014/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
8015/// and `metadata["systemSubtype"]` from the marked text part instead of
8016/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
8017/// OpenCode -> Claude round trip restores the exact original role, not just
8018/// the text. Content is never fabricated — only emitted when non-empty.
8019fn push_opencode_claude_system(
8020    msg_value: &Value,
8021    parts: &[Value],
8022    subtype: String,
8023    out: &mut Vec<ChatMessage>,
8024) {
8025    let Some(text) = parts
8026        .first()
8027        .and_then(|p| p.get("text"))
8028        .and_then(Value::as_str)
8029    else {
8030        return;
8031    };
8032    if text.trim().is_empty() {
8033        return;
8034    }
8035    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8036    set_opencode_msg_timestamp(&mut msg, msg_value);
8037    out.push(msg);
8038}
8039
8040/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8041/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8042/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8043/// the model"); `file` parts with a recognized image shape become
8044/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8045/// `SessionMeta.system_prompt` on the first turn that carries it, and
8046/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8047/// per-user-message, not per-session").
8048/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8049/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8050/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8051/// (opencode's own wire granularity); a `None`/malformed `time.created`
8052/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8053/// `SYNTH_TS`/`SYNTH_TS_MS`.
8054fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8055    if let Some(ms) = msg_value
8056        .get("time")
8057        .and_then(|t| t.get("created"))
8058        .and_then(Value::as_i64)
8059    {
8060        msg.metadata
8061            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8062    }
8063}
8064
8065fn push_opencode_user(
8066    msg_value: &Value,
8067    parts: &[Value],
8068    out: &mut Vec<ChatMessage>,
8069    meta: &mut SessionMeta,
8070    first_system_seen: &mut bool,
8071) {
8072    let mut text = String::new();
8073    let mut image_parts: Vec<Value> = Vec::new();
8074    let mut has_ignored = false;
8075    for p in parts {
8076        match p.get("type").and_then(Value::as_str) {
8077            Some("text") => {
8078                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8079                    has_ignored = true;
8080                    continue; // must never be replayed (§2.2)
8081                }
8082                if let Some(t) = p.get("text").and_then(Value::as_str) {
8083                    push_str_field(&mut text, t);
8084                }
8085            }
8086            Some("file") => {
8087                if let Some(img) = opencode_file_image_part(p) {
8088                    image_parts.push(img);
8089                }
8090            }
8091            // reasoning/tool never appear on a User message; step-start,
8092            // step-finish, snapshot, patch, agent, subtask, retry have no
8093            // clean home (§2.3); compaction is read separately by the
8094            // caller (tail_start_id) and tagged onto the message below.
8095            _ => {}
8096        }
8097    }
8098
8099    let has_images = !image_parts.is_empty();
8100    if text.trim().is_empty() && !has_images {
8101        return;
8102    }
8103    let mut msg = if has_images {
8104        let mut all = Vec::new();
8105        if !text.trim().is_empty() {
8106            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8107        }
8108        all.extend(image_parts);
8109        ChatMessage {
8110            role: Role::User,
8111            content: None,
8112            content_parts: Some(all),
8113            tool_calls: None,
8114            tool_call_id: None,
8115            name: None,
8116            metadata: Default::default(),
8117        }
8118    } else {
8119        ChatMessage::user(text)
8120    };
8121
8122    if has_ignored {
8123        msg.metadata
8124            .insert("oc_has_ignored_part".to_string(), "true".to_string());
8125    }
8126    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8127        msg.metadata
8128            .insert("oc_message_id".to_string(), id.to_string());
8129    }
8130    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8131        msg.metadata.insert("agent".to_string(), agent.to_string());
8132    }
8133    if let Some(model) = msg_value.get("model") {
8134        if !model.is_null() {
8135            msg.metadata.insert("model".to_string(), model.to_string());
8136        }
8137    }
8138    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8139        if !*first_system_seen {
8140            meta.system_prompt = Some(system.to_string());
8141            *first_system_seen = true;
8142        }
8143        msg.metadata
8144            .insert("system".to_string(), system.to_string());
8145    }
8146    for p in parts {
8147        if p.get("type").and_then(Value::as_str) == Some("compaction") {
8148            msg.metadata
8149                .insert("phase".to_string(), "compaction".to_string());
8150            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8151                msg.metadata
8152                    .insert("tail_start_id".to_string(), t.to_string());
8153            }
8154        }
8155    }
8156    set_opencode_msg_timestamp(&mut msg, msg_value);
8157    restore_grok_message_extension(msg_value, &mut msg);
8158    out.push(msg);
8159}
8160
8161/// Map an opencode `Assistant` message + its parts to a canonical
8162/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8163/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8164/// reached `completed`/`error` — the split-by-`callID` opencode's single
8165/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8166/// interrupted turn) synthesize no tool call/result of their own here; the
8167/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8168/// like the other three loaders. A `tool` part whose `state.status` is none
8169/// of the four known values is skipped entirely — raw-only survival, never
8170/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8171fn push_opencode_assistant(
8172    msg_value: &Value,
8173    parts: &[Value],
8174    out: &mut Vec<ChatMessage>,
8175    meta: &mut SessionMeta,
8176) {
8177    let mut text = String::new();
8178    let mut calls: Vec<ToolCall> = Vec::new();
8179    let mut thinking = String::new();
8180    let mut reasoning_seen = false;
8181    let mut thinking_sig: Option<String> = None;
8182    // (call_id, tool_name, the tool part itself) — deferred so the
8183    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8184    // every other loader's message ordering (call, then result).
8185    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8186
8187    for p in parts {
8188        match p.get("type").and_then(Value::as_str) {
8189            Some("text") => {
8190                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8191                    continue;
8192                }
8193                if let Some(t) = p.get("text").and_then(Value::as_str) {
8194                    push_str_field(&mut text, t);
8195                }
8196            }
8197            Some("reasoning") => {
8198                reasoning_seen = true;
8199                if let Some(t) = p.get("text").and_then(Value::as_str) {
8200                    push_str_field(&mut thinking, t);
8201                }
8202                if let Some(sig) = p
8203                    .get("metadata")
8204                    .and_then(|m| m.get("anthropic"))
8205                    .and_then(|a| a.get("signature"))
8206                    .and_then(Value::as_str)
8207                {
8208                    thinking_sig = Some(sig.to_string());
8209                }
8210            }
8211            Some("tool") => {
8212                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8213                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8214                let status = p
8215                    .get("state")
8216                    .and_then(|s| s.get("status"))
8217                    .and_then(Value::as_str);
8218                let known_status = matches!(
8219                    status,
8220                    Some("pending") | Some("running") | Some("completed") | Some("error")
8221                );
8222                if call_id.is_empty() || !known_status {
8223                    // Unknown/unrecognized status, or a malformed part with
8224                    // no callID — raw-only survival, never synthesized.
8225                    continue;
8226                }
8227                let input = p
8228                    .get("state")
8229                    .and_then(|s| s.get("input"))
8230                    .cloned()
8231                    .unwrap_or_else(|| Value::Object(Default::default()));
8232                calls.push(function_call(call_id, tool_name, input.to_string()));
8233                if matches!(status, Some("completed") | Some("error")) {
8234                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8235                }
8236            }
8237            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8238            // — no clean home on an Assistant turn (§2.3).
8239            _ => {}
8240        }
8241    }
8242
8243    let before = out.len();
8244    push_assistant(out, text, calls);
8245    // A native OpenCode assistant record is transcript state even when it
8246    // has no parts. Real stores contain these after an interrupted/empty
8247    // model turn; dropping the record here loses its id, timestamp, model,
8248    // token/cost metadata, and shifts the conversation on every export.
8249    // Keep one empty canonical assistant message so all target writers can
8250    // preserve the turn. This also covers reasoning-only records (whose
8251    // reasoning payload is attached as metadata just below).
8252    if out.len() == before {
8253        let mut empty = ChatMessage {
8254            role: Role::Assistant,
8255            content: None,
8256            content_parts: None,
8257            tool_calls: None,
8258            tool_call_id: None,
8259            name: None,
8260            metadata: Default::default(),
8261        };
8262        if !reasoning_seen {
8263            empty
8264                .metadata
8265                .insert("empty_assistant_record".to_string(), "true".to_string());
8266        }
8267        out.push(empty);
8268    }
8269    if out.len() > before {
8270        let msg = out.last_mut().expect("just pushed");
8271        if reasoning_seen {
8272            msg.metadata.insert("thinking".to_string(), thinking);
8273        }
8274        if let Some(sig) = thinking_sig {
8275            msg.metadata.insert("thinking_signature".to_string(), sig);
8276        }
8277        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8278            msg.metadata
8279                .insert("oc_message_id".to_string(), id.to_string());
8280        }
8281        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8282            msg.metadata.insert("agent".to_string(), agent.to_string());
8283            if meta.agent_id.is_none() {
8284                meta.agent_id = Some(agent.to_string());
8285            }
8286        }
8287        let provider = msg_value.get("providerID").and_then(Value::as_str);
8288        let model_id = msg_value.get("modelID").and_then(Value::as_str);
8289        if let (Some(p), Some(i)) = (provider, model_id) {
8290            let full = format!("{p}/{i}");
8291            msg.metadata.insert("model".to_string(), full.clone());
8292            if meta.model.is_none() {
8293                meta.model = Some(full);
8294            }
8295        }
8296        if let Some(cwd) = msg_value
8297            .get("path")
8298            .and_then(|p| p.get("cwd"))
8299            .and_then(Value::as_str)
8300        {
8301            if meta.cwd.is_none() {
8302                meta.cwd = Some(PathBuf::from(cwd));
8303            }
8304        }
8305        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8306            msg.metadata
8307                .insert("is_summary".to_string(), "true".to_string());
8308        }
8309        for (key, field) in [
8310            ("finish", "finish"),
8311            ("variant", "variant"),
8312            ("mode", "mode"),
8313        ] {
8314            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8315                msg.metadata.insert(key.to_string(), s.to_string());
8316            }
8317        }
8318        for (key, field) in [
8319            ("cost", "cost"),
8320            ("tokens", "tokens"),
8321            ("error", "error"),
8322            ("structured", "structured"),
8323        ] {
8324            if let Some(v) = msg_value.get(field) {
8325                if !v.is_null() {
8326                    msg.metadata.insert(key.to_string(), v.to_string());
8327                }
8328            }
8329        }
8330        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8331        // the spawned child session id — keyed by callID so multiple `task`
8332        // calls in one message never collide.
8333        // `resolve_opencode_parent_tool_use_ids` reads these back once a
8334        // whole session set is loaded.
8335        for p in parts {
8336            if p.get("type").and_then(Value::as_str) == Some("tool")
8337                && p.get("tool").and_then(Value::as_str) == Some("task")
8338            {
8339                if let (Some(call_id), Some(child)) = (
8340                    p.get("callID").and_then(Value::as_str),
8341                    p.get("metadata")
8342                        .and_then(|m| m.get("sessionId"))
8343                        .and_then(Value::as_str),
8344                ) {
8345                    msg.metadata.insert(
8346                        format!("oc_task_child_session_id__{call_id}"),
8347                        child.to_string(),
8348                    );
8349                }
8350            }
8351        }
8352        set_opencode_msg_timestamp(msg, msg_value);
8353        restore_grok_message_extension(msg_value, msg);
8354    }
8355
8356    // Second pass: the paired Tool-role message for each completed/error
8357    // tool part, split by callID (§2.1 — "the SAME part carries call and
8358    // result").
8359    for (call_id, tool_name, part) in tool_results {
8360        let status = part
8361            .get("state")
8362            .and_then(|s| s.get("status"))
8363            .and_then(Value::as_str);
8364        let compacted_at = part
8365            .get("state")
8366            .and_then(|s| s.get("time"))
8367            .and_then(|t| t.get("compacted"))
8368            .and_then(Value::as_i64);
8369        let real_output = part
8370            .get("state")
8371            .and_then(|s| s.get("output"))
8372            .and_then(Value::as_str)
8373            .unwrap_or("")
8374            .to_string();
8375        let (content, is_error) = match status {
8376            Some("completed") => {
8377                if compacted_at.is_some() {
8378                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8379                } else {
8380                    (real_output.clone(), false)
8381                }
8382            }
8383            Some("error") => {
8384                let err = part
8385                    .get("state")
8386                    .and_then(|s| s.get("error"))
8387                    .and_then(Value::as_str)
8388                    .unwrap_or("")
8389                    .to_string();
8390                (err, true)
8391            }
8392            _ => (String::new(), false),
8393        };
8394        let mut tmsg = ChatMessage {
8395            role: Role::Tool,
8396            content: Some(content),
8397            content_parts: None,
8398            tool_calls: None,
8399            tool_call_id: Some(call_id),
8400            name: Some(tool_name),
8401            metadata: Default::default(),
8402        };
8403        if let Some(original_position) = part
8404            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8405            .and_then(Value::as_u64)
8406        {
8407            tmsg.metadata.insert(
8408                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8409                original_position.to_string(),
8410            );
8411        }
8412        if is_error {
8413            crate::mark_tool_error(&mut tmsg);
8414        }
8415        restore_tool_outcome_extension(&part, &mut tmsg);
8416        if let Some(ts) = compacted_at {
8417            // S1: the real output is preserved — reversible, never erased.
8418            tmsg.metadata
8419                .insert("oc_tool_output_compacted".to_string(), real_output);
8420            tmsg.metadata
8421                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8422        }
8423        if status == Some("completed") {
8424            if let Some(atts) = part
8425                .get("state")
8426                .and_then(|s| s.get("attachments"))
8427                .and_then(Value::as_array)
8428            {
8429                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8430                if !images.is_empty() {
8431                    // D-mix consistency fix (Fable-recommended, same
8432                    // pattern as `push_claude_user`'s tool_result arm above):
8433                    // a completed opencode tool part with BOTH `state.output`
8434                    // text and `state.attachments` images is the same
8435                    // non-self-contained hybrid shape — `content_parts` here
8436                    // used to hold images only, so opencode -> pi silently
8437                    // dropped the output text (`pi_content_value` reads
8438                    // `content_parts` exclusively for `Role::Tool`). Prepend
8439                    // the text as part 0 so `content_parts` is
8440                    // self-contained; `tmsg.content` keeps the text too,
8441                    // unchanged, for writers that read it from there and
8442                    // only scan `content_parts` for `image_url` entries.
8443                    let mut parts = Vec::new();
8444                    if let Some(t) = &tmsg.content {
8445                        if !t.is_empty() {
8446                            parts.push(serde_json::json!({"type": "text", "text": t}));
8447                        }
8448                    }
8449                    parts.extend(images);
8450                    tmsg.content_parts = Some(parts);
8451                }
8452            }
8453        }
8454        if let Some(id) = part.get("id").and_then(Value::as_str) {
8455            tmsg.metadata
8456                .insert("oc_part_id".to_string(), id.to_string());
8457        }
8458        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8459        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8460        // cite `state.time.compacted`, but the SAME object also carries
8461        // `start`/`end` on every completed/error call) is this Tool
8462        // message's real source timestamp; prefer `end` (completion, closer
8463        // to when the RESULT — this message's content — was produced) and
8464        // fall back to `start` when only that is present.
8465        let tool_ts = part
8466            .get("state")
8467            .and_then(|s| s.get("time"))
8468            .and_then(|t| t.get("end").or_else(|| t.get("start")))
8469            .and_then(Value::as_i64);
8470        if let Some(ms) = tool_ts {
8471            tmsg.metadata
8472                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8473        }
8474        // OpenCode folds a canonical tool result into the assistant's tool
8475        // part. Restore the portable envelope from that part after native
8476        // fields have been captured so A -> OpenCode -> A retains fields
8477        // OpenCode does not model independently (for example Goose's
8478        // message-level metadata and an intentionally absent tool name).
8479        restore_grok_message_extension(&part, &mut tmsg);
8480        out.push(tmsg);
8481    }
8482}
8483
8484// ---- shared helpers -------------------------------------------------------
8485
8486fn push_text(buf: &mut String, v: Option<&Value>) {
8487    if let Some(Value::String(s)) = v {
8488        if !buf.is_empty() {
8489            buf.push('\n');
8490        }
8491        buf.push_str(s);
8492    }
8493}
8494
8495/// Extract a Claude `tool_result` block's content, preserving non-text items
8496/// instead of silently dropping them:
8497///
8498/// - text blocks are concatenated;
8499/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8500///   PNG / screenshot tool output" shape): `image` blocks are captured into
8501///   the returned `content_parts`-shaped `Vec<Value>` via
8502///   [`claude_image_block_to_part`] — the SAME base64/url conversion the
8503///   top-level `image` content-block path (`push_claude_user`) already uses
8504///   — instead of being flattened to the bare `[image]` marker text that used
8505///   to make the data unrecoverable from every writer. An unconvertible
8506///   source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8507///   etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8508///   vanishing, exactly like the top-level path;
8509/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8510///
8511/// and if the block yields no text/images at all, fall back to the record's
8512/// `toolUseResult` field (string used directly, structured value serialized),
8513/// which is where Claude Code stores the actual result in many cases.
8514///
8515/// Returns `(text, images)`; callers that only need the old text-only
8516/// behavior can ignore the second element — every caller MUST fold non-empty
8517/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8518/// function has no `ChatMessage` to attach to).
8519fn extract_tool_result_content(
8520    content: Option<&Value>,
8521    tool_use_result: Option<&Value>,
8522) -> (String, Vec<Value>) {
8523    let mut parts: Vec<String> = Vec::new();
8524    let mut images: Vec<Value> = Vec::new();
8525    match content {
8526        Some(Value::String(s)) => {
8527            if !s.is_empty() {
8528                parts.push(s.clone());
8529            }
8530        }
8531        Some(Value::Array(items)) => {
8532            for item in items {
8533                match item.get("type").and_then(Value::as_str) {
8534                    Some("text") => {
8535                        if let Some(t) = item.get("text").and_then(Value::as_str) {
8536                            parts.push(t.to_string());
8537                        }
8538                    }
8539                    Some("image") => match claude_image_block_to_part(item) {
8540                        Some(part) => images.push(part),
8541                        None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8542                    },
8543                    Some("tool_reference") => {
8544                        let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8545                        parts.push(format!("[tool_reference: {name}]"));
8546                    }
8547                    _ => {
8548                        if let Some(s) = item.as_str() {
8549                            parts.push(s.to_string());
8550                        }
8551                    }
8552                }
8553            }
8554        }
8555        Some(other) => parts.push(other.to_string()),
8556        None => {}
8557    }
8558
8559    let joined = parts.join("\n");
8560    if !joined.trim().is_empty() || !images.is_empty() {
8561        return (joined, images);
8562    }
8563    // Empty tool_result content — recover from toolUseResult.
8564    match tool_use_result {
8565        Some(Value::String(s)) => (s.clone(), images),
8566        Some(v) => (v.to_string(), images),
8567        None => (joined, images),
8568    }
8569}
8570
8571/// Pull readable text out of a content value that may be a plain string or an
8572/// array of `{ "text": "..." }`-bearing blocks (any block type).
8573fn extract_text_content(v: Option<&Value>) -> String {
8574    match v {
8575        Some(Value::String(s)) => s.clone(),
8576        Some(Value::Array(items)) => {
8577            let mut parts = Vec::new();
8578            for item in items {
8579                if let Some(t) = item.get("text").and_then(Value::as_str) {
8580                    parts.push(t.to_string());
8581                } else if let Some(s) = item.as_str() {
8582                    parts.push(s.to_string());
8583                }
8584            }
8585            parts.join("\n")
8586        }
8587        Some(other) => other.to_string(),
8588        None => String::new(),
8589    }
8590}
8591
8592/// Extract Codex `input_image` content blocks from a `message` response_item's
8593/// `content` value into `content_parts` `image_url` entries — the inverse of
8594/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8595/// block whose `image_url` is a non-empty string is recognized; anything else
8596/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8597/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8598/// the pi/opencode/Claude loaders' image-shape discipline.
8599fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8600    let Some(Value::Array(items)) = content else {
8601        return Vec::new();
8602    };
8603    items
8604        .iter()
8605        .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8606        .filter_map(|item| {
8607            let url = item.get("image_url").and_then(Value::as_str)?;
8608            if url.is_empty() {
8609                return None;
8610            }
8611            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8612        })
8613        .collect()
8614}
8615
8616fn value_to_arg_string(v: &Value) -> String {
8617    match v {
8618        Value::String(s) => s.clone(),
8619        other => other.to_string(),
8620    }
8621}
8622
8623fn push_gemini_user_parts(
8624    messages: &mut Vec<ChatMessage>,
8625    content_parts: Vec<Value>,
8626    timestamp: Option<&str>,
8627    source: &Value,
8628) {
8629    if content_parts.is_empty() {
8630        return;
8631    }
8632    let mut message = ChatMessage {
8633        role: Role::User,
8634        content: None,
8635        content_parts: Some(content_parts),
8636        tool_calls: None,
8637        tool_call_id: None,
8638        name: None,
8639        metadata: Default::default(),
8640    };
8641    if let Some(timestamp) = timestamp {
8642        message
8643            .metadata
8644            .insert("timestamp".into(), timestamp.into());
8645    }
8646    restore_gemini_message_extension(source, &mut message);
8647    messages.push(message);
8648}
8649
8650fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8651    ToolCall {
8652        id: id.to_string(),
8653        kind: "function".to_string(),
8654        function: FunctionCall {
8655            name: name.to_string(),
8656            arguments,
8657        },
8658    }
8659}
8660
8661fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8662    ChatMessage {
8663        role: Role::Tool,
8664        content: Some(content),
8665        content_parts: None,
8666        tool_calls: None,
8667        tool_call_id: Some(tool_call_id.to_string()),
8668        name: None,
8669        metadata: Default::default(),
8670    }
8671}
8672
8673/// Emit a single assistant message combining accumulated text and tool calls.
8674/// A turn with neither (e.g. thinking-only) produces nothing.
8675fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8676    let has_text = !text.trim().is_empty();
8677    if !has_text && calls.is_empty() {
8678        return;
8679    }
8680    out.push(ChatMessage {
8681        role: Role::Assistant,
8682        content: has_text.then_some(text),
8683        content_parts: None,
8684        tool_calls: (!calls.is_empty()).then_some(calls),
8685        tool_call_id: None,
8686        name: None,
8687        metadata: Default::default(),
8688    });
8689}
8690
8691// ---- writers --------------------------------------------------------------
8692
8693/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8694/// fallback (`docs/interop` build brief): every writer now emits a message's
8695/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8696/// field every loader populates) when one is present. `SYNTH_TS` fires only
8697/// for a message with no source timestamp at all — a turn synthesized/
8698/// appended after import (the live agent loop, a splice's appended tail,
8699/// ...), which was never loaded from a real per-message timestamp to begin
8700/// with. Both tools tolerate identical timestamps; callers that need real
8701/// ones for a synthesized turn can post-process.
8702const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8703
8704/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8705/// `time.created`/`time.updated` fields.
8706const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8707
8708/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8709/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8710/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8711/// parse, not just a presence check) so an absent, empty, or malformed
8712/// source value all degrade to the same documented fallback rather than
8713/// propagating garbage verbatim. Used by every writer that emits an
8714/// ISO-8601 timestamp field
8715/// (Claude Code, Codex, pi's entry-level `timestamp`).
8716fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8717    match msg.metadata.get("timestamp") {
8718        Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8719        _ => SYNTH_TS,
8720    }
8721}
8722
8723/// OpenCode reloads an export document by sorting messages on
8724/// `time.created`, so a timestamp-less appended continuation cannot reuse
8725/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8726/// newer. Advance a deterministic cursor for synthesized clocks while still
8727/// preserving every real source timestamp verbatim.
8728fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8729    if let Some(real) = msg
8730        .metadata
8731        .get("timestamp")
8732        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8733    {
8734        // A NativeTurn timestamp is durable provenance minted by supercode,
8735        // not an OpenCode source clock that must be replayed verbatim.
8736        // Multiple turns may be recorded in the same millisecond, while
8737        // OpenCode sorts solely by `time.created`; allocate such turns after
8738        // the existing cursor so their persisted order cannot collapse. This
8739        // also preserves the fail-closed i64::MAX exhaustion behavior.
8740        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8741            *cursor = cursor.checked_add(1).ok_or_else(|| {
8742                crate::Error::Other(
8743                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8744                        .to_string(),
8745                )
8746            })?;
8747            return Ok(*cursor);
8748        }
8749        *cursor = (*cursor).max(real);
8750        return Ok(real);
8751    }
8752    let next = cursor.checked_add(1).ok_or_else(|| {
8753        crate::Error::Other(
8754            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8755        )
8756    })?;
8757    *cursor = next.max(SYNTH_TS_MS);
8758    Ok(*cursor)
8759}
8760
8761/// Largest integer nested under any OpenCode `time` object. Imported
8762/// prefixes carry more clocks than `message.time.created` (assistant
8763/// completion, tool start/end, session updated); a synthesized continuation
8764/// must follow all of them, not merely sort after message creation times.
8765fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8766    fn max_number(value: &Value) -> Option<i64> {
8767        match value {
8768            Value::Number(n) => n.as_i64(),
8769            Value::Array(values) => values.iter().filter_map(max_number).max(),
8770            Value::Object(fields) => fields.values().filter_map(max_number).max(),
8771            _ => None,
8772        }
8773    }
8774
8775    match value {
8776        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8777        Value::Object(fields) => fields
8778            .iter()
8779            .filter_map(|(key, value)| {
8780                if key == "time" {
8781                    max_number(value)
8782                } else {
8783                    opencode_max_timestamp(value)
8784                }
8785            })
8786            .max(),
8787        _ => None,
8788    }
8789}
8790
8791/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8792/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8793/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8794/// reads. The two carry genuinely different values in real pi corpora (a
8795/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8796/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8797/// nested `message.timestamp` field, so a pi -> pi native round-trip
8798/// preserves the source message-level clock value-exact instead of deriving
8799/// it from the (distinct) entry-level timestamp. Falls back to
8800/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8801/// reading (non-pi-sourced, or a synthesized/appended turn).
8802fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8803    msg.metadata
8804        .get("pi_msg_timestamp")
8805        .and_then(|s| s.parse::<i64>().ok())
8806        .unwrap_or(SYNTH_TS_MS)
8807}
8808
8809/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8810fn synth_uuid(n: usize) -> String {
8811    format!("00000000-0000-4000-8000-{n:012x}")
8812}
8813
8814/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8815/// class N2 closed for the Codex spliced path's group ids, see
8816/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8817/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8818/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8819/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8820/// ahead of the tail this counter mints. Without this, re-splicing a
8821/// previously-exported-then-reimported session (export -> reimport -> append
8822/// -> export again) restarts `counter` at 1 with no memory of the prior
8823/// export's tail uuids now sitting in the prefix, so the second tail
8824/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8825/// — a uuid collision across prefix and tail that can mis-link any
8826/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8827/// climbing monotonically even across skips. `used_ids` is also updated for
8828/// each minted or metadata-backed identity, so collisions are prevented both
8829/// against the replayed prefix and within the appended tail.
8830fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8831    loop {
8832        let candidate = synth_uuid(*counter);
8833        *counter += 1;
8834        if used_ids.insert(candidate.clone()) {
8835            return candidate;
8836        }
8837    }
8838}
8839
8840/// Reuse a message's durable native/source UUID when available, falling back
8841/// to the deterministic synthesized sequence only for hand-built or legacy
8842/// messages that never carried identity metadata.
8843fn claude_message_uuid(
8844    msg: &ChatMessage,
8845    counter: &mut usize,
8846    used_ids: &mut HashSet<String>,
8847) -> String {
8848    for key in ["claude_uuid", "supercode_native_uuid"] {
8849        if let Some(candidate) = msg.metadata.get(key) {
8850            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8851                return candidate.clone();
8852            }
8853        }
8854    }
8855    next_claude_uuid(counter, used_ids)
8856}
8857
8858/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8859/// `raw_prefix` — the verbatim RAW lines
8860/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8861/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8862/// the GROUND TRUTH of what physically lands in the exported `out` string
8863/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8864/// the Codex side): each line is parsed as a Claude Code JSONL record and
8865/// its own top-level `uuid` field is read back out of the bytes directly, no
8866/// re-derivation from `self.messages` needed. A line that fails to parse, or
8867/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8868/// record), contributes nothing.
8869fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8870    let mut ids = HashSet::new();
8871    for line in raw_prefix {
8872        if let Ok(v) = serde_json::from_str::<Value>(line) {
8873            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8874                ids.insert(uuid.to_string());
8875            }
8876        }
8877    }
8878    ids
8879}
8880
8881fn push_jsonl(out: &mut String, value: &Value) {
8882    out.push_str(&value.to_string());
8883    out.push('\n');
8884}
8885
8886/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8887/// `new_id` when the line parses as a JSON object carrying that key — used
8888/// by A12's Claude Code splice, where the session id lives at the top level
8889/// of (almost) every record under `key = "sessionId"`. A line that fails to
8890/// parse, or parses but lacks `key`, is copied through byte-for-byte
8891/// (nothing to patch, so nothing is reserialized).
8892fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8893    if let Some(new_id) = new_id {
8894        if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8895            if v.get(key).is_some() {
8896                v[key] = Value::String(new_id.to_string());
8897                out.push_str(&v.to_string());
8898                out.push('\n');
8899                return;
8900            }
8901        }
8902    }
8903    out.push_str(line);
8904    out.push('\n');
8905}
8906
8907impl Session {
8908    fn cwd_string(&self) -> String {
8909        self.meta
8910            .cwd
8911            .as_ref()
8912            .map(|p| p.to_string_lossy().into_owned())
8913            .unwrap_or_else(|| ".".to_string())
8914    }
8915
8916    /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
8917    /// leading `raw` lines / `messages` came from the imported log, as
8918    /// opposed to being appended after import.
8919    ///
8920    /// `imported_message_count` (see its doc comment) pins the message-side
8921    /// boundary directly. The raw-side boundary isn't separately tracked —
8922    /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
8923    /// `raw` line per appended message, so the two lists grow by the same
8924    /// `appended_count` from the same starting point, and
8925    /// `raw.len() - appended_count` recovers it without a second counter.
8926    fn spliced_prefix_lens(&self) -> (usize, usize) {
8927        let message_prefix_len = self
8928            .imported_message_count
8929            .unwrap_or(self.messages.len())
8930            .min(self.messages.len());
8931        let appended_count = self.messages.len() - message_prefix_len;
8932        let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
8933        (raw_prefix_len, message_prefix_len)
8934    }
8935
8936    /// Synthesize a Claude Code transcript.
8937    ///
8938    /// Claude Code transcripts have no slot for the *session-level system
8939    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
8940    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
8941    /// `ChatMessage`s (Claude's own `type: "system"` records with a
8942    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
8943    /// `away_summary` — see `push_claude_system`, the exact inverse of what
8944    /// this writer now does) DO have a first-class slot: the real `type:
8945    /// "system"` record itself. This function used to unconditionally drop
8946    /// every `System` message, silently losing e.g. a real
8947    /// `<local-command-stdout>` record on any format -> Claude Code hop
8948    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
8949    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
8950    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
8951    /// now re-materializes it instead.
8952    fn to_claude_code_jsonl(&self) -> String {
8953        let session_id = self
8954            .meta
8955            .session_id
8956            .clone()
8957            .unwrap_or_else(|| synth_uuid(0));
8958        let cwd = self.cwd_string();
8959        let mut out = String::new();
8960        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
8961        // re-emitted byte-for-byte, ahead of the conversation it applies to —
8962        // this is what makes the record survive the SEMANTIC Claude Code
8963        // writer (the raw-passthrough diagonal in `crates/cli` already
8964        // preserves it by construction; this covers the library `to_jsonl`
8965        // path too, e.g. a `--session-id` override that forces the semantic
8966        // writer).
8967        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
8968            out.push_str(raw);
8969            out.push('\n');
8970        }
8971        // Full synthesis: `out` at this point has no raw prefix ahead of it
8972        // (unlike the A12 splice below), so there are no uuids yet in play
8973        // to seed against — see `next_claude_uuid`'s doc comment.
8974        self.write_claude_code_records(
8975            &mut out,
8976            &self.messages,
8977            &session_id,
8978            &cwd,
8979            None,
8980            1,
8981            &HashSet::new(),
8982        );
8983        if let Some(extension) = codex_provenance_envelope(&self.meta) {
8984            if out.is_empty() {
8985                push_jsonl(
8986                    &mut out,
8987                    &serde_json::json!({
8988                        "type": "file-history-snapshot",
8989                        "messageId": synth_uuid(1),
8990                        "snapshot": {},
8991                        "sessionId": session_id,
8992                        "cwd": cwd,
8993                        "timestamp": SYNTH_TS,
8994                    }),
8995                );
8996            }
8997            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8998        }
8999        out
9000    }
9001
9002    /// Synthesize Claude Code records for `messages` (a full session or an
9003    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
9004    /// the latter), starting the `parentUuid` chain at `parent` and the
9005    /// `synth_uuid` counter at `counter`. Factored out of
9006    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
9007    ///
9008    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
9009    /// every uuid that will ALREADY be present in `out` before this call
9010    /// ever runs — see that function's doc comment for why the A12 splice
9011    /// path needs this and full synthesis doesn't.
9012    // R1: this was already at clippy's `too_many_arguments` threshold (7,
9013    // including `&self`) before the fix; the added `seed_used_ids` param
9014    // pushes it to 8. Every argument here is independently meaningful (two
9015    // record-shape inputs, two id/parent-chain threading values, and now
9016    // the collision seed) — bundling them into a params struct is a larger
9017    // refactor of this already-widely-called private helper than the R1 fix
9018    // warrants, so this is allowed rather than restructured.
9019    #[allow(clippy::too_many_arguments)]
9020    fn write_claude_code_records(
9021        &self,
9022        out: &mut String,
9023        messages: &[ChatMessage],
9024        session_id: &str,
9025        cwd: &str,
9026        mut parent: Option<String>,
9027        mut counter: usize,
9028        seed_used_ids: &HashSet<String>,
9029    ) {
9030        let mut used_ids = seed_used_ids.clone();
9031        for msg in messages {
9032            if is_replay_excluded(msg) {
9033                continue;
9034            }
9035            let blocks: Vec<Value> = match msg.role {
9036                // PARITY-6 dev/02: re-materialize a content-bearing System
9037                // `ChatMessage` as a real Claude Code `type: "system"`
9038                // record — the exact inverse of `push_claude_system`, which
9039                // is what produced it in the first place for a message
9040                // loaded FROM a real Claude Code transcript. `subtype`
9041                // prefers the original `systemSubtype` metadata
9042                // (`push_claude_system`'s `.with_meta`, round-tripped
9043                // through the Codex hop via `write_codex_records`'s
9044                // `claude_system_subtype` metadata channel and restored by
9045                // `push_codex_item`); when that channel didn't carry it
9046                // (e.g. a genuinely native, non-Claude-origin developer
9047                // message), fall back to `local_command` — the observed
9048                // common case, and still one of `push_claude_system`'s own
9049                // `keep` subtypes, so the record survives a *subsequent*
9050                // reload rather than being silently re-dropped. This never
9051                // fabricates content: the real text is always carried
9052                // verbatim, only the subtype label is a best-effort guess
9053                // when the true one wasn't recoverable.
9054                Role::System => {
9055                    let content = msg.content.clone().unwrap_or_default();
9056                    if content.trim().is_empty() {
9057                        continue;
9058                    }
9059                    let subtype = msg
9060                        .metadata
9061                        .get("systemSubtype")
9062                        .cloned()
9063                        .unwrap_or_else(|| "local_command".to_string());
9064                    // R1/B3 union: this mint must ALSO route through
9065                    // `next_claude_uuid` + `seed_used_ids` like the other
9066                    // three arms below — otherwise this System arm (added by
9067                    // B3 after R1 landed) mints a raw `synth_uuid` that can
9068                    // collide with a uuid already sitting in the A12 splice's
9069                    // raw prefix (see `next_claude_uuid`'s doc comment).
9070                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9071                    let mut line = serde_json::json!({
9072                        "parentUuid": parent,
9073                        "type": "system",
9074                        "subtype": subtype,
9075                        "content": content,
9076                        "uuid": uuid,
9077                        "sessionId": session_id,
9078                        "cwd": cwd,
9079                        "timestamp": msg_timestamp_or_synth(msg),
9080                    });
9081                    set_grok_message_extension(&mut line, self.meta.source, msg);
9082                    push_jsonl(out, &line);
9083                    parent = Some(uuid);
9084                    continue;
9085                }
9086                Role::User => {
9087                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9088                    let mut line = serde_json::json!({
9089                        "parentUuid": parent,
9090                        "type": "user",
9091                        "message": {
9092                            "role": "user",
9093                            "content": claude_user_content_value(msg),
9094                        },
9095                        "uuid": uuid,
9096                        "sessionId": session_id,
9097                        "cwd": cwd,
9098                        "timestamp": msg_timestamp_or_synth(msg),
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::Tool => {
9106                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9107                    let mut line = serde_json::json!({
9108                        "parentUuid": parent,
9109                        "type": "user",
9110                        "message": {
9111                            "role": "user",
9112                            "content": [{
9113                                "type": "tool_result",
9114                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9115                                "content": claude_tool_result_content_value(msg),
9116                            }],
9117                        },
9118                        "uuid": uuid,
9119                        "sessionId": session_id,
9120                        "cwd": cwd,
9121                        "timestamp": msg_timestamp_or_synth(msg),
9122                    });
9123                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9124                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9125                    }
9126                    set_grok_message_extension(&mut line, self.meta.source, msg);
9127                    push_jsonl(out, &line);
9128                    parent = Some(uuid);
9129                    continue;
9130                }
9131                Role::Assistant => {
9132                    let mut blocks = Vec::new();
9133                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
9134                    // dev/01): thinking/redacted_thinking must be re-emitted
9135                    // BEFORE text/tool_use, unconditionally whenever
9136                    // retained metadata is present — not only when `blocks`
9137                    // is otherwise empty. The previous `if blocks.is_empty()`
9138                    // gate (now below, applied unconditionally instead)
9139                    // meant a turn that thinks AND THEN answers/calls a tool
9140                    // in the SAME turn — pi's own default emission shape,
9141                    // and the overwhelmingly common real-world case for any
9142                    // reasoning model, not the rare reasoning-only edge case
9143                    // this gate's comment described — silently dropped its
9144                    // entire `thinking` block on Pi -> Claude Code export. A
9145                    // genuine multi-turn pi session driven through pi's own
9146                    // real Agent loop (faux provider, see
9147                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
9148                    // thinking+text turns lost the thinking block entirely.
9149                    // D8: prefer the exact per-block list when present —
9150                    // every `thinking`/`redacted_thinking` block re-emitted
9151                    // SEPARATELY with its own signature/data, exactly as
9152                    // captured (`push_claude_assistant`), instead of the
9153                    // legacy singular fields' lossy collapse (which drops
9154                    // every signature but the last one's on a multi-block
9155                    // message). Falls back to the legacy fields only for a
9156                    // `Session` that never populated `thinking_blocks` (e.g.
9157                    // hand-constructed in another loader/test, or loaded
9158                    // from a non-Claude-Code source like Pi).
9159                    match msg
9160                        .metadata
9161                        .get("thinking_blocks")
9162                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9163                        .and_then(|v| v.as_array().cloned())
9164                    {
9165                        Some(saved_blocks) => blocks.extend(saved_blocks),
9166                        None => {
9167                            if let Some(t) = msg.metadata.get("thinking") {
9168                                let mut block =
9169                                    serde_json::json!({"type": "thinking", "thinking": t});
9170                                if let Some(sig) = msg.metadata.get("thinking_signature") {
9171                                    block["signature"] = Value::String(sig.clone());
9172                                }
9173                                blocks.push(block);
9174                            }
9175                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
9176                                blocks.push(
9177                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
9178                                );
9179                            }
9180                        }
9181                    }
9182                    if let Some(t) = &msg.content {
9183                        if !t.is_empty() {
9184                            blocks.push(serde_json::json!({"type": "text", "text": t}));
9185                        }
9186                    }
9187                    // PARITY-11: an assistant-emitted image (`content_parts`,
9188                    // e.g. a generated image — `push_claude_assistant`'s
9189                    // load-side counterpart) has no slot in `msg.content`;
9190                    // without this, `blocks` stayed empty for an image-only
9191                    // turn and the whole message vanished on Claude Code
9192                    // semantic export, same failure mode the IX-6 Codex
9193                    // writer fix already closed on that side.
9194                    if let Some(parts) = &msg.content_parts {
9195                        for p in parts {
9196                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
9197                                if let Some(url) = p
9198                                    .get("image_url")
9199                                    .and_then(|u| u.get("url"))
9200                                    .and_then(Value::as_str)
9201                                {
9202                                    blocks.push(match parse_data_uri(url) {
9203                                        Some((mime, data)) => serde_json::json!({
9204                                            "type": "image",
9205                                            "source": {"type": "base64", "media_type": mime, "data": data},
9206                                        }),
9207                                        None => serde_json::json!({
9208                                            "type": "image",
9209                                            "source": {"type": "url", "url": url},
9210                                        }),
9211                                    });
9212                                }
9213                            }
9214                        }
9215                    }
9216                    for tc in msg.tool_calls() {
9217                        let input = tc
9218                            .function
9219                            .parsed_arguments()
9220                            .unwrap_or_else(|_| Value::Object(Default::default()));
9221                        blocks.push(serde_json::json!({
9222                            "type": "tool_use",
9223                            "id": tc.id,
9224                            "name": tc.function.name,
9225                            "input": input,
9226                        }));
9227                    }
9228                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9229                    // (no text, no tool_use, no image) still doesn't vanish
9230                    // — the thinking/redacted_thinking prepend above already
9231                    // ran unconditionally, so `blocks` is non-empty here
9232                    // whenever any of those were present.
9233                    blocks
9234                }
9235            };
9236
9237            // An empty assistant content array is a valid native interrupted
9238            // turn and must remain a record. Every non-assistant arm above
9239            // already `continue`s after writing its own shape, so an empty
9240            // `blocks` value here belongs specifically to that assistant.
9241            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9242            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9243            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9244                message["model"] = Value::String(model.clone());
9245            }
9246            let mut line = serde_json::json!({
9247                "parentUuid": parent,
9248                "type": "assistant",
9249                "message": message,
9250                "uuid": uuid,
9251                "sessionId": session_id,
9252                "cwd": cwd,
9253                "timestamp": msg_timestamp_or_synth(msg),
9254            });
9255            set_grok_message_extension(&mut line, self.meta.source, msg);
9256            push_jsonl(out, &line);
9257            parent = Some(uuid);
9258        }
9259    }
9260
9261    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9262    /// (patching `sessionId` on each line when `session_id` is `Some`), then
9263    /// synthesize records only for the appended tail, via
9264    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9265    /// last original `uuid` found anywhere in the raw prefix (not just its
9266    /// final line: a trailing loader-skipped record, e.g.
9267    /// `file-history-snapshot`, may carry no `uuid` of its own).
9268    fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9269        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9270        let sid = session_id
9271            .map(str::to_string)
9272            .or_else(|| self.meta.session_id.clone())
9273            .unwrap_or_else(|| synth_uuid(0));
9274        let cwd = self.cwd_string();
9275
9276        let mut out = String::new();
9277        let mut parent: Option<String> = None;
9278        for line in &self.raw[..raw_prefix_len] {
9279            push_spliced_line(&mut out, line, session_id, "sessionId");
9280            if let Ok(v) = serde_json::from_str::<Value>(line) {
9281                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9282                    parent = Some(uuid.to_string());
9283                }
9284            }
9285        }
9286
9287        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9288        // the tail's collision guard with every uuid the just-replayed RAW
9289        // prefix already carries, so `write_claude_code_records` never
9290        // fabricates a `synth_uuid` for the appended tail that collides with
9291        // one already sitting in the prefix (see `next_claude_uuid`'s and
9292        // `collect_claude_uuids_from_raw`'s doc comments).
9293        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9294        self.write_claude_code_records(
9295            &mut out,
9296            &self.messages[message_prefix_len..],
9297            &sid,
9298            &cwd,
9299            parent,
9300            1,
9301            &seed_used_ids,
9302        );
9303        out
9304    }
9305
9306    /// Synthesize a Codex rollout.
9307    fn to_codex_jsonl(&self) -> String {
9308        let mut out = String::new();
9309
9310        if self.meta.codex_headers.is_empty() {
9311            self.write_synthesized_codex_header(&mut out);
9312        } else {
9313            // Replay the exact header records the original tool wrote — Codex's
9314            // reader validates the header shape strictly — overriding only the
9315            // session id when the caller changed it.
9316            for header in &self.meta.codex_headers {
9317                let mut header = header.clone();
9318                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9319                    if let Some(id) = &self.meta.session_id {
9320                        if let Some(payload) = header.get_mut("payload") {
9321                            payload["id"] = Value::String(id.clone());
9322                        }
9323                    }
9324                }
9325                push_jsonl(&mut out, &header);
9326            }
9327        }
9328
9329        // Full synthesis: `out` at this point is only the header, so there
9330        // are no group ids yet in play to seed against (see
9331        // `write_codex_records`'s doc comment).
9332        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9333        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9334            inject_codex_provenance(&mut out, extension);
9335        }
9336        out
9337    }
9338
9339    /// Synthesize Codex `response_item` records for `messages` (a full
9340    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9341    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9342    /// the record shape is defined once; `tool_search_call_ids` pairing is
9343    /// scoped to this call's `messages`, matching the header-replay
9344    /// contract that only appended records need synthesizing.
9345    ///
9346    /// `seed_used_ids` primes the N2 collision guard below with every group
9347    /// id that will ALREADY be present in `out` before this call ever runs —
9348    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9349    /// header) passes an empty set, since every group id in that case is
9350    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9351    /// splice) passes the ids already used by the verbatim RAW prefix it
9352    /// replayed into `out` just before calling this for the appended tail —
9353    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9354    /// start blind to the prefix and can fabricate/reuse a group id that
9355    /// COLLIDES with one still "open" at the end of the prefix, letting
9356    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9357    /// an unrelated appended message into a historical one — the same
9358    /// bug-class N2 closed for full synthesis, reopened here because the
9359    /// spliced tail's tracking set used to always start empty regardless of
9360    /// what the replayed prefix already contained.
9361    fn write_codex_records(
9362        &self,
9363        out: &mut String,
9364        messages: &[ChatMessage],
9365        seed_used_ids: &std::collections::HashSet<String>,
9366    ) {
9367        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9368        // the matching tool result below can be emitted as the paired
9369        // `tool_search_output` record rather than a generic
9370        // `function_call_output` — the exact inverse of the importer's
9371        // `tool_search_call`/`tool_search_output` normalization
9372        // (`push_codex_item`, above).
9373        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9374        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9375        // records (e.g. a text-only narration turn immediately followed by a
9376        // bare tool-call turn, no user turn between — a real, common Claude
9377        // Code shape) each become their own Codex `message`/`function_call`
9378        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9379        // opportunistically RE-MERGES an assistant `message` immediately
9380        // followed by a `function_call` back into ONE `ChatMessage`, to match
9381        // how a genuinely single Claude turn (text+tool_use in the SAME
9382        // record) round-trips — but with no distinguishing signal, it can't
9383        // tell that case apart from two originally-separate records that
9384        // just happen to be adjacent, so it wrongly recombines them too,
9385        // silently shrinking the message count on every Claude -> Codex ->
9386        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9387        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9388        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9389        // itself emits. `push_codex_item`'s merge already treats a turn_id
9390        // mismatch as "different turn, do not merge" (the pre-existing
9391        // belt-and-suspenders check); real native Codex data almost never
9392        // carries this field (per that check's own comment), so this is a
9393        // no-op there and only sharpens fidelity for OUR OWN synthesized
9394        // export.
9395        let mut next_group_id: u64 = 0;
9396        // N2 (Fable-5 review, turn_id-collision hardening): every group id
9397        // this export has already assigned — whether REUSED from a real
9398        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9399        // `ChatMessage` never emits one that's already in use. Two concrete
9400        // mis-merge scenarios motivate this:
9401        //
9402        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9403        //     own text+tool_use); reload makes A carry REAL turn_id
9404        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9405        //     its own) is then appended. Re-export: A reuses its real
9406        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
9407        //     from `next_group_id == 0` again (nothing bumped it when A's id
9408        //     was reused rather than fabricated) — also `sc-grp-0`.
9409        //     Collision. If A's call has no output (interrupted session),
9410        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9411        //     adjacent with nothing to break the run and merges all three
9412        //     into ONE message (2 -> 1).
9413        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9414        //     truncation/clear event strips `__codex_open_turn` (closing the
9415        //     turn without changing the id), then `function_call(turn-7)`
9416        //     loads as a SECOND, separate `ChatMessage` that still carries
9417        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
9418        //     restamps it). Full-synthesis export naively reuses `turn-7`
9419        //     verbatim for BOTH messages (they're two different loop
9420        //     iterations, each independently reusing its own `real_turn_id`)
9421        //     and emits them adjacent — reimport's merge check can't tell
9422        //     this apart from a single message's own multi-call turn and
9423        //     recombines them (2 -> 1).
9424        //
9425        // Fix: the fabricated-id counter is advanced (skipped) past any id
9426        // already in `used_group_ids`, AND a real id that's already been
9427        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9428        // — never letting two DIFFERENT `ChatMessage`s in this export share
9429        // one group id, since `push_codex_item`'s merge check treats a
9430        // shared id as "same turn, merge". A single `ChatMessage`'s own
9431        // message record + its own tool call records still share ONE group
9432        // id (computed once per loop iteration below, before insertion), so
9433        // the D1 tool_search merge and ordinary same-turn multi-call
9434        // grouping are unaffected — this only stops REUSE across iterations.
9435        //
9436        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9437        // spliced-export tail is likewise blind-proof against the prefix it
9438        // doesn't itself write.
9439        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9440
9441        for msg in messages {
9442            if is_replay_excluded(msg) {
9443                continue;
9444            }
9445            // D3 (Fable-5 review): a message loaded FROM real native Codex
9446            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9447            // (`push_codex_item`'s "message" arm stamps it whenever the
9448            // source record itself has one). The group-id logic below used
9449            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9450            // silently overwriting/discarding that real id on any
9451            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9452            // when present; only fabricate a synthetic id as a fallback for
9453            // our own merge-disambiguation need (PARITY-6/7) when the
9454            // message has no real one of its own.
9455            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9456            match msg.role {
9457                Role::System => {
9458                    // PARITY-6 dev/02: carry the original Claude
9459                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9460                    // through as `metadata.claude_system_subtype`, so
9461                    // `push_codex_item`'s reverse load can restore it and
9462                    // `write_claude_code_records`'s `Role::System` arm can
9463                    // re-materialize the EXACT original subtype rather than
9464                    // guessing on a Codex -> Claude hop.
9465                    let subtype_meta = msg
9466                        .metadata
9467                        .get("systemSubtype")
9468                        .map(|s| ("claude_system_subtype", s.as_str()));
9469                    self.push_codex_message(
9470                        out,
9471                        "developer",
9472                        "input_text",
9473                        msg,
9474                        real_turn_id,
9475                        subtype_meta,
9476                    )
9477                }
9478                Role::User => {
9479                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9480                }
9481                Role::Assistant => {
9482                    // Emit the message record whenever there is text OR
9483                    // content_parts (IX-6 follow-up): an image-only assistant
9484                    // message has `content: None, content_parts:
9485                    // Some([image])` (the loader's `codex_extract_images` is
9486                    // role-general, so this shape can occur on the assistant
9487                    // side too) — gating on `msg.content` alone silently
9488                    // dropped the whole message, image included. A
9489                    // text-only message (content_parts: None) keeps taking
9490                    // the historical byte-identical path via
9491                    // `codex_message_content_blocks`'s `None` arm. A real
9492                    // empty native assistant record carries the
9493                    // loader's explicit marker and must also be emitted.
9494                    // Reasoning-only cross-provider turns deliberately lack
9495                    // that marker and keep the documented Codex residue.
9496                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9497                    let has_message_record = has_text
9498                        || msg.content_parts.is_some()
9499                        || msg.metadata.contains_key("empty_assistant_record");
9500                    // Only assign a synthetic group id when there's actual
9501                    // merge ambiguity to resolve (a message AND its own tool
9502                    // calls, or 2+ of this message's own tool calls) — a
9503                    // pure-text message with no tool calls, or a lone tool
9504                    // call with nothing else from the same `ChatMessage`,
9505                    // has nothing to disambiguate, so it keeps the exact
9506                    // historical byte shape (no `metadata` key at all).
9507                    let group_id: Option<String> = if let Some(real) = real_turn_id {
9508                        if used_group_ids.contains(real) {
9509                            // N2: this real turn_id was already used by an
9510                            // earlier (now-closed) `ChatMessage` in this same
9511                            // export — reusing it verbatim would let the
9512                            // reimport merge check recombine two originally
9513                            // separate messages (see the doc comment above).
9514                            let mut n = 1u64;
9515                            let mut candidate = format!("{real}~dup{n}");
9516                            while used_group_ids.contains(&candidate) {
9517                                n += 1;
9518                                candidate = format!("{real}~dup{n}");
9519                            }
9520                            Some(candidate)
9521                        } else {
9522                            Some(real.to_string())
9523                        }
9524                    } else if !msg.tool_calls().is_empty() {
9525                        // N2: skip past any id already used (e.g. a REAL
9526                        // turn_id that happens to look like `sc-grp-N`, or an
9527                        // id an earlier reused-real case landed on).
9528                        let mut candidate = format!("sc-grp-{next_group_id}");
9529                        next_group_id += 1;
9530                        while used_group_ids.contains(&candidate) {
9531                            candidate = format!("sc-grp-{next_group_id}");
9532                            next_group_id += 1;
9533                        }
9534                        Some(candidate)
9535                    } else {
9536                        None
9537                    };
9538                    if let Some(g) = &group_id {
9539                        used_group_ids.insert(g.clone());
9540                    }
9541                    if has_message_record {
9542                        self.push_codex_message(
9543                            out,
9544                            "assistant",
9545                            "output_text",
9546                            msg,
9547                            group_id.as_deref(),
9548                            None,
9549                        );
9550                    }
9551                    for tc in msg.tool_calls() {
9552                        let custom_tool_call = msg
9553                            .metadata
9554                            .get("codex_custom_tool_call_ids")
9555                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9556                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9557                        if custom_tool_call {
9558                            let input = tc
9559                                .function
9560                                .parsed_arguments()
9561                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9562                            let mut payload = with_turn_id(
9563                                serde_json::json!({
9564                                    "type": "custom_tool_call",
9565                                    "name": tc.function.name,
9566                                    "input": input,
9567                                    "call_id": tc.id,
9568                                }),
9569                                group_id.as_deref(),
9570                            );
9571                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9572                            push_jsonl(
9573                                out,
9574                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9575                            );
9576                        } else if tc.function.name == "tool_search" {
9577                            tool_search_call_ids.insert(tc.id.clone());
9578                            let mut payload = with_turn_id(
9579                                serde_json::json!({
9580                                    "type": "tool_search_call",
9581                                    "arguments": tc.function.arguments,
9582                                    "call_id": tc.id,
9583                                }),
9584                                group_id.as_deref(),
9585                            );
9586                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9587                            push_jsonl(
9588                                out,
9589                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9590                            );
9591                        } else {
9592                            let mut payload = with_turn_id(
9593                                serde_json::json!({
9594                                    "type": "function_call",
9595                                    "name": tc.function.name,
9596                                    "arguments": tc.function.arguments,
9597                                    "call_id": tc.id,
9598                                }),
9599                                group_id.as_deref(),
9600                            );
9601                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9602                            push_jsonl(
9603                                out,
9604                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9605                            );
9606                        }
9607                    }
9608                    // PARITY-11: a genuinely reasoning-only turn (Claude
9609                    // `thinking`/`redacted_thinking` with no text, tool_use,
9610                    // or image — `push_claude_assistant`'s load-side fix for
9611                    // the ~21% of real assistant records that are exactly
9612                    // this shape) has no message record and no tool calls,
9613                    // so nothing above writes anything for it. This is
9614                    // DELIBERATE, not a residual gap: Codex's `reasoning`
9615                    // response_item is understood on import (see the
9616                    // `response_item`/`"reasoning"` arm above), but its
9617                    // real-native semantics is "the reasoning immediately
9618                    // BEFORE the next turn" — the reader attaches it to
9619                    // whatever response_item comes next, unconditionally.
9620                    // For a genuinely standalone Claude reasoning-only turn
9621                    // (no related turn follows in Codex's export at all),
9622                    // emitting one here would get silently misattributed as
9623                    // belonging to some later, unrelated turn instead —
9624                    // strictly worse than the current honest, accounted-for
9625                    // absence (thinking/redacted_thinking is provider-
9626                    // private and "not replayed across providers" by
9627                    // original design; the audit correctly classifies it
9628                    // `Coverage::Dropped`, not `Unmodeled`). See the
9629                    // PARITY-6/7 corpus test's `is_replayable` filter for
9630                    // why this doesn't count as a message-count regression.
9631                }
9632                Role::Tool
9633                    if msg
9634                        .tool_call_id
9635                        .as_deref()
9636                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9637                {
9638                    let content = msg.content.clone().unwrap_or_default();
9639                    let tools =
9640                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9641                    let mut payload = serde_json::json!({
9642                        "type": "tool_search_output",
9643                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9644                        "tools": tools,
9645                    });
9646                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9647                    push_jsonl(
9648                        out,
9649                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9650                    );
9651                }
9652                Role::Tool => {
9653                    let mut payload = serde_json::json!({
9654                        "type": "function_call_output",
9655                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9656                        "output": codex_tool_output_text(msg),
9657                    });
9658                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9659                    push_jsonl(
9660                        out,
9661                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9662                    );
9663                }
9664            }
9665        }
9666    }
9667
9668    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9669    /// line, not just the `session_meta`/`turn_context` headers
9670    /// [`Self::to_codex_jsonl`] replays — overriding only
9671    /// `session_meta.payload.id` when `session_id` is `Some` (every other
9672    /// line, including `response_item`s the stock synthesis would otherwise
9673    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9674    /// `response_item` records only for the appended tail, via
9675    /// [`Self::write_codex_records`].
9676    fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9677        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9678
9679        let mut out = String::new();
9680        for line in &self.raw[..raw_prefix_len] {
9681            match session_id {
9682                Some(id) => {
9683                    let patched = serde_json::from_str::<Value>(line)
9684                        .ok()
9685                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9686                        .map(|mut v| {
9687                            if let Some(payload) = v.get_mut("payload") {
9688                                payload["id"] = Value::String(id.to_string());
9689                            }
9690                            v.to_string()
9691                        });
9692                    out.push_str(patched.as_deref().unwrap_or(line));
9693                }
9694                None => out.push_str(line),
9695            }
9696            out.push('\n');
9697        }
9698
9699        // N2 (spliced-path hardening): seed the tail's collision guard with
9700        // every group id the just-replayed RAW prefix already carries, so
9701        // `write_codex_records` never fabricates/reuses an id for the
9702        // appended tail that collides with one still open at the end of the
9703        // prefix (see that fn's doc comment, and
9704        // `collect_codex_group_ids_from_raw`'s).
9705        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9706        // Belt-and-suspenders: also union in the prefix `messages`' own
9707        // recorded `turn_id` metadata. In the ordinary case this is already
9708        // a subset of what the raw-line scan above found (the loader stamps
9709        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9710        // field the scan reads) — but scanning `messages` too costs nothing
9711        // and means this stays correct even if some future loader path ever
9712        // derives a message's `turn_id` by some means other than a literal
9713        // `payload.metadata.turn_id` copy.
9714        for msg in &self.messages[..message_prefix_len] {
9715            if let Some(tid) = msg.metadata.get("turn_id") {
9716                seed_used_ids.insert(tid.clone());
9717            }
9718        }
9719        self.write_codex_records(
9720            &mut out,
9721            &self.messages[message_prefix_len..],
9722            &seed_used_ids,
9723        );
9724        out
9725    }
9726
9727    /// Build a Codex header from scratch (used when converting from another
9728    /// format, where no original Codex header exists to replay). Emits the
9729    /// fields Codex requires on `session_meta`.
9730    fn write_synthesized_codex_header(&self, out: &mut String) {
9731        let mut meta_payload = serde_json::json!({
9732            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9733            "timestamp": SYNTH_TS,
9734            "cwd": self.cwd_string(),
9735            "originator": "supercode",
9736            "cli_version": env!("CARGO_PKG_VERSION"),
9737            "source": "exec",
9738            "thread_source": "user",
9739            "model_provider": "openai",
9740        });
9741        if let Some(sp) = &self.meta.system_prompt {
9742            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9743        }
9744        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9745        // `capture_claude_meta`) through the Codex hop under a clearly
9746        // namespaced custom field — real Codex tooling ignores unknown
9747        // `session_meta.payload` keys, and `capture_codex_session_meta`
9748        // reads this same key back on import, so a Claude -> Codex -> Claude
9749        // round trip still reconstructs the original record instead of
9750        // silently losing the lineage note on the cross-format hop.
9751        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9752            meta_payload["claude_fork_context_ref"] =
9753                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9754        }
9755        push_jsonl(
9756            out,
9757            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9758        );
9759        if let Some(model) = &self.meta.model {
9760            push_jsonl(
9761                out,
9762                &serde_json::json!({
9763                    "timestamp": SYNTH_TS,
9764                    "type": "turn_context",
9765                    "payload": {"model": model, "cwd": self.cwd_string()},
9766                }),
9767            );
9768        }
9769    }
9770
9771    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9772    /// [`Self::write_codex_records`] — `Some` when the source message
9773    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9774    /// (assistant only) a synthetic disambiguation id when it owns tool
9775    /// calls needing merge disambiguation and has no real id of its own;
9776    /// `None` reproduces the exact historical shape (no `metadata` key at
9777    /// all).
9778    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9779    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9780    /// `Role::System` case in [`Self::write_codex_records`] to carry
9781    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9782    /// system record's subtype survives the Claude -> Codex -> Claude round
9783    /// trip instead of only its text; `None` for every other caller,
9784    /// preserving the exact historical shape).
9785    fn push_codex_message(
9786        &self,
9787        out: &mut String,
9788        role: &str,
9789        text_type: &str,
9790        msg: &ChatMessage,
9791        turn_id: Option<&str>,
9792        extra_metadata: Option<(&str, &str)>,
9793    ) {
9794        let mut payload = with_turn_id(
9795            serde_json::json!({
9796                "type": "message",
9797                "role": role,
9798                "content": codex_message_content_blocks(text_type, msg),
9799            }),
9800            turn_id,
9801        );
9802        if let Some((k, v)) = extra_metadata {
9803            if payload.get("metadata").is_none() {
9804                payload["metadata"] = serde_json::json!({});
9805            }
9806            payload["metadata"][k] = serde_json::json!(v);
9807        }
9808        set_grok_message_extension(&mut payload, self.meta.source, msg);
9809        push_jsonl(
9810            out,
9811            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9812        );
9813    }
9814
9815    /// Synthesize a fresh pi v3 session from the canonical `messages`
9816    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9817    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9818    /// through `raw` + `to_native_jsonl(_v2)` instead).
9819    fn to_pi_jsonl(&self) -> String {
9820        let session_id = self
9821            .meta
9822            .session_id
9823            .clone()
9824            .unwrap_or_else(|| synth_uuid(0));
9825        let cwd = self.cwd_string();
9826        let mut out = String::new();
9827        push_pi_header(
9828            &mut out,
9829            &session_id,
9830            &cwd,
9831            self.meta
9832                .lineage
9833                .get("parent_session_path")
9834                .map(String::as_str),
9835            self.meta.lineage.get("created_at").map(String::as_str),
9836            // D7: carry a captured Claude `fork-context-ref` (see
9837            // `capture_claude_meta`) through the Pi hop too — mirrors the
9838            // Codex hop's `claude_fork_context_ref` passthrough
9839            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9840            // round trip doesn't silently lose fork lineage just because Pi
9841            // has no native slot for it.
9842            self.meta
9843                .lineage
9844                .get("claude_fork_context_ref_raw")
9845                .map(String::as_str),
9846        );
9847        let mut used_ids: HashSet<String> = HashSet::new();
9848        let mut counter: u64 = 0;
9849        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9850        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9851            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9852        }
9853        out
9854    }
9855
9856    /// Synthesize pi `message` entries for `messages` (a full session, or —
9857    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9858    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9859    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9860    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9861    fn write_pi_entries(
9862        &self,
9863        out: &mut String,
9864        messages: &[ChatMessage],
9865        mut parent: Option<String>,
9866        used_ids: &mut HashSet<String>,
9867        counter: &mut u64,
9868    ) {
9869        // Claude Code and Codex do not repeat the tool name on their native
9870        // tool-result records. Recover that redundant Pi field from the
9871        // paired assistant call when a cross-format round trip therefore
9872        // returns a canonical Tool message with `name == None`.
9873        let mut paired_tool_names = HashMap::<String, String>::new();
9874        for msg in messages {
9875            if is_replay_excluded(msg) {
9876                continue;
9877            }
9878            for call in msg.tool_calls() {
9879                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9880            }
9881            let id = pi_fresh_id(used_ids, counter);
9882            let mut entry = match msg.role {
9883                // B4: pi has no session-level system/developer PROMPT slot
9884                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9885                // content-bearing `Role::System` message loaded from a real
9886                // Claude Code `type: "system"` record (`push_claude_system`'s
9887                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9888                // `away_summary`) is NOT a system prompt — it's a real,
9889                // non-regenerable transcript event. Pi's own `role:"custom"`
9890                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9891                // as a user message") is the closest existing, non-fabricated
9892                // slot pi's own parser already understands, so this
9893                // re-materializes the record there instead of silently
9894                // dropping it — the exact allowance push_claude_system's own
9895                // doc comment describes in reverse. `customType` is a
9896                // supercode-namespaced marker (`push_pi_custom_common`
9897                // recognizes it on reload and restores `Role::System` +
9898                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9899                // produced in the first place); a real pi customType never
9900                // collides with this name. `details.claude_system_subtype`
9901                // carries the original subtype losslessly through the pi leg
9902                // (mirrors `write_codex_records`'s `claude_system_subtype`
9903                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9904                // is never fabricated — only emitted when non-empty.
9905                Role::System => {
9906                    let content = msg.content.clone().unwrap_or_default();
9907                    if content.trim().is_empty() {
9908                        continue;
9909                    }
9910                    let subtype = msg
9911                        .metadata
9912                        .get("systemSubtype")
9913                        .cloned()
9914                        .unwrap_or_else(|| "local_command".to_string());
9915                    serde_json::json!({
9916                        "type": "message",
9917                        "id": id,
9918                        "parentId": parent,
9919                        "timestamp": msg_timestamp_or_synth(msg),
9920                        "message": {
9921                            "role": "custom",
9922                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
9923                            "content": content,
9924                            "display": true,
9925                            "details": {"claude_system_subtype": subtype},
9926                            "timestamp": msg_pi_native_timestamp_ms(msg),
9927                        },
9928                    })
9929                }
9930                Role::User => serde_json::json!({
9931                    "type": "message",
9932                    "id": id,
9933                    "parentId": parent,
9934                    "timestamp": msg_timestamp_or_synth(msg),
9935                    "message": {
9936                        "role": "user",
9937                        "content": pi_content_value(msg),
9938                        "timestamp": msg_pi_native_timestamp_ms(msg),
9939                    },
9940                }),
9941                Role::Assistant => {
9942                    let api = msg
9943                        .metadata
9944                        .get("pi_api")
9945                        .cloned()
9946                        .unwrap_or_else(|| "anthropic-messages".to_string());
9947                    let provider = msg
9948                        .metadata
9949                        .get("pi_provider")
9950                        .cloned()
9951                        .unwrap_or_else(|| "anthropic".to_string());
9952                    let model = self
9953                        .meta
9954                        .model
9955                        .clone()
9956                        .unwrap_or_else(|| "unknown".to_string());
9957                    let usage = msg
9958                        .metadata
9959                        .get("pi_usage")
9960                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9961                        .unwrap_or_else(default_pi_usage);
9962                    let stop_reason = msg
9963                        .metadata
9964                        .get("pi_stop_reason")
9965                        .cloned()
9966                        .unwrap_or_else(|| "stop".to_string());
9967                    serde_json::json!({
9968                        "type": "message",
9969                        "id": id,
9970                        "parentId": parent,
9971                        "timestamp": msg_timestamp_or_synth(msg),
9972                        "message": {
9973                            "role": "assistant",
9974                            "content": pi_assistant_content_value(msg),
9975                            "api": api,
9976                            "provider": provider,
9977                            "model": model,
9978                            "usage": usage,
9979                            "stopReason": stop_reason,
9980                            "timestamp": msg_pi_native_timestamp_ms(msg),
9981                        },
9982                    })
9983                }
9984                Role::Tool => serde_json::json!({
9985                    "type": "message",
9986                    "id": id,
9987                    "parentId": parent,
9988                    "timestamp": msg_timestamp_or_synth(msg),
9989                    "message": {
9990                        "role": "toolResult",
9991                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
9992                        "toolName": msg.name.as_deref().or_else(|| {
9993                            msg.tool_call_id
9994                                .as_deref()
9995                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
9996                        }).unwrap_or_default(),
9997                        "content": pi_content_value(msg),
9998                        "isError": is_tool_error_flag(msg),
9999                        "timestamp": msg_pi_native_timestamp_ms(msg),
10000                    },
10001                }),
10002            };
10003            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
10004                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
10005            }
10006            set_grok_message_extension(&mut entry, self.meta.source, msg);
10007            push_jsonl(out, &entry);
10008            parent = Some(id);
10009            if msg.role == Role::Tool {
10010                if let Some(call_id) = msg.tool_call_id.as_deref() {
10011                    paired_tool_names.remove(call_id);
10012                }
10013            }
10014        }
10015    }
10016
10017    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
10018    /// **verbatim** — the header line always has its `version` normalized to
10019    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
10020    /// byte-identity, so the writer never re-emits one; this intentionally
10021    /// breaks byte-identity for pre-v3 originals only, the accepted
10022    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
10023    /// other raw line — every entry — is untouched (pi repeats the session
10024    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
10025    /// entries only for the appended tail via [`Self::write_pi_entries`],
10026    /// chaining from the last entry `id` found in the raw prefix.
10027    fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10028        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10029        if raw_prefix_len == 0 {
10030            return Ok(self.to_pi_jsonl());
10031        }
10032
10033        let mut out = String::new();
10034        let mut used_ids: HashSet<String> = HashSet::new();
10035        let mut leaf: Option<String> = None;
10036        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10037            if i == 0 {
10038                if let Ok(v) = serde_json::from_str::<Value>(line) {
10039                    if v.get("type").and_then(Value::as_str) == Some("session") {
10040                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10041                        // Only reparse+reserialize the header when something
10042                        // actually needs to change — this crate doesn't
10043                        // enable serde_json's `preserve_order`, so a no-op
10044                        // round-trip through `Value` would reorder keys
10045                        // alphabetically and silently break the "prefix
10046                        // bytes unchanged" splice guarantee for the (common)
10047                        // already-v3, no-override case.
10048                        if needs_v3 || session_id.is_some() {
10049                            let mut v = v;
10050                            v["version"] = serde_json::json!(3);
10051                            if let Some(new_id) = session_id {
10052                                v["id"] = Value::String(new_id.to_string());
10053                            }
10054                            out.push_str(&v.to_string());
10055                            out.push('\n');
10056                            continue;
10057                        }
10058                    }
10059                }
10060            }
10061            out.push_str(line);
10062            out.push('\n');
10063            if let Ok(v) = serde_json::from_str::<Value>(line) {
10064                if let Some(id) = v.get("id").and_then(Value::as_str) {
10065                    used_ids.insert(id.to_string());
10066                    leaf = Some(id.to_string());
10067                }
10068            }
10069        }
10070
10071        let mut counter: u64 = 0;
10072        self.write_pi_entries(
10073            &mut out,
10074            &self.messages[message_prefix_len..],
10075            leaf,
10076            &mut used_ids,
10077            &mut counter,
10078        );
10079        Ok(out)
10080    }
10081
10082    // ---- Grok writers -----------------------------------------------
10083
10084    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10085    fn to_grok_jsonl(&self) -> String {
10086        let mut out = String::new();
10087        if let Some(prompt) = self
10088            .meta
10089            .system_prompt
10090            .as_deref()
10091            .filter(|prompt| !prompt.is_empty())
10092        {
10093            push_jsonl(
10094                &mut out,
10095                &serde_json::json!({
10096                    "type": "system",
10097                    "content": prompt,
10098                }),
10099            );
10100        }
10101        self.write_grok_records(&mut out, &self.messages);
10102        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10103            if out.is_empty() {
10104                push_jsonl(
10105                    &mut out,
10106                    &serde_json::json!({"type": "system", "content": ""}),
10107                );
10108            }
10109            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10110        }
10111        out
10112    }
10113
10114    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10115        for message in messages {
10116            if is_replay_excluded(message) {
10117                continue;
10118            }
10119            let mut value = match message.role {
10120                Role::System => serde_json::json!({
10121                    "type": "user",
10122                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10123                    "synthetic_reason": "supercode_system_event",
10124                }),
10125                Role::User => {
10126                    let mut value = serde_json::json!({
10127                        "type": "user",
10128                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10129                    });
10130                    if let Some(object) = value.as_object_mut() {
10131                        for (metadata, field) in [
10132                            ("grok_prompt_index", "prompt_index"),
10133                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10134                            ("grok_synthetic_reason", "synthetic_reason"),
10135                        ] {
10136                            if let Some(raw) = message.metadata.get(metadata) {
10137                                object.insert(
10138                                    field.to_string(),
10139                                    serde_json::from_str(raw)
10140                                        .unwrap_or_else(|_| Value::String(raw.clone())),
10141                                );
10142                            }
10143                        }
10144                    }
10145                    value
10146                }
10147                Role::Assistant => {
10148                    let calls = message
10149                        .tool_calls()
10150                        .iter()
10151                        .map(|call| {
10152                            serde_json::json!({
10153                                "id": call.id,
10154                                "name": call.function.name,
10155                                "arguments": call.function.arguments,
10156                            })
10157                        })
10158                        .collect::<Vec<_>>();
10159                    let mut value = serde_json::json!({
10160                        "type": "assistant",
10161                        "content": message.content.clone().unwrap_or_default(),
10162                        "tool_calls": calls,
10163                        "model_id": message.metadata.get("grok_model_id")
10164                            .or(self.meta.model.as_ref())
10165                            .cloned()
10166                            .unwrap_or_else(|| "unknown".to_string()),
10167                    });
10168                    if let Some(object) = value.as_object_mut() {
10169                        for (metadata, field) in [
10170                            ("grok_model_fingerprint", "model_fingerprint"),
10171                            ("grok_reasoning_effort", "reasoning_effort"),
10172                        ] {
10173                            if let Some(raw) = message.metadata.get(metadata) {
10174                                object.insert(field.to_string(), Value::String(raw.clone()));
10175                            }
10176                        }
10177                    }
10178                    value
10179                }
10180                Role::Tool => serde_json::json!({
10181                    "type": "tool_result",
10182                    "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10183                    "content": message.content.clone().unwrap_or_default(),
10184                }),
10185            };
10186            set_grok_target_message_extension(&mut value, message);
10187            push_jsonl(out, &value);
10188        }
10189    }
10190
10191    /// Replay a Grok imported prefix verbatim, then append newly-created
10192    /// canonical turns. Grok stores the session id in the directory name,
10193    /// not in transcript records, so there is no in-file id to rewrite.
10194    fn to_grok_jsonl_spliced(&self) -> String {
10195        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10196        if raw_prefix_len == 0 {
10197            return self.to_grok_jsonl();
10198        }
10199        let mut out = String::new();
10200        for line in &self.raw[..raw_prefix_len] {
10201            out.push_str(line);
10202            out.push('\n');
10203        }
10204        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10205        out
10206    }
10207
10208    // ---- Gemini writers ---------------------------------------------
10209
10210    fn to_gemini_jsonl(&self) -> String {
10211        let mut out = String::new();
10212        self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10213        self.write_gemini_records(&mut out, &self.messages);
10214        push_jsonl(
10215            &mut out,
10216            &serde_json::json!({
10217                "$set": {"lastUpdated": SYNTH_TS}
10218            }),
10219        );
10220        out
10221    }
10222
10223    fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10224        push_jsonl(
10225            out,
10226            &serde_json::json!({
10227                "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10228                "projectHash": self.meta.lineage.get("gemini_project_hash")
10229                    .cloned().unwrap_or_else(|| "supercode".to_string()),
10230                "startTime": self.meta.lineage.get("created_at")
10231                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10232                "lastUpdated": self.meta.lineage.get("updated_at")
10233                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10234                "kind": self.meta.lineage.get("gemini_session_kind")
10235                    .cloned().unwrap_or_else(|| "main".to_string()),
10236            }),
10237        );
10238    }
10239
10240    fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10241        let mut call_names = HashMap::new();
10242        for (index, message) in messages.iter().enumerate() {
10243            if is_replay_excluded(message) {
10244                continue;
10245            }
10246            let timestamp = message
10247                .metadata
10248                .get("timestamp")
10249                .cloned()
10250                .unwrap_or_else(|| SYNTH_TS.to_string());
10251            match message.role {
10252                Role::System | Role::User => {
10253                    let mut parts = Vec::new();
10254                    let text = message.content.clone().or_else(|| {
10255                        message.content_parts.as_ref().and_then(|parts| {
10256                            let text = parts
10257                                .iter()
10258                                .filter_map(|part| part.get("text").and_then(Value::as_str))
10259                                .collect::<Vec<_>>()
10260                                .join(" ");
10261                            (!text.is_empty()).then_some(text)
10262                        })
10263                    });
10264                    if let Some(text) = text {
10265                        let text = if message.role == Role::System {
10266                            format!("[System] {text}")
10267                        } else {
10268                            text
10269                        };
10270                        parts.push(serde_json::json!({"text": text}));
10271                    }
10272                    if let Some(content_parts) = &message.content_parts {
10273                        for part in content_parts {
10274                            let Some(url) = part
10275                                .get("image_url")
10276                                .and_then(|value| value.get("url"))
10277                                .and_then(Value::as_str)
10278                            else {
10279                                continue;
10280                            };
10281                            let Some(rest) = url.strip_prefix("data:") else {
10282                                continue;
10283                            };
10284                            let Some((media_type, data)) = rest.split_once(";base64,") else {
10285                                continue;
10286                            };
10287                            parts.push(serde_json::json!({
10288                                "inlineData": {"mimeType": media_type, "data": data}
10289                            }));
10290                        }
10291                    }
10292                    if !parts.is_empty() {
10293                        let mut value = serde_json::json!({
10294                            "id": format!("supercode-user-{index}"),
10295                            "timestamp": timestamp,
10296                            "type": "user",
10297                            "content": parts,
10298                        });
10299                        set_gemini_message_extension(&mut value, message);
10300                        push_jsonl(out, &value);
10301                    }
10302                }
10303                Role::Assistant => {
10304                    let mut tool_calls = Vec::new();
10305                    for call in message.tool_calls() {
10306                        call_names.insert(call.id.clone(), call.function.name.clone());
10307                        let args = serde_json::from_str::<Value>(&call.function.arguments)
10308                            .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10309                        tool_calls.push(serde_json::json!({
10310                            "id": call.id,
10311                            "name": call.function.name,
10312                            "args": args,
10313                        }));
10314                    }
10315                    let mut value = serde_json::json!({
10316                        "id": format!("supercode-gemini-{index}"),
10317                        "timestamp": timestamp,
10318                        "type": "gemini",
10319                        "content": message.content.clone().unwrap_or_default(),
10320                        "model": message.metadata.get("gemini_model")
10321                            .or(self.meta.model.as_ref())
10322                            .cloned().unwrap_or_else(|| "unknown".to_string()),
10323                    });
10324                    if !tool_calls.is_empty() {
10325                        value["toolCalls"] = Value::Array(tool_calls);
10326                    }
10327                    if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10328                        value["thoughts"] = serde_json::from_str(thoughts)
10329                            .unwrap_or_else(|_| Value::String(thoughts.clone()));
10330                    }
10331                    set_gemini_message_extension(&mut value, message);
10332                    push_jsonl(out, &value);
10333                }
10334                Role::Tool => {
10335                    let id = message.tool_call_id.clone().unwrap_or_default();
10336                    let name = message
10337                        .name
10338                        .clone()
10339                        .or_else(|| call_names.get(&id).cloned())
10340                        .unwrap_or_else(|| "tool".to_string());
10341                    let output = message.content.clone().unwrap_or_else(|| {
10342                        message
10343                            .content_parts
10344                            .as_ref()
10345                            .map(|parts| Value::Array(parts.clone()))
10346                            .map(|value| value.to_string())
10347                            .unwrap_or_default()
10348                    });
10349                    let mut value = serde_json::json!({
10350                        "id": format!("supercode-tool-{index}"),
10351                        "timestamp": timestamp,
10352                        "type": "user",
10353                        "content": [{
10354                            "functionResponse": {
10355                                "id": id,
10356                                "name": name,
10357                                "response": {"output": output}
10358                            }
10359                        }],
10360                    });
10361                    set_gemini_message_extension(&mut value, message);
10362                    push_jsonl(out, &value);
10363                }
10364            }
10365        }
10366    }
10367
10368    fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10369        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10370        if raw_prefix_len == 0 {
10371            let mut out = String::new();
10372            self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10373            self.write_gemini_records(&mut out, &self.messages);
10374            return out;
10375        }
10376        let mut out = String::new();
10377        for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10378            if index == 0 && session_id.is_some() {
10379                if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10380                    if value.get("type").is_none() && value.get("sessionId").is_some() {
10381                        value["sessionId"] =
10382                            Value::String(session_id.unwrap_or_default().to_string());
10383                        push_jsonl(&mut out, &value);
10384                        continue;
10385                    }
10386                }
10387            }
10388            out.push_str(line);
10389            out.push('\n');
10390        }
10391        self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10392        out
10393    }
10394
10395    // ---- Goose writers ----------------------------------------------
10396
10397    fn to_goose_json(&self) -> String {
10398        if self.meta.source == SessionSource::Goose
10399            && !self.raw.is_empty()
10400            && self.imported_message_count == Some(self.messages.len())
10401        {
10402            return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10403        }
10404        self.synthesized_goose_document(None, &self.messages)
10405    }
10406
10407    fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10408        let message_prefix_len = self
10409            .imported_message_count
10410            .unwrap_or(self.messages.len())
10411            .min(self.messages.len());
10412        if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10413            if session_id.is_none() && message_prefix_len == self.messages.len() {
10414                return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10415            }
10416            let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10417            if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10418                if let Some(session_id) = session_id {
10419                    document["id"] = Value::String(session_id.to_string());
10420                }
10421                let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10422                if let Some(conversation) = document
10423                    .get_mut("conversation")
10424                    .and_then(Value::as_array_mut)
10425                {
10426                    conversation.extend(appended);
10427                    document["message_count"] = Value::from(conversation.len());
10428                }
10429                return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10430                    self.synthesized_goose_document(session_id, &self.messages)
10431                });
10432            }
10433        }
10434        self.synthesized_goose_document(session_id, &self.messages)
10435    }
10436
10437    fn synthesized_goose_document(
10438        &self,
10439        session_id: Option<&str>,
10440        messages: &[ChatMessage],
10441    ) -> String {
10442        let mut document = self
10443            .meta
10444            .goose_header
10445            .clone()
10446            .or_else(|| {
10447                self.messages.iter().find_map(|message| {
10448                    message
10449                        .metadata
10450                        .get("goose_session_header")
10451                        .and_then(|value| serde_json::from_str(value).ok())
10452                })
10453            })
10454            .unwrap_or_else(|| {
10455                serde_json::json!({
10456                    "id": "supercode-goose-session",
10457                    "working_dir": self.cwd_string(),
10458                    "name": "supercode export",
10459                    "user_set_name": false,
10460                    "session_type": "user",
10461                    "created_at": SYNTH_TS,
10462                    "updated_at": SYNTH_TS,
10463                    "extension_data": {},
10464                    "usage": {},
10465                    "accumulated_usage": {},
10466                    "accumulated_cost": Value::Null,
10467                    "schedule_id": Value::Null,
10468                    "recipe": Value::Null,
10469                    "user_recipe_values": Value::Null,
10470                    "message_count": 0,
10471                    "last_message_at": Value::Null,
10472                    "provider_name": Value::Null,
10473                    "model_config": Value::Null,
10474                    "goose_mode": "auto",
10475                    "archived_at": Value::Null,
10476                    "project_id": Value::Null,
10477                    "parent_session_id": Value::Null,
10478                    "last_message_snippet": Value::Null,
10479                })
10480            });
10481        document["id"] = Value::String(
10482            session_id
10483                .map(str::to_string)
10484                .or_else(|| self.meta.session_id.clone())
10485                .unwrap_or_else(|| "supercode-goose-session".to_string()),
10486        );
10487        document["working_dir"] = Value::String(self.cwd_string());
10488        let conversation = self.goose_conversation(messages);
10489        document["message_count"] = Value::from(conversation.len());
10490        document["conversation"] = Value::Array(conversation);
10491        serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10492    }
10493
10494    fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10495        let mut out = Vec::new();
10496        let mut last_native_index: Option<String> = None;
10497        let mut tool_names = HashMap::<String, String>::new();
10498        for (index, message) in messages.iter().enumerate() {
10499            if is_replay_excluded(message) {
10500                continue;
10501            }
10502            if let Some(native_index) = message.metadata.get("goose_native_index") {
10503                if last_native_index.as_ref() == Some(native_index) {
10504                    continue;
10505                }
10506                last_native_index = Some(native_index.clone());
10507                if let Some(native) = message
10508                    .metadata
10509                    .get("goose_native_message")
10510                    .and_then(|value| serde_json::from_str::<Value>(value).ok())
10511                {
10512                    out.push(native);
10513                    continue;
10514                }
10515            } else {
10516                last_native_index = None;
10517            }
10518
10519            for call in message.tool_calls() {
10520                tool_names.insert(call.id.clone(), call.function.name.clone());
10521            }
10522            let created = message
10523                .metadata
10524                .get("goose_created")
10525                .and_then(|value| value.parse::<i64>().ok())
10526                .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10527            let role = match message.role {
10528                Role::Assistant => "assistant",
10529                _ => "user",
10530            };
10531            let mut content = Vec::new();
10532            // A Goose tool response carries its output inside
10533            // `toolResult.value.content`; duplicating it as a sibling text
10534            // block makes the loader normalize one Tool message twice.
10535            if message.role != Role::Tool {
10536                if let Some(text) = &message.content {
10537                    let text = if message.role == Role::System {
10538                        format!("[System] {text}")
10539                    } else {
10540                        text.clone()
10541                    };
10542                    content.push(serde_json::json!({"type": "text", "text": text}));
10543                }
10544                if let Some(parts) = &message.content_parts {
10545                    for part in parts {
10546                        if let Some(text) = part.get("text").and_then(Value::as_str) {
10547                            if message.content.is_none() {
10548                                content.push(serde_json::json!({"type": "text", "text": text}));
10549                            }
10550                        }
10551                        let Some(url) = part
10552                            .get("image_url")
10553                            .and_then(|image| image.get("url"))
10554                            .and_then(Value::as_str)
10555                        else {
10556                            continue;
10557                        };
10558                        let Some(data) = url.strip_prefix("data:") else {
10559                            continue;
10560                        };
10561                        let Some((media_type, data)) = data.split_once(";base64,") else {
10562                            continue;
10563                        };
10564                        content.push(serde_json::json!({
10565                            "type": "image",
10566                            "data": data,
10567                            "mimeType": media_type,
10568                        }));
10569                    }
10570                }
10571            }
10572            for call in message.tool_calls() {
10573                let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10574                    .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10575                content.push(serde_json::json!({
10576                    "type": "toolRequest",
10577                    "id": call.id,
10578                    "toolCall": {
10579                        "status": "success",
10580                        "value": {"name": call.function.name, "arguments": arguments}
10581                    }
10582                }));
10583            }
10584            if message.role == Role::Tool {
10585                let id = message.tool_call_id.clone().unwrap_or_default();
10586                let output = message.content.clone().unwrap_or_else(|| {
10587                    message
10588                        .content_parts
10589                        .as_ref()
10590                        .map(|parts| Value::Array(parts.clone()).to_string())
10591                        .unwrap_or_default()
10592                });
10593                let tool_result = if crate::is_tool_error(message) {
10594                    serde_json::json!({"status": "error", "error": output})
10595                } else {
10596                    serde_json::json!({
10597                        "status": "success",
10598                        "value": {
10599                            "content": [{"type": "text", "text": output}],
10600                            "isError": false
10601                        }
10602                    })
10603                };
10604                content.push(serde_json::json!({
10605                    "type": "toolResponse",
10606                    "id": id,
10607                    "toolResult": tool_result,
10608                    "metadata": {
10609                        "toolName": message.name.as_ref()
10610                            .or_else(|| tool_names.get(&id))
10611                    }
10612                }));
10613            }
10614            if content.is_empty() {
10615                continue;
10616            }
10617            let metadata = message
10618                .metadata
10619                .get("goose_metadata")
10620                .and_then(|value| serde_json::from_str::<Value>(value).ok())
10621                .unwrap_or_else(|| {
10622                    serde_json::json!({
10623                        "userVisible": true,
10624                        "agentVisible": true
10625                    })
10626                });
10627            let mut native = serde_json::json!({
10628                "id": message.metadata.get("goose_message_id")
10629                    .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10630                "role": role,
10631                "created": created,
10632                "content": content,
10633                "metadata": metadata,
10634            });
10635            // Goose tolerates unknown top-level fields on a conversation
10636            // message. Always carry the canonical envelope when Goose is
10637            // the TARGET so metadata absent from Goose's stock schema can
10638            // make a later Goose -> source round trip without residue.
10639            set_grok_target_message_extension(&mut native, message);
10640            out.push(native);
10641        }
10642        out
10643    }
10644
10645    // ---- OpenCode writers ---------------------------------------------
10646
10647    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10648    /// `(message value, part values)` list) directly from `self.raw`'s
10649    /// envelope lines — the same classification
10650    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10651    /// rather than canonical `ChatMessage`s. Used by
10652    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10653    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10654    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10655    /// path for excess keys/timestamps/side-records `opencode import`
10656    /// cannot restore).
10657    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10658        let mut session_info: Option<Value> = None;
10659        let mut msg_order: Vec<String> = Vec::new();
10660        let mut msg_values: HashMap<String, Value> = HashMap::new();
10661        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10662        for line in &self.raw {
10663            let Ok(env) = serde_json::from_str::<Value>(line) else {
10664                continue;
10665            };
10666            let Some(key) = env.get("key").and_then(Value::as_array) else {
10667                continue;
10668            };
10669            let value = env.get("value").cloned().unwrap_or(Value::Null);
10670            match key.first().and_then(Value::as_str) {
10671                Some("session") => session_info = Some(value),
10672                Some("message") => {
10673                    if let Some(id) = value.get("id").and_then(Value::as_str) {
10674                        if !msg_values.contains_key(id) {
10675                            msg_order.push(id.to_string());
10676                        }
10677                        msg_values.insert(id.to_string(), value);
10678                    }
10679                }
10680                Some("part") => {
10681                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10682                        msg_parts.entry(mid.to_string()).or_default().push(value);
10683                    }
10684                }
10685                _ => {}
10686            }
10687        }
10688        let mut ordered: Vec<(String, i64)> = msg_order
10689            .iter()
10690            .map(|id| {
10691                let tc = msg_values
10692                    .get(id)
10693                    .and_then(|v| v.get("time"))
10694                    .and_then(|t| t.get("created"))
10695                    .and_then(Value::as_i64)
10696                    .unwrap_or(0);
10697                (id.clone(), tc)
10698            })
10699            .collect();
10700        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10701        let mut out = Vec::new();
10702        for (id, _) in ordered {
10703            let mut parts = msg_parts.remove(&id).unwrap_or_default();
10704            parts.sort_by(|a, b| {
10705                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10706                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10707                ai.cmp(bi)
10708            });
10709            if let Some(v) = msg_values.remove(&id) {
10710                out.push((v, parts));
10711            }
10712        }
10713        (session_info, out)
10714    }
10715
10716    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10717    /// `raw` prefix exists to replay (a fresh/cross-format-converted
10718    /// session). T3 tier: only what `SessionMeta` carries survives.
10719    fn synthesized_opencode_info(&self) -> Value {
10720        let id = self
10721            .meta
10722            .session_id
10723            .clone()
10724            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10725        let mut info = serde_json::json!({
10726            "id": id,
10727            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10728            // OpenCode 1.2.15's import path writes this into a NOT NULL
10729            // SQLite column. Preserve a real source slug when available and
10730            // mint a stable, human-readable fallback for foreign sessions.
10731            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10732            "directory": self.cwd_string(),
10733            "title": "supercode export",
10734            "version": env!("CARGO_PKG_VERSION"),
10735            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10736        });
10737        if let Some(agent) = &self.meta.agent_id {
10738            info["agent"] = Value::String(agent.clone());
10739        }
10740        if let Some(model) = &self.meta.model {
10741            if let Some((provider, mid)) = model.split_once('/') {
10742                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10743            }
10744        }
10745        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10746            info["parentID"] = Value::String(parent.clone());
10747        }
10748        // D7: carry a captured Claude `fork-context-ref` through the
10749        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10750        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10751        // the `session` header) hops already do — namespaced so real
10752        // OpenCode tooling ignores it, and `capture_opencode_session_info`
10753        // reads this same key back on import so a Claude -> OpenCode ->
10754        // Claude round trip doesn't silently lose fork lineage either.
10755        //
10756        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10757        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10758        // this `claude_fork_context_ref` key on `SessionInfo` survives
10759        // supercode's OWN round-trip (write here, read back by
10760        // `capture_opencode_session_info` above) but NOT a real upstream
10761        // `opencode import` ingestion — that path decodes with
10762        // `Schema.decodeUnknownSync`, which strips any key its schema
10763        // doesn't declare. The direct-file/DB fallback (bypassing
10764        // `opencode import` entirely) is the per-spec fidelity path for
10765        // this lineage to actually reach real OpenCode.
10766        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10767            info["claude_fork_context_ref"] =
10768                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10769        }
10770        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10771            info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10772        }
10773        info
10774    }
10775
10776    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10777    /// synthesized continuation message therefore has to advance the
10778    /// session clock along with its own `time.created` value.
10779    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10780        if !info.get("time").is_some_and(Value::is_object) {
10781            info["time"] = serde_json::json!({});
10782        }
10783        info["time"]["updated"] = serde_json::json!(timestamp);
10784    }
10785
10786    /// Synthesize opencode `{info, parts}` message objects for `messages`
10787    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10788    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10789    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10790    /// back into its call's assistant `tool` part (match by
10791    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10792    /// whole slice)
10793    /// — the exact inverse of the loader's call/result split. This is a
10794    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10795    /// immediately following each assistant: two-or-more consecutive
10796    /// assistant-with-tool-call messages before their results (streamed /
10797    /// parallel tool calls) otherwise strand the earlier call's real result
10798    /// behind a later assistant message, silently downgrading it to
10799    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10800    /// messages exactly like every other writer.
10801    fn append_synthesized_opencode_messages(
10802        &self,
10803        out: &mut Vec<Value>,
10804        messages: &[ChatMessage],
10805        session_id: &str,
10806        counter: &mut u64,
10807        timestamp_cursor: &mut i64,
10808    ) -> Result<()> {
10809        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10810        // over the ENTIRE slice being processed, rather than by scanning
10811        // only the contiguous run of `Role::Tool` messages immediately
10812        // following a given assistant message. Two-or-more consecutive
10813        // assistant-with-tool-call messages before their results (streamed
10814        // / parallel tool calls — extremely common in real Claude Code and
10815        // Codex sessions) break the contiguous-run assumption: the first
10816        // assistant's own result(s) land AFTER a second assistant message,
10817        // not immediately after the first, so a contiguous scan starting
10818        // right after the first assistant finds nothing and silently drops
10819        // its real tool output into the `None => "pending"` branch below.
10820        // A single `id -> result` map is still insufficient: long real
10821        // sessions can reuse provider call ids. Last-write-wins then attaches
10822        // the final output to every earlier occurrence. Collect calls and
10823        // results independently and zip their occurrences in transcript
10824        // order, giving every concrete call position its own result.
10825        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10826        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10827        for (message_index, message) in messages.iter().enumerate() {
10828            if message.role == Role::Assistant {
10829                for (tool_index, call) in message.tool_calls().iter().enumerate() {
10830                    calls_by_id
10831                        .entry(call.id.as_str())
10832                        .or_default()
10833                        .push((message_index, tool_index));
10834                }
10835            } else if message.role == Role::Tool {
10836                if let Some(id) = &message.tool_call_id {
10837                    results_by_id
10838                        .entry(id.as_str())
10839                        .or_default()
10840                        .push((message_index, message));
10841                }
10842            }
10843        }
10844        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10845        for (id, calls) in calls_by_id {
10846            let Some(results) = results_by_id.get(id) else {
10847                continue;
10848            };
10849            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10850                paired_results.insert(call_position, result);
10851            }
10852        }
10853        let mut i = 0;
10854        while i < messages.len() {
10855            let msg = &messages[i];
10856            if is_replay_excluded(msg) {
10857                i += 1;
10858                continue;
10859            }
10860            match msg.role {
10861                // B4: opencode V1 has no session-level system-PROMPT slot
10862                // either — `User.system` is a per-turn system-PROMPT
10863                // OVERRIDE (§2.1), a different thing from a content-bearing
10864                // `Role::System` message loaded from a real Claude `type:
10865                // "system"` record (`push_claude_system`'s keep-listed
10866                // subtypes). Stuffing real transcript content into
10867                // `User.system` would be a genuine misuse — it overrides the
10868                // replayed system prompt, not just annotates a turn — so
10869                // this instead reuses opencode's own `text` part `synthetic`
10870                // flag (§3.1: "injected by opencode, not typed by user"),
10871                // which is EXACTLY the right existing, non-fabricated
10872                // semantic for "system-originated content presented as a
10873                // user turn": a dedicated `User` message with one
10874                // `synthetic: true` text part, tagged with a
10875                // supercode-namespaced part-`metadata` key so
10876                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10877                // recognize it on reload and restore `Role::System` +
10878                // `metadata["systemSubtype"]` rather than treating it as a
10879                // real user turn. Content is never fabricated — only
10880                // emitted when non-empty.
10881                Role::System => {
10882                    let content = msg.content.clone().unwrap_or_default();
10883                    if content.trim().is_empty() {
10884                        i += 1;
10885                        continue;
10886                    }
10887                    let subtype = msg
10888                        .metadata
10889                        .get("systemSubtype")
10890                        .cloned()
10891                        .unwrap_or_else(|| "local_command".to_string());
10892                    let msg_id = opencode_fresh_id("msg", counter);
10893                    let part_id = opencode_fresh_id("prt", counter);
10894                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10895                    let mut info = serde_json::json!({
10896                        "id": msg_id,
10897                        "sessionID": session_id,
10898                        "role": "user",
10899                        "time": {"created": timestamp},
10900                    });
10901                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10902                    let parts = vec![serde_json::json!({
10903                        "id": part_id,
10904                        "sessionID": session_id,
10905                        "messageID": msg_id,
10906                        "type": "text",
10907                        "text": content,
10908                        "synthetic": true,
10909                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
10910                    })];
10911                    out.push(serde_json::json!({"info": info, "parts": parts}));
10912                    i += 1;
10913                }
10914                Role::User => {
10915                    let msg_id = opencode_fresh_id("msg", counter);
10916                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
10917                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10918                    let mut info = serde_json::json!({
10919                        "id": msg_id,
10920                        "sessionID": session_id,
10921                        "role": "user",
10922                        "time": {"created": timestamp},
10923                    });
10924                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10925                    opencode_restore_agent_model_fields(
10926                        &mut info, msg, /* is_assistant */ false,
10927                    );
10928                    set_grok_message_extension(&mut info, self.meta.source, msg);
10929                    out.push(serde_json::json!({
10930                        "info": info,
10931                        "parts": parts,
10932                    }));
10933                    i += 1;
10934                }
10935                Role::Assistant => {
10936                    let msg_id = opencode_fresh_id("msg", counter);
10937                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10938                    let mut parts = Vec::new();
10939                    if let Some(thinking) = msg.metadata.get("thinking") {
10940                        let mut part = serde_json::json!({
10941                            "id": opencode_fresh_id("prt", counter),
10942                            "sessionID": session_id,
10943                            "messageID": msg_id,
10944                            "type": "reasoning",
10945                            "text": thinking,
10946                            // Required by OpenCode V1's native reasoning
10947                            // schema. A synthesized part has no distinct
10948                            // stream start/end, so the source message clock
10949                            // is the honest zero-duration span.
10950                            "time": {"start": timestamp, "end": timestamp},
10951                        });
10952                        if let Some(signature) = msg.metadata.get("thinking_signature") {
10953                            part["metadata"] = serde_json::json!({
10954                                "anthropic": {"signature": signature},
10955                            });
10956                        }
10957                        parts.push(part);
10958                    }
10959                    if let Some(t) = &msg.content {
10960                        if !t.is_empty() {
10961                            parts.push(serde_json::json!({
10962                                "id": opencode_fresh_id("prt", counter),
10963                                "sessionID": session_id,
10964                                "messageID": msg_id,
10965                                "type": "text",
10966                                "text": t,
10967                            }));
10968                        }
10969                    }
10970                    // Fold each tool call's result back into ONE `tool`
10971                    // part, matched by tool_call_id via the GLOBAL
10972                    // `all_results` map built above (not a contiguous scan)
10973                    // — a result may be many messages away when other
10974                    // assistant turns with their own pending calls
10975                    // intervene before it appears.
10976                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
10977                        let input = tc
10978                            .function
10979                            .parsed_arguments()
10980                            .unwrap_or_else(|_| Value::Object(Default::default()));
10981                        let paired_result = paired_results.get(&(i, tool_index)).copied();
10982                        let state = match paired_result {
10983                            Some((_, result)) if crate::is_tool_error(result) => {
10984                                let result_timestamp =
10985                                    opencode_message_timestamp(result, timestamp_cursor)?;
10986                                serde_json::json!({
10987                                    "status": "error",
10988                                    "input": input,
10989                                    "error": result.content.clone().unwrap_or_default(),
10990                                    "time": {"end": result_timestamp},
10991                                })
10992                            }
10993                            Some((_, result)) => {
10994                                let result_timestamp =
10995                                    opencode_message_timestamp(result, timestamp_cursor)?;
10996                                let mut s = serde_json::json!({
10997                                    "status": "completed",
10998                                    "input": input,
10999                                    "output": result.content.clone().unwrap_or_default(),
11000                                    "title": tc.function.name,
11001                                    "time": {"end": result_timestamp},
11002                                });
11003                                // PARITY-11 (nested images): the LOADER already
11004                                // reads a completed tool part's
11005                                // `state.attachments` back into `content_parts`
11006                                // (`opencode_file_image_part`, above) — this is
11007                                // the missing WRITE-side inverse. Without it, a
11008                                // Claude `tool_result`'s nested image (now
11009                                // captured into `content_parts` by
11010                                // `extract_tool_result_content`) reached
11011                                // `content_parts` on the canonical `ChatMessage`
11012                                // but was silently dropped again on re-export to
11013                                // OpenCode, because nothing ever read it back
11014                                // out. `mime`/`url` shape matches exactly what
11015                                // `opencode_file_image_part` expects on reload.
11016                                if let Some(cps) = &result.content_parts {
11017                                    let atts: Vec<Value> = cps
11018                                        .iter()
11019                                        .filter(|p| {
11020                                            p.get("type").and_then(Value::as_str)
11021                                                == Some("image_url")
11022                                        })
11023                                        .filter_map(|p| {
11024                                            let url = p
11025                                                .get("image_url")
11026                                                .and_then(|u| u.get("url"))
11027                                                .and_then(Value::as_str)?;
11028                                            let mime = url
11029                                                .strip_prefix("data:")
11030                                                .and_then(|r| r.split_once(','))
11031                                                .map(|(m, _)| m.trim_end_matches(";base64"))
11032                                                .unwrap_or("application/octet-stream");
11033                                            Some(serde_json::json!({
11034                                                "mime": mime,
11035                                                "url": url,
11036                                            }))
11037                                        })
11038                                        .collect();
11039                                    if !atts.is_empty() {
11040                                        s["attachments"] = Value::Array(atts);
11041                                    }
11042                                }
11043                                s
11044                            }
11045                            None => serde_json::json!({"status": "pending", "input": input}),
11046                        };
11047                        let mut part = serde_json::json!({
11048                            "id": opencode_fresh_id("prt", counter),
11049                            "sessionID": session_id,
11050                            "messageID": msg_id,
11051                            "type": "tool",
11052                            "callID": tc.id,
11053                            "tool": tc.function.name,
11054                            "state": state,
11055                        });
11056                        if let Some((result_position, _)) = paired_result {
11057                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11058                                serde_json::json!(result_position);
11059                        }
11060                        if paired_result.is_some_and(|(_, result)| {
11061                            crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11062                        }) {
11063                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11064                        }
11065                        if let Some((_, result)) = paired_result {
11066                            set_grok_message_extension(&mut part, self.meta.source, result);
11067                        }
11068                        parts.push(part);
11069                    }
11070                    let mut info = serde_json::json!({
11071                        "id": msg_id,
11072                        "sessionID": session_id,
11073                        "role": "assistant",
11074                        "time": {"created": timestamp},
11075                    });
11076                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11077                    opencode_restore_agent_model_fields(
11078                        &mut info, msg, /* is_assistant */ true,
11079                    );
11080                    set_grok_message_extension(&mut info, self.meta.source, msg);
11081                    out.push(serde_json::json!({
11082                        "info": info,
11083                        "parts": parts,
11084                    }));
11085                    i += 1;
11086                }
11087                // A Tool message is always folded into its call's assistant
11088                // `tool` part above (via occurrence-aware global pairing, not
11089                // positional adjacency), so it never needs its own entry
11090                // here — just advance past it.
11091                Role::Tool => i += 1,
11092            }
11093        }
11094        Ok(())
11095    }
11096
11097    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11098    /// `messages` (T3 cross-format/full synthesis tier — mirrors
11099    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11100    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11101    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11102    /// (§1.2 — the `opencode export`/`import` interchange shape).
11103    fn to_opencode_jsonl(&self) -> Result<String> {
11104        let mut info = self.synthesized_opencode_info();
11105        let ses_id = info
11106            .get("id")
11107            .and_then(Value::as_str)
11108            .unwrap_or("ses_new")
11109            .to_string();
11110        let mut messages_json: Vec<Value> = Vec::new();
11111        let mut counter: u64 = 0;
11112        let mut timestamp_cursor =
11113            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11114        self.append_synthesized_opencode_messages(
11115            &mut messages_json,
11116            &self.messages,
11117            &ses_id,
11118            &mut counter,
11119            &mut timestamp_cursor,
11120        )?;
11121        if !messages_json.is_empty() {
11122            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11123        }
11124        let doc = serde_json::json!({"info": info, "messages": messages_json});
11125        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11126    }
11127
11128    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11129    /// imported records **value-equal at their position** in the export
11130    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11131    /// via [`Self::opencode_records_from_raw`], never re-derived from the
11132    /// lossy canonical `messages` — then append freshly synthesized
11133    /// `{info, parts}` objects for the tail via
11134    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11135    /// line-oriented formats' splice, `out` here is a single export
11136    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11137    /// assertion accordingly: value-equality at position, not byte
11138    /// equality of a line range).
11139    fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11140        if self.raw.is_empty() {
11141            return self.to_opencode_jsonl();
11142        }
11143        let (session_info, records) = self.opencode_records_from_raw();
11144        let (_, message_prefix_len) = self.spliced_prefix_lens();
11145
11146        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11147        if let Some(id) = session_id {
11148            info["id"] = Value::String(id.to_string());
11149        }
11150        let ses_id_for_new = info
11151            .get("id")
11152            .and_then(Value::as_str)
11153            .unwrap_or("ses_new")
11154            .to_string();
11155
11156        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11157            .chain(records.iter().flat_map(|(msg, parts)| {
11158                std::iter::once(opencode_max_timestamp(msg))
11159                    .chain(parts.iter().map(opencode_max_timestamp))
11160            }))
11161            .flatten()
11162            .max()
11163            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11164
11165        let mut messages_json: Vec<Value> = records
11166            .into_iter()
11167            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11168            .collect();
11169        let imported_len = messages_json.len();
11170
11171        let mut counter: u64 = 0;
11172        self.append_synthesized_opencode_messages(
11173            &mut messages_json,
11174            &self.messages[message_prefix_len..],
11175            &ses_id_for_new,
11176            &mut counter,
11177            &mut timestamp_cursor,
11178        )?;
11179        if messages_json.len() > imported_len {
11180            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11181        }
11182
11183        let doc = serde_json::json!({"info": info, "messages": messages_json});
11184        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11185    }
11186
11187    /// The **required** direct-write fallback (S5): write the imported
11188    /// OpenCode records **verbatim** — excess/unknown keys, part-row
11189    /// timestamps, and `session_diff`/`todo` side-records intact — to a
11190    /// generation-B JSON-file storage tree
11191    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11192    /// `opencode import` cannot provide (S5: import re-decodes through a
11193    /// strict schema and STRIPS excess keys; inserts part rows without
11194    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11195    /// has no ingestion path for `session_diff`/`todo` at all).
11196    ///
11197    /// Writes the JSON-FILE layout rather than a live SQLite write
11198    /// specifically to avoid a new `rusqlite`-class dependency on this
11199    /// build's memory-constrained box (see the build report); `session_diff`
11200    /// itself is still JSON-written by upstream even on SQLite installs
11201    /// (§1.3), so this is a real fidelity path, not a fictional one.
11202    ///
11203    /// Returns the `storage/session/<projectID>/` directory written to.
11204    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11205        let (session_info, mut records) = self.opencode_records_from_raw();
11206        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11207        let ses_id = info
11208            .get("id")
11209            .and_then(Value::as_str)
11210            .unwrap_or("ses_new")
11211            .to_string();
11212        if info.get("id").is_none() {
11213            info["id"] = Value::String(ses_id.clone());
11214        }
11215        let project_id = info
11216            .get("projectID")
11217            .and_then(Value::as_str)
11218            .unwrap_or("global")
11219            .to_string();
11220
11221        // Appended tail (messages produced after import): synthesize fresh
11222        // message/part VALUES via the same T3 synthesis the splice writer
11223        // uses, so continuation turns get files too. Do this BEFORE creating
11224        // any directories: timestamp exhaustion must fail atomically rather
11225        // than leave a partial direct-write tree behind.
11226        let (_, message_prefix_len) = self.spliced_prefix_lens();
11227        let mut counter: u64 = 0;
11228        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11229            .chain(records.iter().flat_map(|(msg, parts)| {
11230                std::iter::once(opencode_max_timestamp(msg))
11231                    .chain(parts.iter().map(opencode_max_timestamp))
11232            }))
11233            .flatten()
11234            .max()
11235            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11236        let mut appended_json: Vec<Value> = Vec::new();
11237        self.append_synthesized_opencode_messages(
11238            &mut appended_json,
11239            &self.messages[message_prefix_len..],
11240            &ses_id,
11241            &mut counter,
11242            &mut timestamp_cursor,
11243        )?;
11244        if !appended_json.is_empty() {
11245            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11246        }
11247        for entry in appended_json {
11248            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11249            let parts = entry
11250                .get("parts")
11251                .and_then(Value::as_array)
11252                .cloned()
11253                .unwrap_or_default();
11254            records.push((msg, parts));
11255        }
11256
11257        let storage = data_root.join("storage");
11258        let session_dir = storage.join("session").join(&project_id);
11259        std::fs::create_dir_all(&session_dir)?;
11260        std::fs::write(
11261            session_dir.join(format!("{ses_id}.json")),
11262            serde_json::to_string_pretty(&info).unwrap_or_default(),
11263        )?;
11264
11265        let message_dir = storage.join("message").join(&ses_id);
11266        let part_dir = storage.join("part");
11267        std::fs::create_dir_all(&message_dir)?;
11268
11269        for (msg, parts) in &records {
11270            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11271                continue;
11272            };
11273            std::fs::write(
11274                message_dir.join(format!("{msg_id}.json")),
11275                serde_json::to_string_pretty(msg).unwrap_or_default(),
11276            )?;
11277            let this_part_dir = part_dir.join(msg_id);
11278            std::fs::create_dir_all(&this_part_dir)?;
11279            for part in parts {
11280                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11281                    continue;
11282                };
11283                std::fs::write(
11284                    this_part_dir.join(format!("{part_id}.json")),
11285                    serde_json::to_string_pretty(part).unwrap_or_default(),
11286                )?;
11287            }
11288        }
11289
11290        // Side-records (S5c): session_diff / todo have NO ingestion path via
11291        // `opencode import` at all — the direct write is their only
11292        // fidelity path.
11293        for header in &self.meta.opencode_headers {
11294            let Some(key) = header.get("key").and_then(Value::as_array) else {
11295                continue;
11296            };
11297            let Some(kind) = key.first().and_then(Value::as_str) else {
11298                continue;
11299            };
11300            let value = header.get("value").cloned().unwrap_or(Value::Null);
11301            if !matches!(kind, "session_diff" | "todo") {
11302                continue;
11303            }
11304            let dir = storage.join(kind);
11305            std::fs::create_dir_all(&dir)?;
11306            std::fs::write(
11307                dir.join(format!("{ses_id}.json")),
11308                serde_json::to_string_pretty(&value).unwrap_or_default(),
11309            )?;
11310        }
11311
11312        Ok(session_dir)
11313    }
11314}
11315
11316fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11317    *counter += 1;
11318    format!("{prefix}_synth{counter:06}")
11319}
11320
11321/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11322/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11323/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11324/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11325/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11326/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11327/// ONLY when its metadata key is present (a synthesized continuation turn, or
11328/// a User message that never carried `agent`, stays clean — no spurious
11329/// null/empty fields).
11330///
11331/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11332///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11333///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11334/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11335///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11336///   inverse must match per-role:
11337///   - User: `push_opencode_user` stores `metadata["model"]` as the
11338///     STRINGIFIED `{providerID, modelID, variant?}` object
11339///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11340///     as that same object under `"model"`.
11341///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11342///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11343///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
11344///     join; a `modelID` containing further `/`s round-trips correctly since
11345///     `split_once` only consumes the first) and re-emitted as the two
11346///     top-level `providerID`/`modelID` fields the loader actually reads.
11347/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11348///   fields exist on opencode's `User` schema) — `is_summary` re-expands
11349///   `"true"` back to the native `summary: true` bool (the loader only ever
11350///   sets the metadata key on `Some(true)`, never on absent/false, so the
11351///   inverse never needs to emit `false`); `finish` is a plain string;
11352///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11353///   `Value` (a number and an object respectively), so they're re-parsed
11354///   from that stringified form and re-emitted as the native JSON value —
11355///   NOT as strings — matching `msg_value.get(field)` shape exactly.
11356fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11357    if let Some(agent) = msg.metadata.get("agent") {
11358        info["agent"] = Value::String(agent.clone());
11359    }
11360    if let Some(model) = msg.metadata.get("model") {
11361        if is_assistant {
11362            if let Some((provider, model_id)) = model.split_once('/') {
11363                info["providerID"] = Value::String(provider.to_string());
11364                info["modelID"] = Value::String(model_id.to_string());
11365            }
11366        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11367            info["model"] = v;
11368        }
11369    }
11370    if !is_assistant {
11371        return;
11372    }
11373    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11374        info["summary"] = Value::Bool(true);
11375    }
11376    if let Some(finish) = msg.metadata.get("finish") {
11377        info["finish"] = Value::String(finish.clone());
11378    }
11379    if let Some(cost) = msg.metadata.get("cost") {
11380        if let Ok(v) = serde_json::from_str::<Value>(cost) {
11381            info["cost"] = v;
11382        }
11383    }
11384    if let Some(tokens) = msg.metadata.get("tokens") {
11385        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11386            info["tokens"] = v;
11387        }
11388    }
11389}
11390
11391fn opencode_user_parts_from_message(
11392    msg: &ChatMessage,
11393    msg_id: &str,
11394    session_id: &str,
11395    counter: &mut u64,
11396) -> Vec<Value> {
11397    let mut parts = Vec::new();
11398    if let Some(cps) = &msg.content_parts {
11399        for p in cps {
11400            match p.get("type").and_then(Value::as_str) {
11401                Some("text") => {
11402                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11403                        parts.push(serde_json::json!({
11404                            "id": opencode_fresh_id("prt", counter),
11405                            "sessionID": session_id,
11406                            "messageID": msg_id,
11407                            "type": "text",
11408                            "text": t,
11409                        }));
11410                    }
11411                }
11412                Some("image_url") => {
11413                    if let Some(url) = p
11414                        .get("image_url")
11415                        .and_then(|u| u.get("url"))
11416                        .and_then(Value::as_str)
11417                    {
11418                        let mime = url
11419                            .strip_prefix("data:")
11420                            .and_then(|r| r.split_once(','))
11421                            .map(|(m, _)| m.trim_end_matches(";base64"))
11422                            .unwrap_or("application/octet-stream");
11423                        parts.push(serde_json::json!({
11424                            "id": opencode_fresh_id("prt", counter),
11425                            "sessionID": session_id,
11426                            "messageID": msg_id,
11427                            "type": "file",
11428                            "mime": mime,
11429                            "url": url,
11430                        }));
11431                    }
11432                }
11433                _ => {}
11434            }
11435        }
11436    } else if let Some(t) = &msg.content {
11437        if !t.is_empty() {
11438            parts.push(serde_json::json!({
11439                "id": opencode_fresh_id("prt", counter),
11440                "sessionID": session_id,
11441                "messageID": msg_id,
11442                "type": "text",
11443                "text": t,
11444            }));
11445        }
11446    }
11447    parts
11448}
11449
11450fn codex_response_item(payload: Value, ts: &str) -> Value {
11451    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11452}
11453
11454/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11455/// see [`Session::write_codex_records`]); a no-op returning `payload`
11456/// untouched when `None`, so the historical byte shape is preserved for
11457/// every record that has no merge ambiguity to disambiguate.
11458fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11459    if let Some(tid) = turn_id {
11460        payload["metadata"] = serde_json::json!({"turn_id": tid});
11461    }
11462    payload
11463}
11464
11465/// Build a Codex `message` response_item's `content` block array from a
11466/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11467/// parse. When `content_parts` is `None` this MUST reproduce the historical
11468/// single-block shape exactly (IX-5's overriding constraint: a text-only
11469/// message's export stays byte-identical) — only a multimodal message gets
11470/// one `{text_type}` block per non-empty text part plus one native Codex
11471/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11472/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11473/// `output_text` blocks already follow the family of) per `image_url` part.
11474fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11475    match &msg.content_parts {
11476        Some(parts) => {
11477            let mut blocks = Vec::new();
11478            for p in parts {
11479                match p.get("type").and_then(Value::as_str) {
11480                    Some("text") => {
11481                        if let Some(t) = p.get("text").and_then(Value::as_str) {
11482                            if !t.is_empty() {
11483                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
11484                            }
11485                        }
11486                    }
11487                    Some("image_url") => {
11488                        if let Some(url) = p
11489                            .get("image_url")
11490                            .and_then(|u| u.get("url"))
11491                            .and_then(Value::as_str)
11492                        {
11493                            blocks.push(serde_json::json!({
11494                                "type": "input_image",
11495                                "image_url": url,
11496                            }));
11497                        }
11498                    }
11499                    _ => {}
11500                }
11501            }
11502            Value::Array(blocks)
11503        }
11504        None => {
11505            let text = msg.content.clone().unwrap_or_default();
11506            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11507        }
11508    }
11509}
11510
11511/// PARITY-11 (nested images, honest-residue side): a Codex
11512/// `function_call_output` response_item's `output` field is a BARE STRING
11513/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11514/// no structured content array, so [`codex_message_content_blocks`]'s
11515/// `input_image` slot genuinely does not apply here). A nested image captured
11516/// off a Claude `tool_result` (`extract_tool_result_content`,
11517/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11518/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11519/// from a real, intentional annotation and impossible to tell apart from
11520/// "the data survived") or dropping it with zero trace, fold in an honest,
11521/// countable disclosure of exactly how many images were dropped and why —
11522/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11523/// on the WRITE side instead of the read side. `content_parts` being `None`
11524/// (every pre-existing call site, and any tool result with no nested image)
11525/// reproduces the historical `msg.content` text byte-for-byte.
11526fn codex_tool_output_text(msg: &ChatMessage) -> String {
11527    let mut text = msg.content.clone().unwrap_or_default();
11528    if let Some(parts) = &msg.content_parts {
11529        let n = parts
11530            .iter()
11531            .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11532            .count();
11533        if n > 0 {
11534            if !text.is_empty() {
11535                text.push('\n');
11536            }
11537            text.push_str(&format!(
11538                "[image: {n} nested image(s) dropped — codex tool output has no \
11539                 structured content slot to carry them]"
11540            ));
11541        }
11542    }
11543    text
11544}
11545
11546// ---- Pi writer helpers -----------------------------------------------------
11547
11548/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11549/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11550/// file in place on first resume (`pi-fields.md` sm:848-850).
11551fn push_pi_header(
11552    out: &mut String,
11553    id: &str,
11554    cwd: &str,
11555    parent_session: Option<&str>,
11556    created_at: Option<&str>,
11557    claude_fork_context_ref: Option<&str>,
11558) {
11559    let mut header = serde_json::json!({
11560        "type": "session",
11561        "version": 3,
11562        "id": id,
11563        "timestamp": created_at.unwrap_or(SYNTH_TS),
11564        "cwd": cwd,
11565    });
11566    if let Some(ps) = parent_session {
11567        header["parentSession"] = Value::String(ps.to_string());
11568    }
11569    // D7: namespaced passthrough field, exactly like the Codex writer's
11570    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11571    // header keys, and `capture_pi_header` reads this same key back on
11572    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11573    // fork-context-ref record instead of silently losing it on this hop.
11574    if let Some(raw) = claude_fork_context_ref {
11575        header["claude_fork_context_ref"] =
11576            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11577    }
11578    push_jsonl(out, &header);
11579}
11580
11581/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11582/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11583/// deterministic here rather than random, which still satisfies "fresh,
11584/// collision-free" without an extra RNG dependency).
11585fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11586    loop {
11587        *counter += 1;
11588        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11589        let id = format!("{:08x}", (h >> 32) as u32);
11590        if used.insert(id.clone()) {
11591            return id;
11592        }
11593    }
11594}
11595
11596/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11597/// inverse of the loader's `data:{mime};base64,{data}` construction.
11598fn parse_data_uri(url: &str) -> Option<(String, String)> {
11599    let rest = url.strip_prefix("data:")?;
11600    let (meta, data) = rest.split_once(',')?;
11601    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11602    Some((mime.to_string(), data.to_string()))
11603}
11604
11605/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11606/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11607/// `toolResult` entries (both use the identical union on the wire).
11608fn pi_content_value(msg: &ChatMessage) -> Value {
11609    if let Some(parts) = &msg.content_parts {
11610        let mut arr = Vec::new();
11611        for p in parts {
11612            match p.get("type").and_then(Value::as_str) {
11613                Some("text") => {
11614                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11615                        arr.push(serde_json::json!({"type": "text", "text": t}));
11616                    }
11617                }
11618                Some("image_url") => {
11619                    if let Some(url) = p
11620                        .get("image_url")
11621                        .and_then(|u| u.get("url"))
11622                        .and_then(Value::as_str)
11623                    {
11624                        if let Some((mime, data)) = parse_data_uri(url) {
11625                            arr.push(
11626                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11627                            );
11628                        }
11629                    }
11630                }
11631                _ => {}
11632            }
11633        }
11634        Value::Array(arr)
11635    } else {
11636        Value::String(msg.content.clone().unwrap_or_default())
11637    }
11638}
11639
11640fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11641    let mut arr = Vec::new();
11642    if let Some(thinking) = msg.metadata.get("thinking") {
11643        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11644        if let Some(sig) = msg.metadata.get("thinking_signature") {
11645            block["thinkingSignature"] = Value::String(sig.clone());
11646        }
11647        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11648            block["redacted"] = Value::Bool(true);
11649        }
11650        arr.push(block);
11651    }
11652    if let Some(text) = &msg.content {
11653        if !text.is_empty() {
11654            let mut block = serde_json::json!({"type": "text", "text": text});
11655            if let Some(sig) = msg.metadata.get("pi_text_signature") {
11656                block["textSignature"] = Value::String(sig.clone());
11657            }
11658            arr.push(block);
11659        }
11660    }
11661    for tc in msg.tool_calls() {
11662        let args = tc
11663            .function
11664            .parsed_arguments()
11665            .unwrap_or_else(|_| Value::Object(Default::default()));
11666        let mut block = serde_json::json!({
11667            "type": "toolCall",
11668            "id": tc.id,
11669            "name": tc.function.name,
11670            "arguments": args,
11671        });
11672        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11673            block["thoughtSignature"] = Value::String(sig.clone());
11674        }
11675        arr.push(block);
11676    }
11677    Value::Array(arr)
11678}
11679
11680fn default_pi_usage() -> Value {
11681    serde_json::json!({
11682        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11683        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11684    })
11685}
11686
11687fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11688    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11689}
11690
11691#[cfg(test)]
11692mod tests {
11693    use super::{
11694        opencode_message_timestamp, parent_tool_use_index, truncate_messages_with_anchor, Session,
11695        SessionFormat,
11696    };
11697    use crate::message::ChatMessage;
11698
11699    #[test]
11700    fn a_new_user_message_slides_the_display_tail_without_becoming_its_only_anchor() {
11701        let mut messages = vec![
11702            ChatMessage::user("original prompt"),
11703            ChatMessage::assistant("one"),
11704            ChatMessage::assistant("two"),
11705            ChatMessage::assistant("three"),
11706            ChatMessage::assistant("four"),
11707            ChatMessage::assistant("five"),
11708            ChatMessage::user("new prompt"),
11709        ];
11710
11711        truncate_messages_with_anchor(&mut messages, 4, None);
11712
11713        assert_eq!(messages.len(), 4);
11714        assert_eq!(messages[0].content.as_deref(), Some("original prompt"));
11715        assert_eq!(messages[3].content.as_deref(), Some("new prompt"));
11716    }
11717
11718    #[test]
11719    fn bounded_codex_display_history_reports_the_unbounded_message_total() {
11720        let jsonl = (0..6)
11721            .map(|index| {
11722                let role = if index % 2 == 0 { "user" } else { "assistant" };
11723                format!(
11724                    r#"{{"type":"response_item","payload":{{"type":"message","role":"{role}","content":[{{"type":"input_text","text":"message-{index}"}}]}}}}"#,
11725                )
11726            })
11727            .collect::<Vec<_>>()
11728            .join("\n");
11729
11730        let session = Session::from_codex_display_str(&jsonl, 2).unwrap();
11731
11732        assert_eq!(session.messages.len(), 2);
11733        assert_eq!(session.imported_message_count, Some(6));
11734    }
11735
11736    #[test]
11737    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
11738        let base = Session::from_native_messages(Vec::new());
11739        let mut native = base.to_native_jsonl_v2(&[]);
11740        native.push_str("{\"supercode_turn\":1}\n");
11741
11742        let parsed = Session::from_native_str(&native).unwrap();
11743        assert_eq!(parsed.parse_error_lines, 1);
11744        assert!(parsed.messages.is_empty());
11745        assert_eq!(
11746            parsed.raw.last().map(String::as_str),
11747            Some("{\"supercode_turn\":1}")
11748        );
11749    }
11750
11751    #[test]
11752    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
11753        let imported = Session::from_claude_code_str(
11754            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
11755        )
11756        .unwrap();
11757        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
11758        native.push_str("{\"supercode_turn\":1}\n");
11759
11760        let parsed = Session::from_native_str(&native).unwrap();
11761        let error = parsed
11762            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
11763            .unwrap_err();
11764        assert!(error.to_string().contains("parse loss"), "{error}");
11765    }
11766
11767    #[test]
11768    fn sidecar_loader_requires_a_supported_native_header() {
11769        for malformed in [
11770            "",
11771            "not-json\n",
11772            "{}\n",
11773            "{\"supercode_native\":2}\n",
11774            "{\"supercode_native\":99,\"source\":\"native\"}\n",
11775        ] {
11776            let error = Session::from_sidecar_str(malformed).unwrap_err();
11777            assert!(error.to_string().contains("sidecar header"), "{error}");
11778        }
11779    }
11780
11781    #[test]
11782    fn gemini_user_parts_preserve_text_media_and_response_order() {
11783        let session = Session::from_gemini_str(
11784            r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
11785{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
11786{"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"}]}
11787"#,
11788        )
11789        .unwrap();
11790
11791        assert_eq!(session.messages.len(), 6);
11792        assert_eq!(
11793            session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
11794            "before"
11795        );
11796        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
11797        assert!(
11798            session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
11799                .as_str()
11800                .unwrap()
11801                .starts_with("data:image/png;base64,")
11802        );
11803        assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
11804        assert_eq!(
11805            session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
11806            "after"
11807        );
11808    }
11809
11810    #[test]
11811    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
11812        let msg = ChatMessage::user("continuation");
11813        let mut cursor = i64::MAX - 1;
11814        assert_eq!(
11815            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
11816            i64::MAX
11817        );
11818        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
11819        assert!(err.to_string().contains("after i64::MAX"));
11820        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
11821    }
11822
11823    /// Pin of the single-pass indexer against the relevant Claude tool-result
11824    /// shape (SUP-21). An id absent from the transcript must map to nothing.
11825    #[test]
11826    fn parent_tool_use_index_matches_known_fixture_linkage() {
11827        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"}}"#;
11828
11829        let ids = vec![
11830            "ad8dc6cf98b49eea6".to_string(),
11831            "no-such-agent-id".to_string(),
11832        ];
11833        let index = parent_tool_use_index(main_text, &ids);
11834
11835        assert_eq!(
11836            index.get("ad8dc6cf98b49eea6").map(String::as_str),
11837            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
11838            "known agent id must resolve to the pinned parent tool_use_id"
11839        );
11840        assert_eq!(
11841            index.get("no-such-agent-id"),
11842            None,
11843            "unknown agent id must yield no entry (best-effort None)"
11844        );
11845    }
11846
11847    #[test]
11848    fn parent_tool_use_index_empty_ids_returns_empty_map() {
11849        let index = parent_tool_use_index("irrelevant text", &[]);
11850        assert!(index.is_empty());
11851    }
11852}