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 mut read_limit = message_limit.max(1);
457        let mut previous_window_len = 0usize;
458        let (mut session, omitted_prefix) = loop {
459            let (source, text, omitted_prefix) = read_display_jsonl(path, read_limit)?;
460            let mut candidate = match source {
461                Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
462                Some(SessionSource::Gemini) => {
463                    let mut session = Self::from_gemini_str(&text)?;
464                    session.raw_is_verbatim = false;
465                    session.load_residue.push(
466                        "display history is a bounded native-record projection, not a complete Gemini artifact"
467                            .to_string(),
468                    );
469                    session
470                }
471                Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
472                Some(SessionSource::Grok) => {
473                    let mut session = Self::from_grok_str(&text)?;
474                    session.capture_grok_path_metadata(path);
475                    session
476                }
477                Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
478                _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
479            };
480            let observed_messages = candidate
481                .imported_message_count
482                .unwrap_or(candidate.messages.len())
483                .max(candidate.messages.len());
484            let human_turns = candidate
485                .messages
486                .iter()
487                .filter(|message| message.role == Role::User)
488                .count();
489            let window_len = text.len();
490            let sufficient =
491                !omitted_prefix || (observed_messages > message_limit.max(1) && human_turns >= 2);
492            // 16 KiB/message with a 64 MiB ceiling means 4096 is the first
493            // read limit that cannot grow the native byte window further.
494            // Smaller repeated lengths can be the intentional 4 MiB floor;
495            // keep doubling through that plateau instead of declaring a
496            // false pagination end.
497            let byte_window_exhausted = window_len <= previous_window_len && read_limit >= 4096;
498            if sufficient || byte_window_exhausted {
499                if omitted_prefix {
500                    // The prefix is known to contain more native history even
501                    // when this bounded window cannot cheaply normalize its
502                    // exact size. Never turn that into a false end-of-history.
503                    candidate.imported_message_count =
504                        Some(observed_messages.max(message_limit.max(1).saturating_add(1)));
505                }
506                break (candidate, omitted_prefix);
507            }
508            previous_window_len = window_len;
509            read_limit = read_limit.saturating_mul(2);
510        };
511        if omitted_prefix {
512            session.load_residue.push(
513                "older native records remain outside this bounded display window".to_string(),
514            );
515        }
516        truncate_session_messages(&mut session, message_limit);
517        Ok(session)
518    }
519
520    fn load_with_fidelity_and_subagents(
521        path: impl AsRef<Path>,
522        fidelity: Fidelity,
523        include_subagents: bool,
524    ) -> Result<Session> {
525        let path = path.as_ref();
526        if path.is_dir() {
527            return match detect_opencode_storage_surface(path) {
528                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
529                    Self::from_opencode_sqlite(&db_path, None)
530                }
531                Some((
532                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
533                    _,
534                )) => Err(crate::Error::Other(format!(
535                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
536                         supercode does not load directly — point `inspect`/`convert`/`resume` \
537                         at the store's `opencode*.db` SQLite file if this install has one, or \
538                         use `audit --format opencode {}` instead",
539                    path.display(),
540                    path.display()
541                ))),
542                None => Err(crate::Error::Other(format!(
543                    "{} is a directory, but no session file or OpenCode store was found in it \
544                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
545                     tree)",
546                    path.display()
547                ))),
548            };
549        }
550        if looks_like_sqlite(path) {
551            return Self::from_opencode_sqlite(path, None);
552        }
553        let text = read_utf8_or_diagnose(path)?;
554        match detect_source(&text) {
555            Some(SessionSource::Codex) => Self::from_codex_str(&text),
556            Some(SessionSource::Pi) => Self::from_pi_str(&text),
557            Some(SessionSource::Grok) => {
558                let mut session = Self::from_grok_str(&text)?;
559                session.capture_grok_path_metadata(path);
560                Ok(session)
561            }
562            Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
563            Some(SessionSource::Goose) => Self::from_goose_str(&text),
564            // IX-3: a detected OpenCode session must route to its own
565            // loader, not the Claude Code fallback below
566            // (`docs/interop/build-followups.md`).
567            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
568            _ => {
569                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
570                if include_subagents {
571                    session.attach_claude_subagents(path, &text, fidelity)?;
572                }
573                Ok(session)
574            }
575        }
576    }
577
578    /// Load a Claude Code transcript from a file, attaching any subagent
579    /// (`Task`) sub-conversations stored alongside it.
580    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
581        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
582    }
583
584    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
585    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
586    pub fn from_claude_code_with_fidelity(
587        path: impl AsRef<Path>,
588        fidelity: Fidelity,
589    ) -> Result<Session> {
590        let text = std::fs::read_to_string(path.as_ref())?;
591        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
592        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
593        Ok(session)
594    }
595
596    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
597    /// Claude Code transcript at `main_path`, linking each back to the parent
598    /// `Task` tool call via the agent id embedded in the parent's tool result.
599    fn attach_claude_subagents(
600        &mut self,
601        main_path: &Path,
602        main_text: &str,
603        fidelity: Fidelity,
604    ) -> Result<()> {
605        let Some(dir) = subagents_dir_for(main_path) else {
606            return Ok(());
607        };
608        let entries = std::fs::read_dir(&dir).map_err(|error| {
609            crate::Error::Other(format!(
610                "failed to enumerate Claude subagents at {}: {error}",
611                dir.display()
612            ))
613        })?;
614        let mut files = Vec::new();
615        for entry in entries {
616            let entry = entry.map_err(|error| {
617                crate::Error::Other(format!(
618                    "failed to enumerate Claude subagents at {}: {error}",
619                    dir.display()
620                ))
621            })?;
622            let path = entry.path();
623            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
624                files.push(path);
625            }
626        }
627        files.sort();
628
629        // Phase 1 — collect each subagent + its recovered agent id, without
630        // touching the main transcript yet.
631        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
632        for file in files {
633            let text = read_utf8_or_diagnose(&file).map_err(|error| {
634                crate::Error::Other(format!(
635                    "failed to read Claude subagent {}: {error}",
636                    file.display()
637                ))
638            })?;
639            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
640                Ok(sub) => sub,
641                // A read-only VIEW keeps the main conversation rather than
642                // losing the whole session to one unreconstructable child;
643                // the skip is named, not silent. Every stricter fidelity
644                // still propagates the child's failure.
645                Err(error) if fidelity.tolerates_residue() => {
646                    self.load_residue.push(format!(
647                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
648                        file.display()
649                    ));
650                    continue;
651                }
652                Err(error) => {
653                    return Err(crate::Error::Other(format!(
654                        "failed to reconstruct Claude subagent {}: {error}",
655                        file.display()
656                    )))
657                }
658            };
659            // agentId: prefer the file's own record, fall back to the filename stem.
660            let agent_id = first_agent_id(&text).or_else(|| {
661                file.file_stem()
662                    .and_then(|s| s.to_str())
663                    .map(|s| s.trim_start_matches("agent-").to_string())
664            });
665            collected.push((sub, agent_id));
666        }
667
668        // Phase 2 — single pass over the main transcript to index every
669        // requested agent id at once, then assign each subagent's parent by
670        // an O(1) lookup.
671        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
672        let index = parent_tool_use_index(main_text, &agent_ids);
673
674        for (mut sub, agent_id) in collected {
675            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
676            sub.meta.agent_id = agent_id;
677            self.subagents.push(sub);
678        }
679        Ok(())
680    }
681
682    /// Load a Codex rollout from a file.
683    pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
684        Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
685    }
686
687    /// Parse a Claude Code transcript from an in-memory JSONL string.
688    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
689        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
690    }
691
692    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
693    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
694    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
695        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
696        let mut messages = Vec::new();
697        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
698        // whitespace all preserved) — separate from the blank-skipping
699        // `non_empty_lines` walk just below, which still parses records only
700        // (a blank line is not a JSON record and must not become one).
701        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
702        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
703        // PARITY-15: a malformed/truncated line is still tolerated (a
704        // single bad line must not make an otherwise-healthy multi-
705        // thousand-line session unloadable) — but it's no longer INVISIBLE.
706        let mut parse_error_lines = 0usize;
707        let mut index = ClaudeReplayIndex::default();
708
709        // Claude transcripts are append-only trees, not linear chat logs.
710        // Build a lightweight graph index first so normalization sees the
711        // same single active, post-compaction branch Claude Code would
712        // resume. `raw` above deliberately remains the complete source.
713        for (line_index, line) in raw_lines.iter().enumerate() {
714            if line.trim().is_empty() {
715                continue;
716            }
717            let v: Value = match serde_json::from_str(line) {
718                Ok(v) => v,
719                Err(_) => {
720                    parse_error_lines += 1; // tolerate stray/corrupt lines
721                    continue;
722                }
723            };
724            capture_claude_meta(&v, &mut meta, line)?;
725            index.observe(line_index, &v)?;
726        }
727
728        let ClaudeReplaySelection {
729            lines: replay_lines,
730            residue: load_residue,
731        } = index.select_lines(fidelity)?;
732        let mut pending_assistant: Option<Value> = None;
733
734        for line_index in replay_lines {
735            let line = raw_lines[line_index];
736            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
737
738            if v.get("type").and_then(Value::as_str) == Some("assistant") {
739                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
740                    flush_claude_assistant(&mut pending_assistant, &mut messages);
741                    continue;
742                }
743                if let Some(pending) = pending_assistant.as_mut() {
744                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
745                        claude_assistant_message_id(&v) == Some(message_id)
746                    }) {
747                        merge_claude_assistant_chunk(pending, &v);
748                        continue;
749                    }
750                    flush_claude_assistant(&mut pending_assistant, &mut messages);
751                }
752                pending_assistant = Some(v);
753                continue;
754            }
755
756            flush_claude_assistant(&mut pending_assistant, &mut messages);
757
758            // WAVE-2 item 1: every Claude Code record carries a real
759            // top-level `timestamp` (ISO-8601) — provenance stamping below
760            // attaches it to every canonical `ChatMessage` this line
761            // produces, together with the record UUID and assistant model.
762            // `entry(...).or_insert_with` preserves any more-precise value a
763            // role-specific loader already supplied.
764            let before = messages.len();
765            match v.get("type").and_then(Value::as_str) {
766                Some("user") => push_claude_user(&v, &mut messages),
767                Some("assistant") => push_claude_assistant(&v, &mut messages),
768                Some("attachment") => push_claude_attachment(&v, &mut messages),
769                Some("system") => push_claude_system(&v, &mut messages),
770                _ => {} // mode, queue-operation, ... — skip
771            }
772            // UUID/model provenance remains meaningful even for legacy
773            // records that predate Claude Code's timestamp field.
774            capture_claude_record_provenance(&v, &mut messages[before..]);
775            restore_single_grok_message(&v, &mut messages[before..]);
776        }
777        flush_claude_assistant(&mut pending_assistant, &mut messages);
778
779        reorder_tool_results_after_calls(&mut messages);
780        ensure_tool_results_paired(&mut messages);
781        let imported_message_count = Some(messages.len());
782        Ok(Session {
783            meta,
784            messages,
785            subagents: Vec::new(),
786            raw,
787            raw_trailing_newline,
788            imported_message_count,
789            // Claude Code is line-oriented: `raw` is split directly out of
790            // the source text (strict-verbatim, IX-1).
791            raw_is_verbatim: true,
792            parse_error_lines,
793            load_residue,
794        })
795    }
796
797    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
798    ///
799    /// Codex stores subagents as separate rollout files linked to their parent
800    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
801    /// collection of sessions, this nests each child into its parent's
802    /// [`Session::subagents`] and returns only the roots. Children whose parent
803    /// isn't in the set are returned as roots themselves (best effort).
804    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
805        use std::collections::HashMap;
806        // Index each session's position by its session_id.
807        let mut idx: HashMap<String, usize> = HashMap::new();
808        for (i, s) in sessions.iter().enumerate() {
809            if let Some(id) = &s.meta.session_id {
810                idx.insert(id.clone(), i);
811            }
812        }
813        // Determine each session's parent (by index), if present in the set.
814        let parent_of: Vec<Option<usize>> = sessions
815            .iter()
816            .map(|s| {
817                s.meta
818                    .lineage
819                    .get("parent_thread_id")
820                    .and_then(|p| idx.get(p).copied())
821            })
822            .collect();
823
824        // Move children into parents, deepest-first so chains nest correctly.
825        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
826        let mut order: Vec<usize> = (0..slots.len()).collect();
827        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
828        for i in order {
829            if let Some(p) = parent_of[i] {
830                if p != i {
831                    if let Some(child) = slots[i].take() {
832                        if let Some(parent) = slots[p].as_mut() {
833                            parent.subagents.push(child);
834                        } else {
835                            slots[i] = Some(child); // parent already moved; keep as root
836                        }
837                    }
838                }
839            }
840        }
841        slots.into_iter().flatten().collect()
842    }
843
844    /// Parse a session of a known format from an in-memory JSONL string.
845    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
846        match format {
847            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
848            SessionFormat::Codex => Self::from_codex_str(jsonl),
849            SessionFormat::Pi => Self::from_pi_str(jsonl),
850            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
851            SessionFormat::Grok => Self::from_grok_str(jsonl),
852            SessionFormat::Gemini => Self::from_gemini_str(jsonl),
853            SessionFormat::Goose => Self::from_goose_str(jsonl),
854        }
855    }
856
857    /// Serialize this session to JSONL in the given format.
858    ///
859    /// The conversation is synthesized from the canonical messages, so this
860    /// works for sessions loaded from *either* tool as well as ones supercode
861    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
862    /// "export": format-specific framing that has no slot in the target may be
863    /// dropped, but the user/assistant/tool conversation is preserved.
864    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
865        match format {
866            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
867            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
868            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
869            SessionFormat::OpenCode => self.to_opencode_jsonl(),
870            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
871            SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
872            SessionFormat::Goose => Ok(self.to_goose_json()),
873        }
874    }
875
876    /// Export back to `format`, replaying the imported `raw` prefix
877    /// **verbatim** — original uuids/ids, real timestamps, and
878    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
879    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
880    /// is the session's own origin (`format.source() == self.meta.source`,
881    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
882    /// Only messages appended *after* import (tracked by
883    /// [`Self::imported_message_count`]) are synthesized, chained onto the
884    /// last original record found in the raw prefix.
885    ///
886    /// `session_id` of `Some(new)` rewrites the session id on every emitted
887    /// line, raw and synthesized alike (`sessionId` for Claude Code,
888    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
889    ///
890    /// Cross-format export (no verbatim prefix exists in the target dialect,
891    /// by definition) and a session with no `raw` lines both fall back
892    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
893    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
894    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
895    /// cross-format stays at the documented semantic tier.
896    pub fn to_jsonl_spliced(
897        &self,
898        format: SessionFormat,
899        session_id: Option<&str>,
900    ) -> Result<String> {
901        if self.parse_error_lines > 0
902            || self
903                .subagents
904                .iter()
905                .any(|subagent| subagent.parse_error_lines > 0)
906        {
907            return Err(Error::InvalidSession(
908                "refusing spliced export because the loaded session contains parse loss"
909                    .to_string(),
910            ));
911        }
912        if self.raw.is_empty() || format.source() != self.meta.source {
913            if let Some(session_id) = session_id {
914                let mut rewritten = self.clone();
915                rewritten.meta.session_id = Some(session_id.to_string());
916                return rewritten.to_jsonl(format);
917            }
918            return self.to_jsonl(format);
919        }
920        match format {
921            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
922            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
923            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
924            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
925            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
926            SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
927            SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
928        }
929    }
930
931    /// Write this session to `path` in the given format.
932    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
933        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
934        Ok(())
935    }
936
937    /// Reconstruct the exact source bytes this `Session` was loaded from,
938    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
939    /// inverse of the strict-verbatim capture those two fields record — see
940    /// `join_lines_verbatim`).
941    ///
942    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
943    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
944    /// original text, so this reproduces the original file byte-for-byte —
945    /// the P008/P009 diagonal-convert fix (`convert <file> --to
946    /// <same-format>` is byte-identical to `<file>`) is built on exactly
947    /// this. The one documented exception is an OpenCode **export-document**
948    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
949    /// is RE-SYNTHESIZED as one envelope line per record (see
950    /// `from_opencode_export_doc`'s contract), so this returns a
951    /// verbatim reproduction of THAT captured representation rather than the
952    /// original pretty-printed document — a known, narrow residue, not a
953    /// silent loss (the same records are all still present).
954    pub fn raw_verbatim(&self) -> String {
955        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
956    }
957
958    /// Serialize to the **supercode-native** lossless format: a header line
959    /// recording the original source, followed by every original JSONL line
960    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
961    /// schema and is necessarily lossy), this preserves *everything* — including
962    /// records with no canonical representation — so [`Self::from_native_str`]
963    /// reconstructs the session with full fidelity.
964    pub fn to_native_jsonl(&self) -> String {
965        let source = match self.meta.source {
966            SessionSource::ClaudeCode => "claude_code",
967            SessionSource::Codex => "codex",
968            SessionSource::Pi => "pi",
969            SessionSource::OpenCode => "opencode",
970            SessionSource::Grok => "grok",
971            SessionSource::Gemini => "gemini",
972            SessionSource::Goose => "goose",
973            // P5-3 safety-hardening fix: a natively-spawned session must
974            // never be written to disk labeled as an imported CC session.
975            SessionSource::Native => "native",
976        };
977        let header = serde_json::json!({
978            "supercode_native": 1,
979            "source": source,
980            // IX-1: carries whether the ORIGINAL imported source text ended
981            // with a trailing newline — `from_native_str` needs this to
982            // reconstruct the exact source bytes (not just the `raw` line
983            // list) when re-parsing the body with the per-source loader.
984            "raw_trailing_newline": self.raw_trailing_newline,
985        })
986        .to_string();
987        let mut out =
988            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
989        out.push_str(&header);
990        out.push('\n');
991        for line in &self.raw {
992            out.push_str(line);
993            out.push('\n');
994        }
995        out
996    }
997
998    /// Serialize to the **supercode-native v2** format: the same imported-body
999    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
1000    /// followed by every `Session.raw` line verbatim), plus one
1001    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
1002    /// produced after import, which have no backing `raw` line of their own.
1003    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
1004    /// serde), so nothing the live agent loop records is lost to disk.
1005    ///
1006    /// `appended` is caller-supplied rather than inferred from
1007    /// `self.messages`: A1 doesn't track which of `self.messages` came from
1008    /// import vs. the live loop — that bookkeeping belongs to the live writer
1009    /// built on top of this (A2/A3).
1010    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
1011        self.to_native_jsonl_v2_with_timestamp(appended, None)
1012    }
1013
1014    pub(crate) fn to_native_jsonl_v2_with_timestamp(
1015        &self,
1016        appended: &[ChatMessage],
1017        fixed_timestamp: Option<&str>,
1018    ) -> String {
1019        let source = match self.meta.source {
1020            SessionSource::ClaudeCode => "claude_code",
1021            SessionSource::Codex => "codex",
1022            SessionSource::Pi => "pi",
1023            SessionSource::OpenCode => "opencode",
1024            SessionSource::Grok => "grok",
1025            SessionSource::Gemini => "gemini",
1026            SessionSource::Goose => "goose",
1027            // P5-3 safety-hardening fix: a natively-spawned session must
1028            // never be written to disk labeled as an imported CC session.
1029            SessionSource::Native => "native",
1030        };
1031        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
1032        // already parses CC sidechains + CX lineage on import"): a
1033        // natively-spawned subagent's own `Session` carries its lineage on
1034        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
1035        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
1036        // all, so a native-spawned child's lineage was lost the instant it
1037        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
1038        // (`skip_serializing_if`-equivalent via manual omission below) so a
1039        // plain top-level session's header is byte-identical to before this
1040        // change.
1041        let mut header_obj = serde_json::json!({
1042            "supercode_native": 2,
1043            "source": source,
1044            "session_id": self.meta.session_id,
1045            "created": fixed_timestamp
1046                .map(ToOwned::to_owned)
1047                .unwrap_or_else(crate::sidecar::now_rfc3339),
1048            // IX-1: see `to_native_jsonl`'s header field of the same name.
1049            "raw_trailing_newline": self.raw_trailing_newline,
1050        });
1051        if let Some(obj) = header_obj.as_object_mut() {
1052            if let Some(agent_id) = &self.meta.agent_id {
1053                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
1054            }
1055            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
1056                obj.insert(
1057                    "parent_tool_use_id".to_string(),
1058                    Value::String(parent_tool_use_id.clone()),
1059                );
1060            }
1061            if !self.meta.lineage.is_empty() {
1062                obj.insert(
1063                    "lineage".to_string(),
1064                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
1065                );
1066            }
1067        }
1068        let header = header_obj.to_string();
1069        let mut out =
1070            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
1071        out.push_str(&header);
1072        out.push('\n');
1073        for line in &self.raw {
1074            out.push_str(line);
1075            out.push('\n');
1076        }
1077        for (turn_index, msg) in appended.iter().enumerate() {
1078            let turn = match fixed_timestamp {
1079                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1080                    msg,
1081                    timestamp.to_string(),
1082                    turn_index as u64,
1083                ),
1084                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1085                    msg,
1086                    crate::sidecar::now_rfc3339(),
1087                    turn_index as u64,
1088                ),
1089            };
1090            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
1091            out.push('\n');
1092        }
1093        out
1094    }
1095
1096    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
1097    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
1098    /// exactly as before. A v2 file's appended `NativeTurn` records —
1099    /// discriminated by the `supercode_turn` key, which never appears in a v1
1100    /// body — are split out before the imported body is handed to the
1101    /// per-source loader, then reattached in file order: to `messages` (via
1102    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
1103    /// so a v2 file round-trips byte-for-byte through
1104    /// [`Self::to_native_jsonl_v2`] again.
1105    pub fn from_native_str(jsonl: &str) -> Result<Session> {
1106        // IX-1: the native WRAPPER's own lines are split verbatim (not via
1107        // the blank-skipping `non_empty_lines`) so that any `raw` line it
1108        // carries — which can itself be blank, CRLF-terminated, or
1109        // whitespace-padded, now that raw-capture is strict-verbatim —
1110        // survives being embedded in (and re-extracted from) this wrapper
1111        // bit-for-bit. The wrapper we ourselves emit never has a blank line
1112        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
1113        // record per line), so this is a behavior-preserving switch for any
1114        // native text this crate produced; it also makes a hand-fed/legacy
1115        // native string tolerated exactly as `non_empty_lines` used to.
1116        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
1117        let mut lines = all_lines.into_iter();
1118        let header = lines.next().unwrap_or("");
1119        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
1120        let source = hv.get("source").and_then(Value::as_str);
1121        // IX-1: whether the ORIGINAL imported source (before it was wrapped
1122        // in this native format) ended with a trailing newline — a property
1123        // of the pre-wrap source, not of this wrapper (which always
1124        // LF-terminates every line it writes, regardless). Missing on a
1125        // native file written before IX-1 (or a hand-built header in an
1126        // older test/sidecar) — default `true`, the historical
1127        // always-newline-terminated assumption.
1128        let raw_trailing_newline = hv
1129            .get("raw_trailing_newline")
1130            .and_then(Value::as_bool)
1131            .unwrap_or(true);
1132
1133        // Split appended NativeTurn records (v2) out of the imported body. A
1134        // v1 body never carries a `supercode_turn` key, so this is a no-op
1135        // there — one code path serves both versions.
1136        let mut body_lines: Vec<String> = Vec::new();
1137        let mut turn_lines: Vec<&str> = Vec::new();
1138        for line in lines {
1139            let is_turn = serde_json::from_str::<Value>(line)
1140                .ok()
1141                .is_some_and(|v| v.get("supercode_turn").is_some());
1142            if is_turn {
1143                turn_lines.push(line);
1144            } else {
1145                body_lines.push(line.to_string());
1146            }
1147        }
1148        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1149        // `body_lines.join("\n")` alone would silently gain a trailing
1150        // newline the original source never had (or lose one it did have).
1151        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1152
1153        // The remaining lines are the original log; re-parse with the right loader.
1154        let mut session = match source {
1155            Some("codex") => Self::from_codex_str(&body)?,
1156            Some("claude_code") => Self::from_claude_code_str(&body)?,
1157            Some("pi") => Self::from_pi_str(&body)?,
1158            Some("opencode") => Self::from_opencode_str(&body)?,
1159            Some("grok") => Self::from_grok_str(&body)?,
1160            Some("gemini") => Self::from_gemini_str(&body)?,
1161            Some("goose") => Self::from_goose_str(&body)?,
1162            // P5-3 safety-hardening fix: a natively-spawned session's body
1163            // is always empty (it never had any foreign-tool prefix to
1164            // begin with — see `SessionSource::Native`'s doc comment), so
1165            // any loader would parse it identically; `from_claude_code_str`
1166            // is reused purely as a blank-skeleton builder (empty
1167            // `raw`/`messages`), then its `meta.source` is corrected to
1168            // `Native` — never left mislabeled as `ClaudeCode`.
1169            Some("native") => {
1170                let mut s = Self::from_claude_code_str(&body)?;
1171                s.meta.source = SessionSource::Native;
1172                s
1173            }
1174            // No/unknown header — auto-detect the body.
1175            _ => match detect_source(&body) {
1176                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1177                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1178                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1179                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1180                Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
1181                Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
1182                _ => Self::from_claude_code_str(&body)?,
1183            },
1184        };
1185
1186        for line in turn_lines {
1187            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1188                Ok(turn) => {
1189                    session.raw.push(line.to_string());
1190                    session.messages.push(turn.into_message());
1191                }
1192                Err(_) => {
1193                    // A valid JSON object carrying the native-turn
1194                    // discriminator belongs to this wrapper, not to the
1195                    // imported body. If its required fields are malformed,
1196                    // count it as parse loss so every fail-loud caller can
1197                    // refuse continuation instead of silently dropping a
1198                    // native history record. Keep the rejected source line
1199                    // in `raw` as well: diagnostics must count it in their
1200                    // denominator, and even corrupt input must not disappear
1201                    // merely because it reached the parser.
1202                    session.raw.push(line.to_string());
1203                    session.parse_error_lines += 1;
1204                }
1205            }
1206        }
1207
1208        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1209        // header block): recover a natively-spawned subagent's own lineage
1210        // from the v2 header, when present. Overlays (rather than merges
1211        // into) whatever the per-source body loader may have already set on
1212        // `session.meta` — these three keys are ONLY ever written by
1213        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1214        // header that carries them is authoritative for a file this crate
1215        // produced.
1216        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1217            session.meta.agent_id = Some(agent_id.to_string());
1218        }
1219        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1220            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1221        }
1222        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1223            for (k, v) in lineage {
1224                if let Some(s) = v.as_str() {
1225                    session.meta.lineage.insert(k.clone(), s.to_string());
1226                }
1227            }
1228        }
1229
1230        Ok(session)
1231    }
1232
1233    /// The full-fidelity [`Session`] a sidecar denotes.
1234    ///
1235    /// The sidecar (native-v2 format, D1) is the imported body plus every
1236    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1237    /// tolerant lower-level native parser, this persisted-store entry point
1238    /// validates its framing header before loading anything: a missing,
1239    /// malformed, or unsupported header must never become a zero-message
1240    /// session that callers could continue as if it were complete.
1241    pub fn from_sidecar_str(s: &str) -> Result<Session> {
1242        let header = s.lines().next().ok_or_else(|| {
1243            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1244        })?;
1245        let value: Value = serde_json::from_str(header).map_err(|error| {
1246            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1247        })?;
1248        let version = value.get("supercode_native").and_then(Value::as_u64);
1249        if !matches!(version, Some(1 | 2)) {
1250            return Err(Error::InvalidSession(
1251                "sidecar header must declare supported `supercode_native` version 1 or 2"
1252                    .to_string(),
1253            ));
1254        }
1255        let source = value.get("source").and_then(Value::as_str);
1256        if !matches!(
1257            source,
1258            Some(
1259                "native"
1260                    | "claude_code"
1261                    | "codex"
1262                    | "gemini"
1263                    | "goose"
1264                    | "opencode"
1265                    | "pi"
1266                    | "grok"
1267            )
1268        ) {
1269            return Err(Error::InvalidSession(
1270                "sidecar header must declare a supported `source`".to_string(),
1271            ));
1272        }
1273        Self::from_native_str(s)
1274    }
1275
1276    /// Parse a Codex rollout from an in-memory JSONL string.
1277    pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1278        let mut meta = SessionMeta::new(SessionSource::Codex);
1279        let mut messages = Vec::new();
1280
1281        // First pass: collect the text of every assistant message that exists as
1282        // a canonical `response_item`. In normal sessions the streamed
1283        // `event_msg/agent_message` events duplicate these and are safely
1284        // skipped; in collab/multi-agent sessions the assistant narration lives
1285        // ONLY as `agent_message` events, so we recover the ones with no
1286        // response_item counterpart (deduping by exact text).
1287        let assistant_texts = collect_codex_assistant_texts(jsonl);
1288        // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1289        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1290        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1291        let mut pending_reasoning = String::new();
1292        let mut pending_reasoning_content = String::new();
1293        let mut pending_reasoning_encrypted = false;
1294        // PARITY-15: see `from_claude_code_str`'s identical counter.
1295        let mut parse_error_lines = 0usize;
1296        let mut restored_embedded_codex_provenance = false;
1297
1298        for (record_index, raw_line) in raw_lines.iter().enumerate() {
1299            let line = raw_line.trim();
1300            if line.is_empty() {
1301                continue;
1302            }
1303            let v: Value = match serde_json::from_str(line) {
1304                Ok(v) => v,
1305                Err(_) => {
1306                    parse_error_lines += 1;
1307                    continue;
1308                }
1309            };
1310            let payload = v.get("payload").unwrap_or(&Value::Null);
1311            if !restored_embedded_codex_provenance
1312                && v.get("type").and_then(Value::as_str) == Some("session_meta")
1313                && payload
1314                    .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1315                    .map(|extension| restore_codex_provenance(extension, &mut meta))
1316                    .transpose()?
1317                    .unwrap_or(false)
1318            {
1319                restored_embedded_codex_provenance = true;
1320            }
1321            if !restored_embedded_codex_provenance {
1322                capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1323            }
1324            // WAVE-2 item 1: every Codex record carries a real top-level
1325            // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1326            // line produces via `stamp_new_codex_messages` below, at each
1327            // arm that pushes messages.
1328            let line_ts = v.get("timestamp").and_then(Value::as_str);
1329
1330            match v.get("type").and_then(Value::as_str) {
1331                Some("session_meta") => {
1332                    capture_codex_session_meta(payload, &mut meta);
1333                    if !restored_embedded_codex_provenance {
1334                        meta.codex_headers.push(v.clone());
1335                    }
1336                }
1337                Some("turn_context") => {
1338                    if meta.model.is_none() {
1339                        meta.model = payload
1340                            .get("model")
1341                            .and_then(Value::as_str)
1342                            .map(str::to_string);
1343                    }
1344                    if !restored_embedded_codex_provenance {
1345                        meta.codex_headers.push(v.clone());
1346                    }
1347                }
1348                Some("response_item")
1349                    if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1350                {
1351                    // Retain reasoning (P3): summary text if any, the raw
1352                    // `content` chain-of-thought text if any (N2 — this used
1353                    // to be dropped despite `Coverage::Retained` claiming the
1354                    // whole item survived; see `crate::audit`'s doc comment),
1355                    // plus a flag for the opaque encrypted_content a
1356                    // same-model continuation can replay. Stashed onto the
1357                    // next assistant message below.
1358                    let summary = extract_text_content(payload.get("summary"));
1359                    if !summary.trim().is_empty() {
1360                        push_str_field(&mut pending_reasoning, &summary);
1361                    }
1362                    // N2: `content` is `null` on the vast majority of real
1363                    // turns (raw reasoning text is only ever populated for
1364                    // certain reasoning-transcript configurations) — guard
1365                    // on non-null BEFORE calling `extract_text_content`,
1366                    // since `Some(&Value::Null)` would otherwise fall into
1367                    // its `Some(other) => other.to_string()` arm and
1368                    // stringify to the literal text `"null"`.
1369                    if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1370                        let text = extract_text_content(Some(raw_content));
1371                        if !text.trim().is_empty() {
1372                            push_str_field(&mut pending_reasoning_content, &text);
1373                        }
1374                    }
1375                    // N1: `serde_json` returns `Some(&Value::Null)` for a
1376                    // present-but-null `encrypted_content` key — which is
1377                    // what EVERY real rollout's reasoning item carries
1378                    // (upstream always serializes the field, never
1379                    // `skip_serializing_if`, `codex-rs/protocol/src/
1380                    // models.rs:970-983`). The old `.is_some()` check
1381                    // false-flagged every single reasoning item as
1382                    // "encrypted" on real data; only a genuinely non-null
1383                    // value means the model actually returned an opaque
1384                    // blob that a same-model continuation could replay.
1385                    if payload
1386                        .get("encrypted_content")
1387                        .is_some_and(|v| !v.is_null())
1388                    {
1389                        pending_reasoning_encrypted = true;
1390                    }
1391                }
1392                Some("response_item") => {
1393                    let before = messages.len();
1394                    push_codex_item(payload, &mut messages);
1395                    // Attach any pending reasoning to a newly produced assistant turn.
1396                    if messages.len() > before
1397                        && (!pending_reasoning.is_empty()
1398                            || !pending_reasoning_content.is_empty()
1399                            || pending_reasoning_encrypted)
1400                    {
1401                        let is_assistant = messages
1402                            .last()
1403                            .map(|m| m.role == Role::Assistant)
1404                            .unwrap_or(false);
1405                        if is_assistant {
1406                            let last = messages.last_mut().expect("checked above");
1407                            if !pending_reasoning.is_empty() {
1408                                last.metadata.insert(
1409                                    "reasoning".to_string(),
1410                                    std::mem::take(&mut pending_reasoning),
1411                                );
1412                            }
1413                            if !pending_reasoning_content.is_empty() {
1414                                last.metadata.insert(
1415                                    "reasoning_content".to_string(),
1416                                    std::mem::take(&mut pending_reasoning_content),
1417                                );
1418                            }
1419                            if pending_reasoning_encrypted {
1420                                last.metadata
1421                                    .insert("reasoning_encrypted".to_string(), "true".to_string());
1422                                pending_reasoning_encrypted = false;
1423                            }
1424                        } else {
1425                            // N3: the item that just landed is NOT the
1426                            // assistant turn the pending reasoning was for
1427                            // (e.g. an aborted turn's reasoning directly
1428                            // followed by a user message) — the old code
1429                            // unconditionally cleared the pending state
1430                            // here, silently discarding it. Flush it as its
1431                            // own message instead, inserted just before the
1432                            // interrupting item so replay order stays
1433                            // chronological, keeping `Coverage::Retained`
1434                            // honest for this shape too.
1435                            let orphan = orphaned_reasoning_message(
1436                                &mut pending_reasoning,
1437                                &mut pending_reasoning_content,
1438                                &mut pending_reasoning_encrypted,
1439                            );
1440                            messages.insert(before, orphan);
1441                        }
1442                    }
1443                    stamp_new_codex_messages(&mut messages, before, line_ts);
1444                    restore_single_grok_message(payload, &mut messages[before..]);
1445                }
1446                // A compaction record replaces all prior turns with its
1447                // summarized `replacement_history` — exactly how Codex itself
1448                // resumes a compacted session.
1449                Some("compacted") => {
1450                    messages.clear();
1451                    if let Some(Value::Array(history)) = payload.get("replacement_history") {
1452                        for item in history {
1453                            push_codex_item(item, &mut messages);
1454                        }
1455                    }
1456                    // `replacement_history` items carry no per-item
1457                    // timestamp of their own (observed corpora) — the
1458                    // `compacted` record's own timestamp (when it happened)
1459                    // is the best-effort real source for every message it
1460                    // synthesizes, so it stamps the whole rebuilt vec (index
1461                    // 0, since `clear()` reset it above).
1462                    stamp_new_codex_messages(&mut messages, 0, line_ts);
1463                    // IX-6 fix: replaying `replacement_history` through
1464                    // `push_codex_item` can leave the LAST replayed message
1465                    // marked `__codex_open_turn` (if it's an assistant
1466                    // `message`, per the combined-turn merge below). That
1467                    // marker must not survive past the compaction boundary —
1468                    // a live `function_call` arriving after this record is a
1469                    // NEW turn, not a continuation of the compaction
1470                    // summary's synthetic turn, so it must not merge into it.
1471                    if let Some(last) = messages.last_mut() {
1472                        last.metadata.remove("__codex_open_turn");
1473                    }
1474                }
1475                Some("event_msg")
1476                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1477                {
1478                    let before = messages.len();
1479                    let text = agent_message_text(payload);
1480                    if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1481                        push_assistant(&mut messages, text, Vec::new());
1482                        if let Some(last) = messages.last_mut() {
1483                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1484                                last.metadata.insert("phase".to_string(), phase.to_string());
1485                            }
1486                        }
1487                    }
1488                    stamp_new_codex_messages(&mut messages, before, line_ts);
1489                }
1490                // The user rolled back (undid) the last N turns — replay must
1491                // drop them so the reloaded conversation matches what the user
1492                // actually kept.
1493                Some("event_msg")
1494                    if payload.get("type").and_then(Value::as_str)
1495                        == Some("thread_rolled_back") =>
1496                {
1497                    let n = payload
1498                        .get("num_turns")
1499                        .and_then(Value::as_u64)
1500                        .unwrap_or(1);
1501                    for _ in 0..n {
1502                        remove_last_turn(&mut messages);
1503                    }
1504                }
1505                // The natural-language goal assigned to this thread (sometimes
1506                // the only place the objective text is recorded).
1507                Some("event_msg")
1508                    if payload.get("type").and_then(Value::as_str)
1509                        == Some("thread_goal_updated") =>
1510                {
1511                    let before = messages.len();
1512                    let goal = payload.get("goal");
1513                    if let Some(obj) = goal
1514                        .and_then(|g| g.get("objective"))
1515                        .and_then(Value::as_str)
1516                    {
1517                        if !obj.trim().is_empty() {
1518                            messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1519                            // D4: `goal.objective` alone used to be the ONLY
1520                            // captured field, but the audit labeled this
1521                            // `Retained` as if the whole record survived.
1522                            // `goal.status`/`goal.tokenBudget` (real
1523                            // `ThreadGoal` wire fields, camelCase) are
1524                            // captured too so that label is honest — see
1525                            // `crate::audit::event_msg_coverage`'s doc
1526                            // comment.
1527                            if let Some(last) = messages.last_mut() {
1528                                if let Some(status) =
1529                                    goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1530                                {
1531                                    last.metadata
1532                                        .insert("goal_status".to_string(), status.to_string());
1533                                }
1534                                if let Some(budget) = goal
1535                                    .and_then(|g| g.get("tokenBudget"))
1536                                    .and_then(Value::as_i64)
1537                                {
1538                                    last.metadata.insert(
1539                                        "goal_token_budget".to_string(),
1540                                        budget.to_string(),
1541                                    );
1542                                }
1543                            }
1544                        }
1545                    }
1546                    stamp_new_codex_messages(&mut messages, before, line_ts);
1547                }
1548                // Code-review output — unique assistant-generated content with no
1549                // `message` counterpart.
1550                Some("event_msg")
1551                    if payload.get("type").and_then(Value::as_str)
1552                        == Some("exited_review_mode") =>
1553                {
1554                    let before = messages.len();
1555                    if let Some(review) = payload.get("review_output") {
1556                        let text = review
1557                            .get("overall_explanation")
1558                            .and_then(Value::as_str)
1559                            .map(str::to_string)
1560                            .unwrap_or_else(|| review.to_string());
1561                        push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1562                        // D4: `overall_explanation` alone used to be the ONLY
1563                        // captured field, but the audit labeled this
1564                        // `Retained` as if `review_output.findings` survived
1565                        // too. Capture `findings` verbatim (as JSON, onto
1566                        // metadata) so that label is honest — this is the
1567                        // only place review-mode findings (title/body/
1568                        // confidence_score/priority/code_location) live.
1569                        if let Some(findings) = review.get("findings") {
1570                            if findings.as_array().is_some_and(|a| !a.is_empty()) {
1571                                if let Some(last) = messages.last_mut() {
1572                                    if let Ok(s) = serde_json::to_string(findings) {
1573                                        last.metadata.insert("review_findings".to_string(), s);
1574                                    }
1575                                }
1576                            }
1577                        }
1578                        // N4: `overall_correctness`/`overall_confidence_score`
1579                        // are the review's actual verdict — distinct from the
1580                        // findings list and the explanation prose already
1581                        // captured above — and were neither captured nor
1582                        // disclosed as residue while the audit doc stayed
1583                        // silent about them. Capture both onto the same
1584                        // message's metadata, same pattern as `findings`.
1585                        if let Some(last) = messages.last_mut() {
1586                            if let Some(correctness) =
1587                                review.get("overall_correctness").and_then(Value::as_str)
1588                            {
1589                                last.metadata.insert(
1590                                    "review_overall_correctness".to_string(),
1591                                    correctness.to_string(),
1592                                );
1593                            }
1594                            if let Some(score) = review
1595                                .get("overall_confidence_score")
1596                                .and_then(Value::as_f64)
1597                            {
1598                                last.metadata.insert(
1599                                    "review_overall_confidence_score".to_string(),
1600                                    score.to_string(),
1601                                );
1602                            }
1603                        }
1604                    }
1605                    stamp_new_codex_messages(&mut messages, before, line_ts);
1606                }
1607                _ => {} // other event_msg, token_count, ... — UI events, skip
1608            }
1609        }
1610
1611        // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1612        // shape a real rollout can leave behind (the process was
1613        // interrupted mid-turn, after the model reasoned but before it
1614        // replied — end of file, or a rollback/compaction boundary that
1615        // clears the pending state some other way) — the old code silently
1616        // dropped it here (nothing ever consumed the pending buffers once
1617        // the loop ended). Flush it as its own trailing message instead, so
1618        // `Coverage::Retained` holds for this shape too. Superset of the
1619        // independently-discovered PARITY-11 fix: also folds in
1620        // `pending_reasoning_content` (the raw chain-of-thought, distinct
1621        // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1622        // message` helper, which the interrupted-by-a-user-message shape
1623        // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1624        // relies on — a trailing-EOF-only flush here would miss that case.
1625        if !pending_reasoning.is_empty()
1626            || !pending_reasoning_content.is_empty()
1627            || pending_reasoning_encrypted
1628        {
1629            let orphan = orphaned_reasoning_message(
1630                &mut pending_reasoning,
1631                &mut pending_reasoning_content,
1632                &mut pending_reasoning_encrypted,
1633            );
1634            messages.push(orphan);
1635        }
1636
1637        ensure_tool_results_paired(&mut messages);
1638        // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1639        // combined-turn merge above — strip it so it never leaks out as
1640        // visible `ChatMessage` metadata.
1641        for m in &mut messages {
1642            m.metadata.remove("__codex_open_turn");
1643            if m.metadata
1644                .remove("__grok_remove_synthetic_turn_id")
1645                .is_some()
1646            {
1647                m.metadata.remove("turn_id");
1648            }
1649        }
1650        let imported_message_count = Some(messages.len());
1651        Ok(Session {
1652            meta,
1653            messages,
1654            subagents: Vec::new(),
1655            raw,
1656            raw_trailing_newline,
1657            imported_message_count,
1658            // Codex is line-oriented: `raw` is split directly out of the
1659            // source text (strict-verbatim, IX-1).
1660            raw_is_verbatim: true,
1661            parse_error_lines,
1662            load_residue: Vec::new(),
1663        })
1664    }
1665
1666    /// Parse a Codex rollout as bounded human-visible history rather than as
1667    /// resumable model context. This deliberately ignores outer `compacted`
1668    /// replacement semantics: the original `response_item` records remain in
1669    /// the rollout and are the authoritative UI history.
1670    fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
1671        let mut meta = SessionMeta::new(SessionSource::Codex);
1672        let mut messages: Vec<ChatMessage> = Vec::new();
1673        let mut preceding_users = Vec::new();
1674        let mut parse_error_lines = 0usize;
1675        let mut record_count = 0usize;
1676        let mut total_message_count = 0usize;
1677        let retain = message_limit.max(1).saturating_add(64);
1678        let mut canonical_assistant_texts = HashSet::new();
1679
1680        for raw_line in non_empty_lines(jsonl) {
1681            record_count += 1;
1682            let value: Value = match serde_json::from_str(raw_line) {
1683                Ok(value) => value,
1684                Err(_) => {
1685                    parse_error_lines += 1;
1686                    continue;
1687                }
1688            };
1689            let payload = value.get("payload").unwrap_or(&Value::Null);
1690            let line_ts = value.get("timestamp").and_then(Value::as_str);
1691            match value.get("type").and_then(Value::as_str) {
1692                Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
1693                Some("turn_context") if meta.model.is_none() => {
1694                    meta.model = payload
1695                        .get("model")
1696                        .and_then(Value::as_str)
1697                        .map(str::to_string);
1698                }
1699                Some("response_item")
1700                    if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
1701                {
1702                    let assistant_text = (payload.get("type").and_then(Value::as_str)
1703                        == Some("message")
1704                        && payload.get("role").and_then(Value::as_str) == Some("assistant"))
1705                    .then(|| extract_text_content(payload.get("content")))
1706                    .filter(|text| !text.trim().is_empty());
1707                    if let Some(text) = assistant_text.as_deref() {
1708                        if let Some(index) = messages.iter().rposition(|message| {
1709                            message.metadata.contains_key("codex_event_message")
1710                                && message.content.as_deref() == Some(text)
1711                        }) {
1712                            messages.remove(index);
1713                            total_message_count = total_message_count.saturating_sub(1);
1714                        }
1715                        canonical_assistant_texts.insert(text.trim().to_string());
1716                    }
1717                    let before = messages.len();
1718                    push_codex_item(payload, &mut messages);
1719                    total_message_count += messages.len().saturating_sub(before);
1720                    stamp_new_codex_messages(&mut messages, before, line_ts);
1721                    restore_single_grok_message(payload, &mut messages[before..]);
1722                }
1723                Some("event_msg")
1724                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1725                {
1726                    let text = agent_message_text(payload);
1727                    if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
1728                        let before = messages.len();
1729                        push_assistant(&mut messages, text, Vec::new());
1730                        total_message_count += 1;
1731                        if let Some(last) = messages.last_mut() {
1732                            last.metadata
1733                                .insert("codex_event_message".to_string(), "true".to_string());
1734                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1735                                last.metadata.insert("phase".to_string(), phase.to_string());
1736                            }
1737                        }
1738                        stamp_new_codex_messages(&mut messages, before, line_ts);
1739                    }
1740                }
1741                // `compacted` changes continuation context, not what was
1742                // already visible in scrollback. Other event records are UI
1743                // lifecycle noise or duplicate canonical response items.
1744                _ => {}
1745            }
1746            if messages.len() > retain {
1747                let remove = messages.len() - retain;
1748                for message in messages.drain(..remove) {
1749                    if message.role == Role::User {
1750                        preceding_users.push(message);
1751                        if preceding_users.len() > 2 {
1752                            preceding_users.remove(0);
1753                        }
1754                    }
1755                }
1756            }
1757        }
1758
1759        for message in &mut messages {
1760            message.metadata.remove("__codex_open_turn");
1761            message.metadata.remove("codex_event_message");
1762            if message
1763                .metadata
1764                .remove("__grok_remove_synthetic_turn_id")
1765                .is_some()
1766            {
1767                message.metadata.remove("turn_id");
1768            }
1769        }
1770        truncate_messages_with_anchor(&mut messages, message_limit, preceding_users);
1771        let imported_message_count = Some(total_message_count);
1772        Ok(Session {
1773            meta,
1774            messages,
1775            subagents: Vec::new(),
1776            // Preserve the cheap count without retaining hundreds of
1777            // megabytes of source lines in a display-only value.
1778            raw: vec![String::new(); record_count],
1779            raw_trailing_newline: jsonl.ends_with('\n'),
1780            imported_message_count,
1781            raw_is_verbatim: false,
1782            parse_error_lines,
1783            load_residue: vec![
1784                "display history is a bounded native-record projection, not resumable model context"
1785                    .to_string(),
1786            ],
1787        })
1788    }
1789
1790    /// Load a Pi session from a file.
1791    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1792        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1793    }
1794
1795    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1796    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1797    ///
1798    /// Line 1 is the `session` header; every other line is one `SessionEntry`
1799    /// in a tree keyed by `id`/`parentId` — file order is append order, not
1800    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1801    /// exactly like Claude Code/Codex). `messages` is the **active path
1802    /// only**: pi's own leaf rule is "the last entry in file order"
1803    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1804    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1805    /// state records (`thinking_level_change`/`model_change`/`custom`/
1806    /// `session_info`) are never visited by that walk — they survive in
1807    /// `raw` only, pi's defining residue (§1.1).
1808    ///
1809    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1810    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1811    /// `custom`) produces no canonical message — raw-only survival, never a
1812    /// panic — and the Pi corpus audit turns that into a
1813    /// visible coverage failure rather than a silent drop.
1814    ///
1815    /// Same fail-loud discipline applies to `ImageContent` blocks
1816    /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1817    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1818    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1819    /// cites the containing union) — a follow-up TR tracks confirming it
1820    /// against a real corpus. Until then, an image block that doesn't match
1821    /// that shape never gets silently synthesized as an empty/corrupt
1822    /// `image_url` part; the containing message survives in `raw` only and
1823    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1824    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1825        let mut meta = SessionMeta::new(SessionSource::Pi);
1826        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1827        // blank-skipping PARSE walk (`lines_v`) below, which must keep
1828        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1829        // records (a blank line is never a record, on either view).
1830        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1831        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1832        let non_empty_line_count = non_empty_lines(jsonl).count();
1833        let lines_v: Vec<Value> = non_empty_lines(jsonl)
1834            .filter_map(|l| serde_json::from_str(l).ok())
1835            .collect();
1836        // PARITY-15: every line that failed to even deserialize as JSON at
1837        // all (never mind whether it then parsed as a recognized
1838        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1839        // counter.
1840        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1841
1842        if let Some(header) = lines_v.first() {
1843            capture_pi_header(header, &mut meta)?;
1844        }
1845
1846        // Every non-header entry that parses as an object carrying an `id`.
1847        // (A line that fails to parse, or a header re-parsed as an entry,
1848        // simply never enters `by_id` — it survives in `raw` only, exactly
1849        // like a malformed/non-conversational line in the other loaders.)
1850        struct PiEntry {
1851            id: String,
1852            parent_id: Option<String>,
1853            value: Value,
1854        }
1855        let mut entries: Vec<PiEntry> = Vec::new();
1856        let mut by_id: HashMap<String, usize> = HashMap::new();
1857        for v in lines_v.iter().skip(1) {
1858            let Some(id) = v.get("id").and_then(Value::as_str) else {
1859                continue;
1860            };
1861            let parent_id = v
1862                .get("parentId")
1863                .and_then(Value::as_str)
1864                .map(str::to_string);
1865            by_id.insert(id.to_string(), entries.len());
1866            entries.push(PiEntry {
1867                id: id.to_string(),
1868                parent_id,
1869                value: v.clone(),
1870            });
1871        }
1872
1873        if entries.is_empty() {
1874            return Ok(Session {
1875                meta,
1876                messages: Vec::new(),
1877                subagents: Vec::new(),
1878                raw,
1879                raw_trailing_newline,
1880                imported_message_count: Some(0),
1881                // Pi is line-oriented: `raw` is split directly out of the
1882                // source text (strict-verbatim, IX-1), even for this
1883                // no-entries early return.
1884                raw_is_verbatim: true,
1885                parse_error_lines,
1886                load_residue: Vec::new(),
1887            });
1888        }
1889
1890        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1891        // necessarily a `message` entry — a trailing `label`/`session_info`
1892        // still anchors the walk correctly since the walk just follows
1893        // `parentId` regardless of the leaf's own type.
1894        let leaf_idx = entries.len() - 1;
1895        let mut chain_rev: Vec<usize> = Vec::new();
1896        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1897        let mut guard = 0usize;
1898        while let Some(id) = cur {
1899            let Some(&idx) = by_id.get(&id) else { break };
1900            chain_rev.push(idx);
1901            cur = entries[idx].parent_id.clone();
1902            guard += 1;
1903            if guard > entries.len() + 1 {
1904                break; // cycle guard — malformed parentId chain
1905            }
1906        }
1907        chain_rev.reverse();
1908        let active = chain_rev; // indices into `entries`, root..leaf order
1909
1910        let pos_in_active: HashMap<&str, usize> = active
1911            .iter()
1912            .enumerate()
1913            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1914            .collect();
1915
1916        // First pass: compaction discipline (§2.1 S3) — every message from an
1917        // entry before the LATEST `firstKeptEntryId` on the active path is
1918        // excluded from replay (`compacted_out`), mirroring pi's own
1919        // `buildContextEntries` slice (`sm:414-450`).
1920        let mut kept_from_pos = 0usize;
1921        for &idx in &active {
1922            let e = &entries[idx];
1923            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1924                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1925                    if let Some(&p) = pos_in_active.get(fk) {
1926                        kept_from_pos = kept_from_pos.max(p);
1927                    }
1928                }
1929            }
1930        }
1931
1932        let mut messages = Vec::new();
1933        let mut current_model: Option<String> = None;
1934        for (pos, &idx) in active.iter().enumerate() {
1935            let e = &entries[idx];
1936            let v = &e.value;
1937            let entry_ts = v
1938                .get("timestamp")
1939                .and_then(Value::as_str)
1940                .map(str::to_string);
1941            let before = messages.len();
1942            match v.get("type").and_then(Value::as_str) {
1943                Some("message") => {
1944                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1945                    match msg_v.get("role").and_then(Value::as_str) {
1946                        Some("user") => push_pi_user(&msg_v, &mut messages),
1947                        Some("assistant") => {
1948                            push_pi_assistant(&msg_v, &mut messages);
1949                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1950                                current_model = Some(m.to_string());
1951                            }
1952                        }
1953                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1954                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1955                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1956                        // OPEN UNION (S6): any other role — raw-only survival.
1957                        _ => {}
1958                    }
1959                }
1960                Some("custom_message") => push_pi_custom_common(v, &mut messages),
1961                Some("compaction") => push_pi_compaction(v, &mut messages),
1962                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1963                Some("model_change") => {
1964                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1965                        current_model = Some(m.to_string());
1966                    }
1967                }
1968                Some("session_info") => {
1969                    if let Some(name) = v.get("name").and_then(Value::as_str) {
1970                        if !name.is_empty() {
1971                            meta.lineage
1972                                .insert("session_name".to_string(), name.to_string());
1973                        }
1974                    }
1975                }
1976                // thinking_level_change, custom (entry-level state), label —
1977                // no clean home, raw-only (§2.3).
1978                _ => {}
1979            }
1980            let is_summary = matches!(
1981                v.get("type").and_then(Value::as_str),
1982                Some("compaction") | Some("branch_summary")
1983            );
1984            for m in &mut messages[before..] {
1985                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1986                if let Some(p) = &e.parent_id {
1987                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
1988                }
1989                if let Some(ts) = &entry_ts {
1990                    m.metadata
1991                        .entry("timestamp".to_string())
1992                        .or_insert_with(|| ts.clone());
1993                }
1994                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1995                // is pi's authoritative, always-monotonic-in-file-order
1996                // wall-clock (mandatory on every entry) and wins whenever
1997                // present. The nested `message.timestamp` (unix-ms) is only
1998                // reached here — via `entry(...).or_insert_with`, so it
1999                // never overwrites the entry-level value — in the rare case
2000                // an entry lacks its own `timestamp`. This intentionally
2001                // does NOT prefer the msg-level field even though it LOOKS
2002                // more precise: unlike the entry-level timestamp, it is not
2003                // guaranteed monotonic with this loader's root->leaf
2004                // linearization (e.g. a rewound-branch entry can carry an
2005                // earlier msg-level clock reading than its file-order
2006                // neighbors), and OpenCode's own loader re-sorts messages by
2007                // this canonical timestamp — a non-monotonic source would
2008                // silently scramble replay order on a pi->opencode hop.
2009                if let Some(ms) = v
2010                    .get("message")
2011                    .and_then(|mm| mm.get("timestamp"))
2012                    .and_then(Value::as_u64)
2013                {
2014                    m.metadata
2015                        .entry("timestamp".to_string())
2016                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
2017                }
2018                // A compaction/branch-summary message IS the retained marker
2019                // — never mark it excluded, regardless of its own position.
2020                if !is_summary && pos < kept_from_pos {
2021                    m.metadata
2022                        .insert("compacted_out".to_string(), "true".to_string());
2023                }
2024            }
2025            restore_single_grok_message(v, &mut messages[before..]);
2026            for message in &mut messages[before..] {
2027                restore_tool_outcome_extension(v, message);
2028            }
2029        }
2030
2031        meta.model = current_model;
2032        ensure_tool_results_paired(&mut messages);
2033        let imported_message_count = Some(messages.len());
2034        Ok(Session {
2035            meta,
2036            messages,
2037            subagents: Vec::new(),
2038            raw,
2039            raw_trailing_newline,
2040            imported_message_count,
2041            // Pi is line-oriented: `raw` is split directly out of the
2042            // source text (strict-verbatim, IX-1).
2043            raw_is_verbatim: true,
2044            parse_error_lines,
2045            load_residue: Vec::new(),
2046        })
2047    }
2048
2049    /// Load Grok's resumable `chat_history.jsonl` transcript.
2050    ///
2051    /// The surrounding session directory carries the session id, workspace,
2052    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
2053    /// itself while this path-aware entry point overlays that directory
2054    /// metadata.
2055    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
2056        let path = path.as_ref();
2057        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
2058        session.capture_grok_path_metadata(path);
2059        Ok(session)
2060    }
2061
2062    /// Parse Grok's line-oriented `chat_history.jsonl` format.
2063    ///
2064    /// Conversational records are `user`, `assistant`, and `tool_result`.
2065    /// `system` is the regenerated base prompt and is retained in
2066    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2067    /// state remain byte-exact in [`Session::raw`] but are intentionally not
2068    /// replayed as chat turns.
2069    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2070        let mut meta = SessionMeta::new(SessionSource::Grok);
2071        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2072        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2073        let mut messages = Vec::new();
2074        let mut parse_error_lines = 0usize;
2075        let mut tool_names: HashMap<String, String> = HashMap::new();
2076
2077        for line in non_empty_lines(jsonl) {
2078            let value: Value = match serde_json::from_str(line) {
2079                Ok(value) => value,
2080                Err(_) => {
2081                    parse_error_lines += 1;
2082                    continue;
2083                }
2084            };
2085            restore_codex_provenance_from_top_level(&value, &mut meta)?;
2086            match value.get("type").and_then(Value::as_str) {
2087                Some("system") => {
2088                    if meta.system_prompt.is_none() {
2089                        meta.system_prompt = value
2090                            .get("content")
2091                            .and_then(Value::as_str)
2092                            .map(str::to_string);
2093                    }
2094                }
2095                Some("user") => {
2096                    let content = extract_text_content(value.get("content"));
2097                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
2098                        == Some("supercode_system_event")
2099                    {
2100                        Role::System
2101                    } else {
2102                        Role::User
2103                    };
2104                    let content = if role == Role::User {
2105                        match grok_human_user_text(&content) {
2106                            Some(content) => content,
2107                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2108                                String::new()
2109                            }
2110                            None => continue,
2111                        }
2112                    } else {
2113                        content
2114                    };
2115                    let mut message = ChatMessage {
2116                        role,
2117                        content: Some(content),
2118                        content_parts: None,
2119                        tool_calls: None,
2120                        tool_call_id: None,
2121                        name: None,
2122                        metadata: Default::default(),
2123                    };
2124                    capture_grok_scalar_metadata(
2125                        &value,
2126                        &mut message,
2127                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2128                    );
2129                    restore_grok_message_extension(&value, &mut message);
2130                    messages.push(message);
2131                }
2132                Some("assistant") => {
2133                    let calls: Vec<ToolCall> = value
2134                        .get("tool_calls")
2135                        .and_then(Value::as_array)
2136                        .into_iter()
2137                        .flatten()
2138                        .filter_map(|call| {
2139                            let id = call.get("id")?.as_str()?.to_string();
2140                            let name = call.get("name")?.as_str()?.to_string();
2141                            let arguments = call
2142                                .get("arguments")
2143                                .map(value_to_arg_string)
2144                                .unwrap_or_else(|| "{}".to_string());
2145                            tool_names.insert(id.clone(), name.clone());
2146                            Some(function_call(&id, &name, arguments))
2147                        })
2148                        .collect();
2149                    let content = value
2150                        .get("content")
2151                        .and_then(Value::as_str)
2152                        .filter(|content| !content.is_empty())
2153                        .map(str::to_string);
2154                    let mut message = ChatMessage {
2155                        role: Role::Assistant,
2156                        content,
2157                        content_parts: None,
2158                        tool_calls: (!calls.is_empty()).then_some(calls),
2159                        tool_call_id: None,
2160                        name: None,
2161                        metadata: Default::default(),
2162                    };
2163                    capture_grok_scalar_metadata(
2164                        &value,
2165                        &mut message,
2166                        &["model_id", "model_fingerprint", "reasoning_effort"],
2167                    );
2168                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2169                        meta.model = Some(model.to_string());
2170                    }
2171                    restore_grok_message_extension(&value, &mut message);
2172                    messages.push(message);
2173                }
2174                Some("tool_result") => {
2175                    let id = value
2176                        .get("tool_call_id")
2177                        .and_then(Value::as_str)
2178                        .unwrap_or_default();
2179                    let content = value
2180                        .get("content")
2181                        .map(|value| match value {
2182                            Value::String(text) => text.clone(),
2183                            other => extract_text_content(Some(other)),
2184                        })
2185                        .unwrap_or_default();
2186                    let mut message = tool_message(id, content);
2187                    message.name = tool_names.get(id).cloned();
2188                    restore_grok_message_extension(&value, &mut message);
2189                    messages.push(message);
2190                }
2191                // `reasoning` contains encrypted chain-of-thought and
2192                // `backend_tool_call` is execution bookkeeping. Both survive
2193                // verbatim in raw without being replayed to another model.
2194                _ => {}
2195            }
2196        }
2197
2198        ensure_tool_results_paired(&mut messages);
2199        let imported_message_count = Some(messages.len());
2200        Ok(Session {
2201            meta,
2202            messages,
2203            subagents: Vec::new(),
2204            raw,
2205            raw_trailing_newline,
2206            imported_message_count,
2207            raw_is_verbatim: true,
2208            parse_error_lines,
2209            load_residue: Vec::new(),
2210        })
2211    }
2212
2213    fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2214        let Some(session_dir) = transcript.parent() else {
2215            return;
2216        };
2217        self.meta.session_id = session_dir
2218            .file_name()
2219            .and_then(|name| name.to_str())
2220            .map(str::to_string);
2221        self.meta.cwd = session_dir
2222            .parent()
2223            .and_then(Path::file_name)
2224            .and_then(|name| name.to_str())
2225            .and_then(percent_decode_path)
2226            .map(PathBuf::from);
2227
2228        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2229            return;
2230        };
2231        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2232            return;
2233        };
2234        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2235            self.meta.model = Some(model.to_string());
2236        }
2237        for (source, target) in [
2238            ("generated_title", "session_name"),
2239            ("created_at", "created_at"),
2240            ("updated_at", "updated_at"),
2241            ("chat_format_version", "grok_chat_format_version"),
2242        ] {
2243            if let Some(value) = summary.get(source) {
2244                self.meta.lineage.insert(
2245                    target.to_string(),
2246                    value
2247                        .as_str()
2248                        .map(str::to_string)
2249                        .unwrap_or_else(|| value.to_string()),
2250                );
2251            }
2252        }
2253    }
2254
2255    /// Load a Gemini CLI transcript from disk.
2256    pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2257        Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2258    }
2259
2260    /// Parse Gemini CLI's line-oriented session format.
2261    ///
2262    /// Gemini stores a header without a `type`, followed by `user` and
2263    /// `gemini` records. Function calls are embedded in assistant content
2264    /// parts and function responses in user content parts. Unknown records
2265    /// remain byte-exact in [`Session::raw`] instead of silently entering the
2266    /// replay conversation.
2267    pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2268        let mut meta = SessionMeta::new(SessionSource::Gemini);
2269        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2270        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2271        let mut messages = Vec::new();
2272        let mut parse_error_lines = 0usize;
2273        let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2274
2275        for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2276            let value: Value = match serde_json::from_str(line) {
2277                Ok(value) => value,
2278                Err(_) => {
2279                    parse_error_lines += 1;
2280                    continue;
2281                }
2282            };
2283            let kind = value.get("type").and_then(Value::as_str);
2284            if kind.is_none() {
2285                if meta.session_id.is_none() {
2286                    meta.session_id = value
2287                        .get("sessionId")
2288                        .and_then(Value::as_str)
2289                        .map(str::to_string);
2290                }
2291                for (source, target) in [
2292                    ("projectHash", "gemini_project_hash"),
2293                    ("startTime", "created_at"),
2294                    ("lastUpdated", "updated_at"),
2295                    ("kind", "gemini_session_kind"),
2296                ] {
2297                    if let Some(raw) = value.get(source) {
2298                        meta.lineage.insert(
2299                            target.to_string(),
2300                            raw.as_str()
2301                                .map(str::to_string)
2302                                .unwrap_or_else(|| raw.to_string()),
2303                        );
2304                    }
2305                }
2306                continue;
2307            }
2308            if kind != Some("user") && kind != Some("gemini") {
2309                continue;
2310            }
2311
2312            let timestamp = value.get("timestamp").and_then(Value::as_str);
2313            let model = value.get("model").and_then(Value::as_str);
2314            if let Some(model) = model {
2315                meta.model = Some(model.to_string());
2316            }
2317            let content = value.get("content").unwrap_or(&Value::Null);
2318            let parts = content.as_array();
2319            let text = match content {
2320                Value::String(text) => text.clone(),
2321                Value::Array(parts) => parts
2322                    .iter()
2323                    .filter_map(|part| part.get("text").and_then(Value::as_str))
2324                    .collect::<Vec<_>>()
2325                    .join(" ")
2326                    .trim()
2327                    .to_string(),
2328                _ => String::new(),
2329            };
2330
2331            if kind == Some("gemini") {
2332                let legacy_calls = parts
2333                    .into_iter()
2334                    .flatten()
2335                    .filter_map(|part| part.get("functionCall"));
2336                let native_calls = value
2337                    .get("toolCalls")
2338                    .and_then(Value::as_array)
2339                    .into_iter()
2340                    .flatten();
2341                let calls = native_calls
2342                    .chain(legacy_calls)
2343                    .enumerate()
2344                    .filter_map(|(call_index, call)| {
2345                        let name = call.get("name")?.as_str()?.to_string();
2346                        let id = call
2347                            .get("id")
2348                            .and_then(Value::as_str)
2349                            .map(str::to_string)
2350                            .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2351                        pending_by_name
2352                            .entry(name.clone())
2353                            .or_default()
2354                            .push(id.clone());
2355                        let arguments = call
2356                            .get("args")
2357                            .map(value_to_arg_string)
2358                            .unwrap_or_else(|| "{}".to_string());
2359                        Some(function_call(&id, &name, arguments))
2360                    })
2361                    .collect::<Vec<_>>();
2362                let mut message = ChatMessage {
2363                    role: Role::Assistant,
2364                    content: (!text.is_empty()).then_some(text),
2365                    content_parts: None,
2366                    tool_calls: (!calls.is_empty()).then_some(calls),
2367                    tool_call_id: None,
2368                    name: None,
2369                    metadata: Default::default(),
2370                };
2371                if let Some(timestamp) = timestamp {
2372                    message
2373                        .metadata
2374                        .insert("timestamp".into(), timestamp.into());
2375                }
2376                if let Some(model) = model {
2377                    message.metadata.insert("gemini_model".into(), model.into());
2378                }
2379                if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2380                    message
2381                        .metadata
2382                        .insert("gemini_thoughts".into(), thoughts.to_string());
2383                }
2384                restore_gemini_message_extension(&value, &mut message);
2385                if message.content.is_some() || message.tool_calls.is_some() {
2386                    messages.push(message);
2387                }
2388                continue;
2389            }
2390
2391            let mut user_parts = Vec::new();
2392            if let Some(parts) = parts {
2393                for part in parts {
2394                    if let Some(response) = part.get("functionResponse") {
2395                        push_gemini_user_parts(
2396                            &mut messages,
2397                            std::mem::take(&mut user_parts),
2398                            timestamp,
2399                            &value,
2400                        );
2401                        let name = response
2402                            .get("name")
2403                            .and_then(Value::as_str)
2404                            .unwrap_or("tool")
2405                            .to_string();
2406                        let explicit_id = response
2407                            .get("id")
2408                            .and_then(Value::as_str)
2409                            .map(str::to_string);
2410                        if let Some(id) = explicit_id.as_deref() {
2411                            if let Some(ids) = pending_by_name.get_mut(&name) {
2412                                if let Some(position) = ids.iter().position(|pending| pending == id)
2413                                {
2414                                    ids.remove(position);
2415                                }
2416                            }
2417                        }
2418                        let id = explicit_id
2419                            .or_else(|| {
2420                                pending_by_name
2421                                    .get_mut(&name)
2422                                    .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2423                            })
2424                            .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2425                        let output = response
2426                            .get("response")
2427                            .and_then(|response| response.get("output"))
2428                            .map(|output| {
2429                                output
2430                                    .as_str()
2431                                    .map(str::to_string)
2432                                    .unwrap_or_else(|| output.to_string())
2433                            })
2434                            .or_else(|| response.get("response").map(Value::to_string))
2435                            .unwrap_or_default();
2436                        let mut message = tool_message(&id, output);
2437                        message.name = Some(name);
2438                        if let Some(timestamp) = timestamp {
2439                            message
2440                                .metadata
2441                                .insert("timestamp".into(), timestamp.into());
2442                        }
2443                        restore_gemini_message_extension(&value, &mut message);
2444                        messages.push(message);
2445                        continue;
2446                    }
2447                    if let Some(text) = part.get("text").and_then(Value::as_str) {
2448                        user_parts.push(serde_json::json!({"type": "text", "text": text}));
2449                        continue;
2450                    }
2451                    if let Some(inline) = part.get("inlineData") {
2452                        let Some(data) = inline.get("data").and_then(Value::as_str) else {
2453                            continue;
2454                        };
2455                        let media_type = inline
2456                            .get("mimeType")
2457                            .and_then(Value::as_str)
2458                            .unwrap_or("application/octet-stream");
2459                        user_parts.push(serde_json::json!({
2460                            "type": "image_url",
2461                            "image_url": {"url": format!("data:{media_type};base64,{data}")},
2462                        }));
2463                    }
2464                }
2465            } else if !text.is_empty() {
2466                user_parts.push(serde_json::json!({"type": "text", "text": text}));
2467            }
2468            push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2469        }
2470
2471        ensure_tool_results_paired(&mut messages);
2472        let imported_message_count = Some(messages.len());
2473        Ok(Session {
2474            meta,
2475            messages,
2476            subagents: Vec::new(),
2477            raw,
2478            raw_trailing_newline,
2479            imported_message_count,
2480            raw_is_verbatim: true,
2481            parse_error_lines,
2482            load_residue: Vec::new(),
2483        })
2484    }
2485
2486    /// Load a Goose session-export JSON document from disk.
2487    pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2488        Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2489    }
2490
2491    /// Parse Goose's official native import/export document.
2492    ///
2493    /// Goose's durable store is SQLite, but its own
2494    /// `_goose/unstable/session/export` and `/session/import` boundary is one
2495    /// JSON object containing a `conversation` array. Unknown native content
2496    /// blocks are retained on the first canonical message in a namespaced
2497    /// portability envelope; unchanged same-format exports replay the exact
2498    /// source bytes.
2499    pub fn from_goose_str(json: &str) -> Result<Session> {
2500        let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2501        let object = document.as_object().ok_or_else(|| {
2502            Error::InvalidSession("Goose session export must be a JSON object".to_string())
2503        })?;
2504        let conversation = object
2505            .get("conversation")
2506            .and_then(Value::as_array)
2507            .ok_or_else(|| {
2508                Error::InvalidSession(
2509                    "Goose session export must contain a conversation array".to_string(),
2510                )
2511            })?;
2512
2513        let mut meta = SessionMeta::new(SessionSource::Goose);
2514        meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2515        meta.cwd = object
2516            .get("working_dir")
2517            .or_else(|| object.get("workingDir"))
2518            .and_then(Value::as_str)
2519            .map(PathBuf::from);
2520        meta.model = object
2521            .get("model_config")
2522            .or_else(|| object.get("modelConfig"))
2523            .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2524            .and_then(Value::as_str)
2525            .map(str::to_string);
2526        for (source, target) in [
2527            ("name", "session_name"),
2528            ("created_at", "created_at"),
2529            ("updated_at", "updated_at"),
2530            ("session_type", "goose_session_type"),
2531            ("goose_mode", "goose_mode"),
2532            ("provider_name", "goose_provider_name"),
2533            ("parent_session_id", "parent_session_id"),
2534        ] {
2535            if let Some(value) = object.get(source) {
2536                meta.lineage.insert(
2537                    target.to_string(),
2538                    value
2539                        .as_str()
2540                        .map(str::to_string)
2541                        .unwrap_or_else(|| value.to_string()),
2542                );
2543            }
2544        }
2545        let mut header = document.clone();
2546        if let Some(header) = header.as_object_mut() {
2547            header.remove("conversation");
2548        }
2549        meta.goose_header = Some(header.clone());
2550
2551        let mut messages = Vec::new();
2552        for (native_index, native) in conversation.iter().enumerate() {
2553            let before = messages.len();
2554            normalize_goose_message(native, native_index, &mut messages);
2555            if let Some(first) = messages.get_mut(before) {
2556                first
2557                    .metadata
2558                    .insert("goose_native_message".to_string(), native.to_string());
2559                first
2560                    .metadata
2561                    .insert("goose_native_index".to_string(), native_index.to_string());
2562                if native_index == 0 {
2563                    first
2564                        .metadata
2565                        .insert("goose_session_header".to_string(), header.to_string());
2566                }
2567                restore_grok_message_extension(native, first);
2568            }
2569            for message in messages.iter_mut().skip(before + 1) {
2570                message
2571                    .metadata
2572                    .insert("goose_native_index".to_string(), native_index.to_string());
2573            }
2574        }
2575        ensure_tool_results_paired(&mut messages);
2576
2577        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2578        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2579        let imported_message_count = Some(messages.len());
2580        Ok(Session {
2581            meta,
2582            messages,
2583            subagents: Vec::new(),
2584            raw,
2585            raw_trailing_newline,
2586            imported_message_count,
2587            raw_is_verbatim: true,
2588            parse_error_lines: 0,
2589            load_residue: Vec::new(),
2590        })
2591    }
2592
2593    /// Load one Goose session directly from its native SQLite store.
2594    ///
2595    /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2596    /// uses Goose's own public export shape, so the ordinary Goose codec is
2597    /// the single normalization boundary for both files and the live store.
2598    pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2599        Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2600    }
2601
2602    /// Bounded Goose store read for transcript UI surfaces. The inner query
2603    /// selects only the newest native rows; the outer query restores their
2604    /// chronological order. Export/continue callers deliberately use the
2605    /// unbounded public loader above.
2606    #[doc(hidden)]
2607    pub fn from_goose_sqlite_display(
2608        db_path: &Path,
2609        session_id: &str,
2610        message_limit: usize,
2611    ) -> Result<Session> {
2612        Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2613    }
2614
2615    fn from_goose_sqlite_with_limit(
2616        db_path: &Path,
2617        session_id: &str,
2618        message_limit: Option<usize>,
2619    ) -> Result<Session> {
2620        let connection = Connection::open_with_flags(
2621            db_path,
2622            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2623        )
2624        .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2625        let mut statement = connection
2626            .prepare(
2627                "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2628                    extension_data, goose_mode, provider_name, model_config_json \
2629             FROM sessions WHERE id = ?1",
2630            )
2631            .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2632        let mut document = statement
2633            .query_row([session_id], |row| {
2634                let extension_data: Option<String> = row.get(6)?;
2635                let model_config: Option<String> = row.get(9)?;
2636                Ok(serde_json::json!({
2637                    "id": row.get::<_, String>(0)?,
2638                    "working_dir": row.get::<_, String>(2)?,
2639                    "name": row.get::<_, String>(1)?,
2640                    "user_set_name": false,
2641                    "session_type": row.get::<_, String>(5)?,
2642                    "created_at": row.get::<_, String>(3)?,
2643                    "updated_at": row.get::<_, String>(4)?,
2644                    "extension_data": extension_data
2645                        .as_deref()
2646                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2647                        .unwrap_or_else(|| serde_json::json!({})),
2648                    "usage": {},
2649                    "accumulated_usage": {},
2650                    "accumulated_cost": Value::Null,
2651                    "schedule_id": Value::Null,
2652                    "recipe": Value::Null,
2653                    "user_recipe_values": Value::Null,
2654                    "conversation": [],
2655                    "message_count": 0,
2656                    "last_message_at": Value::Null,
2657                    "provider_name": row.get::<_, Option<String>>(8)?,
2658                    "model_config": model_config
2659                        .as_deref()
2660                        .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2661                    "goose_mode": row.get::<_, String>(7)?,
2662                    "archived_at": Value::Null,
2663                    "project_id": Value::Null,
2664                    "parent_session_id": Value::Null,
2665                    "last_message_snippet": Value::Null,
2666                }))
2667            })
2668            .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2669
2670        let message_query = message_limit.map_or_else(
2671            || {
2672                "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2673                 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2674                    .to_string()
2675            },
2676            |limit| {
2677                format!(
2678                    "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2679                     FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2680                                  created_timestamp, metadata_json \
2681                           FROM messages WHERE session_id = ?1 \
2682                           ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2683                     ORDER BY created_timestamp, native_row_id"
2684                )
2685            },
2686        );
2687        let mut message_statement = connection
2688            .prepare(&message_query)
2689            .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2690        let rows = message_statement
2691            .query_map([session_id], |row| {
2692                let content: String = row.get(2)?;
2693                let metadata: Option<String> = row.get(4)?;
2694                Ok(serde_json::json!({
2695                    "id": row.get::<_, Option<String>>(0)?,
2696                    "role": row.get::<_, String>(1)?,
2697                    "created": row.get::<_, i64>(3)?,
2698                    "content": serde_json::from_str::<Value>(&content)
2699                        .unwrap_or_else(|_| Value::Array(Vec::new())),
2700                    "metadata": metadata
2701                        .as_deref()
2702                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2703                        .unwrap_or_else(|| serde_json::json!({
2704                            "userVisible": true,
2705                            "agentVisible": true
2706                        })),
2707                }))
2708            })
2709            .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2710        let conversation = rows
2711            .collect::<std::result::Result<Vec<_>, _>>()
2712            .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2713        document["message_count"] = Value::from(conversation.len());
2714        document["conversation"] = Value::Array(conversation);
2715        let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2716        let mut session = Self::from_goose_str(&json)?;
2717        // SQLite was reconstructed through values, not captured byte-for-byte.
2718        session.raw_is_verbatim = false;
2719        Ok(session)
2720    }
2721
2722    /// Load an OpenCode session from a file — either read surface, see
2723    /// [`Self::from_opencode_str`].
2724    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2725        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2726    }
2727
2728    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2729    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2730    /// most-recently-updated top-level session, see
2731    /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2732    /// envelope form [`Self::from_opencode_str`] already parses for the
2733    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2734    /// discipline, S1 tool-output masking, …) is shared code, not
2735    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2736    /// for the envelope-construction rules this follows (all-columns rule,
2737    /// raw `revert` column carried verbatim).
2738    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2739        let conn = opencode_sqlite_open(db_path)?;
2740        let id = match session_id {
2741            Some(id) => id.to_string(),
2742            None => opencode_sqlite_primary_session_id(&conn)?,
2743        };
2744        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2745        let mut text = lines.join("\n");
2746        text.push('\n');
2747        let mut session = Self::from_opencode_str(&text)?;
2748        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2749        // not the original source bytes (a binary `.db` file has no
2750        // "verbatim" line-oriented form to begin with). `from_opencode_str`
2751        // defaults `raw_is_verbatim` to `true` because for its OTHER two
2752        // callers (an actual envelope-form file's own text, an actual
2753        // export-document's text) that really is the source. It is NEVER
2754        // true for this diagonal — mirrors the export-document fix just
2755        // above for the same reason (`from_opencode_export_doc`, `false`).
2756        // `convert opencode.db --to opencode` must not claim byte-identical.
2757        session.raw_is_verbatim = false;
2758        Ok(session)
2759    }
2760
2761    /// Parse an OpenCode session from either of its two frozen **read
2762    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2763    /// `opencode-fields.md`):
2764    ///
2765    /// - the **envelope form**: each line is
2766    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
2767    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
2768    ///   generations;
2769    /// - the **export-document form**: a single pretty-printed JSON document
2770    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2771    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
2772    ///   what the OpenCode writer emits.
2773    ///
2774    /// Both forms are parsed into the same `(session_info, side_records,
2775    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2776    /// `opencode_session_from_records` — so the same underlying records
2777    /// produce identical `messages` regardless of which surface carried
2778    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2779    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2780    /// exercises): previously this function parsed the envelope form only
2781    /// and silently returned an empty-but-`Ok` `Session` for an export
2782    /// document — the confirmed footgun this now closes.
2783    ///
2784    /// Record classification (envelope form) is driven by the envelope
2785    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2786    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2787    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2788    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2789    /// every column, `data` and non-`data` alike — e.g. the `session` row's
2790    /// `revert` column under the V2 `Revert.State` schema, whose extra
2791    /// `files` field the CLI's own row→V1 reconstruction drops; the
2792    /// envelope's `raw` capture keeps that raw column value regardless of
2793    /// what this loader's canonicalization understands).
2794    ///
2795    /// Mapping to canonical `messages` (§2.1, shared by both forms via
2796    /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2797    /// text parts → `content`; a `User` `file` part whose `mime` is an image
2798    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2799    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2800    /// `ToolCall`, and the SAME part's `state.completed.output` /
2801    /// `state.error.error` → a paired `Tool` message split by `callID`
2802    /// (opencode keeps call+result on one record; this loader splits it
2803    /// into the two OpenAI-shape messages the other loaders already
2804    /// produce).
2805    ///
2806    /// **S1 (`time.compacted`):** when a `tool` part's
2807    /// `state.completed.time.compacted` is set, the emitted `Tool`
2808    /// message's `content` is the placeholder
2809    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2810    /// own `toModelMessage` replays — while the REAL output survives in
2811    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2812    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2813    /// it is reversible, never actually lost.
2814    ///
2815    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2816    /// every message strictly before that message id
2817    /// `metadata["compacted_out"]="true"` (honored uniformly by
2818    /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2819    /// message, which opencode itself hoists in FRONT of the retained tail
2820    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2821    /// regardless of its position, mirroring pi's identical exemption for
2822    /// its own compaction/branch-summary entries.
2823    ///
2824    /// **Unknown part `type` or unknown `tool.state.status`:** never
2825    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2826    /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2827    /// is what turns that into a visible coverage failure rather than a
2828    /// silent drop.
2829    ///
2830    /// **Export-document `raw`:** an export document is a single
2831    /// pretty-printed JSON value with no per-line envelope structure of its
2832    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2833    /// envelope line per `session`/`message`/`part` record found in the
2834    /// document, in the exact `{"key":[...],"value":...}` shape the native
2835    /// envelope form uses — so every native/T1-value-tier path
2836    /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2837    /// splice/direct-write writers) stays consistent regardless of which
2838    /// read surface produced this `Session`.
2839    ///
2840    /// **Malformed input:** input that reaches this function non-empty but
2841    /// yields zero session/message/part records under EITHER form returns a
2842    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2843    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2844    /// input must not silently succeed with an empty session). A
2845    /// legitimately-empty session — a real `session` record with zero
2846    /// messages, or a valid export document with an empty `messages` array
2847    /// — is not an error.
2848    pub fn from_opencode_str(text: &str) -> Result<Session> {
2849        let trimmed = text.trim();
2850
2851        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2852        // own precedence: try the whole-text parse before the per-line
2853        // envelope loop below, since a pretty-printed multi-line document
2854        // has no individually-valid-JSON lines for that loop to match.
2855        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2856            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2857            {
2858                return Self::from_opencode_export_doc(&doc);
2859            }
2860        }
2861
2862        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2863        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2864        // blank-skipping PARSE walk just below, which keeps skipping
2865        // blank/whitespace-only lines when it looks for envelope records.
2866        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2867        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2868        let mut session_info: Option<Value> = None;
2869        let mut side_records: Vec<Value> = Vec::new();
2870        let mut msgs: Vec<OcMsg> = Vec::new();
2871        let mut msg_index: HashMap<String, usize> = HashMap::new();
2872        // PARITY-15: see `from_claude_code_str`'s identical counter — only
2873        // a genuinely malformed line (fails to deserialize as JSON at all),
2874        // not a well-formed envelope this loader simply doesn't recognize.
2875        let mut parse_error_lines = 0usize;
2876
2877        for line in non_empty_lines(text) {
2878            let Ok(env) = serde_json::from_str::<Value>(line) else {
2879                parse_error_lines += 1;
2880                continue; // malformed line — raw-only, exactly like the other loaders
2881            };
2882            let Some(key) = env.get("key").and_then(Value::as_array) else {
2883                continue; // not an envelope record — raw-only
2884            };
2885            let value = env.get("value").cloned().unwrap_or(Value::Null);
2886            match key.first().and_then(Value::as_str) {
2887                Some("session") => session_info = Some(value),
2888                Some("message") => {
2889                    let Some(id) = value.get("id").and_then(Value::as_str) else {
2890                        continue;
2891                    };
2892                    let time_created = value
2893                        .get("time")
2894                        .and_then(|t| t.get("created"))
2895                        .and_then(Value::as_i64)
2896                        .unwrap_or(0);
2897                    msg_index.insert(id.to_string(), msgs.len());
2898                    msgs.push(OcMsg {
2899                        id: id.to_string(),
2900                        time_created,
2901                        value,
2902                        parts: Vec::new(),
2903                    });
2904                }
2905                Some("part") => {
2906                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2907                        if let Some(&idx) = msg_index.get(msg_id) {
2908                            msgs[idx].parts.push(value);
2909                        }
2910                        // A part whose message wasn't captured (out-of-order
2911                        // envelope) — still fully present in `raw`, just not
2912                        // attached to a canonical message.
2913                    }
2914                }
2915                Some("session_diff") | Some("todo") => {
2916                    side_records.push(serde_json::json!({"key": key, "value": value}));
2917                }
2918                _ => {} // unrecognized top-level key — raw-only
2919            }
2920        }
2921
2922        opencode_guard_against_silent_empty(
2923            !trimmed.is_empty(),
2924            &session_info,
2925            &msgs,
2926            &side_records,
2927        )?;
2928        opencode_session_from_records(
2929            session_info,
2930            side_records,
2931            msgs,
2932            raw,
2933            raw_trailing_newline,
2934            // Envelope form: `raw` is split directly out of the source text
2935            // (strict-verbatim, IX-1) — genuinely reproduces the original
2936            // bytes on replay.
2937            true,
2938            parse_error_lines,
2939        )
2940    }
2941
2942    /// The **export-document** read surface of [`Self::from_opencode_str`]
2943    /// — see that function's doc comment for the shared canonicalization
2944    /// and the `raw` re-synthesis this performs. `doc` is already known to
2945    /// have the `{info, messages:[...]}` shape (the caller checks this,
2946    /// matching `detect_source`'s own S9a check) before calling this.
2947    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2948        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2949        let messages_arr = doc
2950            .get("messages")
2951            .and_then(Value::as_array)
2952            .cloned()
2953            .unwrap_or_default();
2954
2955        let session_id = session_info
2956            .as_ref()
2957            .and_then(|si| si.get("id"))
2958            .and_then(Value::as_str)
2959            .unwrap_or("ses_unknown")
2960            .to_string();
2961        let project_id = session_info
2962            .as_ref()
2963            .and_then(|si| si.get("projectID"))
2964            .and_then(Value::as_str)
2965            .unwrap_or("global")
2966            .to_string();
2967
2968        // Re-synthesize one envelope line per record — see the doc comment
2969        // on `from_opencode_str` ("Export-document `raw`").
2970        let mut raw: Vec<String> = Vec::new();
2971        if let Some(si) = &session_info {
2972            raw.push(
2973                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2974                    .to_string(),
2975            );
2976        }
2977
2978        let mut msgs: Vec<OcMsg> = Vec::new();
2979        for entry in &messages_arr {
2980            let Some(info) = entry.get("info") else {
2981                continue; // malformed message entry — no clean home, raw-only
2982            };
2983            let Some(id) = info.get("id").and_then(Value::as_str) else {
2984                continue;
2985            };
2986            let time_created = info
2987                .get("time")
2988                .and_then(|t| t.get("created"))
2989                .and_then(Value::as_i64)
2990                .unwrap_or(0);
2991            let parts: Vec<Value> = entry
2992                .get("parts")
2993                .and_then(Value::as_array)
2994                .cloned()
2995                .unwrap_or_default();
2996
2997            raw.push(
2998                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2999            );
3000            for p in &parts {
3001                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
3002                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
3003            }
3004
3005            msgs.push(OcMsg {
3006                id: id.to_string(),
3007                time_created,
3008                value: info.clone(),
3009                parts,
3010            });
3011        }
3012
3013        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
3014        opencode_session_from_records(
3015            session_info,
3016            Vec::new(),
3017            msgs,
3018            raw,
3019            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
3020            // line re-derived per record, no real per-line source bytes to
3021            // measure) — matches the historical always-newline-terminated
3022            // behavior; see `Session::raw_trailing_newline`'s doc comment.
3023            true,
3024            // Export-document form: `raw` above is RE-SYNTHESIZED, one
3025            // envelope line derived per record — not the original document's
3026            // bytes (see this function's doc comment). `convert`'s
3027            // byte-identical claim must not fire on this diagonal.
3028            false,
3029            // PARITY-15: a pretty-printed export document is parsed WHOLE
3030            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
3031            // there's no per-line parse-loss concept here; a malformed
3032            // document fails that top-level parse and never reaches this
3033            // function at all.
3034            0,
3035        )
3036    }
3037
3038    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
3039    /// core.session(tree-addressable transcript)"): materialize this
3040    /// session's linear [`Self::messages`] into a native in-place
3041    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
3042    /// FIRST time it wants to run a tree operation (rewind/branch/label)
3043    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
3044    /// synthesized node (see
3045    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
3046    /// why a single timestamp is used: the source linear messages carry no
3047    /// per-turn timestamp of their own here).
3048    ///
3049    /// This does not mutate `self` or persist anything — see
3050    /// the composition layer's session-store tree writer for persistence, and
3051    /// [`Self::apply_session_tree`] for the inverse bridge.
3052    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
3053        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
3054    }
3055
3056    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
3057    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
3058    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3059    /// existing linear consumer — the agent loop, exporters — working
3060    /// unchanged after a tree operation runs). Nothing else on `self`
3061    /// (`meta`, `raw`, ...) is touched.
3062    ///
3063    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3064    /// `Err` rather than applying anything — a structurally-corrupt tree
3065    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3066    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3067    /// `self` is left untouched on `Err` (the assignment only happens after
3068    /// the projection has already succeeded).
3069    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3070        self.messages = tree.linear_projection()?;
3071        Ok(())
3072    }
3073}
3074
3075/// One opencode `message` record plus its `part` children, gathered from
3076/// EITHER read surface (envelope-form records or export-document
3077/// `{info, parts}` entries) before the shared per-record canonicalization
3078/// in [`opencode_session_from_records`].
3079struct OcMsg {
3080    id: String,
3081    time_created: i64,
3082    value: Value,
3083    parts: Vec<Value>,
3084}
3085
3086const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3087const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3088const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3089
3090/// Guard against the confirmed footgun: input that reached
3091/// [`Session::from_opencode_str`] non-empty but produced no
3092/// session/message/part record under either read surface returns `Err`
3093/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3094/// (a real session record with zero messages, or a valid empty `messages`
3095/// array) is not an error — only genuinely unparseable content is.
3096fn opencode_guard_against_silent_empty(
3097    non_empty_input: bool,
3098    session_info: &Option<Value>,
3099    msgs: &[OcMsg],
3100    side_records: &[Value],
3101) -> Result<()> {
3102    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3103        || !msgs.is_empty()
3104        || !side_records.is_empty();
3105    if non_empty_input && !has_any_record {
3106        return Err(crate::Error::Other(
3107            "opencode input was recognized as an OpenCode source (envelope or \
3108             export-document form) but no session/message/part record could be parsed from \
3109             it — refusing to silently return an empty session"
3110                .to_string(),
3111        ));
3112    }
3113    Ok(())
3114}
3115
3116/// The shared per-record canonicalization for BOTH of
3117/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3118/// export-document form): frozen ordering, `SessionMeta` capture, the
3119/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3120/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3121/// same underlying `(session_info, side_records, msgs)` regardless of which
3122/// surface produced them, this produces byte-for-byte identical `messages`
3123/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3124fn opencode_session_from_records(
3125    session_info: Option<Value>,
3126    side_records: Vec<Value>,
3127    mut msgs: Vec<OcMsg>,
3128    raw: Vec<String>,
3129    raw_trailing_newline: bool,
3130    raw_is_verbatim: bool,
3131    parse_error_lines: usize,
3132) -> Result<Session> {
3133    let mut meta = SessionMeta::new(SessionSource::OpenCode);
3134
3135    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3136    // each message id to its PRE-sort position — used only to resolve a
3137    // `tail_start_id` reference in the compaction-boundary pass further
3138    // down. In every real opencode session (either surface) records
3139    // already arrive/are listed in creation order, so pre- and post-sort
3140    // positions coincide; this mirrors the original envelope-only
3141    // implementation's behavior exactly (not a new invariant introduced by
3142    // sharing this code across both surfaces).
3143    let msg_index: HashMap<String, usize> = msgs
3144        .iter()
3145        .enumerate()
3146        .map(|(i, m)| (m.id.clone(), i))
3147        .collect();
3148
3149    // Frozen order (§1.2): messages by (time.created, id); each
3150    // message's parts by id.
3151    msgs.sort_by(|a, b| {
3152        a.time_created
3153            .cmp(&b.time_created)
3154            .then_with(|| a.id.cmp(&b.id))
3155    });
3156    for m in &mut msgs {
3157        m.parts.sort_by(|a, b| {
3158            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3159            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3160            ai.cmp(bi)
3161        });
3162    }
3163
3164    meta.opencode_headers
3165        .push(session_info.clone().unwrap_or(Value::Null));
3166    meta.opencode_headers.extend(side_records);
3167    if let Some(si) = &session_info {
3168        capture_opencode_session_info(si, &mut meta)?;
3169    }
3170
3171    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3172    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3173    // active path in opencode's own linear message list, so no branch
3174    // walk is needed the way pi's tree requires).
3175    let mut tail_start_pos: Option<usize> = None;
3176    for m in &msgs {
3177        for p in &m.parts {
3178            if p.get("type").and_then(Value::as_str) == Some("compaction") {
3179                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3180                    if let Some(&tp) = msg_index.get(t) {
3181                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3182                    }
3183                }
3184            }
3185        }
3186    }
3187
3188    let mut messages = Vec::new();
3189    let mut first_system_seen = false;
3190    for (pos, m) in msgs.iter().enumerate() {
3191        let before = messages.len();
3192        match m.value.get("role").and_then(Value::as_str) {
3193            // B4: a `User` message that's actually
3194            // `append_synthesized_opencode_messages`'s own re-materialized
3195            // Claude `system` record (one `synthetic: true` text part
3196            // carrying the supercode marker key — see
3197            // `opencode_claude_system_subtype`'s doc comment) restores
3198            // `Role::System`, not a genuine user turn.
3199            Some("user") => match opencode_claude_system_subtype(&m.parts) {
3200                Some(subtype) => {
3201                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3202                }
3203                None => push_opencode_user(
3204                    &m.value,
3205                    &m.parts,
3206                    &mut messages,
3207                    &mut meta,
3208                    &mut first_system_seen,
3209                ),
3210            },
3211            Some("assistant") => {
3212                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3213            }
3214            // Unrecognized/missing role — raw-only survival;
3215            // `audit::Corpus::OpenCode` scores this as Unmodeled.
3216            _ => {}
3217        }
3218        if let Some(original_position) = m
3219            .value
3220            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3221            .and_then(Value::as_u64)
3222        {
3223            if let Some(message) = messages[before..]
3224                .iter_mut()
3225                .find(|message| message.role != Role::Tool)
3226            {
3227                message.metadata.insert(
3228                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3229                    original_position.to_string(),
3230                );
3231            }
3232        }
3233        for msg in &mut messages[before..] {
3234            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3235            if !is_summary {
3236                if let Some(tsp) = tail_start_pos {
3237                    if pos < tsp {
3238                        msg.metadata
3239                            .insert("compacted_out".to_string(), "true".to_string());
3240                    }
3241                }
3242            }
3243        }
3244    }
3245
3246    let marked_slots = messages
3247        .iter()
3248        .enumerate()
3249        .filter_map(|(index, message)| {
3250            message
3251                .metadata
3252                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3253                .then_some(index)
3254        })
3255        .collect::<Vec<_>>();
3256    if !marked_slots.is_empty() {
3257        // A spliced OpenCode export can contain an unmarked native prefix
3258        // followed by a marked synthesized tail. Reorder only among the
3259        // marked slots so the tail never jumps in front of its raw prefix.
3260        let mut marked_messages = marked_slots
3261            .iter()
3262            .map(|index| messages[*index].clone())
3263            .collect::<Vec<_>>();
3264        marked_messages.sort_by_key(|message| {
3265            message
3266                .metadata
3267                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3268                .and_then(|position| position.parse::<usize>().ok())
3269                .unwrap_or(usize::MAX)
3270        });
3271        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3272            messages[slot] = message;
3273        }
3274        for message in &mut messages {
3275            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3276        }
3277    }
3278    ensure_tool_results_paired(&mut messages);
3279    let imported_message_count = Some(messages.len());
3280    Ok(Session {
3281        meta,
3282        messages,
3283        subagents: Vec::new(),
3284        raw,
3285        raw_trailing_newline,
3286        imported_message_count,
3287        raw_is_verbatim,
3288        parse_error_lines,
3289        load_residue: Vec::new(),
3290    })
3291}
3292
3293/// Resolve each opencode subagent (`task`) child session's
3294/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3295/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3296/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3297/// `opencode-fields.md` `task.ts:145,171-176`).
3298///
3299/// Nesting itself needs no opencode-specific pass:
3300/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3301/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3302/// so the existing generic [`Session::reconstruct_tree`] nests these
3303/// sessions correctly on its own. Call this FIRST — it only reads
3304/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3305/// the same `Vec` to `reconstruct_tree`.
3306pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3307    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3308    for i in 0..sessions.len() {
3309        let child_id = sessions[i].meta.session_id.clone();
3310        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3311        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3312            continue;
3313        };
3314        let Some(parent_idx) = ids
3315            .iter()
3316            .position(|id| id.as_deref() == Some(parent_id.as_str()))
3317        else {
3318            continue;
3319        };
3320        for m in &sessions[parent_idx].messages {
3321            for (k, v) in &m.metadata {
3322                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3323                    if v == &child_id {
3324                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3325                    }
3326                }
3327            }
3328        }
3329    }
3330}
3331
3332/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3333///
3334/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3335/// structure but are NOT guaranteed to be well-formed in raw file order: async
3336/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3337/// line BEFORE the assistant `tool_use` line that owns it, even though the
3338/// parent/child tree itself is fine. The active-branch projection restores
3339/// parent-before-child order, but a result can still trail a later assistant
3340/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3341/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3342/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3343///
3344/// This reorders `messages` so every OWNED `Role::Tool` result (its
3345/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3346/// message anywhere in the list) sits immediately after the `Role::Assistant`
3347/// message that owns it, while leaving every other message's relative order
3348/// untouched. Orphan tool results — no matching call anywhere in the list —
3349/// are left in their ORIGINAL position, untouched; they are never moved. It
3350/// is a pure reorder: same message count, same multiset of messages, in/out.
3351///
3352/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3353/// — each appears exactly once as a call and once as its result — so a
3354/// simple id -> owning-assistant map is sufficient; no special-casing is
3355/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3356/// already pushes those inline with their own distinct ids.
3357///
3358/// Results whose matching call is missing entirely (no owner found) are left
3359/// in place untouched — `ensure_tool_results_paired` (which runs right after
3360/// this) is responsible for synthesizing a placeholder result for any call
3361/// that ends up unanswered; this pass never drops or fabricates anything.
3362fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3363    // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3364    //    some assistant message anywhere in the list — and the position of
3365    //    that owning assistant. Owned as `String` (not borrowed) so this map
3366    //    can outlive the later `messages.drain(..)`.
3367    let mut owner_positions: HashMap<String, usize> = HashMap::new();
3368    for (index, m) in messages.iter().enumerate() {
3369        if m.role == Role::Assistant {
3370            for c in m.tool_calls() {
3371                if !c.id.is_empty() {
3372                    owner_positions.entry(c.id.clone()).or_insert(index);
3373                }
3374            }
3375        }
3376    }
3377
3378    // Fast, cheap detection of "nothing to do": every owned result must be
3379    // in the contiguous tool-result block immediately following its owning
3380    // assistant. Checking only result-before-owner inversions is insufficient
3381    // after Claude's active-branch projection: that projection can put the
3382    // owner first while leaving its result behind a later assistant turn.
3383    // Mere orphans never set this flag. A canonical session returns with
3384    // `messages` byte-for-byte unchanged, mirroring
3385    // `ensure_tool_results_paired`'s own no-op guard.
3386    let mut contiguous_owner = None;
3387    let needs_reorder =
3388        messages
3389            .iter()
3390            .enumerate()
3391            .any(|(message_index, message)| match message.role {
3392                Role::Assistant => {
3393                    contiguous_owner = Some(message_index);
3394                    false
3395                }
3396                Role::Tool => match message
3397                    .tool_call_id
3398                    .as_deref()
3399                    .and_then(|id| owner_positions.get(id))
3400                    .copied()
3401                {
3402                    Some(owner) => Some(owner) != contiguous_owner,
3403                    None => {
3404                        // An orphan or unlinked tool message interrupts the
3405                        // owner's contiguous result block but never moves by
3406                        // itself.
3407                        contiguous_owner = None;
3408                        false
3409                    }
3410                },
3411                _ => {
3412                    contiguous_owner = None;
3413                    false
3414                }
3415            });
3416    if !needs_reorder {
3417        return;
3418    }
3419
3420    // 1. Second pass: route messages into the "spine" (everything that stays
3421    //    at its own position — non-tool messages AND orphan tool results)
3422    //    versus owned tool results (pulled out, to be reattached right after
3423    //    their owner). Record, for each spine index that's an assistant, the
3424    //    set of tool_call_ids it owns.
3425    let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3426    let mut call_owner: HashMap<String, usize> = HashMap::new();
3427    // Buffer of (original_position, message) for every OWNED tool result,
3428    // built alongside the spine; a result can reference a call emitted later
3429    // in file order, so owner spine-index is resolved in a later step once
3430    // `call_owner` is complete.
3431    let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3432
3433    let drained: Vec<ChatMessage> = std::mem::take(messages);
3434    for (orig_pos, msg) in drained.into_iter().enumerate() {
3435        if msg.role == Role::Tool {
3436            let is_owned = msg
3437                .tool_call_id
3438                .as_deref()
3439                .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3440                .unwrap_or(false);
3441            if is_owned {
3442                owned_results.push((orig_pos, msg));
3443                continue;
3444            }
3445            // Orphan: no matching call anywhere. Treat exactly like a
3446            // non-tool message for placement — it joins the spine at its
3447            // current position and is never moved.
3448            spine.push(msg);
3449            continue;
3450        }
3451        if msg.role == Role::Assistant {
3452            let spine_idx = spine.len();
3453            for c in msg.tool_calls() {
3454                if !c.id.is_empty() {
3455                    call_owner.entry(c.id.clone()).or_insert(spine_idx);
3456                }
3457            }
3458        }
3459        spine.push(msg);
3460    }
3461
3462    // 2. Resolve each owned result's owner spine-index now that `call_owner`
3463    //    is complete, then bucket results by owner spine-index. Every result
3464    //    here was routed as "owned" because its id was found in `owned_ids`,
3465    //    which was built from the exact same `tool_calls()` scan that
3466    //    populates `call_owner` below, so the lookup is guaranteed to hit.
3467    let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3468    for (orig_pos, msg) in owned_results.into_iter() {
3469        let id = msg
3470            .tool_call_id
3471            .as_deref()
3472            .filter(|id| !id.is_empty())
3473            .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3474        let idx = *call_owner
3475            .get(id)
3476            .expect("owned id must have an owning assistant in call_owner");
3477        buckets.entry(idx).or_default().push((orig_pos, msg));
3478    }
3479    // Keep each bucket's results in their original relative file order.
3480    for v in buckets.values_mut() {
3481        v.sort_by_key(|(pos, _)| *pos);
3482    }
3483
3484    // 3. Rebuild: emit each spine message (which now includes orphans at
3485    //    their original position, untouched) in order; immediately after
3486    //    emitting an assistant message that owns one or more tool results,
3487    //    emit its owned results, in original relative order.
3488    let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3489    for (idx, msg) in spine.into_iter().enumerate() {
3490        out.push(msg);
3491        if let Some(results) = buckets.remove(&idx) {
3492            for (_, r) in results {
3493                out.push(r);
3494            }
3495        }
3496    }
3497    *messages = out;
3498}
3499
3500/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3501/// result. Interrupted/aborted turns leave a tool call with no result, which
3502/// many chat-completions endpoints reject when the conversation is replayed.
3503/// We insert a synthetic placeholder result immediately after the assistant
3504/// turn so the transcript stays valid for continuation. (Orphan results — a
3505/// tool message with no preceding call — do not occur in practice and are left
3506/// untouched.)
3507fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3508    let answered: HashSet<String> = messages
3509        .iter()
3510        .filter(|m| m.role == Role::Tool)
3511        .filter_map(|m| m.tool_call_id.clone())
3512        .collect();
3513
3514    // Nothing missing? Leave the vector byte-for-byte unchanged.
3515    let any_missing = messages.iter().any(|m| {
3516        m.tool_calls()
3517            .iter()
3518            .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3519    });
3520    if !any_missing {
3521        return;
3522    }
3523
3524    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3525    for msg in messages.drain(..) {
3526        let synth: Vec<ChatMessage> = msg
3527            .tool_calls()
3528            .iter()
3529            .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3530            .map(|c| {
3531                let mut m = ChatMessage::tool_result(
3532                    c.id.clone(),
3533                    c.function.name.clone(),
3534                    "[no tool result recorded — turn interrupted]".to_string(),
3535                );
3536                // TR-10: an interrupted call never executed to completion —
3537                // never a candidate for `ReductionKind::ToolInputElided`
3538                // (the "still-pending calls are never input-elided"
3539                // boundary).
3540                crate::mark_tool_error(&mut m);
3541                m
3542            })
3543            .collect();
3544        out.push(msg);
3545        out.extend(synth);
3546    }
3547    *messages = out;
3548}
3549
3550/// Whether `msg` is excluded from every replay/export path — the frozen
3551/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3552/// a message marked `compacted_out` (pre-compaction history a source harness
3553/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3554/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3555/// format, not just the one that produced the marker — so a translated
3556/// compacted session replays the same sliced context the source harness
3557/// would, instead of double-including history plus its own summary.
3558fn is_replay_excluded(msg: &ChatMessage) -> bool {
3559    msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3560        || msg
3561            .metadata
3562            .get("pi_exclude_from_context")
3563            .map(String::as_str)
3564            == Some("true")
3565}
3566
3567// ---- detection ------------------------------------------------------------
3568
3569fn detect_source(text: &str) -> Option<SessionSource> {
3570    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3571    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3572    // pretty-printed, MULTI-LINE JSON document, unlike every other format
3573    // this crate reads. It cannot be recognized by the per-line loop below
3574    // (no individual line of a pretty-printed document is itself valid
3575    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3576    // attempt: a real JSONL file (many newline-separated objects) fails this
3577    // parse immediately (trailing-data error) and falls through unaffected.
3578    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3579        if v.get("conversation").and_then(Value::as_array).is_some()
3580            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3581        {
3582            return Some(SessionSource::Goose);
3583        }
3584        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3585            return Some(SessionSource::OpenCode);
3586        }
3587    }
3588    for line in non_empty_lines(text) {
3589        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3590        // than abandoning detection — the loaders themselves skip bad lines, so
3591        // bailing here would silently misroute an otherwise-valid Codex file.
3592        let Ok(v) = serde_json::from_str::<Value>(line) else {
3593            continue;
3594        };
3595        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3596        // one record per line — the synthesized raw-capture unit for the
3597        // JSON-tree/SQLite generations alike. No other format's lines carry
3598        // both a top-level `key` ARRAY and a `value` field, so this is
3599        // unambiguous against Codex/Pi/Claude Code.
3600        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3601            return Some(SessionSource::OpenCode);
3602        }
3603        // Codex envelopes always carry a `payload`; Claude Code lines never do.
3604        if v.get("payload").is_some() {
3605            return Some(SessionSource::Codex);
3606        }
3607        // Gemini CLI starts with an untyped session header. Its project hash
3608        // and timestamps distinguish it from Claude Code records that also
3609        // carry `sessionId`.
3610        if v.get("sessionId").and_then(Value::as_str).is_some()
3611            && (v.get("projectHash").is_some()
3612                || v.get("startTime").is_some()
3613                || v.get("lastUpdated").is_some())
3614            && v.get("type").is_none()
3615        {
3616            return Some(SessionSource::Gemini);
3617        }
3618        // Grok's resumable `chat_history.jsonl` stores the role/type and
3619        // content directly on each record. Claude Code uses a nested
3620        // `message` envelope for the overlapping `user`/`assistant` tags.
3621        let tag = v.get("type").and_then(Value::as_str);
3622        if tag == Some("gemini") && v.get("content").is_some() {
3623            return Some(SessionSource::Gemini);
3624        }
3625        if v.get("message").is_none()
3626            && v.get("uuid").is_none()
3627            && v.get("sessionId").is_none()
3628            && matches!(
3629                tag,
3630                Some(
3631                    "system"
3632                        | "user"
3633                        | "assistant"
3634                        | "tool_result"
3635                        | "reasoning"
3636                        | "backend_tool_call"
3637                )
3638            )
3639            && (v.get("content").is_some()
3640                || v.get("tool_calls").is_some()
3641                || v.get("tool_call_id").is_some()
3642                || v.get("encrypted_content").is_some()
3643                || v.get("kind").is_some())
3644        {
3645            return Some(SessionSource::Grok);
3646        }
3647        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3648        // (the session id) with no `message`/`uuid` — Claude Code's own
3649        // `type`-bearing lines always carry one or the other, never a
3650        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3651        // §1).
3652        if v.get("type").and_then(Value::as_str) == Some("session")
3653            && v.get("id").and_then(Value::as_str).is_some()
3654            && v.get("message").is_none()
3655            && v.get("uuid").is_none()
3656        {
3657            return Some(SessionSource::Pi);
3658        }
3659        if v.get("type").is_some() || v.get("message").is_some() {
3660            return Some(SessionSource::ClaudeCode);
3661        }
3662    }
3663    None
3664}
3665
3666/// Which on-disk OpenCode storage surface is present under a data root
3667/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3668/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3669/// generation A. This is a **filesystem classifier only** — it answers
3670/// "which generation is this?" for a corpus-discovery tool; it does not
3671/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3672/// for the envelope form any of these three surfaces synthesizes into, and
3673/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3674/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3675/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3676/// round-trips via the JSON store per upstream's own behavior even on a
3677/// SQLite install, so nothing is silently lost by not reading the legacy
3678/// trees directly).
3679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3680pub enum OpenCodeStorageSurface {
3681    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3682    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3683    /// [`opencode_sqlite_corpus_envelope_text`].
3684    Sqlite,
3685    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3686    /// marker file `storage/migration`.
3687    JsonTreeB,
3688    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3689    JsonTreeA,
3690}
3691
3692/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3693/// storage surface present, per the discovery rules frozen in
3694/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3695/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3696/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3697/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3698/// `project/` subtree. Returns `None` if nothing is found.
3699pub fn detect_opencode_storage_surface(
3700    data_root: &Path,
3701) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3702    if let Ok(p) = std::env::var("OPENCODE_DB") {
3703        let pb = PathBuf::from(p);
3704        if pb.is_file() {
3705            return Some((OpenCodeStorageSurface::Sqlite, pb));
3706        }
3707    }
3708    if let Ok(entries) = std::fs::read_dir(data_root) {
3709        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3710        // NOT deterministic — a store with both a default-channel
3711        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3712        // are legal, e.g. after switching install channels) previously
3713        // returned "whichever the OS happened to list first", which could
3714        // differ between two `inspect`/`audit`/`convert` runs against the
3715        // exact same directory. Collect every `opencode*.db` candidate and
3716        // pick deterministically: the exact `opencode.db` name wins if
3717        // present (the default/most-common channel); otherwise the
3718        // lexicographically-smallest match, so repeated runs always agree.
3719        let mut candidates: Vec<PathBuf> = entries
3720            .flatten()
3721            .map(|entry| entry.path())
3722            .filter(|p| {
3723                p.file_name()
3724                    .and_then(|n| n.to_str())
3725                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3726            })
3727            .collect();
3728        candidates.sort();
3729        if let Some(exact) = candidates
3730            .iter()
3731            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3732        {
3733            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3734        }
3735        if let Some(first) = candidates.into_iter().next() {
3736            return Some((OpenCodeStorageSurface::Sqlite, first));
3737        }
3738    }
3739    let storage = data_root.join("storage");
3740    if storage.join("migration").is_file() {
3741        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3742    }
3743    let project_dir = data_root.join("project");
3744    if project_dir.is_dir() {
3745        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3746    }
3747    None
3748}
3749
3750/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3751/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3752///
3753/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3754/// `\r` survives as part of the returned line's own content; blank lines and
3755/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3756/// trimmed. This is what makes `Session.raw` — populated from this at every
3757/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3758/// just well-formed LF JSONL with no blank lines.
3759///
3760/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3761/// distinguish a source that ended with a trailing newline from one that
3762/// didn't (both split into the same line list), so `ends_with_newline`
3763/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3764/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3765/// source has zero lines, not one blank line.
3766fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3767    if text.is_empty() {
3768        return (Vec::new(), false);
3769    }
3770    let ends_with_newline = text.ends_with('\n');
3771    let body = if ends_with_newline {
3772        &text[..text.len() - 1]
3773    } else {
3774        text
3775    };
3776    (body.split('\n').collect(), ends_with_newline)
3777}
3778
3779/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3780/// source bytes from its verbatim lines plus the trailing-newline flag.
3781fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3782    let mut out = lines.join("\n");
3783    if ends_with_newline {
3784        out.push('\n');
3785    }
3786    out
3787}
3788
3789// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3790//
3791// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3792// SQLite — no system library dependency) and reconstructs the SAME envelope
3793// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3794// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3795// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3796// `session.ts` `fromRow` (session table: columnar fields recombined into the
3797// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3798// carried as the RAW column value, not upstream's own `fromRow`
3799// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3800// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3801// `message`/`part` rows are simpler: their `data` column is already the V1
3802// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3803// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3804// `id`/`sessionID`(/`messageID`).
3805
3806/// First 16 bytes of every SQLite database file — the format's own magic,
3807/// independent of file extension.
3808const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3809
3810/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3811/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3812/// the SQLite magic, OR its extension is `.db` — the latter so a
3813/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3814/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3815/// A non-existent path is NOT considered SQLite here — the missing-file
3816/// diagnostic in that case comes from the normal load path (`with_context`
3817/// at the CLI call sites), which already names the path clearly.
3818pub fn looks_like_sqlite(path: &Path) -> bool {
3819    if !path.is_file() {
3820        return false;
3821    }
3822    if path.extension().and_then(|e| e.to_str()) == Some("db") {
3823        return true;
3824    }
3825    use std::io::Read;
3826    let Ok(mut f) = std::fs::File::open(path) else {
3827        return false;
3828    };
3829    let mut buf = [0u8; 16];
3830    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3831}
3832
3833/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3834/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3835/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3836/// accept there. Binary SQLite input never reaches this function: callers
3837/// check [`looks_like_sqlite`] first and route to
3838/// [`Session::from_opencode_sqlite`] instead.
3839fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3840    let bytes = std::fs::read(path)?;
3841    String::from_utf8(bytes).map_err(|_| {
3842        crate::Error::Other(format!(
3843            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3844             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3845             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3846            path.display()
3847        ))
3848    })
3849}
3850
3851/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3852///
3853/// The first record carries durable session metadata (especially for Codex),
3854/// while the trailing window carries the messages the viewport will render.
3855/// Full lossless loaders intentionally continue to read every byte.
3856fn read_display_jsonl(
3857    path: &Path,
3858    message_limit: usize,
3859) -> Result<(Option<SessionSource>, String, bool)> {
3860    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3861    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3862    const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3863
3864    let mut first = String::new();
3865    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3866    let source = detect_source(&first);
3867    if !matches!(
3868        source,
3869        Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
3870    ) {
3871        let text = read_utf8_or_diagnose(path)?;
3872        return Ok((detect_source(&text), text, false));
3873    }
3874
3875    let mut file = std::fs::File::open(path)?;
3876    let file_len = file.metadata()?.len();
3877    let requested = (message_limit.max(1) as u64)
3878        .saturating_mul(BYTES_PER_MESSAGE)
3879        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3880    if file_len <= requested {
3881        let text = read_utf8_or_diagnose(path)?;
3882        return Ok((source, text, false));
3883    }
3884
3885    let start = file_len - requested;
3886    file.seek(SeekFrom::Start(start))?;
3887    let mut bytes = Vec::with_capacity(requested as usize);
3888    file.read_to_end(&mut bytes)?;
3889    // The window normally starts in the middle of a JSON record. Discard that
3890    // partial prefix so every line passed to the existing parsers is valid.
3891    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3892        bytes.drain(..=newline);
3893    }
3894    let mut tail = String::from_utf8(bytes).map_err(|_| {
3895        crate::Error::Other(format!(
3896            "{} contains non-UTF-8 data in its display window",
3897            path.display()
3898        ))
3899    })?;
3900    if start > 0 {
3901        // Always recover the human boundary immediately before the byte
3902        // window, even when the window already contains newer prompts. A
3903        // long run of large tool records can otherwise make the numeric tail
3904        // begin in one old turn while its only retained users belong to much
3905        // newer turns. The display projector then (correctly) hides the
3906        // orphaned activity, making pagination appear inert.
3907        //
3908        // Search backward independently of the render window and retain only
3909        // two complete human JSONL records. The search grows geometrically but
3910        // never reads more than the same 64 MiB hard ceiling as the display
3911        // window, and none of the intervening tool bytes are normalized or
3912        // sent over RPC.
3913        let max_search_bytes = start.min(MAX_TAIL_BYTES);
3914        let mut search_bytes = requested.min(max_search_bytes);
3915        let anchors = loop {
3916            let search_start = start - search_bytes;
3917            file.seek(SeekFrom::Start(search_start))?;
3918            let mut search = Vec::with_capacity(search_bytes as usize);
3919            (&mut file).take(search_bytes).read_to_end(&mut search)?;
3920            if search_start > 0 {
3921                if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3922                    search.drain(..=newline);
3923                } else {
3924                    search.clear();
3925                }
3926            }
3927            // `start` normally cuts the record whose remainder the tail
3928            // reader discarded. Exclude its incomplete prefix here too.
3929            if let Some(newline) = search.iter().rposition(|byte| *byte == b'\n') {
3930                search.truncate(newline + 1);
3931            } else {
3932                search.clear();
3933            }
3934            let anchors = std::str::from_utf8(&search)
3935                .ok()
3936                .map(|search| {
3937                    let mut found = search
3938                        .lines()
3939                        .rev()
3940                        .filter(|line| native_display_human_line(line, source))
3941                        .take(2)
3942                        .map(str::to_string)
3943                        .collect::<Vec<_>>();
3944                    found.reverse();
3945                    found
3946                })
3947                .unwrap_or_default();
3948            if anchors.len() >= 2 || search_start == 0 || search_bytes == max_search_bytes {
3949                break anchors;
3950            }
3951            search_bytes = search_bytes.saturating_mul(2).min(max_search_bytes);
3952        };
3953        if !anchors.is_empty() {
3954            tail = format!("{}\n{tail}", anchors.join("\n"));
3955        }
3956    }
3957    let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
3958        format!("{first}{tail}")
3959    } else {
3960        tail
3961    };
3962    Ok((source, text, true))
3963}
3964
3965fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3966    if !line
3967        .as_bytes()
3968        .windows(6)
3969        .any(|window| window == b"\"user\"")
3970    {
3971        return false;
3972    }
3973    let Ok(value) = serde_json::from_str::<Value>(line) else {
3974        return false;
3975    };
3976    match source {
3977        Some(SessionSource::Codex) => {
3978            value.get("type").and_then(Value::as_str) == Some("response_item")
3979                && value
3980                    .get("payload")
3981                    .and_then(|payload| payload.get("type"))
3982                    .and_then(Value::as_str)
3983                    == Some("message")
3984                && value
3985                    .get("payload")
3986                    .and_then(|payload| payload.get("role"))
3987                    .and_then(Value::as_str)
3988                    == Some("user")
3989        }
3990        Some(SessionSource::ClaudeCode) => {
3991            value.get("type").and_then(Value::as_str) == Some("user")
3992                && value
3993                    .get("message")
3994                    .and_then(|message| message.get("content"))
3995                    .is_some_and(|content| match content {
3996                        Value::String(text) => !text.trim().is_empty(),
3997                        Value::Array(parts) => parts.iter().any(|part| {
3998                            part.get("type").and_then(Value::as_str) == Some("text")
3999                                && part
4000                                    .get("text")
4001                                    .and_then(Value::as_str)
4002                                    .is_some_and(|text| !text.trim().is_empty())
4003                        }),
4004                        _ => false,
4005                    })
4006        }
4007        Some(SessionSource::Gemini) => {
4008            value.get("type").and_then(Value::as_str) == Some("user")
4009                && value.get("content").is_some_and(|content| match content {
4010                    Value::String(text) => !text.trim().is_empty(),
4011                    Value::Array(parts) => parts.iter().any(|part| {
4012                        part.get("text")
4013                            .and_then(Value::as_str)
4014                            .is_some_and(|text| !text.trim().is_empty())
4015                    }),
4016                    _ => false,
4017                })
4018        }
4019        _ => false,
4020    }
4021}
4022
4023fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
4024    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
4025}
4026
4027/// Open `db_path` read-only and confirm it carries the expected V1 schema
4028/// (a `session` table) — the shared entry point for every SQLite read below,
4029/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
4030/// path, not-a-database, and wrong/unsupported schema are each named
4031/// distinctly rather than surfacing later as "zero sessions" or a generic
4032/// parse failure.
4033fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
4034    if !db_path.is_file() {
4035        return Err(crate::Error::Other(format!(
4036            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
4037             (see `docs/interop/opencode-pi-spec.md` §1.2)",
4038            db_path.display()
4039        )));
4040    }
4041    let conn = Connection::open_with_flags(
4042        db_path,
4043        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
4044    )
4045    .map_err(|e| {
4046        crate::Error::Other(format!(
4047            "{} does not look like a valid OpenCode SQLite database: {e}",
4048            db_path.display()
4049        ))
4050    })?;
4051    let has_session_table: i64 = conn
4052        .query_row(
4053            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
4054            [],
4055            |r| r.get(0),
4056        )
4057        .map_err(|e| {
4058            crate::Error::Other(format!(
4059                "failed to read the OpenCode SQLite schema at {}: {e}",
4060                db_path.display()
4061            ))
4062        })?;
4063    if has_session_table == 0 {
4064        return Err(crate::Error::Other(format!(
4065            "{} is a SQLite database but has no `session` table — not a recognized \
4066             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
4067            db_path.display()
4068        )));
4069    }
4070    Ok(conn)
4071}
4072
4073/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
4074/// …). D7: an unparseable non-empty column previously degraded to
4075/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
4076/// absent/NULL column, so a corrupt `data`/`metadata` value silently
4077/// vanished (e.g. a message whose `data` fails to parse loses its entire
4078/// canonical content with no trace). A `tracing::warn!` now surfaces the
4079/// column name and context (session/record id) whenever this happens, so
4080/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
4081/// (still the least-wrong placeholder for a broken column; changing it to a
4082/// sentinel would risk misleading every legitimate `.is_null()` check
4083/// elsewhere) but the frontend/log now knows it happened.
4084fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
4085    match s.as_deref() {
4086        None => Value::Null,
4087        Some(t) => match serde_json::from_str::<Value>(t) {
4088            Ok(v) => v,
4089            Err(e) => {
4090                tracing::warn!(
4091                    column = col,
4092                    context,
4093                    error = %e,
4094                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
4095                );
4096                Value::Null
4097            }
4098        },
4099    }
4100}
4101
4102/// Columns the `session` table has in a GIVEN store, read once per session
4103/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4104/// `opencode` generation may lack columns the newest schema added, e.g.
4105/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4106/// "Invalid column name" on an absent column, so callers must check
4107/// membership before reading a not-guaranteed column instead of reading it
4108/// unconditionally).
4109fn opencode_session_columns(
4110    conn: &Connection,
4111) -> rusqlite::Result<std::collections::HashSet<String>> {
4112    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4113    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4114    names.collect()
4115}
4116
4117/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4118/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4119/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4120/// `revert` carries the raw column value verbatim rather than upstream's
4121/// field-selecting reconstruction (spec S9c: that reconstruction silently
4122/// drops the V2 `Revert.State` schema's extra `files` field).
4123///
4124/// D3: not every column this loader would like to read is guaranteed to
4125/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4126/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4127/// `agent`/`model` entirely. Those are read defensively (guarded by
4128/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4129/// generation this loader has ever targeted are still read unconditionally.
4130fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4131    let cols = opencode_session_columns(conn)
4132        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4133    let has = |name: &str| cols.contains(name);
4134
4135    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4136        let id: String = r.get("id")?;
4137        let project_id: String = r.get("project_id")?;
4138        let workspace_id: Option<String> = if has("workspace_id") {
4139            r.get("workspace_id")?
4140        } else {
4141            None
4142        };
4143        let parent_id: Option<String> = r.get("parent_id")?;
4144        let slug: String = r.get("slug")?;
4145        let directory: String = r.get("directory")?;
4146        let path: Option<String> = if has("path") { r.get("path")? } else { None };
4147        let title: String = r.get("title")?;
4148        let version: String = r.get("version")?;
4149        let share_url: Option<String> = r.get("share_url")?;
4150        let summary_additions: Option<i64> = r.get("summary_additions")?;
4151        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4152        let summary_files: Option<i64> = r.get("summary_files")?;
4153        let summary_diffs: Option<String> = r.get("summary_diffs")?;
4154        let metadata: Option<String> = if has("metadata") {
4155            r.get("metadata")?
4156        } else {
4157            None
4158        };
4159        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4160        let tokens_input: i64 = if has("tokens_input") {
4161            r.get("tokens_input")?
4162        } else {
4163            0
4164        };
4165        let tokens_output: i64 = if has("tokens_output") {
4166            r.get("tokens_output")?
4167        } else {
4168            0
4169        };
4170        let tokens_reasoning: i64 = if has("tokens_reasoning") {
4171            r.get("tokens_reasoning")?
4172        } else {
4173            0
4174        };
4175        let tokens_cache_read: i64 = if has("tokens_cache_read") {
4176            r.get("tokens_cache_read")?
4177        } else {
4178            0
4179        };
4180        let tokens_cache_write: i64 = if has("tokens_cache_write") {
4181            r.get("tokens_cache_write")?
4182        } else {
4183            0
4184        };
4185        let revert: Option<String> = r.get("revert")?;
4186        let permission: Option<String> = if has("permission") {
4187            r.get("permission")?
4188        } else {
4189            None
4190        };
4191        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4192        let model: Option<String> = if has("model") { r.get("model")? } else { None };
4193        let time_created: i64 = r.get("time_created")?;
4194        let time_updated: i64 = r.get("time_updated")?;
4195        let time_compacting: Option<i64> = if has("time_compacting") {
4196            r.get("time_compacting")?
4197        } else {
4198            None
4199        };
4200        let time_archived: Option<i64> = if has("time_archived") {
4201            r.get("time_archived")?
4202        } else {
4203            None
4204        };
4205
4206        let summary =
4207            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4208                .then(|| {
4209                    serde_json::json!({
4210                        "additions": summary_additions.unwrap_or(0),
4211                        "deletions": summary_deletions.unwrap_or(0),
4212                        "files": summary_files.unwrap_or(0),
4213                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4214                    })
4215                });
4216        let share = share_url.map(|u| serde_json::json!({"url": u}));
4217
4218        Ok(serde_json::json!({
4219            "id": id,
4220            "slug": slug,
4221            "projectID": project_id,
4222            "workspaceID": workspace_id,
4223            "directory": directory,
4224            "path": path,
4225            "parentID": parent_id,
4226            "summary": summary,
4227            "cost": cost,
4228            "tokens": {
4229                "input": tokens_input,
4230                "output": tokens_output,
4231                "reasoning": tokens_reasoning,
4232                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4233            },
4234            "share": share,
4235            "title": title,
4236            "agent": agent,
4237            "model": opencode_json_col(model, "model", session_id),
4238            "version": version,
4239            "metadata": opencode_json_col(metadata, "metadata", session_id),
4240            "time": {
4241                "created": time_created,
4242                "updated": time_updated,
4243                "compacting": time_compacting,
4244                "archived": time_archived,
4245            },
4246            "permission": opencode_json_col(permission, "permission", session_id),
4247            // S9c: raw column value, not a field-selecting reconstruction —
4248            // see this function's doc comment.
4249            "revert": opencode_json_col(revert, "revert", session_id),
4250        }))
4251    })
4252    .map_err(|e| match e {
4253        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4254            "OpenCode session `{session_id}` not found in this SQLite store"
4255        )),
4256        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4257    })
4258}
4259
4260/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4261/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4262/// re-inject them, matching what a JSON-tree file (or the export document)
4263/// carries at this same key. Also re-injects the row's own `time_created`/
4264/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4265/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4266/// in the envelope so `raw` is value-complete and re-writable without
4267/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4268/// which is a different, in-schema field with different semantics).
4269fn opencode_row_message_value(
4270    id: &str,
4271    session_id: &str,
4272    data_json: &str,
4273    time_created: i64,
4274    time_updated: i64,
4275) -> Value {
4276    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4277    if let Value::Object(map) = &mut v {
4278        map.insert("id".to_string(), Value::String(id.to_string()));
4279        map.insert(
4280            "sessionID".to_string(),
4281            Value::String(session_id.to_string()),
4282        );
4283        map.insert("time_created".to_string(), Value::from(time_created));
4284        map.insert("time_updated".to_string(), Value::from(time_updated));
4285    }
4286    v
4287}
4288
4289fn opencode_row_part_value(
4290    id: &str,
4291    session_id: &str,
4292    message_id: &str,
4293    data_json: &str,
4294    time_created: i64,
4295    time_updated: i64,
4296) -> Value {
4297    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4298    if let Value::Object(map) = &mut v {
4299        map.insert("id".to_string(), Value::String(id.to_string()));
4300        map.insert(
4301            "sessionID".to_string(),
4302            Value::String(session_id.to_string()),
4303        );
4304        map.insert(
4305            "messageID".to_string(),
4306            Value::String(message_id.to_string()),
4307        );
4308        map.insert("time_created".to_string(), Value::from(time_created));
4309        map.insert("time_updated".to_string(), Value::from(time_updated));
4310    }
4311    v
4312}
4313
4314/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4315/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4316/// info first, then each message (by `time_created, id`) immediately
4317/// followed by its own parts (by `id`) — parts MUST directly follow their
4318/// owning message line, since `Session::from_opencode_str`'s envelope parser
4319/// attaches a `part` line to whichever message id is already in its index
4320/// and silently leaves an out-of-order part `raw`-only otherwise — then
4321/// `todo` side-records, then a `session_diff` side-record if the JSON
4322/// sidecar file for this session exists (order-independent).
4323///
4324/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4325/// it "is still JSON-written even on SQLite installs" — verified against
4326/// `packages/opencode/src/session/revert.ts:76` /
4327/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4328/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4329/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4330/// separate from the `session.revert` DB column this loader already
4331/// captures. Without this, revert diffs vanish from `raw` and audit
4332/// under-counts `session_diff` records for real reverted sessions.
4333fn opencode_sqlite_session_envelope_lines(
4334    conn: &Connection,
4335    db_path: &Path,
4336    session_id: &str,
4337) -> Result<Vec<String>> {
4338    let mut lines = Vec::new();
4339
4340    let session_info = opencode_row_session_info(conn, session_id)?;
4341    let project_id = session_info
4342        .get("projectID")
4343        .and_then(Value::as_str)
4344        .unwrap_or("global")
4345        .to_string();
4346    lines.push(
4347        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4348            .to_string(),
4349    );
4350
4351    let mut msg_stmt = conn
4352        .prepare(
4353            "SELECT id, data, time_created, time_updated FROM message \
4354             WHERE session_id = ?1 ORDER BY time_created, id",
4355        )
4356        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4357    let msg_rows = msg_stmt
4358        .query_map([session_id], |r| {
4359            let id: String = r.get("id")?;
4360            let data: String = r.get("data")?;
4361            let time_created: i64 = r.get("time_created")?;
4362            let time_updated: i64 = r.get("time_updated")?;
4363            Ok((id, data, time_created, time_updated))
4364        })
4365        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4366
4367    let mut part_stmt = conn
4368        .prepare(
4369            "SELECT id, data, time_created, time_updated FROM part \
4370             WHERE message_id = ?1 ORDER BY id",
4371        )
4372        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4373
4374    for row in msg_rows {
4375        let (msg_id, data, msg_time_created, msg_time_updated) =
4376            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4377        let msg_value = opencode_row_message_value(
4378            &msg_id,
4379            session_id,
4380            &data,
4381            msg_time_created,
4382            msg_time_updated,
4383        );
4384        lines.push(
4385            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4386                .to_string(),
4387        );
4388
4389        let part_rows = part_stmt
4390            .query_map([&msg_id], |r| {
4391                let id: String = r.get("id")?;
4392                let data: String = r.get("data")?;
4393                let time_created: i64 = r.get("time_created")?;
4394                let time_updated: i64 = r.get("time_updated")?;
4395                Ok((id, data, time_created, time_updated))
4396            })
4397            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4398        for prow in part_rows {
4399            let (part_id, pdata, part_time_created, part_time_updated) =
4400                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4401            let part_value = opencode_row_part_value(
4402                &part_id,
4403                session_id,
4404                &msg_id,
4405                &pdata,
4406                part_time_created,
4407                part_time_updated,
4408            );
4409            lines.push(
4410                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4411                    .to_string(),
4412            );
4413        }
4414    }
4415
4416    let mut todo_stmt = conn
4417        .prepare(
4418            "SELECT content, status, priority, position, time_created, time_updated \
4419             FROM todo WHERE session_id = ?1 ORDER BY position",
4420        )
4421        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4422    let todo_rows = todo_stmt
4423        .query_map([session_id], |r| {
4424            let content: String = r.get("content")?;
4425            let status: String = r.get("status")?;
4426            let priority: String = r.get("priority")?;
4427            let position: i64 = r.get("position")?;
4428            let time_created: i64 = r.get("time_created")?;
4429            let time_updated: i64 = r.get("time_updated")?;
4430            Ok(serde_json::json!({
4431                "sessionID": session_id,
4432                "content": content,
4433                "status": status,
4434                "priority": priority,
4435                "position": position,
4436                "time": {"created": time_created, "updated": time_updated},
4437            }))
4438        })
4439        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4440    for trow in todo_rows {
4441        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4442        let position = tv.get("position").cloned().unwrap_or(Value::Null);
4443        lines.push(
4444            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4445        );
4446    }
4447
4448    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4449        lines.push(
4450            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4451                .to_string(),
4452        );
4453    }
4454
4455    Ok(lines)
4456}
4457
4458/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4459/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4460/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4461/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4462/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4463/// case (most sessions never revert) and is not an error; an existing-but-
4464/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4465/// vanishing.
4466fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4467    let dir = db_path.parent()?;
4468    let sidecar = dir
4469        .join("storage")
4470        .join("session_diff")
4471        .join(format!("{session_id}.json"));
4472    let text = std::fs::read_to_string(&sidecar).ok()?;
4473    match serde_json::from_str::<Value>(&text) {
4474        Ok(v) => Some(v),
4475        Err(e) => {
4476            tracing::warn!(
4477                path = %sidecar.display(),
4478                error = %e,
4479                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4480            );
4481            None
4482        }
4483    }
4484}
4485
4486/// Pick the "primary" session for a bare `.db` path with no explicit session
4487/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4488/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4489/// descending) — a subagent/task child session is never picked over an
4490/// available root session, mirroring `most_recent_session`'s "latest wins"
4491/// convention used elsewhere in this crate for supercode's own store.
4492fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4493    conn.query_row(
4494        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4495        [],
4496        |r| r.get::<_, String>(0),
4497    )
4498    .map_err(|e| match e {
4499        rusqlite::Error::QueryReturnedNoRows => {
4500            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4501        }
4502        e => opencode_sql_err(e, "selecting the primary session"),
4503    })
4504}
4505
4506fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4507    let mut stmt = conn
4508        .prepare("SELECT id FROM session ORDER BY time_created, id")
4509        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4510    let rows = stmt
4511        .query_map([], |r| r.get::<_, String>(0))
4512        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4513    let mut ids = Vec::new();
4514    for row in rows {
4515        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4516        if limit.is_some_and(|n| ids.len() >= n) {
4517            break;
4518        }
4519    }
4520    Ok(ids)
4521}
4522
4523/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4524/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4525/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4526/// silently picks just the primary one. Previously nothing surfaced this:
4527/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4528/// and no way to name a different one.
4529pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4530    let conn = opencode_sqlite_open(db_path)?;
4531    opencode_sqlite_all_session_ids(&conn, None)
4532}
4533
4534/// D6: the same "most-recently-updated top-level session" selection
4535/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4536/// no explicit session id is given — exposed so a CLI-level warning can name
4537/// which one was chosen.
4538pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4539    let conn = opencode_sqlite_open(db_path)?;
4540    opencode_sqlite_primary_session_id(&conn)
4541}
4542
4543/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4544/// `inspect`'s "reports the audited real store's sessions, messages, and
4545/// parts" summary (PARITY-3 AC01).
4546#[derive(Debug, Clone, Copy, Default)]
4547#[non_exhaustive]
4548pub struct OpenCodeSqliteStoreStats {
4549    /// Row count of the `session` table.
4550    pub sessions: u64,
4551    /// Row count of the `message` table.
4552    pub messages: u64,
4553    /// Row count of the `part` table.
4554    pub parts: u64,
4555    /// Row count of the `todo` table.
4556    pub todos: u64,
4557}
4558
4559/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4560/// without loading any of them (PARITY-3 AC01).
4561pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4562    let conn = opencode_sqlite_open(db_path)?;
4563    let count = |table: &str| -> Result<u64> {
4564        let sql = format!("SELECT count(*) FROM {table}");
4565        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4566            .map(|n| n.max(0) as u64)
4567            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4568    };
4569    Ok(OpenCodeSqliteStoreStats {
4570        sessions: count("session")?,
4571        messages: count("message")?,
4572        parts: count("part")?,
4573        todos: count("todo")?,
4574    })
4575}
4576
4577/// Combined envelope text spanning every session in `db_path` (or up to
4578/// `limit_sessions`) — for corpus-style scanning
4579/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4580/// Safe to concatenate multiple sessions' records into one text even though
4581/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4582/// (single-session semantics) — the audit line-classifier
4583/// (`audit_opencode_line`) scores each line independently and doesn't care
4584/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4585/// one session as a real [`Session`].
4586pub fn opencode_sqlite_corpus_envelope_text(
4587    db_path: &Path,
4588    limit_sessions: Option<usize>,
4589) -> Result<String> {
4590    let conn = opencode_sqlite_open(db_path)?;
4591    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4592    let mut out = String::new();
4593    for id in ids {
4594        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4595            out.push_str(&line);
4596            out.push('\n');
4597        }
4598    }
4599    Ok(out)
4600}
4601
4602/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4603/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4604/// everywhere a loader walks lines looking for JSON *records*, where a blank
4605/// line is simply not a record and must not become a spurious parse
4606/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4607/// (IX-1) — see [`split_lines_verbatim`] for that.
4608fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4609    text.lines().map(str::trim).filter(|l| !l.is_empty())
4610}
4611
4612// ---- Claude Code ----------------------------------------------------------
4613
4614/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4615/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4616fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4617    let dir = main_path.parent()?;
4618    let stem = main_path.file_stem()?.to_str()?;
4619    let candidate = dir.join(stem).join("subagents");
4620    candidate.is_dir().then_some(candidate)
4621}
4622
4623/// The first `agentId` recorded in a subagent transcript.
4624fn first_agent_id(jsonl: &str) -> Option<String> {
4625    for line in non_empty_lines(jsonl) {
4626        if let Ok(v) = serde_json::from_str::<Value>(line) {
4627            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4628                return Some(id.to_string());
4629            }
4630        }
4631    }
4632    None
4633}
4634
4635/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4636/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4637/// serialized content mentions the agent id. Best effort: an id with no
4638/// qualifying match is simply absent from the returned map.
4639///
4640/// Single pass over `main_text` — each line is parsed at most once,
4641/// regardless of how many agent ids are being sought — with each id's result
4642/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4643/// return: the first line (in file order) whose raw text contains the id and
4644/// which — the first qualifying `tool_result` block in that line, in block
4645/// order — has a string `tool_use_id` and a serialized form that also
4646/// contains the id. A `tool_result` block matching on raw-line/serialized
4647/// containment but lacking a `tool_use_id` yields nothing for that id and
4648/// does not shadow a later match.
4649fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4650    let mut index: HashMap<String, String> = HashMap::new();
4651    if agent_ids.is_empty() {
4652        return index;
4653    }
4654
4655    for line in non_empty_lines(main_text) {
4656        if index.len() == agent_ids.len() {
4657            break;
4658        }
4659        // Cheap prefilter: every match this function can ever return comes
4660        // from a block whose raw line carries the literal JSON string value
4661        // `tool_result` (no JSON-escape variants of that ASCII literal).
4662        if !line.contains("tool_result") {
4663            continue;
4664        }
4665        let still_unmapped: Vec<&String> = agent_ids
4666            .iter()
4667            .filter(|id| !index.contains_key(id.as_str()))
4668            .collect();
4669        if still_unmapped.is_empty() {
4670            break;
4671        }
4672        let Ok(v) = serde_json::from_str::<Value>(line) else {
4673            continue;
4674        };
4675        let content = v.get("message").and_then(|m| m.get("content"));
4676        let Some(Value::Array(blocks)) = content else {
4677            continue;
4678        };
4679        for b in blocks {
4680            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4681                continue;
4682            }
4683            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4684                continue;
4685            };
4686            let block_str = b.to_string();
4687            for id in &still_unmapped {
4688                if index.contains_key(id.as_str()) {
4689                    continue;
4690                }
4691                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4692                    index.insert((*id).clone(), tool_use_id.to_string());
4693                }
4694            }
4695        }
4696    }
4697
4698    index
4699}
4700
4701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4702enum ClaudeReplayKind {
4703    User,
4704    Assistant,
4705    Attachment,
4706    System,
4707}
4708
4709impl ClaudeReplayKind {
4710    fn is_conversation(self) -> bool {
4711        matches!(self, Self::User | Self::Assistant)
4712    }
4713}
4714
4715#[derive(Debug, Clone)]
4716struct ClaudeReplayNode {
4717    line_index: usize,
4718    uuid: String,
4719    parent_uuid: Option<String>,
4720    kind: ClaudeReplayKind,
4721    is_sidechain: bool,
4722    assistant_message_id: Option<String>,
4723    is_tool_result: bool,
4724    compact: Option<ClaudeCompactBoundary>,
4725}
4726
4727#[derive(Debug, Clone)]
4728struct ClaudeCompactBoundary {
4729    anchor_uuid: Option<String>,
4730    preserved_uuids: Vec<String>,
4731    preserved_segment: Option<(String, String)>,
4732}
4733
4734/// One projection of a Claude transcript graph: the source lines to replay,
4735/// plus whatever the projection had to give up to produce them (always empty
4736/// below [`Fidelity::Semantic`], which is the only level that degrades
4737/// instead of failing).
4738#[derive(Debug, Default)]
4739struct ClaudeReplaySelection {
4740    lines: Vec<usize>,
4741    residue: Vec<String>,
4742}
4743
4744#[derive(Debug, Default)]
4745struct ClaudeReplayIndex {
4746    nodes: Vec<ClaudeReplayNode>,
4747    by_uuid: HashMap<String, usize>,
4748    segment_anchors: HashSet<String>,
4749    last_prompt: Option<(String, bool)>,
4750    linear_lines: Vec<usize>,
4751}
4752
4753impl ClaudeReplayIndex {
4754    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4755        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4756            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4757                self.last_prompt = Some((
4758                    leaf.to_string(),
4759                    v.get("explicit").and_then(Value::as_bool) == Some(true),
4760                ));
4761            }
4762            return Ok(());
4763        }
4764
4765        // A fork-context-ref is a real Claude graph anchor, but not a replay
4766        // message. Its child is the first conversational record in the
4767        // exported fork, so reaching this UUID terminates the locally
4768        // replayable segment rather than indicating a broken parent edge.
4769        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4770            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4771                self.segment_anchors.insert(uuid.to_string());
4772            }
4773            return Ok(());
4774        }
4775
4776        let kind = match v.get("type").and_then(Value::as_str) {
4777            Some("user") => ClaudeReplayKind::User,
4778            Some("assistant") => ClaudeReplayKind::Assistant,
4779            Some("attachment") => ClaudeReplayKind::Attachment,
4780            Some("system") => ClaudeReplayKind::System,
4781            _ => return Ok(()),
4782        };
4783        self.linear_lines.push(line_index);
4784        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4785            return Ok(());
4786        };
4787        if self.by_uuid.contains_key(uuid) {
4788            return Err(claude_replay_error(format!(
4789                "duplicate uuid `{uuid}` in Claude transcript"
4790            )));
4791        }
4792
4793        let compact = (kind == ClaudeReplayKind::System
4794            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4795        .then(|| ClaudeCompactBoundary::from_value(v));
4796        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4797            .then(|| claude_assistant_message_id(v).map(str::to_string))
4798            .flatten();
4799        let is_tool_result = kind == ClaudeReplayKind::User
4800            && v.get("message")
4801                .and_then(|m| m.get("content"))
4802                .and_then(Value::as_array)
4803                .is_some_and(|blocks| {
4804                    blocks
4805                        .iter()
4806                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4807                });
4808        let node = ClaudeReplayNode {
4809            line_index,
4810            uuid: uuid.to_string(),
4811            parent_uuid: v
4812                .get("parentUuid")
4813                .and_then(Value::as_str)
4814                .map(str::to_string),
4815            kind,
4816            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4817            assistant_message_id,
4818            is_tool_result,
4819            compact,
4820        };
4821        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4822        self.nodes.push(node);
4823        Ok(())
4824    }
4825
4826    /// Project the transcript at `fidelity`.
4827    ///
4828    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4829    /// continuation, transfer and export path depends on: reconstruct
4830    /// Claude's own single active post-compaction branch, or fail naming what
4831    /// could not be reconstructed.
4832    ///
4833    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4834    /// that has been compacted, summarized, or resumed across files routinely
4835    /// contains a live record whose `parentUuid` names a record that is no
4836    /// longer on disk. Strict projection rightly refuses — a continuation
4837    /// built on a guessed graph is silent loss — but a VIEW does not need a
4838    /// continuation, so this mode anchors each dangling edge as a segment
4839    /// root, projects every severed segment exactly as the active branch is
4840    /// projected, splices them back together in transcript order, and names
4841    /// every degradation in the returned residue instead of erroring.
4842    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4843        let lenient = fidelity.tolerates_residue();
4844        let mut residue = Vec::new();
4845        if self.nodes.is_empty() {
4846            // Older exports and many hand-authored compatibility fixtures do
4847            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4848            // branch information to project in that shape, so preserve the
4849            // historical linear normalization behavior. Native graph-bearing
4850            // transcripts always take the projection below.
4851            return Ok(ClaudeReplaySelection {
4852                lines: self.linear_lines,
4853                residue,
4854            });
4855        }
4856        if lenient {
4857            self.anchor_dangling_parents(&mut residue);
4858        }
4859        // Last resort for a VIEW: a transcript whose graph is unprojectable
4860        // for some OTHER reason (a cycle, an unresolvable compact boundary)
4861        // still renders as the file's own record order. A read-only mirror
4862        // that cannot open a session at all is the defect this mode exists
4863        // to remove, so `Semantic` never returns an error.
4864        let fallback = lenient.then(|| self.linear_lines.clone());
4865        match self.project(lenient, &mut residue) {
4866            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4867            Err(error) => match fallback {
4868                Some(lines) => {
4869                    residue.push(format!(
4870                        "the Claude record graph could not be projected ({error}); \
4871                         every record was stitched in transcript order instead"
4872                    ));
4873                    Ok(ClaudeReplaySelection { lines, residue })
4874                }
4875                None => Err(error),
4876            },
4877        }
4878    }
4879
4880    /// Turn every edge that points outside the transcript into a segment
4881    /// root, naming the dangling uuids as residue.
4882    ///
4883    /// A `fork-context-ref` anchor is already a declared segment boundary,
4884    /// not a break, so it is left alone.
4885    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4886        let mut dangling = Vec::new();
4887        for idx in 0..self.nodes.len() {
4888            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4889                continue;
4890            };
4891            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4892                continue;
4893            }
4894            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4895            self.nodes[idx].parent_uuid = None;
4896        }
4897        if dangling.is_empty() {
4898            return;
4899        }
4900        const NAMED: usize = 8;
4901        let total = dangling.len();
4902        let overflow = total.saturating_sub(NAMED);
4903        dangling.truncate(NAMED);
4904        let mut listed = dangling.join(", ");
4905        if overflow > 0 {
4906            listed.push_str(&format!(", and {overflow} more"));
4907        }
4908        residue.push(format!(
4909            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4910             anchored as segment roots: {listed}"
4911        ));
4912    }
4913
4914    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4915        let mut retained = vec![true; self.nodes.len()];
4916        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4917            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4918                self.nodes
4919                    .iter()
4920                    .map(|node| node.parent_uuid.clone())
4921                    .collect()
4922            });
4923            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4924                let Some(parents) = parents else {
4925                    return Err(error);
4926                };
4927                // The boundary rewrites parents as it goes, so restore the
4928                // graph it half-edited before continuing without it.
4929                for (node, parent) in self.nodes.iter_mut().zip(parents) {
4930                    node.parent_uuid = parent;
4931                }
4932                retained.iter_mut().for_each(|keep| *keep = true);
4933                residue.push(format!(
4934                    "the latest Claude compact boundary could not be projected ({error}); \
4935                     no pre-compaction record was pruned from this view"
4936                ));
4937            }
4938        }
4939        let sidechain_only = self
4940            .nodes
4941            .iter()
4942            .enumerate()
4943            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4944            .all(|(_, node)| node.is_sidechain);
4945
4946        let explicit_leaf = self
4947            .last_prompt
4948            .as_ref()
4949            .filter(|(_, explicit)| *explicit)
4950            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4951            .filter(|idx| retained[*idx]);
4952        let newest_non_sidechain = self
4953            .nodes
4954            .iter()
4955            .enumerate()
4956            .rev()
4957            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4958            .map(|(idx, _)| idx);
4959        // Dedicated Claude subagent transcripts are sidechains by design:
4960        // every record, including their root user prompt, has
4961        // `isSidechain:true`. When there is no main-chain candidate, resume
4962        // the newest retained sidechain leaf instead of rejecting the child.
4963        let newest_sidechain = self
4964            .nodes
4965            .iter()
4966            .enumerate()
4967            .rev()
4968            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4969            .map(|(idx, _)| idx);
4970        let mut active = explicit_leaf
4971            .or(newest_non_sidechain)
4972            .or(newest_sidechain)
4973            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4974
4975        // Metadata descendants such as turn_duration are leaves in the raw
4976        // graph. Claude resumes from their nearest user/assistant ancestor,
4977        // then appends those descendants to the reconstructed chain.
4978        let mut seeking = HashSet::new();
4979        while !self.nodes[active].kind.is_conversation() {
4980            if !seeking.insert(active) {
4981                return Err(claude_replay_error(
4982                    "cycle while resolving active Claude leaf",
4983                ));
4984            }
4985            active = self.parent_index(active, &retained)?;
4986        }
4987
4988        let mut segments =
4989            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4990        if lenient {
4991            for leaf in self.severed_segment_leaves(active, &retained) {
4992                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4993            }
4994            if segments.len() > 1 {
4995                residue.push(format!(
4996                    "{} conversation segments were stitched in transcript order because the \
4997                     Claude record graph is severed",
4998                    segments.len()
4999                ));
5000            }
5001        }
5002        // Each segment keeps its own reconstructed order; the segments
5003        // themselves are spliced by where they start in the file.
5004        segments.retain(|segment| !segment.is_empty());
5005        segments.sort_by_key(|segment| {
5006            segment
5007                .iter()
5008                .map(|idx| self.nodes[*idx].line_index)
5009                .min()
5010                .unwrap_or(usize::MAX)
5011        });
5012        let mut ordered = Vec::new();
5013        let mut placed = HashSet::new();
5014        for idx in segments.into_iter().flatten() {
5015            if placed.insert(idx) {
5016                ordered.push(idx);
5017            }
5018        }
5019
5020        self.recover_parallel_assistant_chunks(ordered, &retained)
5021            .map(|indices| {
5022                indices
5023                    .into_iter()
5024                    .map(|idx| self.nodes[idx].line_index)
5025                    .collect()
5026            })
5027    }
5028
5029    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
5030    /// non-conversation descendants rooted at it.
5031    fn project_segment(
5032        &self,
5033        leaf: usize,
5034        retained: &[bool],
5035        sidechain_only: bool,
5036        lenient: bool,
5037    ) -> Result<Vec<usize>> {
5038        let mut reversed = Vec::new();
5039        let mut seen = HashSet::new();
5040        let mut cursor = Some(leaf);
5041        while let Some(idx) = cursor {
5042            if !seen.insert(idx) {
5043                return Err(claude_replay_error(format!(
5044                    "cycle in active Claude parentUuid chain at `{}`",
5045                    self.nodes[idx].uuid
5046                )));
5047            }
5048            reversed.push(idx);
5049            cursor = match self.nodes[idx].parent_uuid.as_deref() {
5050                Some(parent) => match self.by_uuid.get(parent).copied() {
5051                    Some(parent) => Some(parent),
5052                    None if self.segment_anchors.contains(parent) => None,
5053                    // Claude can resume a background child in-place while
5054                    // retaining only the new segment in that child's JSONL.
5055                    // Its first record then points to a UUID not present in
5056                    // the sidechain file. That external edge is a segment
5057                    // boundary, not corruption; the complete source remains
5058                    // available byte-for-byte in `raw`.
5059                    None if sidechain_only => None,
5060                    None => {
5061                        return Err(claude_replay_error(format!(
5062                            "active Claude record `{}` has missing parentUuid `{parent}`",
5063                            self.nodes[idx].uuid
5064                        )));
5065                    }
5066                },
5067                None => None,
5068            };
5069            if cursor.is_some_and(|parent| !retained[parent]) {
5070                if lenient {
5071                    // A compaction boundary is where this segment ends; the
5072                    // records it pruned stay pruned.
5073                    break;
5074                }
5075                return Err(claude_replay_error(format!(
5076                    "active Claude chain crosses an excluded compaction record from `{}`",
5077                    self.nodes[idx].uuid
5078                )));
5079            }
5080        }
5081        reversed.reverse();
5082
5083        // Include non-conversation descendants rooted at the segment's leaf
5084        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
5085        let mut descendants = Vec::new();
5086        let mut frontier = vec![leaf];
5087        let mut head = 0;
5088        while head < frontier.len() {
5089            let parent = frontier[head];
5090            head += 1;
5091            for (idx, node) in self.nodes.iter().enumerate() {
5092                if !retained[idx]
5093                    || node.kind.is_conversation()
5094                    || seen.contains(&idx)
5095                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
5096                {
5097                    continue;
5098                }
5099                seen.insert(idx);
5100                descendants.push(idx);
5101                frontier.push(idx);
5102            }
5103        }
5104        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5105        reversed.extend(descendants);
5106        Ok(reversed)
5107    }
5108
5109    /// The newest retained conversation record of every component the active
5110    /// leaf's own component cannot reach.
5111    ///
5112    /// Only a severed graph produces any: a healthy transcript is one
5113    /// component, so the abandoned branches a rewind left behind stay
5114    /// abandoned here exactly as they do under strict projection.
5115    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5116        let active_root = self.component_root(active, retained);
5117        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5118        for idx in 0..self.nodes.len() {
5119            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5120                continue;
5121            }
5122            let Some(root) = self.component_root(idx, retained) else {
5123                continue;
5124            };
5125            if Some(root) == active_root {
5126                continue;
5127            }
5128            let newest = newest_by_root.entry(root).or_insert(idx);
5129            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5130                *newest = idx;
5131            }
5132        }
5133        newest_by_root.into_values().collect()
5134    }
5135
5136    /// Walk `idx` up to the record that anchors its component, stopping at a
5137    /// root, an edge that leaves the transcript, or a pruned parent. `None`
5138    /// when the walk cycles.
5139    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5140        let mut cursor = idx;
5141        let mut seen = HashSet::new();
5142        loop {
5143            if !seen.insert(cursor) {
5144                return None;
5145            }
5146            let next = self.nodes[cursor]
5147                .parent_uuid
5148                .as_deref()
5149                .and_then(|parent| self.by_uuid.get(parent).copied())
5150                .filter(|parent| retained[*parent]);
5151            match next {
5152                Some(parent) => cursor = parent,
5153                None => return Some(cursor),
5154            }
5155        }
5156    }
5157
5158    fn apply_latest_compaction(
5159        &mut self,
5160        boundary_index: usize,
5161        retained: &mut [bool],
5162    ) -> Result<()> {
5163        let compact = self.nodes[boundary_index]
5164            .compact
5165            .clone()
5166            .expect("called with compact boundary");
5167        let mut preserved = compact.preserved_uuids;
5168        if preserved.is_empty() {
5169            if let Some((head, tail)) = compact.preserved_segment {
5170                preserved = self.walk_preserved_segment(&head, &tail)?;
5171            }
5172        }
5173
5174        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5175        for uuid in &preserved {
5176            if !self.by_uuid.contains_key(uuid) {
5177                return Err(claude_replay_error(format!(
5178                    "latest compact boundary references missing preserved uuid `{uuid}`"
5179                )));
5180            }
5181        }
5182
5183        let removed_uuids: HashSet<String> = self
5184            .nodes
5185            .iter()
5186            .enumerate()
5187            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5188            .map(|(_, node)| node.uuid.clone())
5189            .collect();
5190        for (idx, node) in self.nodes.iter().enumerate() {
5191            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5192                retained[idx] = false;
5193            }
5194        }
5195
5196        if preserved.is_empty() {
5197            return Ok(());
5198        }
5199        let anchor = compact.anchor_uuid.ok_or_else(|| {
5200            claude_replay_error("preserved compact boundary is missing anchorUuid")
5201        })?;
5202        if !self.by_uuid.contains_key(&anchor) {
5203            return Err(claude_replay_error(format!(
5204                "latest compact boundary references missing anchor uuid `{anchor}`"
5205            )));
5206        }
5207        let tail = preserved.last().cloned().expect("non-empty preserved list");
5208        let mut parent = anchor.clone();
5209        for uuid in &preserved {
5210            let idx = self.by_uuid[uuid];
5211            self.nodes[idx].parent_uuid = Some(parent);
5212            parent = uuid.clone();
5213        }
5214        let first = &preserved[0];
5215        for node in &mut self.nodes {
5216            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5217                node.parent_uuid = Some(tail.clone());
5218            }
5219        }
5220        for node in &mut self.nodes {
5221            if node.kind.is_conversation()
5222                && node
5223                    .parent_uuid
5224                    .as_ref()
5225                    .is_some_and(|parent| removed_uuids.contains(parent))
5226            {
5227                node.parent_uuid = Some(tail.clone());
5228            }
5229        }
5230        Ok(())
5231    }
5232
5233    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5234        let mut reversed = Vec::new();
5235        let mut seen = HashSet::new();
5236        let mut cursor = tail;
5237        loop {
5238            if !seen.insert(cursor.to_string()) {
5239                return Err(claude_replay_error("cycle in compact preservedSegment"));
5240            }
5241            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5242                claude_replay_error(format!(
5243                    "compact preservedSegment references missing uuid `{cursor}`"
5244                ))
5245            })?;
5246            reversed.push(cursor.to_string());
5247            if cursor == head {
5248                reversed.reverse();
5249                return Ok(reversed);
5250            }
5251            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5252                claude_replay_error(format!(
5253                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5254                ))
5255            })?;
5256        }
5257    }
5258
5259    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5260        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5261            claude_replay_error(format!(
5262                "Claude record `{}` has no conversational ancestor",
5263                self.nodes[idx].uuid
5264            ))
5265        })?;
5266        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5267            claude_replay_error(format!(
5268                "Claude record `{}` has missing parentUuid `{parent}`",
5269                self.nodes[idx].uuid
5270            ))
5271        })?;
5272        if !retained[parent_idx] {
5273            return Err(claude_replay_error(format!(
5274                "Claude record `{}` points into compacted-out history",
5275                self.nodes[idx].uuid
5276            )));
5277        }
5278        Ok(parent_idx)
5279    }
5280
5281    fn recover_parallel_assistant_chunks(
5282        &self,
5283        base: Vec<usize>,
5284        retained: &[bool],
5285    ) -> Result<Vec<usize>> {
5286        let selected: HashSet<usize> = base.iter().copied().collect();
5287        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5288        let mut skipped_positions = HashSet::new();
5289        let mut handled_ids = HashSet::new();
5290
5291        for (base_pos, idx) in base.iter().copied().enumerate() {
5292            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5293                continue;
5294            };
5295            if !handled_ids.insert(message_id.to_string()) {
5296                continue;
5297            }
5298            let base_positions: Vec<usize> = base
5299                .iter()
5300                .enumerate()
5301                .filter(|(_, candidate)| {
5302                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5303                })
5304                .map(|(pos, _)| pos)
5305                .collect();
5306            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5307            skipped_positions.extend(base_positions.iter().copied().skip(1));
5308
5309            // A streamed Anthropic response can be stored as sibling records
5310            // rather than a literal parent chain. Reassemble every chunk at
5311            // the first active occurrence and restore raw chunk order before
5312            // the normalizer coalesces their content blocks.
5313            let mut chunks: Vec<usize> = self
5314                .nodes
5315                .iter()
5316                .enumerate()
5317                .filter(|(candidate, node)| {
5318                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5319                })
5320                .map(|(candidate, _)| candidate)
5321                .collect();
5322            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5323
5324            let assistant_uuids: HashSet<&str> = self
5325                .nodes
5326                .iter()
5327                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5328                .map(|node| node.uuid.as_str())
5329                .collect();
5330            let mut results: Vec<usize> = self
5331                .nodes
5332                .iter()
5333                .enumerate()
5334                .filter(|(candidate, node)| {
5335                    retained[*candidate]
5336                        && !selected.contains(candidate)
5337                        && node.is_tool_result
5338                        && node
5339                            .parent_uuid
5340                            .as_deref()
5341                            .is_some_and(|parent| assistant_uuids.contains(parent))
5342                })
5343                .map(|(candidate, _)| candidate)
5344                .collect();
5345            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5346            chunks.extend(results);
5347            replacements.insert(anchor_pos, chunks);
5348        }
5349
5350        let mut out = Vec::with_capacity(selected.len());
5351        for (pos, idx) in base.into_iter().enumerate() {
5352            if let Some(replacement) = replacements.remove(&pos) {
5353                out.extend(replacement);
5354            } else if !skipped_positions.contains(&pos) {
5355                out.push(idx);
5356            }
5357        }
5358        Ok(out)
5359    }
5360}
5361
5362impl ClaudeCompactBoundary {
5363    fn from_value(v: &Value) -> Self {
5364        let metadata = v.get("compactMetadata");
5365        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5366        let anchor_uuid = preserved_messages
5367            .and_then(|p| p.get("anchorUuid"))
5368            .and_then(Value::as_str)
5369            .or_else(|| {
5370                metadata
5371                    .and_then(|m| m.get("preservedSegment"))
5372                    .and_then(|p| p.get("anchorUuid"))
5373                    .and_then(Value::as_str)
5374            })
5375            .map(str::to_string);
5376        let preserved_uuids = preserved_messages
5377            .and_then(|p| p.get("uuids"))
5378            .and_then(Value::as_array)
5379            .map(|uuids| {
5380                uuids
5381                    .iter()
5382                    .filter_map(Value::as_str)
5383                    .map(str::to_string)
5384                    .collect()
5385            })
5386            .unwrap_or_default();
5387        let preserved_segment =
5388            metadata
5389                .and_then(|m| m.get("preservedSegment"))
5390                .and_then(|segment| {
5391                    Some((
5392                        segment.get("headUuid")?.as_str()?.to_string(),
5393                        segment.get("tailUuid")?.as_str()?.to_string(),
5394                    ))
5395                });
5396        Self {
5397            anchor_uuid,
5398            preserved_uuids,
5399            preserved_segment,
5400        }
5401    }
5402}
5403
5404fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5405    crate::Error::Other(format!(
5406        "cannot reconstruct lossless Claude continuation: {}",
5407        message.into()
5408    ))
5409}
5410
5411fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5412    v.get("message")
5413        .and_then(|message| message.get("id"))
5414        .and_then(Value::as_str)
5415}
5416
5417fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5418    let Some(target_message) = target.get_mut("message") else {
5419        return;
5420    };
5421    let Some(chunk_message) = chunk.get("message") else {
5422        return;
5423    };
5424    let mut content = target_message
5425        .get("content")
5426        .and_then(Value::as_array)
5427        .cloned()
5428        .unwrap_or_default();
5429    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5430        content.extend(blocks.iter().cloned());
5431    }
5432    let mut merged_message = chunk_message.clone();
5433    merged_message["content"] = Value::Array(content);
5434    *target_message = merged_message;
5435}
5436
5437fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5438    let Some(v) = pending.take() else {
5439        return;
5440    };
5441    let reasoning_only = claude_assistant_message_id(&v).is_some()
5442        && v.get("message")
5443            .and_then(|message| message.get("content"))
5444            .and_then(Value::as_array)
5445            .is_some_and(|blocks| {
5446                !blocks.is_empty()
5447                    && blocks.iter().all(|block| {
5448                        matches!(
5449                            block.get("type").and_then(Value::as_str),
5450                            Some("thinking" | "redacted_thinking")
5451                        )
5452                    })
5453            });
5454    if reasoning_only {
5455        return;
5456    }
5457    let before = out.len();
5458    push_claude_assistant(&v, out);
5459    capture_claude_record_provenance(&v, &mut out[before..]);
5460    restore_single_grok_message(&v, &mut out[before..]);
5461}
5462
5463/// Attach the record identity, clock, and actual assistant model to every
5464/// canonical message produced from one Claude JSONL record. These fields are
5465/// deliberately per-message: a continued transcript can cross a provider
5466/// boundary, so the session-level source model is not authoritative for its
5467/// appended tail.
5468fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5469    let timestamp = v.get("timestamp").and_then(Value::as_str);
5470    let uuid = v.get("uuid").and_then(Value::as_str);
5471    let model = v
5472        .get("message")
5473        .and_then(|message| message.get("model"))
5474        .and_then(Value::as_str);
5475    for message in messages {
5476        if let Some(timestamp) = timestamp {
5477            message
5478                .metadata
5479                .entry("timestamp".to_string())
5480                .or_insert_with(|| timestamp.to_string());
5481        }
5482        if let Some(uuid) = uuid {
5483            message
5484                .metadata
5485                .entry("claude_uuid".to_string())
5486                .or_insert_with(|| uuid.to_string());
5487        }
5488        if let Some(model) = model {
5489            message
5490                .metadata
5491                .entry("model".to_string())
5492                .or_insert_with(|| model.to_string());
5493        }
5494    }
5495}
5496
5497fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5498    restore_codex_provenance_from_top_level(v, meta)?;
5499    if meta.session_id.is_none() {
5500        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5501            meta.session_id = Some(id.to_string());
5502        }
5503    }
5504    if meta.cwd.is_none() {
5505        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5506            meta.cwd = Some(PathBuf::from(cwd));
5507        }
5508    }
5509    if meta.model.is_none() {
5510        if let Some(model) = v
5511            .get("message")
5512            .and_then(|m| m.get("model"))
5513            .and_then(Value::as_str)
5514        {
5515            meta.model = Some(model.to_string());
5516        }
5517    }
5518    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5519    // real Claude Code record with no confirmed field shape (see
5520    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5521    // named fields and risk silently mis-modeling it, stash the WHOLE raw
5522    // line verbatim under a lineage key. `write_claude_code_records` (below)
5523    // re-emits it byte-for-byte, so the record survives the Claude Code
5524    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5525    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5526    // so a Claude -> Codex -> Claude round trip can still reconstruct it
5527    // (dev/03). A session can only fork from one context, so the first one
5528    // seen wins, matching every other "first wins" field above.
5529    //
5530    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5531    // a RE-SERIALIZATION of the parsed `Value`, not the original source
5532    // text. `serde_json::Value` here has no `preserve_order` feature (see
5533    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5534    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5535    // this very comment was false. Fixed the cheap+honest way: store the
5536    // caller's own already-verbatim source `raw_line` text instead of
5537    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5538    // (key order, spacing, everything) rather than merely
5539    // structurally-equivalent JSON.
5540    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5541        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5542    {
5543        meta.lineage.insert(
5544            "claude_fork_context_ref_raw".to_string(),
5545            raw_line.to_string(),
5546        );
5547    }
5548    Ok(())
5549}
5550
5551fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5552    let content = v.get("message").and_then(|m| m.get("content"));
5553    let provenance = claude_user_provenance(v);
5554    match content {
5555        Some(Value::String(s)) => {
5556            if !s.trim().is_empty() {
5557                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5558            }
5559        }
5560        Some(Value::Array(blocks)) => {
5561            let mut text = String::new();
5562            // IX-5: image blocks alongside/instead of text — collected
5563            // separately (never synthesized on a malformed shape, see
5564            // `claude_image_block_to_part`) so a multimodal user turn
5565            // survives as `content_parts` instead of the image silently
5566            // vanishing.
5567            let mut images: Vec<Value> = Vec::new();
5568            // D5: an `image` block whose `source` isn't base64/url (e.g. a
5569            // Files-API `{"source":{"type":"file","file_id":..}}`
5570            // reference) makes `claude_image_block_to_part` return `None` —
5571            // track that it was SEEN even though it couldn't be converted,
5572            // so an image-ONLY record (no text, no convertible image) isn't
5573            // silently dropped below (the same vanishing-record bug-class
5574            // PARITY-11 fixed for reasoning-only turns).
5575            let mut saw_unconvertible_image = false;
5576            for b in blocks {
5577                match b.get("type").and_then(Value::as_str) {
5578                    Some("text") => push_text(&mut text, b.get("text")),
5579                    Some("tool_result") => {
5580                        let id = b
5581                            .get("tool_use_id")
5582                            .and_then(Value::as_str)
5583                            .unwrap_or_default();
5584                        // PARITY-11 (nested images): `extract_tool_result_content`
5585                        // captures any `image` blocks nested inside this
5586                        // `tool_result` into `content_parts` (via
5587                        // `claude_image_block_to_part`, the same conversion the
5588                        // top-level `image` block path already uses) instead of
5589                        // flattening them to the bare `[image]` marker text the
5590                        // old `extract_tool_result` emitted — the everyday
5591                        // "Read a PNG / screenshot tool output" shape.
5592                        let (result, images) =
5593                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5594                        let mut msg = tool_message(id, result);
5595                        if !images.is_empty() {
5596                            // D-mix (Fable review, must-fix): `content_parts`
5597                            // is a self-contained contract — the pi writer
5598                            // (`pi_content_value`) reads ONLY `content_parts`
5599                            // for a `Role::Tool` message and never falls back
5600                            // to `msg.content`, so on a MIXED text+image
5601                            // tool_result a bare `content_parts: [image]`
5602                            // silently drops the sibling text on `convert
5603                            // --to pi` (a regression vs. the pre-PARITY-11
5604                            // baseline, which at least preserved the text).
5605                            // Prepend the text as part 0, exactly mirroring
5606                            // `pi_content_to_text_and_parts` and
5607                            // `push_opencode_user`'s identical
5608                            // self-contained-parts construction. `msg.content`
5609                            // keeps the text too (unchanged) for the writers
5610                            // that read text from `msg.content` and only scan
5611                            // `content_parts` for `image_url` entries
5612                            // (`claude_tool_result_content_value`,
5613                            // `codex_tool_output_text`, the opencode
5614                            // assistant writer) — those already filter
5615                            // strictly on `image_url`/text-typed lookups, so
5616                            // this text part is never double-counted.
5617                            let mut parts = Vec::new();
5618                            if let Some(t) = &msg.content {
5619                                if !t.is_empty() {
5620                                    parts.push(serde_json::json!({"type": "text", "text": t}));
5621                                }
5622                            }
5623                            parts.extend(images);
5624                            msg.content_parts = Some(parts);
5625                        }
5626                        // The assistant turn that issued this tool call — the
5627                        // tool-pairing graph edge (parallel to parentUuid).
5628                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5629                        {
5630                            msg.metadata
5631                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5632                        }
5633                        // TR-10: preserve the Claude wire `is_error` flag so
5634                        // the reduction layer's success/failure boundary
5635                        // (`ReductionKind::ToolInputElided` must never target
5636                        // an errored call) survives import — `ChatMessage`
5637                        // otherwise has no structural slot for it.
5638                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5639                            crate::mark_tool_error(&mut msg);
5640                        } else {
5641                            restore_tool_outcome_extension(v, &mut msg);
5642                        }
5643                        out.push(msg);
5644                    }
5645                    Some("image") => match claude_image_block_to_part(b) {
5646                        Some(part) => images.push(part),
5647                        None => saw_unconvertible_image = true,
5648                    },
5649                    _ => {} // document / unknown — skip
5650                }
5651            }
5652            // D5: nothing convertible landed in `text`/`images` but an
5653            // image block WAS present — fold in the same short bracketed
5654            // marker convention already used for `[web_search]`/`[model
5655            // fallback: ...]` rather than letting the record vanish.
5656            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5657                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5658            }
5659            let before = out.len();
5660            if !images.is_empty() {
5661                let mut parts = Vec::new();
5662                if !text.trim().is_empty() {
5663                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5664                }
5665                parts.extend(images);
5666                out.push(
5667                    ChatMessage {
5668                        role: Role::User,
5669                        content: None,
5670                        content_parts: Some(parts),
5671                        tool_calls: None,
5672                        tool_call_id: None,
5673                        name: None,
5674                        metadata: Default::default(),
5675                    }
5676                    .with_metas(&provenance),
5677                );
5678            } else if !text.trim().is_empty() {
5679                out.push(ChatMessage::user(text).with_metas(&provenance));
5680            }
5681            if saw_unconvertible_image && out.len() > before {
5682                if let Some(msg) = out.last_mut() {
5683                    msg.metadata
5684                        .insert("image_source_unconvertible".to_string(), "true".to_string());
5685                }
5686            }
5687        }
5688        _ => {}
5689    }
5690}
5691
5692/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5693/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5694/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5695/// else in the record survives either — matches the existing
5696/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5697/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5698const UNCONVERTIBLE_IMAGE_MARKER: &str =
5699    "[image: source not captured — unsupported/unconvertible image reference]";
5700
5701/// Parse a Claude Code user-turn `image` content block
5702/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5703/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5704/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5705/// bare URL for the url form) — the inverse of
5706/// [`claude_user_content_value`]'s emission. Only a well-formed source
5707/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5708/// anything else — including a well-formed but unconvertible source like a
5709/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5710/// residue rather than synthesizing a corrupt/empty part (mirrors the
5711/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5712/// discipline). Callers must not let that turn the record invisible though:
5713/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5714fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5715    let source = b.get("source")?;
5716    match source.get("type").and_then(Value::as_str) {
5717        Some("base64") => {
5718            let mime = source.get("media_type").and_then(Value::as_str)?;
5719            let data = source.get("data").and_then(Value::as_str)?;
5720            if mime.is_empty() || data.is_empty() {
5721                return None;
5722            }
5723            Some(serde_json::json!({
5724                "type": "image_url",
5725                "image_url": {"url": format!("data:{mime};base64,{data}")},
5726            }))
5727        }
5728        Some("url") => {
5729            let url = source.get("url").and_then(Value::as_str)?;
5730            if url.is_empty() {
5731                return None;
5732            }
5733            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5734        }
5735        _ => None,
5736    }
5737}
5738
5739/// Rebuild a Claude Code user-turn `message.content` value from a
5740/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5741/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5742/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5743/// overriding constraint: a text-only message's export stays byte-identical)
5744/// — only a multimodal message (`content_parts` present, e.g. imported from
5745/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5746/// content-array shape, one `text` block (if any non-empty text part) plus
5747/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5748/// any other URL → `source.url`).
5749fn claude_user_content_value(msg: &ChatMessage) -> Value {
5750    match &msg.content_parts {
5751        Some(parts) => {
5752            let mut blocks = Vec::new();
5753            for p in parts {
5754                match p.get("type").and_then(Value::as_str) {
5755                    Some("text") => {
5756                        if let Some(t) = p.get("text").and_then(Value::as_str) {
5757                            if !t.is_empty() {
5758                                blocks.push(serde_json::json!({"type": "text", "text": t}));
5759                            }
5760                        }
5761                    }
5762                    Some("image_url") => {
5763                        if let Some(url) = p
5764                            .get("image_url")
5765                            .and_then(|u| u.get("url"))
5766                            .and_then(Value::as_str)
5767                        {
5768                            blocks.push(match parse_data_uri(url) {
5769                                Some((mime, data)) => serde_json::json!({
5770                                    "type": "image",
5771                                    "source": {"type": "base64", "media_type": mime, "data": data},
5772                                }),
5773                                None => serde_json::json!({
5774                                    "type": "image",
5775                                    "source": {"type": "url", "url": url},
5776                                }),
5777                            });
5778                        }
5779                    }
5780                    _ => {}
5781                }
5782            }
5783            Value::Array(blocks)
5784        }
5785        None => Value::String(msg.content.clone().unwrap_or_default()),
5786    }
5787}
5788
5789/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5790/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5791/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5792/// the historical plain-string `content` exactly (same IX-5-style constraint
5793/// `claude_user_content_value` follows) — only a `tool_result` that actually
5794/// carries a captured nested image gets the Anthropic content-array shape,
5795/// one `text` block (the existing `msg.content`, if any) plus one `image`
5796/// block per `image_url` part (mirrors `claude_user_content_value`'s
5797/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5798fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5799    match &msg.content_parts {
5800        Some(parts) if !parts.is_empty() => {
5801            let mut blocks = Vec::new();
5802            if let Some(t) = &msg.content {
5803                if !t.is_empty() {
5804                    blocks.push(serde_json::json!({"type": "text", "text": t}));
5805                }
5806            }
5807            for p in parts {
5808                if p.get("type").and_then(Value::as_str) == Some("image_url") {
5809                    if let Some(url) = p
5810                        .get("image_url")
5811                        .and_then(|u| u.get("url"))
5812                        .and_then(Value::as_str)
5813                    {
5814                        blocks.push(match parse_data_uri(url) {
5815                            Some((mime, data)) => serde_json::json!({
5816                                "type": "image",
5817                                "source": {"type": "base64", "media_type": mime, "data": data},
5818                            }),
5819                            None => serde_json::json!({
5820                                "type": "image",
5821                                "source": {"type": "url", "url": url},
5822                            }),
5823                        });
5824                    }
5825                }
5826            }
5827            Value::Array(blocks)
5828        }
5829        _ => Value::String(msg.content.clone().unwrap_or_default()),
5830    }
5831}
5832
5833/// Collect the Claude Code user-turn provenance fields that distinguish real
5834/// human input from system-injected turns and record replay-relevant state.
5835pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5836    let mut out = Vec::new();
5837    let mut take_str = |key: &str| {
5838        if let Some(s) = v.get(key).and_then(Value::as_str) {
5839            out.push((key.to_string(), s.to_string()));
5840        }
5841    };
5842    take_str("promptSource"); // typed | queued | system | sdk
5843    take_str("interruptedMessageId");
5844    take_str("sourceToolUseID");
5845    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5846        if v.get(flag).and_then(Value::as_bool) == Some(true) {
5847            out.push((flag.to_string(), "true".to_string()));
5848        }
5849    }
5850    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5851        out.push(("queuePriority".to_string(), n.to_string()));
5852    }
5853    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5854    if let Some(kind) = v
5855        .get("origin")
5856        .and_then(|o| o.get("kind"))
5857        .and_then(Value::as_str)
5858    {
5859        out.push(("origin".to_string(), kind.to_string()));
5860    }
5861    out
5862}
5863
5864/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5865/// `local_command`, `away_summary`) carry real text that's part of the
5866/// interaction; fold them in as system context. Marker/metric subtypes
5867/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5868/// no conversational content and are skipped.
5869fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5870    let keep = matches!(
5871        v.get("subtype").and_then(Value::as_str),
5872        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5873    );
5874    if !keep {
5875        return;
5876    }
5877    if let Some(content) = v.get("content").and_then(Value::as_str) {
5878        if !content.trim().is_empty() {
5879            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5880            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5881        }
5882    }
5883}
5884
5885/// Fold content-bearing Claude Code `attachment` records into the conversation
5886/// as user-role messages. Most attachment subtypes (`task_reminder`,
5887/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5888/// are regenerable system injections and are skipped; only the four that carry
5889/// non-regenerable user/external content are kept.
5890fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5891    let att = match v.get("attachment") {
5892        Some(a) => a,
5893        None => return,
5894    };
5895    let kind = match att.get("type").and_then(Value::as_str) {
5896        Some(kind) => kind,
5897        None => return,
5898    };
5899    let text = match kind {
5900        // A queued prompt. `commandMode` says whose: `prompt` is the person's
5901        // own text, `task-notification` is the runtime reporting a finished
5902        // background task. Kept verbatim below.
5903        "queued_command" => att
5904            .get("prompt")
5905            .and_then(Value::as_str)
5906            .map(str::to_string),
5907        // A file the user attached: header + contents.
5908        "file" => attachment_with_path(att, "attached file", "filename", "content"),
5909        // A user-edited file snippet.
5910        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5911        // Injected project memory (CLAUDE.md), point-in-time.
5912        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5913        _ => None, // regenerable system injection — skip
5914    };
5915    let Some(text) = text else { return };
5916    if text.trim().is_empty() {
5917        return;
5918    }
5919    // An attachment record wears the user's ROLE, but the record itself says
5920    // who actually spoke — and that fact is lost the moment the attachment is
5921    // flattened to `[label: path]` text, so carry it as metadata the way
5922    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5923    //
5924    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
5925    //                     `nested_memory` are envelopes the runtime built
5926    //                     around a file body; a frontend that trusts the role
5927    //                     shows the reader a numbered source listing in a
5928    //                     chat bubble apparently sent by themselves.
5929    //   `commandMode`     present on `queued_command` only, and the whole
5930    //                     story for it. Measured over the local Claude Code
5931    //                     corpus (2,512 `queued_command` attachments): 926
5932    //                     `prompt`, every one of them plain human text, and
5933    //                     1,586 `task-notification`, every one of them a
5934    //                     `<task-notification>` frame — the same text Claude
5935    //                     Code also writes as a `type:"user"` record stamped
5936    //                     `origin.kind = "task-notification"`.
5937    //
5938    // Presentation policy (which of these a frontend hides) belongs to the
5939    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5940    // job is to stop discarding the producer's own answer.
5941    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5942    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5943        message = message.with_meta("commandMode", mode);
5944    }
5945    out.push(message);
5946}
5947
5948/// Format an attachment as `[<label>: <path>]\n<body>`.
5949fn attachment_with_path(
5950    att: &Value,
5951    label: &str,
5952    path_key: &str,
5953    body_key: &str,
5954) -> Option<String> {
5955    let body = att.get(body_key).and_then(Value::as_str)?;
5956    let path = att
5957        .get(path_key)
5958        .or_else(|| att.get("displayPath"))
5959        .and_then(Value::as_str)
5960        .unwrap_or("");
5961    Some(format!("[{label}: {path}]\n{body}"))
5962}
5963
5964fn push_str_field(buf: &mut String, s: &str) {
5965    if !buf.is_empty() {
5966        buf.push('\n');
5967    }
5968    buf.push_str(s);
5969}
5970
5971/// N3: build a synthesized message for reasoning that could not attach to a
5972/// following assistant turn — either interrupted mid-stream by a
5973/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5974/// the three pending buffers (all empty/`false` afterward) so callers don't
5975/// separately have to remember to clear them.
5976fn orphaned_reasoning_message(
5977    reasoning: &mut String,
5978    reasoning_content: &mut String,
5979    encrypted: &mut bool,
5980) -> ChatMessage {
5981    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5982    if !reasoning.is_empty() {
5983        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5984    }
5985    if !reasoning_content.is_empty() {
5986        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5987    }
5988    if *encrypted {
5989        msg = msg.with_meta("reasoning_encrypted", "true");
5990        *encrypted = false;
5991    }
5992    msg
5993}
5994
5995fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5996    let content = v.get("message").and_then(|m| m.get("content"));
5997    let mut text = String::new();
5998    let mut calls: Vec<ToolCall> = Vec::new();
5999    // Legacy singular fields — kept for backward compatibility with every
6000    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
6001    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
6002    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
6003    // message carries MULTIPLE `thinking` blocks, collapsing them down to
6004    // these singular fields silently drops every signature but the last
6005    // one's — a real Anthropic `thinking` block's `signature` cryptographically
6006    // covers ONLY that block's own text, so re-emitting block 1's text under
6007    // block 2's signature (or vice versa) produces a signature that will
6008    // never verify. `thinking_blocks` below is the fix: every block
6009    // preserved SEPARATELY, in order, each with its own (optional)
6010    // signature/data — the writer prefers it over the legacy fields
6011    // whenever present.
6012    let mut thinking = String::new();
6013    let mut signature: Option<String> = None;
6014    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
6015    // `image` assistant blocks, and (rarely) a `fallback` model-routing
6016    // marker — none handled before, all silently vanishing (audit's own
6017    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
6018    // `fallback` blocks in the reference corpus).
6019    //
6020    // D8: `redacted_thinking` is real data ONLY — never a fabricated
6021    // placeholder. The pre-fix code defaulted a missing `data` field to the
6022    // literal string `"<redacted>"`, which is indistinguishable from an
6023    // actual (if oddly-named) opaque payload on re-emit — a caller reading
6024    // it back has no way to tell "no data was ever captured" from "the
6025    // provider's own opaque blob happens to be the string `<redacted>`".
6026    // `redacted_thinking_seen` tracks block PRESENCE independently of
6027    // whether it had real data, so the reasoning-only-turn rescue below
6028    // still fires even when no block had a `data` field at all.
6029    let mut redacted_thinking: Option<String> = None;
6030    let mut redacted_thinking_seen = false;
6031    let mut images: Vec<Value> = Vec::new();
6032    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
6033    // `thinking` string alongside a real `signature` (the summarized/
6034    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
6035    // would miss those, so track "a thinking block existed at all"
6036    // separately from whether it had visible text.
6037    let mut thinking_block_seen = false;
6038    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
6039    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
6040    // above. Serialized as a single JSON-array metadata string
6041    // (`ChatMessage::metadata` is a flat string map) under
6042    // `"thinking_blocks"`.
6043    let mut thinking_blocks: Vec<Value> = Vec::new();
6044    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
6045    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
6046    // not silently vanish the whole record when nothing else survives.
6047    let mut saw_unconvertible_image = false;
6048
6049    match content {
6050        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
6051        Some(Value::Array(blocks)) => {
6052            for b in blocks {
6053                match b.get("type").and_then(Value::as_str) {
6054                    Some("text") => push_text(&mut text, b.get("text")),
6055                    Some("tool_use") => {
6056                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
6057                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
6058                        let args = b
6059                            .get("input")
6060                            .map(|i| i.to_string())
6061                            .unwrap_or_else(|| "{}".to_string());
6062                        calls.push(function_call(id, name, args));
6063                    }
6064                    // Thinking is not replayed across providers, but retain it in
6065                    // (skip-serialized) metadata so a same-model continuation can
6066                    // re-inject it. See P3.
6067                    Some("thinking") => {
6068                        thinking_block_seen = true;
6069                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
6070                        if !t.is_empty() {
6071                            push_str_field(&mut thinking, t); // legacy concatenated field
6072                        }
6073                        let sig = b.get("signature").and_then(Value::as_str);
6074                        if let Some(s) = sig {
6075                            signature = Some(s.to_string()); // legacy last-wins field
6076                        }
6077                        // D8: this block's OWN text + signature, not folded
6078                        // into the running concatenation above.
6079                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
6080                        if let Some(s) = sig {
6081                            block["signature"] = Value::String(s.to_string());
6082                        }
6083                        thinking_blocks.push(block);
6084                    }
6085                    // Anthropic's redacted reasoning: an opaque, provider-private
6086                    // payload (flagged content the API declines to show in the
6087                    // clear). Like `thinking`, it's not replayable, but the raw
6088                    // `data` is retained in metadata rather than silently
6089                    // vanishing — a same-model continuation can still replay it
6090                    // verbatim even though supercode never renders it.
6091                    Some("redacted_thinking") => {
6092                        redacted_thinking_seen = true;
6093                        let data = b.get("data").and_then(Value::as_str);
6094                        // D8: no fabricated fallback — `data` is only ever
6095                        // the real captured payload, or genuinely absent.
6096                        if let Some(d) = data {
6097                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
6098                        }
6099                        let mut block = serde_json::json!({"type": "redacted_thinking"});
6100                        if let Some(d) = data {
6101                            block["data"] = Value::String(d.to_string());
6102                        }
6103                        thinking_blocks.push(block);
6104                    }
6105                    // An assistant-emitted image block (e.g. a generated
6106                    // image) — collected exactly like `push_claude_user`'s
6107                    // user-turn image handling (`claude_image_block_to_part`
6108                    // is role-general), so it survives as `content_parts`
6109                    // instead of vanishing.
6110                    Some("image") => match claude_image_block_to_part(b) {
6111                        Some(part) => images.push(part),
6112                        None => saw_unconvertible_image = true,
6113                    },
6114                    // A provider-routing note (real shape:
6115                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6116                    // — a mid-generation model swap, e.g. an overloaded model
6117                    // falling back to another). Carries no replayable
6118                    // conversational content, but folding it into `text` as a
6119                    // short bracketed marker — the same convention the Codex
6120                    // loader already uses for `[web_search]`/
6121                    // `[image_generation] ...` — keeps it visible instead of
6122                    // silently vanishing, including the case where it's the
6123                    // ONLY block in the turn (see the reasoning-only-turn fix
6124                    // below: before this, that shape dropped the entire
6125                    // message).
6126                    Some("fallback") => {
6127                        let from = b
6128                            .get("from")
6129                            .and_then(|f| f.get("model"))
6130                            .and_then(Value::as_str)
6131                            .unwrap_or("?");
6132                        let to = b
6133                            .get("to")
6134                            .and_then(|t| t.get("model"))
6135                            .and_then(Value::as_str)
6136                            .unwrap_or("?");
6137                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6138                    }
6139                    _ => {}
6140                }
6141            }
6142        }
6143        _ => {}
6144    }
6145
6146    // D5: nothing convertible landed in `text`/`images` but an image block
6147    // WAS present — fold in the same bracketed-marker convention `fallback`
6148    // uses above, so a genuinely image-only (unconvertible source) turn
6149    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6150    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6151        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6152    }
6153
6154    let before = out.len();
6155    if !images.is_empty() {
6156        let mut parts = Vec::new();
6157        if !text.trim().is_empty() {
6158            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6159        }
6160        parts.extend(images);
6161        out.push(ChatMessage {
6162            role: Role::Assistant,
6163            content: None,
6164            content_parts: Some(parts),
6165            tool_calls: (!calls.is_empty()).then_some(calls),
6166            tool_call_id: None,
6167            name: None,
6168            metadata: Default::default(),
6169        });
6170    } else {
6171        push_assistant(out, text, calls);
6172        // A recognized native assistant record remains transcript state even
6173        // when its content array is empty (for example, an interrupted model
6174        // turn). Force a bare message whenever `push_assistant` had nothing
6175        // to emit. This includes the reasoning-only case and also preserves
6176        // genuinely part-less records instead of silently changing turn
6177        // count/order during translation.
6178        if out.len() == before {
6179            let mut empty = ChatMessage {
6180                role: Role::Assistant,
6181                content: None,
6182                content_parts: None,
6183                tool_calls: None,
6184                tool_call_id: None,
6185                name: None,
6186                metadata: Default::default(),
6187            };
6188            if !thinking_block_seen && !redacted_thinking_seen {
6189                empty
6190                    .metadata
6191                    .insert("empty_assistant_record".to_string(), "true".to_string());
6192            }
6193            out.push(empty);
6194        }
6195    }
6196    // Attach retained reasoning + attribution to the message we just produced.
6197    if out.len() > before {
6198        if let Some(msg) = out.last_mut() {
6199            // Insert "thinking" (even as an empty string) whenever a
6200            // `thinking` block was actually seen, not just when it had
6201            // visible text — a real `thinking` block commonly carries an
6202            // empty `thinking` string alongside a real `signature` (the
6203            // summarized-away-but-still-replayable case), and the writer
6204            // below keys its re-emission decision off this metadata key's
6205            // PRESENCE, not its content.
6206            if thinking_block_seen {
6207                msg.metadata.insert("thinking".to_string(), thinking);
6208            }
6209            if let Some(sig) = signature {
6210                msg.metadata.insert("thinking_signature".to_string(), sig);
6211            }
6212            if let Some(rt) = redacted_thinking {
6213                msg.metadata.insert("redacted_thinking".to_string(), rt);
6214            }
6215            // D8: exact per-block re-emission list — every `thinking`/
6216            // `redacted_thinking` block preserved separately, in order, each
6217            // with its own (optional) signature/data. The writer prefers
6218            // this over the legacy singular fields above whenever present,
6219            // so a multi-block message round-trips losslessly instead of
6220            // collapsing to one block under one (now-unverifiable)
6221            // signature.
6222            if !thinking_blocks.is_empty() {
6223                msg.metadata.insert(
6224                    "thinking_blocks".to_string(),
6225                    Value::Array(thinking_blocks).to_string(),
6226                );
6227            }
6228            // D5: honest signal that this message contained an image block
6229            // whose source this loader couldn't convert — the actual image
6230            // content is NOT captured, only a marker/partial record.
6231            if saw_unconvertible_image {
6232                msg.metadata
6233                    .insert("image_source_unconvertible".to_string(), "true".to_string());
6234            }
6235            // Attribution: which skill / subagent / MCP server+tool produced
6236            // this turn, plus the model `slug`.
6237            for key in [
6238                "attributionSkill",
6239                "attributionAgent",
6240                "attributionMcpServer",
6241                "attributionMcpTool",
6242                "slug",
6243            ] {
6244                if let Some(s) = v.get(key).and_then(Value::as_str) {
6245                    msg.metadata.insert(key.to_string(), s.to_string());
6246                }
6247            }
6248        }
6249    }
6250}
6251
6252// ---- Codex ----------------------------------------------------------------
6253
6254const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6255
6256fn codex_provenance_kind(record: &Value) -> Option<&str> {
6257    match record.get("type").and_then(Value::as_str) {
6258        Some("session_meta") => Some("session_meta"),
6259        Some("turn_context") => Some("turn_context"),
6260        Some("compacted") => Some("compacted"),
6261        Some("event_msg") => match record
6262            .get("payload")
6263            .and_then(|payload| payload.get("type"))
6264            .and_then(Value::as_str)
6265        {
6266            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6267            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6268            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6269            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6270            _ => None,
6271        },
6272        _ => None,
6273    }
6274}
6275
6276fn capture_codex_provenance_record(
6277    meta: &mut SessionMeta,
6278    record_index: usize,
6279    raw_line: &str,
6280    record: &Value,
6281) {
6282    let Some(kind) = codex_provenance_kind(record) else {
6283        return;
6284    };
6285    meta.codex_provenance.push(serde_json::json!({
6286        "record_index": record_index,
6287        "kind": kind,
6288        "raw": raw_line,
6289    }));
6290}
6291
6292fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6293    (!meta.codex_provenance.is_empty()).then(|| {
6294        serde_json::json!({
6295            "version": 1,
6296            "records": &meta.codex_provenance,
6297        })
6298    })
6299}
6300
6301fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6302    if extension.get("version").and_then(Value::as_u64) != Some(1) {
6303        return Err(Error::InvalidSession(
6304            "invalid portable Codex provenance: expected version 1".to_string(),
6305        ));
6306    }
6307    let Some(records) = extension.get("records").and_then(Value::as_array) else {
6308        return Err(Error::InvalidSession(
6309            "invalid portable Codex provenance: `records` must be an array".to_string(),
6310        ));
6311    };
6312    if records.is_empty() {
6313        return Err(Error::InvalidSession(
6314            "invalid portable Codex provenance: `records` must not be empty".to_string(),
6315        ));
6316    }
6317    let mut restored = Vec::with_capacity(records.len());
6318    for entry in records {
6319        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6320            return Err(Error::InvalidSession(
6321                "invalid portable Codex provenance: record_index must be an integer".to_string(),
6322            ));
6323        };
6324        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6325            return Err(Error::InvalidSession(
6326                "invalid portable Codex provenance: kind must be a string".to_string(),
6327            ));
6328        };
6329        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6330            return Err(Error::InvalidSession(
6331                "invalid portable Codex provenance: raw must be a string".to_string(),
6332            ));
6333        };
6334        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6335            return Err(Error::InvalidSession(
6336                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6337            ));
6338        };
6339        if codex_provenance_kind(&record) != Some(kind) {
6340            return Err(Error::InvalidSession(format!(
6341                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6342            )));
6343        }
6344        restored.push(entry.clone());
6345    }
6346    meta.codex_provenance = restored;
6347    meta.codex_headers.clear();
6348    for entry in &meta.codex_provenance {
6349        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6350            continue;
6351        };
6352        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6353            continue;
6354        };
6355        if matches!(
6356            record.get("type").and_then(Value::as_str),
6357            Some("session_meta") | Some("turn_context")
6358        ) {
6359            meta.codex_headers.push(record);
6360        }
6361    }
6362    Ok(true)
6363}
6364
6365fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6366    match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6367        Some(extension) => restore_codex_provenance(extension, meta),
6368        None => Ok(false),
6369    }
6370}
6371
6372fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6373    let Some(line_end) = out.find('\n') else {
6374        return;
6375    };
6376    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6377        return;
6378    };
6379    let Some(object) = record.as_object_mut() else {
6380        return;
6381    };
6382    object.insert(key.to_string(), extension);
6383    out.replace_range(..line_end, &record.to_string());
6384}
6385
6386fn inject_codex_provenance(out: &mut String, extension: Value) {
6387    let Some(line_end) = out.find('\n') else {
6388        return;
6389    };
6390    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6391        return;
6392    };
6393    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6394        return;
6395    }
6396    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6397        return;
6398    };
6399    payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6400    out.replace_range(..line_end, &record.to_string());
6401}
6402
6403/// Remove the last conversational turn from `messages`: everything from the
6404/// last `user` message to the end (the user prompt plus the assistant's
6405/// response and any tool calls/results it triggered).
6406fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6407    if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6408        messages.truncate(idx);
6409    } else {
6410        messages.clear();
6411    }
6412    // IX-6 fix: the new tail exposed by `truncate` may still carry
6413    // `__codex_open_turn` from when it was marked (it was NOT the last
6414    // message at that time — items after it, now removed by the rollback,
6415    // intervened). A bare `function_call` arriving after the rollback is a
6416    // genuinely NEW turn and must get its own message, not merge into this
6417    // stale marked tail — close it out here so `push_codex_item`'s
6418    // adjacency check (`out.last()` + marker) can't be fooled by the
6419    // truncation re-exposing it.
6420    if let Some(last) = messages.last_mut() {
6421        last.metadata.remove("__codex_open_turn");
6422    }
6423}
6424
6425fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6426    truncate_messages_with_anchor(messages, message_limit, Vec::new());
6427}
6428
6429fn truncate_messages_with_anchor(
6430    messages: &mut Vec<ChatMessage>,
6431    message_limit: usize,
6432    preceding_users: Vec<ChatMessage>,
6433) {
6434    let limit = message_limit.max(1);
6435    // Anchor the numeric tail at its own human boundary. Choosing the newest
6436    // users anywhere in the window is not enough: after several short prompts
6437    // followed by a tool-heavy turn, those users can all sit near the end while
6438    // the tail begins in older orphaned tool activity. The UI then correctly
6439    // hides that activity, so increasing `tail_messages` appears to load
6440    // nothing and the next prompt can collapse the visible history.
6441    //
6442    // Keep up to two users from before the raw tail boundary. The nearest one
6443    // makes the retained activity a real turn; the preceding one preserves
6444    // overlap when one turn alone exceeds the whole numeric window.
6445    let raw_tail_start = messages.len().saturating_sub(limit);
6446    let anchor_limit = if messages.len() < limit {
6447        limit.saturating_sub(messages.len()).min(2)
6448    } else {
6449        limit.min(2)
6450    };
6451    let mut anchor_candidates = preceding_users;
6452    anchor_candidates.extend(
6453        messages[..raw_tail_start]
6454            .iter()
6455            .filter(|message| message.role == Role::User)
6456            .cloned(),
6457    );
6458    let mut anchors = anchor_candidates
6459        .into_iter()
6460        .rev()
6461        .take(anchor_limit)
6462        .collect::<Vec<_>>();
6463    anchors.reverse();
6464
6465    if messages.len() <= limit && anchors.is_empty() {
6466        return;
6467    }
6468    let tail_count = limit.saturating_sub(anchors.len()).min(messages.len());
6469    let tail_start = messages.len() - tail_count;
6470    let mut selected = Vec::with_capacity(anchors.len() + tail_count);
6471    selected.append(&mut anchors);
6472    selected.extend(messages[tail_start..].iter().cloned());
6473    debug_assert_eq!(selected.len(), limit.min(messages.len() + anchor_limit));
6474    *messages = selected;
6475}
6476
6477fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6478    truncate_messages(&mut session.messages, message_limit);
6479}
6480
6481/// The text of a Codex `agent_message` event. `message` is usually a string but
6482/// can be a structured object (e.g. review output) — fall back to its JSON.
6483fn agent_message_text(payload: &Value) -> String {
6484    match payload.get("message") {
6485        Some(Value::String(s)) => s.clone(),
6486        Some(other) => extract_text_content(Some(other)),
6487        None => String::new(),
6488    }
6489}
6490
6491/// Trimmed texts of all assistant messages present as `response_item` — the
6492/// dedup set for recovering collab-only `agent_message` narration.
6493fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6494    let mut set = std::collections::HashSet::new();
6495    for line in non_empty_lines(jsonl) {
6496        let Ok(v) = serde_json::from_str::<Value>(line) else {
6497            continue;
6498        };
6499        if v.get("type").and_then(Value::as_str) != Some("response_item") {
6500            continue;
6501        }
6502        let payload = v.get("payload").unwrap_or(&Value::Null);
6503        if payload.get("type").and_then(Value::as_str) == Some("message")
6504            && payload.get("role").and_then(Value::as_str) == Some("assistant")
6505        {
6506            let text = extract_text_content(payload.get("content"));
6507            if !text.trim().is_empty() {
6508                set.insert(text.trim().to_string());
6509            }
6510        }
6511    }
6512    set
6513}
6514
6515fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6516    if meta.session_id.is_none() {
6517        if let Some(id) = payload.get("id").and_then(Value::as_str) {
6518            meta.session_id = Some(id.to_string());
6519        }
6520    }
6521    if meta.cwd.is_none() {
6522        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6523            meta.cwd = Some(PathBuf::from(cwd));
6524        }
6525    }
6526    if meta.system_prompt.is_none() {
6527        // `base_instructions` may be a string or `{ "text": "..." }`.
6528        let bi = payload.get("base_instructions");
6529        let text = match bi {
6530            Some(Value::String(s)) => Some(s.clone()),
6531            Some(Value::Object(_)) => bi
6532                .and_then(|b| b.get("text"))
6533                .and_then(Value::as_str)
6534                .map(str::to_string),
6535            _ => None,
6536        };
6537        meta.system_prompt = text;
6538    }
6539    if meta.model.is_none() {
6540        if let Some(m) = payload.get("model").and_then(Value::as_str) {
6541            meta.model = Some(m.to_string());
6542        }
6543    }
6544    // Cross-file lineage keys for multi-agent / forked sessions.
6545    let mut put = |key: &str, v: Option<&Value>| {
6546        if let Some(s) = v.and_then(Value::as_str) {
6547            meta.lineage.insert(key.to_string(), s.to_string());
6548        }
6549    };
6550    put("parent_thread_id", payload.get("parent_thread_id"));
6551    put("forked_from_id", payload.get("forked_from_id"));
6552    put("thread_source", payload.get("thread_source"));
6553    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6554    // passthrough — restores a captured Claude `fork-context-ref` so a
6555    // Claude -> Codex -> Claude round trip reconstructs the original record
6556    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6557    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6558        if let Some(v) = payload.get("claude_fork_context_ref") {
6559            meta.lineage
6560                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6561        }
6562    }
6563    if let Some(spawn) = payload
6564        .get("source")
6565        .and_then(|s| s.get("subagent"))
6566        .and_then(|s| s.get("thread_spawn"))
6567    {
6568        // parent_thread_id can also live here (preferred when both present).
6569        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6570            meta.lineage
6571                .insert("parent_thread_id".to_string(), p.to_string());
6572        }
6573        for k in ["agent_role", "agent_nickname"] {
6574            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6575                meta.lineage.insert(k.to_string(), s.to_string());
6576            }
6577        }
6578        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6579            meta.lineage.insert("depth".to_string(), d.to_string());
6580        }
6581    }
6582}
6583
6584/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6585fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6586    let mut d = 0;
6587    let mut guard = 0;
6588    while let Some(p) = parent_of[i] {
6589        if p == i || guard > parent_of.len() {
6590            break;
6591        }
6592        i = p;
6593        d += 1;
6594        guard += 1;
6595    }
6596    d
6597}
6598
6599/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6600fn codex_turn_id(payload: &Value) -> Option<&str> {
6601    payload
6602        .get("metadata")
6603        .and_then(|m| m.get("turn_id"))
6604        .and_then(Value::as_str)
6605}
6606
6607/// N2 (spliced-export hardening): every Codex group id already present in
6608/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6609/// replays ahead of the appended tail it synthesizes via
6610/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6611/// physically lands in the exported `out` string for the prefix: each line
6612/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6613/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6614/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6615/// export) is extracted directly — no re-derivation from `self.messages`
6616/// needed (that would have to reconstruct which ids the ORIGINAL export
6617/// happened to assign, which this sidesteps entirely by reading them back
6618/// out of the bytes themselves). A line that fails to parse, isn't a
6619/// `response_item`, or carries no `turn_id` contributes nothing — headers
6620/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6621/// never carry this field to begin with.
6622fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6623    let mut ids = HashSet::new();
6624    for line in raw_prefix {
6625        if let Ok(v) = serde_json::from_str::<Value>(line) {
6626            if let Some(payload) = v.get("payload") {
6627                if let Some(tid) = codex_turn_id(payload) {
6628                    ids.insert(tid.to_string());
6629                }
6630            }
6631        }
6632    }
6633    ids
6634}
6635
6636/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6637/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6638/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6639/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6640/// message that already carries a more specific timestamp of its own is
6641/// never overwritten (none currently do on the Codex side, but this keeps
6642/// every loader consistent). A no-op when `ts` is `None` (a line with no
6643/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6644fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6645    let Some(ts) = ts else { return };
6646    let Some(slice) = messages.get_mut(from..) else {
6647        return;
6648    };
6649    for m in slice {
6650        m.metadata
6651            .entry("timestamp".to_string())
6652            .or_insert_with(|| ts.to_string());
6653    }
6654}
6655
6656fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6657    match payload.get("type").and_then(Value::as_str) {
6658        Some("message") => {
6659            let role = match payload.get("role").and_then(Value::as_str) {
6660                Some("user") => Role::User,
6661                Some("assistant") => Role::Assistant,
6662                // "developer" and "system" both carry operator instructions.
6663                _ => Role::System,
6664            };
6665            let content = payload.get("content");
6666            let text = extract_text_content(content);
6667            // IX-5: `input_image` blocks alongside/instead of text — see
6668            // `codex_extract_images`. A text-only message (no image blocks)
6669            // takes the historical `content: Some(text)` shape unchanged.
6670            let images = codex_extract_images(content);
6671            let is_empty_assistant =
6672                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6673            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6674                let content_parts = if images.is_empty() {
6675                    None
6676                } else {
6677                    let mut parts = Vec::new();
6678                    if !text.trim().is_empty() {
6679                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6680                    }
6681                    parts.extend(images);
6682                    Some(parts)
6683                };
6684                let mut msg = ChatMessage {
6685                    role,
6686                    content: if content_parts.is_some() || text.is_empty() {
6687                        None
6688                    } else {
6689                        Some(text)
6690                    },
6691                    content_parts,
6692                    tool_calls: None,
6693                    tool_call_id: None,
6694                    name: None,
6695                    metadata: Default::default(),
6696                };
6697                // Preserve the assistant `phase` (commentary vs final_answer) so
6698                // a reloaded transcript can distinguish narration from the answer.
6699                if role == Role::Assistant {
6700                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6701                        msg.metadata.insert("phase".to_string(), phase.to_string());
6702                    }
6703                    // IX-6: mark this as an open, mergeable combined-turn
6704                    // candidate — a `function_call` response_item found
6705                    // immediately after (still `out.last()` when reached,
6706                    // i.e. no other item intervened) merges into this SAME
6707                    // `ChatMessage` instead of splitting into a second one,
6708                    // matching how Claude's parser keeps a text+tool_use
6709                    // turn together. Stripped again before the loaded
6710                    // `Session` is returned (`from_codex_str`), so it never
6711                    // leaks as visible metadata.
6712                    msg.metadata
6713                        .insert("__codex_open_turn".to_string(), "true".to_string());
6714                }
6715                // The per-turn grouping key (Codex batches items by turn_id).
6716                if let Some(tid) = codex_turn_id(payload) {
6717                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
6718                }
6719                // PARITY-6 dev/02: restore the original Claude
6720                // `systemSubtype` for a `developer`/`system` message that
6721                // was itself synthesized FROM a real Claude system record
6722                // (`write_codex_records`'s `Role::System` arm stamps
6723                // `claude_system_subtype`) — the exact inverse, so
6724                // `write_claude_code_records`'s `Role::System` arm can
6725                // re-materialize the real Claude `type: "system"` record
6726                // faithfully on a Codex -> Claude Code hop instead of
6727                // guessing a fallback subtype.
6728                if role == Role::System {
6729                    if let Some(subtype) = payload
6730                        .get("metadata")
6731                        .and_then(|m| m.get("claude_system_subtype"))
6732                        .and_then(Value::as_str)
6733                    {
6734                        msg.metadata
6735                            .insert("systemSubtype".to_string(), subtype.to_string());
6736                    }
6737                }
6738                if is_empty_assistant {
6739                    msg.metadata
6740                        .insert("empty_assistant_record".to_string(), "true".to_string());
6741                }
6742                out.push(msg);
6743            }
6744        }
6745        Some("function_call") => {
6746            let id = payload
6747                .get("call_id")
6748                .and_then(Value::as_str)
6749                .unwrap_or_default();
6750            let raw_name = payload
6751                .get("name")
6752                .and_then(Value::as_str)
6753                .unwrap_or_default();
6754            // Preserve the MCP `namespace` by qualifying the tool name
6755            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6756            // so the tool identity isn't ambiguous on round-trip.
6757            let qualified;
6758            let name = match payload.get("namespace").and_then(Value::as_str) {
6759                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6760                    qualified = format!("{ns}__{raw_name}");
6761                    qualified.as_str()
6762                }
6763                _ => raw_name,
6764            };
6765            let args = payload
6766                .get("arguments")
6767                .map(value_to_arg_string)
6768                .unwrap_or_else(|| "{}".to_string());
6769            let call = function_call(id, name, args);
6770            // IX-6: a `function_call` immediately after an assistant `message`
6771            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6772            // by the "message" arm above, and not yet closed by anything else)
6773            // merges into that ONE `ChatMessage` — text→`content`,
6774            // call→`tool_calls` — instead of splitting into a second message.
6775            // A bare `function_call` with no such preceding turn (the marker
6776            // absent, or `out.last()` not an assistant message) is unaffected:
6777            // it still gets its own synthesized message, exactly as before.
6778            //
6779            // Belt-and-suspenders (PARITY-6/7 tightened): if this
6780            // `function_call` response_item itself carries a `turn_id` (rare
6781            // in observed real-native-Codex corpora — Codex usually only
6782            // stamps it on `message` payloads — but ALWAYS present on OUR
6783            // OWN synthesized export whenever a `ChatMessage`'s own tool
6784            // calls need merge disambiguation, see `write_codex_records`),
6785            // it must match the marked assistant message's recorded
6786            // `turn_id` EXACTLY — including "the marked message has none at
6787            // all" counting as a mismatch. That's exactly the shape of two
6788            // genuinely separate, adjacent `ChatMessage`s (an unrelated
6789            // text-only turn immediately followed by a different,
6790            // tool-call-only turn): the tool-only turn's own `function_call`s
6791            // carry a synthetic id while the unrelated preceding text
6792            // message carries none, so this correctly refuses the merge
6793            // instead of falling through to a permissive default. Only when
6794            // this `function_call` carries NO `turn_id` at all (the ordinary
6795            // real-native-Codex shape) does this fall back to the original
6796            // permissive "adjacency + open marker is enough" rule —
6797            // unchanged from before for the vast majority of real Codex
6798            // data. The truncation/clear strip above is what actually closes
6799            // the marker across rollback/compaction boundaries; this is only
6800            // an extra guard for the case where a stale-but-unstripped
6801            // marker and a turn_id mismatch coincide.
6802            let can_merge = out.last().is_some_and(|last| {
6803                last.role == Role::Assistant
6804                    && last.metadata.contains_key("__codex_open_turn")
6805                    && match codex_turn_id(payload) {
6806                        Some(fc_tid) => {
6807                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6808                        }
6809                        None => true,
6810                    }
6811            });
6812            if can_merge {
6813                out.last_mut()
6814                    .expect("can_merge implies out.last() is Some")
6815                    .tool_calls
6816                    .get_or_insert_with(Vec::new)
6817                    .push(call);
6818            } else {
6819                push_assistant(out, String::new(), vec![call]);
6820                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6821                // in this turn, so nothing set `__codex_open_turn` above) can
6822                // still be the FIRST of several tool calls that all belong to
6823                // the SAME original `ChatMessage` (`write_codex_records`
6824                // stamps every one of a message's own tool calls with the
6825                // identical synthetic `turn_id`). Re-open THIS freshly
6826                // created message — but ONLY when a real `turn_id` is
6827                // present — so the NEXT `function_call` in the same group
6828                // merges into it instead of becoming its own message too.
6829                // Gated on `codex_turn_id(payload).is_some()` (not the bare
6830                // default `true` the belt-and-suspenders check above uses)
6831                // so real native Codex data — which almost never carries
6832                // this field on `function_call` payloads (see the comment
6833                // above) — keeps its existing "every bare tool call is its
6834                // own turn" behavior exactly as before.
6835                if let Some(tid) = codex_turn_id(payload) {
6836                    if let Some(last) = out.last_mut() {
6837                        last.metadata
6838                            .insert("__codex_open_turn".to_string(), "true".to_string());
6839                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6840                    }
6841                }
6842            }
6843        }
6844        Some("function_call_output") => {
6845            let id = payload
6846                .get("call_id")
6847                .and_then(Value::as_str)
6848                .unwrap_or_default();
6849            let result = match payload.get("output") {
6850                Some(Value::String(s)) => s.clone(),
6851                Some(v) => extract_text_content(Some(v)),
6852                None => String::new(),
6853            };
6854            let mut message = tool_message(id, result);
6855            // TR-13: Codex v1 exposes no structured success/error field on
6856            // this record. Free-text output is not a safe classifier, so the
6857            // reduction engine must treat the outcome as explicitly unknown
6858            // and fail closed on both success-only and error-only pruning.
6859            crate::mark_tool_outcome_unknown(&mut message);
6860            out.push(message);
6861        }
6862        // Custom / MCP tool calls are shaped like function calls but carry their
6863        // arguments under `input` (a JSON-encoded string). Normalize them the
6864        // same way so MCP-using sessions don't lose those turns.
6865        Some("custom_tool_call") => {
6866            let id = payload
6867                .get("call_id")
6868                .and_then(Value::as_str)
6869                .unwrap_or_default();
6870            let name = payload
6871                .get("name")
6872                .and_then(Value::as_str)
6873                .unwrap_or_default();
6874            // Unlike `function_call.arguments`, Codex custom tools accept a
6875            // free-form `input` string (apply_patch is the common case).
6876            // Canonical `FunctionCall::arguments` must remain valid JSON, so
6877            // retain the input's JSON type instead of treating a free-form
6878            // string as if it were already a JSON document. This lets every
6879            // target harness carry the value rather than silently replacing
6880            // it with `{}` when `parsed_arguments()` fails.
6881            let args = payload
6882                .get("input")
6883                .map(Value::to_string)
6884                .unwrap_or_else(|| "{}".to_string());
6885            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6886            if let Some(message) = out.last_mut() {
6887                message.metadata.insert(
6888                    "codex_custom_tool_call_ids".to_string(),
6889                    serde_json::json!([id]).to_string(),
6890                );
6891            }
6892        }
6893        Some("custom_tool_call_output") => {
6894            let id = payload
6895                .get("call_id")
6896                .and_then(Value::as_str)
6897                .unwrap_or_default();
6898            let result = match payload.get("output") {
6899                Some(Value::String(s)) => s.clone(),
6900                Some(v) => extract_text_content(Some(v)),
6901                None => String::new(),
6902            };
6903            let mut message = tool_message(id, result);
6904            crate::mark_tool_outcome_unknown(&mut message);
6905            out.push(message);
6906        }
6907        // Tool-search is a clean call/output pair keyed by call_id.
6908        //
6909        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6910        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6911        // its own synthesized `tool_search_call` records (see the PARITY-6/7
6912        // comment there and on `codex_turn_id`/the `function_call` arm
6913        // above). That left the same bug-class the turn_id work fixed for
6914        // `function_call` half-done here: a single Claude assistant message
6915        // containing text + a `tool_search` block reloaded as 2 messages
6916        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6917        // reloaded as 3. Mirror the `function_call` arm's merge check
6918        // exactly so a `tool_search_call` immediately following an open
6919        // assistant turn (or another tool call sharing the same `turn_id`)
6920        // merges into that SAME `ChatMessage` instead of splitting.
6921        Some("tool_search_call") => {
6922            let id = payload
6923                .get("call_id")
6924                .and_then(Value::as_str)
6925                .unwrap_or_default();
6926            let args = payload
6927                .get("arguments")
6928                .map(value_to_arg_string)
6929                .unwrap_or_else(|| "{}".to_string());
6930            let call = function_call(id, "tool_search", args);
6931            let can_merge = out.last().is_some_and(|last| {
6932                last.role == Role::Assistant
6933                    && last.metadata.contains_key("__codex_open_turn")
6934                    && match codex_turn_id(payload) {
6935                        Some(fc_tid) => {
6936                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6937                        }
6938                        None => true,
6939                    }
6940            });
6941            if can_merge {
6942                out.last_mut()
6943                    .expect("can_merge implies out.last() is Some")
6944                    .tool_calls
6945                    .get_or_insert_with(Vec::new)
6946                    .push(call);
6947            } else {
6948                push_assistant(out, String::new(), vec![call]);
6949                // Re-open the freshly created message so a FOLLOWING
6950                // `function_call`/`tool_search_call` sharing this same
6951                // `turn_id` merges into it too — matching the bare
6952                // `function_call` case's own re-open logic above.
6953                if let Some(tid) = codex_turn_id(payload) {
6954                    if let Some(last) = out.last_mut() {
6955                        last.metadata
6956                            .insert("__codex_open_turn".to_string(), "true".to_string());
6957                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6958                    }
6959                }
6960            }
6961        }
6962        Some("tool_search_output") => {
6963            let id = payload
6964                .get("call_id")
6965                .and_then(Value::as_str)
6966                .unwrap_or_default();
6967            let result = payload
6968                .get("tools")
6969                .map(value_to_arg_string)
6970                .unwrap_or_default();
6971            out.push(tool_message(id, result));
6972        }
6973        // Web-search / image-generation response_items carry no paired output
6974        // here (results live in event_msg), so emit an assistant marker rather
6975        // than a dangling unanswered tool call.
6976        Some("web_search_call") => {
6977            push_assistant(out, "[web_search]".to_string(), Vec::new());
6978        }
6979        Some("image_generation_call") => {
6980            let prompt = payload
6981                .get("revised_prompt")
6982                .and_then(Value::as_str)
6983                .unwrap_or("");
6984            push_assistant(
6985                out,
6986                format!("[image_generation] {prompt}").trim().to_string(),
6987                Vec::new(),
6988            );
6989        }
6990        // "reasoning" and anything else — dropped.
6991        _ => {}
6992    }
6993}
6994
6995// ---- Grok -------------------------------------------------------------
6996
6997const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6998
6999fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
7000    value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
7001        "schema": 1,
7002        "role": message.role,
7003        "content": message.content,
7004        "content_parts": message.content_parts,
7005        "tool_calls": message.tool_calls,
7006        "tool_call_id": message.tool_call_id,
7007        "name": message.name,
7008        "metadata": message.metadata,
7009    });
7010}
7011
7012fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
7013    let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
7014        return;
7015    };
7016    if extension.get("schema").and_then(Value::as_u64) != Some(1) {
7017        return;
7018    }
7019    if let Some(role) = extension
7020        .get("role")
7021        .and_then(|value| serde_json::from_value(value.clone()).ok())
7022    {
7023        message.role = role;
7024    }
7025    message.content = extension
7026        .get("content")
7027        .and_then(Value::as_str)
7028        .map(str::to_string);
7029    message.content_parts = extension
7030        .get("content_parts")
7031        .and_then(|value| serde_json::from_value(value.clone()).ok());
7032    message.tool_calls = extension
7033        .get("tool_calls")
7034        .and_then(|value| serde_json::from_value(value.clone()).ok());
7035    message.tool_call_id = extension
7036        .get("tool_call_id")
7037        .and_then(Value::as_str)
7038        .map(str::to_string);
7039    message.name = extension
7040        .get("name")
7041        .and_then(Value::as_str)
7042        .map(str::to_string);
7043    message.metadata.clear();
7044    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7045        for (key, value) in metadata {
7046            if let Some(value) = value.as_str() {
7047                message.metadata.insert(key.clone(), value.to_string());
7048            }
7049        }
7050    }
7051}
7052
7053fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
7054    for key in keys {
7055        if let Some(value) = value.get(*key) {
7056            message.metadata.insert(
7057                format!("grok_{key}"),
7058                value
7059                    .as_str()
7060                    .map(str::to_string)
7061                    .unwrap_or_else(|| value.to_string()),
7062            );
7063        }
7064    }
7065}
7066
7067fn grok_human_user_text(raw: &str) -> Option<String> {
7068    let text = raw.trim();
7069    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
7070        return None;
7071    }
7072    let unwrapped = text
7073        .strip_prefix("<user_query>")
7074        .and_then(|value| value.strip_suffix("</user_query>"))
7075        .map(str::trim)
7076        .unwrap_or(text);
7077    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
7078}
7079
7080/// Portable extension for messages whose canonical fields cannot be expressed
7081/// by the target's stock schema. It was introduced for Grok and retains that
7082/// on-disk key for compatibility. Gemini has the same need: Claude Code and
7083/// Codex have no native slot for a tool-result name or Gemini-only metadata.
7084/// Their readers tolerate unknown namespaced fields, so forwarding this
7085/// adapter-owned envelope keeps those cross-format hops reversible without
7086/// pretending the stock schemas represent the fields directly.
7087const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
7088
7089/// Namespaced line-level extension carrying the one tool-result outcome state
7090/// Claude cannot represent natively. Keeping this narrower than the full Grok
7091/// portability envelope avoids changing unrelated target-message projection.
7092const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
7093
7094fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
7095    if !crate::is_tool_error(message)
7096        && value
7097            .get(SUPERCODE_TOOL_OUTCOME_KEY)
7098            .and_then(Value::as_str)
7099            == Some("unknown")
7100    {
7101        crate::mark_tool_outcome_unknown(message);
7102    }
7103}
7104
7105fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
7106    let metadata = message
7107        .metadata
7108        .iter()
7109        .filter(|(key, _)| {
7110            key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
7111        })
7112        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
7113        .collect::<serde_json::Map<_, _>>();
7114
7115    // `meta.source` changes after every reload. Keying portability only on
7116    // the immediate source therefore made Grok metadata survive one hop but
7117    // disappear on A -> B -> C translations. Once Grok-owned fields are
7118    // present, keep forwarding them regardless of the current container.
7119    let has_portable_fields =
7120        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7121    (matches!(
7122        source,
7123        SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7124    ) || has_portable_fields
7125        || message.content_parts.is_some())
7126    .then(|| {
7127        serde_json::json!({
7128            "schema": 2,
7129            "role": message.role,
7130            "content": message.content,
7131            "content_parts": message.content_parts,
7132            "tool_calls": message.tool_calls,
7133            "tool_call_id": message.tool_call_id,
7134            "name": message.name,
7135            "metadata": message.metadata,
7136        })
7137    })
7138}
7139
7140fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7141    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7142        "schema": 2,
7143        "role": message.role,
7144        "content": message.content,
7145        "content_parts": message.content_parts,
7146        "tool_calls": message.tool_calls,
7147        "tool_call_id": message.tool_call_id,
7148        "name": message.name,
7149        "metadata": message.metadata,
7150    });
7151}
7152
7153fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7154    if let Some(extension) = grok_message_extension(source, message) {
7155        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7156    }
7157}
7158
7159fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7160    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7161        return;
7162    };
7163    // Codex temporarily marks a text assistant item so immediately-following
7164    // function-call items can merge back into the same canonical turn. The
7165    // portable envelope must not erase that loader-private marker before the
7166    // merge happens; `from_codex_str` removes it before returning.
7167    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7168    let codex_turn_id = message.metadata.get("turn_id").cloned();
7169    let extension_has_turn_id = extension
7170        .get("metadata")
7171        .and_then(Value::as_object)
7172        .is_some_and(|metadata| metadata.contains_key("turn_id"));
7173    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7174        if let Some(role) = extension
7175            .get("role")
7176            .and_then(|value| serde_json::from_value(value.clone()).ok())
7177        {
7178            message.role = role;
7179        }
7180        message.content = extension
7181            .get("content")
7182            .and_then(Value::as_str)
7183            .map(str::to_string);
7184        message.content_parts = extension
7185            .get("content_parts")
7186            .and_then(|value| serde_json::from_value(value.clone()).ok());
7187        // Tool calls are shared native structure in every supported format.
7188        // Keep the loader's reconstruction instead of restoring this copy:
7189        // Codex stores a combined text+tool turn across multiple records, so
7190        // eagerly restoring calls on its text record would duplicate them
7191        // when the following function-call records merge.
7192        message.tool_call_id = extension
7193            .get("tool_call_id")
7194            .and_then(Value::as_str)
7195            .map(str::to_string);
7196        message.name = extension
7197            .get("name")
7198            .and_then(Value::as_str)
7199            .map(str::to_string);
7200        message.metadata.clear();
7201    }
7202    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7203        for (key, value) in metadata {
7204            if let Some(value) = value.as_str() {
7205                message.metadata.insert(key.clone(), value.to_string());
7206            }
7207        }
7208    }
7209    if let Some(name) = extension.get("name").and_then(Value::as_str) {
7210        message.name = Some(name.to_string());
7211    }
7212    if let Some(marker) = codex_open_turn {
7213        message
7214            .metadata
7215            .insert("__codex_open_turn".to_string(), marker);
7216    }
7217    if let Some(turn_id) = codex_turn_id {
7218        message.metadata.insert("turn_id".to_string(), turn_id);
7219        if !extension_has_turn_id {
7220            message.metadata.insert(
7221                "__grok_remove_synthetic_turn_id".to_string(),
7222                "true".to_string(),
7223            );
7224        }
7225    }
7226}
7227
7228fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7229    if let [message] = messages {
7230        restore_grok_message_extension(value, message);
7231    }
7232}
7233
7234fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7235    let role = match native.get("role").and_then(Value::as_str) {
7236        Some("assistant") => Role::Assistant,
7237        _ => Role::User,
7238    };
7239    let created = native.get("created").and_then(Value::as_i64);
7240    let native_id = native.get("id").and_then(Value::as_str);
7241    let mut text = Vec::new();
7242    let mut content_parts = Vec::new();
7243    let mut tool_calls = Vec::new();
7244    let mut tool_results = Vec::new();
7245
7246    for (block_index, block) in native
7247        .get("content")
7248        .and_then(Value::as_array)
7249        .into_iter()
7250        .flatten()
7251        .enumerate()
7252    {
7253        match block.get("type").and_then(Value::as_str) {
7254            Some("text") => {
7255                if let Some(value) = block.get("text").and_then(Value::as_str) {
7256                    text.push(value.to_string());
7257                    content_parts.push(serde_json::json!({"type": "text", "text": value}));
7258                }
7259            }
7260            Some("image") => {
7261                let data = block
7262                    .get("data")
7263                    .and_then(Value::as_str)
7264                    .unwrap_or_default();
7265                let media_type = block
7266                    .get("mimeType")
7267                    .or_else(|| block.get("mime_type"))
7268                    .and_then(Value::as_str)
7269                    .unwrap_or("application/octet-stream");
7270                content_parts.push(serde_json::json!({
7271                    "type": "image_url",
7272                    "image_url": {"url": format!("data:{media_type};base64,{data}")},
7273                }));
7274            }
7275            Some("toolRequest" | "frontendToolRequest") => {
7276                let id = block
7277                    .get("id")
7278                    .and_then(Value::as_str)
7279                    .map(str::to_string)
7280                    .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7281                let call = block
7282                    .get("toolCall")
7283                    .and_then(|call| {
7284                        (call.get("status").and_then(Value::as_str) == Some("success"))
7285                            .then(|| call.get("value"))
7286                            .flatten()
7287                    })
7288                    .or_else(|| block.get("toolCall"));
7289                let Some(call) = call else { continue };
7290                let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7291                let arguments = call
7292                    .get("arguments")
7293                    .map(value_to_arg_string)
7294                    .unwrap_or_else(|| "{}".to_string());
7295                tool_calls.push(function_call(&id, name, arguments));
7296            }
7297            Some("toolResponse") => tool_results.push(block.clone()),
7298            _ => {}
7299        }
7300    }
7301
7302    if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7303        let has_non_text = content_parts
7304            .iter()
7305            .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7306        let mut message = ChatMessage {
7307            role,
7308            content: (!text.is_empty()).then(|| text.join("\n")),
7309            content_parts: has_non_text.then_some(content_parts),
7310            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7311            tool_call_id: None,
7312            name: None,
7313            metadata: Default::default(),
7314        };
7315        capture_goose_message_metadata(native, created, native_id, &mut message);
7316        out.push(message);
7317    }
7318
7319    for (result_index, block) in tool_results.into_iter().enumerate() {
7320        let id = block
7321            .get("id")
7322            .and_then(Value::as_str)
7323            .map(str::to_string)
7324            .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7325        let result = block.get("toolResult").unwrap_or(&Value::Null);
7326        let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7327        let value = result.get("value").unwrap_or(result);
7328        let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7329        let output = if status_error {
7330            result
7331                .get("error")
7332                .and_then(Value::as_str)
7333                .unwrap_or("Goose tool call failed")
7334                .to_string()
7335        } else {
7336            value
7337                .get("content")
7338                .and_then(Value::as_array)
7339                .map(|content| {
7340                    content
7341                        .iter()
7342                        .filter_map(|part| {
7343                            part.get("text")
7344                                .and_then(Value::as_str)
7345                                .map(str::to_string)
7346                                .or_else(|| Some(part.to_string()))
7347                        })
7348                        .collect::<Vec<_>>()
7349                        .join("\n")
7350                })
7351                .unwrap_or_else(|| value.to_string())
7352        };
7353        let mut message = tool_message(&id, output);
7354        if is_error {
7355            crate::mark_tool_error(&mut message);
7356        }
7357        capture_goose_message_metadata(native, created, native_id, &mut message);
7358        out.push(message);
7359    }
7360}
7361
7362fn capture_goose_message_metadata(
7363    native: &Value,
7364    created: Option<i64>,
7365    native_id: Option<&str>,
7366    message: &mut ChatMessage,
7367) {
7368    if let Some(created) = created {
7369        message
7370            .metadata
7371            .insert("goose_created".to_string(), created.to_string());
7372    }
7373    if let Some(native_id) = native_id {
7374        message
7375            .metadata
7376            .insert("goose_message_id".to_string(), native_id.to_string());
7377    }
7378    if let Some(metadata) = native.get("metadata") {
7379        message
7380            .metadata
7381            .insert("goose_metadata".to_string(), metadata.to_string());
7382    }
7383}
7384
7385#[doc(hidden)]
7386pub fn percent_decode_path(encoded: &str) -> Option<String> {
7387    fn hex(byte: u8) -> Option<u8> {
7388        match byte {
7389            b'0'..=b'9' => Some(byte - b'0'),
7390            b'a'..=b'f' => Some(byte - b'a' + 10),
7391            b'A'..=b'F' => Some(byte - b'A' + 10),
7392            _ => None,
7393        }
7394    }
7395
7396    let bytes = encoded.as_bytes();
7397    let mut decoded = Vec::with_capacity(bytes.len());
7398    let mut index = 0usize;
7399    while index < bytes.len() {
7400        if bytes[index] == b'%' {
7401            let high = *bytes.get(index + 1)?;
7402            let low = *bytes.get(index + 2)?;
7403            decoded.push(hex(high)? * 16 + hex(low)?);
7404            index += 3;
7405        } else {
7406            decoded.push(bytes[index]);
7407            index += 1;
7408        }
7409    }
7410    String::from_utf8(decoded).ok()
7411}
7412
7413// ---- Pi ---------------------------------------------------------------
7414
7415fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7416    restore_codex_provenance_from_top_level(v, meta)?;
7417    if let Some(id) = v.get("id").and_then(Value::as_str) {
7418        meta.session_id = Some(id.to_string());
7419    }
7420    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7421        meta.cwd = Some(PathBuf::from(cwd));
7422    }
7423    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7424    let version = v
7425        .get("version")
7426        .and_then(Value::as_u64)
7427        .map(|n| n.to_string())
7428        .unwrap_or_else(|| "1".to_string());
7429    meta.lineage.insert("pi_version".to_string(), version);
7430    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7431        meta.lineage
7432            .insert("created_at".to_string(), ts.to_string());
7433    }
7434    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7435        meta.lineage
7436            .insert("parent_session_path".to_string(), ps.to_string());
7437    }
7438    // D7: the other half of `push_pi_header`'s passthrough — restores a
7439    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7440    // trip reconstructs the original record (mirrors
7441    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7442    // restore for the Codex hop).
7443    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7444        if let Some(v) = v.get("claude_fork_context_ref") {
7445            meta.lineage
7446                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7447        }
7448    }
7449    Ok(())
7450}
7451
7452/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7453/// `(mime, data)` when it looks like a real image payload.
7454///
7455/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7456/// `ai:316-350` for the `ImageContent` content-block union but does not
7457/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7458/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7459/// Anthropic multimodal wire shape) is this loader's best guess, not a
7460/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7461/// against a real pi corpus. Until then this function VALIDATES rather than
7462/// assumes: both fields must be present, non-empty strings, and `data` must
7463/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7464/// else is an unknown/unexpected image shape, and the caller must route the
7465/// whole message to raw-only survival (S6-style fail loud) instead of
7466/// silently synthesizing a corrupt/empty `image_url` part.
7467fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7468    let mime = item.get("mimeType").and_then(Value::as_str)?;
7469    let data = item.get("data").and_then(Value::as_str)?;
7470    if mime.is_empty() || data.is_empty() {
7471        return None;
7472    }
7473    if !data
7474        .bytes()
7475        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7476    {
7477        return None;
7478    }
7479    Some((mime.to_string(), data.to_string()))
7480}
7481
7482/// True if `content` (a pi content value: bare string or
7483/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7484/// that does not match [`pi_image_shape`] — shared by the loader (which
7485/// routes such a message to raw-only survival, never a synthesized-empty
7486/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7487/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7488/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7489#[doc(hidden)]
7490pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7491    let Some(Value::Array(items)) = content else {
7492        return false;
7493    };
7494    items.iter().any(|item| {
7495        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7496    })
7497}
7498
7499/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7500/// into concatenated text plus, when a WELL-FORMED image block is present,
7501/// the full `content_parts` array (leading text block + one `image_url` part
7502/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7503/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7504/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7505///
7506/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7507/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7508/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7509/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7510/// every caller must treat that as raw-only survival for the whole message
7511/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7512/// guessed wrong fails loud instead of silently dropping/corrupting the
7513/// image.
7514fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7515    match content {
7516        Some(Value::String(s)) => (s.clone(), None, false),
7517        Some(Value::Array(items)) => {
7518            let mut text = String::new();
7519            let mut parts: Vec<Value> = Vec::new();
7520            let mut has_image = false;
7521            let mut unknown_image_shape = false;
7522            for item in items {
7523                match item.get("type").and_then(Value::as_str) {
7524                    Some("text") => {
7525                        if let Some(t) = item.get("text").and_then(Value::as_str) {
7526                            push_str_field(&mut text, t);
7527                        }
7528                    }
7529                    Some("image") => {
7530                        has_image = true;
7531                        match pi_image_shape(item) {
7532                            Some((mime, data)) => {
7533                                parts.push(serde_json::json!({
7534                                    "type": "image_url",
7535                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
7536                                }));
7537                            }
7538                            None => unknown_image_shape = true,
7539                        }
7540                    }
7541                    _ => {}
7542                }
7543            }
7544            if unknown_image_shape {
7545                // Never synthesize an empty/corrupt part for a shape we
7546                // don't recognize — raw-only survival for the whole message;
7547                // the coverage guard is what turns this into a visible
7548                // failure (S6-style).
7549                return (String::new(), None, true);
7550            }
7551            if has_image {
7552                if !text.trim().is_empty() {
7553                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7554                }
7555                (text, Some(parts), false)
7556            } else {
7557                (text, None, false)
7558            }
7559        }
7560        _ => (String::new(), None, false),
7561    }
7562}
7563
7564fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7565    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7566    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7567    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7568    // `message/UnknownImageShape` bucket is what turns this into a visible
7569    // coverage failure.
7570    if unknown_image_shape {
7571        return;
7572    }
7573    if text.trim().is_empty() && parts.is_none() {
7574        return;
7575    }
7576    let mut msg = match parts {
7577        Some(parts) => ChatMessage {
7578            role: Role::User,
7579            content: None,
7580            content_parts: Some(parts),
7581            tool_calls: None,
7582            tool_call_id: None,
7583            name: None,
7584            metadata: Default::default(),
7585        },
7586        None => ChatMessage::user(text),
7587    };
7588    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7589    // (`message.timestamp`) is a DISTINCT field from the canonical
7590    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7591    // carry genuinely different values in real corpora (the fixture's are
7592    // ~6 months apart). Preserve it separately so it isn't silently lost for
7593    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7594    // native round-trip consumer) and the INHERENT residue note on
7595    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7596    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7597        msg.metadata
7598            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7599    }
7600    out.push(msg);
7601}
7602
7603fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7604    let mut text = String::new();
7605    let mut calls: Vec<ToolCall> = Vec::new();
7606    let mut thinking = String::new();
7607    let mut thinking_seen = false;
7608    let mut thinking_sig: Option<String> = None;
7609    let mut thinking_redacted = false;
7610    let mut text_sig: Option<String> = None;
7611    let mut thought_sig: Option<String> = None;
7612
7613    if let Some(Value::Array(blocks)) = msg_v.get("content") {
7614        for b in blocks {
7615            match b.get("type").and_then(Value::as_str) {
7616                Some("text") => {
7617                    if let Some(t) = b.get("text").and_then(Value::as_str) {
7618                        push_str_field(&mut text, t);
7619                    }
7620                    if let Some(sig) = b.get("textSignature") {
7621                        text_sig = Some(match sig {
7622                            Value::String(s) => s.clone(),
7623                            other => other.to_string(),
7624                        });
7625                    }
7626                }
7627                Some("thinking") => {
7628                    thinking_seen = true;
7629                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7630                        push_str_field(&mut thinking, t);
7631                    }
7632                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7633                        thinking_sig = Some(sig.to_string());
7634                    }
7635                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7636                        thinking_redacted = true;
7637                    }
7638                }
7639                Some("toolCall") => {
7640                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7641                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7642                    // `arguments` is a JSON OBJECT on pi's wire, not a string
7643                    // (`pi-fields.md` §3b open question 4) — serialize to the
7644                    // string `FunctionCall::arguments` expects.
7645                    let args = b
7646                        .get("arguments")
7647                        .cloned()
7648                        .unwrap_or_else(|| Value::Object(Default::default()));
7649                    calls.push(function_call(id, name, args.to_string()));
7650                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7651                        thought_sig = Some(sig.to_string());
7652                    }
7653                }
7654                _ => {}
7655            }
7656        }
7657    }
7658
7659    let before = out.len();
7660    push_assistant(out, text, calls);
7661    // A recognized native assistant entry remains transcript state even
7662    // when its content array is empty, except Pi's explicit empty error
7663    // response: that record has no replayable content and is established
7664    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7665    // non-error turns and Pi's standalone thinking-block shape.
7666    let is_empty_error =
7667        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7668    if out.len() == before && !is_empty_error {
7669        let mut empty = ChatMessage {
7670            role: Role::Assistant,
7671            content: None,
7672            content_parts: None,
7673            tool_calls: None,
7674            tool_call_id: None,
7675            name: None,
7676            metadata: Default::default(),
7677        };
7678        if !thinking_seen {
7679            empty
7680                .metadata
7681                .insert("empty_assistant_record".to_string(), "true".to_string());
7682        }
7683        out.push(empty);
7684    }
7685    if out.len() > before {
7686        let msg = out.last_mut().expect("just pushed");
7687        if thinking_seen {
7688            msg.metadata.insert("thinking".to_string(), thinking);
7689        }
7690        if let Some(s) = thinking_sig {
7691            msg.metadata.insert("thinking_signature".to_string(), s);
7692        }
7693        if thinking_redacted {
7694            msg.metadata
7695                .insert("pi_thinking_redacted".to_string(), "true".to_string());
7696        }
7697        if let Some(s) = text_sig {
7698            msg.metadata.insert("pi_text_signature".to_string(), s);
7699        }
7700        if let Some(s) = thought_sig {
7701            msg.metadata.insert("pi_thought_signature".to_string(), s);
7702        }
7703        for (key, field) in [
7704            ("pi_api", "api"),
7705            ("pi_provider", "provider"),
7706            ("pi_response_model", "responseModel"),
7707            ("pi_response_id", "responseId"),
7708            ("pi_stop_reason", "stopReason"),
7709            ("pi_error_message", "errorMessage"),
7710        ] {
7711            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7712                msg.metadata.insert(key.to_string(), s.to_string());
7713            }
7714        }
7715        if let Some(diag) = msg_v.get("diagnostics") {
7716            if !diag.is_null() {
7717                msg.metadata
7718                    .insert("pi_diagnostics".to_string(), diag.to_string());
7719            }
7720        }
7721        if let Some(usage) = msg_v.get("usage") {
7722            if !usage.is_null() {
7723                msg.metadata
7724                    .insert("pi_usage".to_string(), usage.to_string());
7725            }
7726        }
7727        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7728        // separately from the canonical entry-level ISO `timestamp` — see
7729        // `push_pi_user`.
7730        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7731            msg.metadata
7732                .insert("pi_msg_timestamp".to_string(), ts.to_string());
7733        }
7734    }
7735}
7736
7737fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7738    let id = msg_v
7739        .get("toolCallId")
7740        .and_then(Value::as_str)
7741        .unwrap_or_default();
7742    let name = msg_v
7743        .get("toolName")
7744        .and_then(Value::as_str)
7745        .unwrap_or_default();
7746    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7747    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7748    // survival, never a synthesized-empty part. Dropping the toolResult
7749    // message here leaves its `toolCallId` unanswered, which
7750    // `ensure_tool_results_paired` already turns into a visible
7751    // "[no tool result recorded — turn interrupted]" placeholder — a loud
7752    // failure mode, not a silent one.
7753    if unknown_image_shape {
7754        return;
7755    }
7756    let mut msg = ChatMessage {
7757        role: Role::Tool,
7758        content: Some(text),
7759        content_parts: parts,
7760        tool_calls: None,
7761        tool_call_id: Some(id.to_string()),
7762        name: Some(name.to_string()),
7763        metadata: Default::default(),
7764    };
7765    if let Some(details) = msg_v.get("details") {
7766        if !details.is_null() {
7767            msg.metadata
7768                .insert("pi_tool_details".to_string(), details.to_string());
7769        }
7770    }
7771    let is_error = msg_v
7772        .get("isError")
7773        .and_then(Value::as_bool)
7774        .unwrap_or(false);
7775    msg.metadata
7776        .insert("pi_is_error".to_string(), is_error.to_string());
7777    if is_error {
7778        crate::mark_tool_error(&mut msg);
7779    }
7780    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7781    // separately from the canonical entry-level ISO `timestamp` — see
7782    // `push_pi_user`.
7783    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7784        msg.metadata
7785            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7786    }
7787    out.push(msg);
7788}
7789
7790/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7791/// pi itself sends the model, mirroring `bashExecutionToText`
7792/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7793/// aren't reproduced in the frozen research doc (only cited by file:line),
7794/// so this is a faithful, clearly-labeled reconstruction — every structured
7795/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7796fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7797    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7798    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7799    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7800    let cancelled = msg_v
7801        .get("cancelled")
7802        .and_then(Value::as_bool)
7803        .unwrap_or(false);
7804    let truncated = msg_v
7805        .get("truncated")
7806        .and_then(Value::as_bool)
7807        .unwrap_or(false);
7808
7809    let mut text = format!("$ {command}\n{output}");
7810    if let Some(code) = exit_code {
7811        if code != 0 {
7812            text.push_str(&format!("\n[exit code: {code}]"));
7813        }
7814    }
7815    if cancelled {
7816        text.push_str("\n[cancelled]");
7817    }
7818    if truncated {
7819        text.push_str("\n[truncated]");
7820    }
7821
7822    let mut msg = ChatMessage::user(text);
7823    msg.metadata
7824        .insert("pi_bash_command".to_string(), command.to_string());
7825    msg.metadata
7826        .insert("pi_bash_output".to_string(), output.to_string());
7827    if let Some(code) = exit_code {
7828        msg.metadata
7829            .insert("pi_bash_exit_code".to_string(), code.to_string());
7830    }
7831    msg.metadata
7832        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7833    msg.metadata
7834        .insert("pi_bash_truncated".to_string(), truncated.to_string());
7835    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7836        msg.metadata
7837            .insert("pi_bash_full_output_path".to_string(), p.to_string());
7838    }
7839    // `!!` — hidden from the model context; honored by `is_replay_excluded`
7840    // on every writer, not just pi's own (§2.2).
7841    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7842        msg.metadata
7843            .insert("pi_exclude_from_context".to_string(), "true".to_string());
7844    }
7845    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7846    // separately from the canonical entry-level ISO `timestamp` — see
7847    // `push_pi_user`.
7848    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7849        msg.metadata
7850            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7851    }
7852    out.push(msg);
7853}
7854
7855/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7856/// stamps on a re-materialized content-bearing Claude `system` record (see
7857/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7858/// never collide with a real pi `CustomMessage.customType` — pi's own
7859/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7860/// migration targets), never this literal string.
7861const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7862
7863/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7864/// `custom_message` entries (§9) — both enter context as a `User` message
7865/// with the same `customType`/`display`/`details` residue.
7866///
7867/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7868/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7869/// actually a re-materialized content-bearing Claude `system` record round-
7870/// tripping through pi, not a genuine pi extension message — restore
7871/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7872/// claude_system_subtype`, falling back to `local_command` — still one of
7873/// `push_claude_system`'s own keep subtypes — exactly like
7874/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7875/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7876/// the exact original role, not just the text.
7877fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7878    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7879        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7880        if content.trim().is_empty() {
7881            return;
7882        }
7883        let subtype = v
7884            .get("details")
7885            .and_then(|d| d.get("claude_system_subtype"))
7886            .and_then(Value::as_str)
7887            .unwrap_or("local_command");
7888        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7889        return;
7890    }
7891    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7892    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7893    // survival, never a synthesized-empty part.
7894    if unknown_image_shape {
7895        return;
7896    }
7897    if text.trim().is_empty() && parts.is_none() {
7898        return;
7899    }
7900    let mut msg = match parts {
7901        Some(parts) => ChatMessage {
7902            role: Role::User,
7903            content: None,
7904            content_parts: Some(parts),
7905            tool_calls: None,
7906            tool_call_id: None,
7907            name: None,
7908            metadata: Default::default(),
7909        },
7910        None => ChatMessage::user(text),
7911    };
7912    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7913        msg.metadata
7914            .insert("pi_custom_type".to_string(), ct.to_string());
7915    }
7916    if let Some(d) = v.get("display").and_then(Value::as_bool) {
7917        msg.metadata.insert("pi_display".to_string(), d.to_string());
7918    }
7919    if let Some(details) = v.get("details") {
7920        if !details.is_null() {
7921            msg.metadata
7922                .insert("pi_details".to_string(), details.to_string());
7923        }
7924    }
7925    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7926    // separately from the canonical entry-level ISO `timestamp` — see
7927    // `push_pi_user`. `v` here is the `message` object for the `role:
7928    // "custom"` case; for the top-level `custom_message` case `v` is the
7929    // entry itself, whose `timestamp` is the entry-level ISO string (not a
7930    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7931    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7932        msg.metadata
7933            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7934    }
7935    out.push(msg);
7936}
7937
7938/// pi's own prefix-wrapped user text for a `compaction` entry summary
7939/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7940/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7941/// reproduced in the frozen research doc; this is a clearly-labeled
7942/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7943fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7944    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7945    if summary.trim().is_empty() {
7946        return;
7947    }
7948    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7949    msg.metadata
7950        .insert("pi_type".to_string(), "compaction".to_string());
7951    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7952        msg.metadata
7953            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7954    }
7955    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7956        msg.metadata
7957            .insert("pi_tokens_before".to_string(), tb.to_string());
7958    }
7959    if let Some(d) = entry_v.get("details") {
7960        if !d.is_null() {
7961            msg.metadata.insert("pi_details".to_string(), d.to_string());
7962        }
7963    }
7964    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7965        msg.metadata
7966            .insert("pi_from_hook".to_string(), "true".to_string());
7967    }
7968    out.push(msg);
7969}
7970
7971/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7972/// rewind-with-summary) — same reconstruction caveat as
7973/// [`push_pi_compaction`].
7974fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7975    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7976    if summary.trim().is_empty() {
7977        return;
7978    }
7979    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
7980    msg.metadata
7981        .insert("pi_type".to_string(), "branch_summary".to_string());
7982    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7983        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7984    }
7985    if let Some(d) = entry_v.get("details") {
7986        if !d.is_null() {
7987            msg.metadata.insert("pi_details".to_string(), d.to_string());
7988        }
7989    }
7990    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7991        msg.metadata
7992            .insert("pi_from_hook".to_string(), "true".to_string());
7993    }
7994    out.push(msg);
7995}
7996
7997// ---- OpenCode ---------------------------------------------------------
7998
7999/// The placeholder opencode's own replay substitutes for a `tool` part's
8000/// output once `state.completed.time.compacted` is set
8001/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
8002/// erased from the record (S1); it survives in `raw` and in this loader's
8003/// `metadata["oc_tool_output_compacted"]`.
8004pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
8005
8006fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
8007    restore_codex_provenance_from_top_level(si, meta)?;
8008    if let Some(id) = si.get("id").and_then(Value::as_str) {
8009        meta.session_id = Some(id.to_string());
8010    }
8011    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
8012        meta.cwd = Some(PathBuf::from(dir));
8013    }
8014    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
8015        meta.agent_id = Some(agent.to_string());
8016    }
8017    if let Some(model) = si.get("model") {
8018        let provider = model.get("providerID").and_then(Value::as_str);
8019        let id = model.get("id").and_then(Value::as_str);
8020        if let (Some(p), Some(i)) = (provider, id) {
8021            meta.model = Some(format!("{p}/{i}"));
8022        }
8023    }
8024    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
8025        meta.lineage
8026            .insert("projectID".to_string(), project_id.to_string());
8027    }
8028    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
8029        meta.lineage.insert("slug".to_string(), slug.to_string());
8030    }
8031    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
8032        meta.lineage
8033            .insert("workspaceID".to_string(), ws.to_string());
8034    }
8035    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
8036        meta.lineage
8037            .insert("parent_session_id".to_string(), parent.to_string());
8038        // Mirrored under the Codex-originated lineage key so the existing
8039        // generic `Session::reconstruct_tree` nests opencode subagent
8040        // sessions too, with no format-specific nesting pass (§2.1: "child
8041        // session's parentID ... → drives reconstruct_tree").
8042        meta.lineage
8043            .insert("parent_thread_id".to_string(), parent.to_string());
8044    }
8045    // D7: the other half of `synthesized_opencode_info`'s passthrough —
8046    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
8047    // -> Claude round trip reconstructs the original record (mirrors
8048    // `capture_codex_session_meta`/`capture_pi_header`'s identical
8049    // `claude_fork_context_ref` restore for the Codex/Pi hops).
8050    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
8051        if let Some(v) = si.get("claude_fork_context_ref") {
8052            meta.lineage
8053                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
8054        }
8055    }
8056    Ok(())
8057}
8058
8059/// An opencode `User`/`Assistant` `file` part's image data-URI →
8060/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
8061/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
8062/// a bare filesystem path, an `https:` link, or a non-image mime is left as
8063/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
8064/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
8065/// coverage with the SAME test this loader uses to canonicalize it (D5) —
8066/// one definition of "is this file part actually replayed", not two.
8067#[doc(hidden)]
8068pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
8069    let mime = part.get("mime").and_then(Value::as_str)?;
8070    let url = part.get("url").and_then(Value::as_str)?;
8071    if !mime.starts_with("image/") || !url.starts_with("data:") {
8072        return None;
8073    }
8074    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8075}
8076
8077/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
8078/// `Role::System` arm stamps on the one `synthetic: true` text part of a
8079/// re-materialized content-bearing Claude `system` record (see that arm's
8080/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
8081/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
8082const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
8083
8084/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
8085/// `User` message with EXACTLY one `synthetic: true` text part carrying
8086/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
8087/// opencode data is never misclassified — a genuine opencode `synthetic`
8088/// text part never carries this supercode-namespaced key, and a real
8089/// multi-part user message (text + an attached file, say) never matches
8090/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
8091/// (e.g. `local_command`) on a match.
8092fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
8093    let [part] = parts else { return None };
8094    if part.get("type").and_then(Value::as_str) != Some("text") {
8095        return None;
8096    }
8097    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
8098        return None;
8099    }
8100    part.get("metadata")
8101        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
8102        .and_then(Value::as_str)
8103        .map(str::to_string)
8104}
8105
8106/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
8107/// and `metadata["systemSubtype"]` from the marked text part instead of
8108/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
8109/// OpenCode -> Claude round trip restores the exact original role, not just
8110/// the text. Content is never fabricated — only emitted when non-empty.
8111fn push_opencode_claude_system(
8112    msg_value: &Value,
8113    parts: &[Value],
8114    subtype: String,
8115    out: &mut Vec<ChatMessage>,
8116) {
8117    let Some(text) = parts
8118        .first()
8119        .and_then(|p| p.get("text"))
8120        .and_then(Value::as_str)
8121    else {
8122        return;
8123    };
8124    if text.trim().is_empty() {
8125        return;
8126    }
8127    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8128    set_opencode_msg_timestamp(&mut msg, msg_value);
8129    out.push(msg);
8130}
8131
8132/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8133/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8134/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8135/// the model"); `file` parts with a recognized image shape become
8136/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8137/// `SessionMeta.system_prompt` on the first turn that carries it, and
8138/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8139/// per-user-message, not per-session").
8140/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8141/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8142/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8143/// (opencode's own wire granularity); a `None`/malformed `time.created`
8144/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8145/// `SYNTH_TS`/`SYNTH_TS_MS`.
8146fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8147    if let Some(ms) = msg_value
8148        .get("time")
8149        .and_then(|t| t.get("created"))
8150        .and_then(Value::as_i64)
8151    {
8152        msg.metadata
8153            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8154    }
8155}
8156
8157fn push_opencode_user(
8158    msg_value: &Value,
8159    parts: &[Value],
8160    out: &mut Vec<ChatMessage>,
8161    meta: &mut SessionMeta,
8162    first_system_seen: &mut bool,
8163) {
8164    let mut text = String::new();
8165    let mut image_parts: Vec<Value> = Vec::new();
8166    let mut has_ignored = false;
8167    for p in parts {
8168        match p.get("type").and_then(Value::as_str) {
8169            Some("text") => {
8170                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8171                    has_ignored = true;
8172                    continue; // must never be replayed (§2.2)
8173                }
8174                if let Some(t) = p.get("text").and_then(Value::as_str) {
8175                    push_str_field(&mut text, t);
8176                }
8177            }
8178            Some("file") => {
8179                if let Some(img) = opencode_file_image_part(p) {
8180                    image_parts.push(img);
8181                }
8182            }
8183            // reasoning/tool never appear on a User message; step-start,
8184            // step-finish, snapshot, patch, agent, subtask, retry have no
8185            // clean home (§2.3); compaction is read separately by the
8186            // caller (tail_start_id) and tagged onto the message below.
8187            _ => {}
8188        }
8189    }
8190
8191    let has_images = !image_parts.is_empty();
8192    if text.trim().is_empty() && !has_images {
8193        return;
8194    }
8195    let mut msg = if has_images {
8196        let mut all = Vec::new();
8197        if !text.trim().is_empty() {
8198            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8199        }
8200        all.extend(image_parts);
8201        ChatMessage {
8202            role: Role::User,
8203            content: None,
8204            content_parts: Some(all),
8205            tool_calls: None,
8206            tool_call_id: None,
8207            name: None,
8208            metadata: Default::default(),
8209        }
8210    } else {
8211        ChatMessage::user(text)
8212    };
8213
8214    if has_ignored {
8215        msg.metadata
8216            .insert("oc_has_ignored_part".to_string(), "true".to_string());
8217    }
8218    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8219        msg.metadata
8220            .insert("oc_message_id".to_string(), id.to_string());
8221    }
8222    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8223        msg.metadata.insert("agent".to_string(), agent.to_string());
8224    }
8225    if let Some(model) = msg_value.get("model") {
8226        if !model.is_null() {
8227            msg.metadata.insert("model".to_string(), model.to_string());
8228        }
8229    }
8230    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8231        if !*first_system_seen {
8232            meta.system_prompt = Some(system.to_string());
8233            *first_system_seen = true;
8234        }
8235        msg.metadata
8236            .insert("system".to_string(), system.to_string());
8237    }
8238    for p in parts {
8239        if p.get("type").and_then(Value::as_str) == Some("compaction") {
8240            msg.metadata
8241                .insert("phase".to_string(), "compaction".to_string());
8242            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8243                msg.metadata
8244                    .insert("tail_start_id".to_string(), t.to_string());
8245            }
8246        }
8247    }
8248    set_opencode_msg_timestamp(&mut msg, msg_value);
8249    restore_grok_message_extension(msg_value, &mut msg);
8250    out.push(msg);
8251}
8252
8253/// Map an opencode `Assistant` message + its parts to a canonical
8254/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8255/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8256/// reached `completed`/`error` — the split-by-`callID` opencode's single
8257/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8258/// interrupted turn) synthesize no tool call/result of their own here; the
8259/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8260/// like the other three loaders. A `tool` part whose `state.status` is none
8261/// of the four known values is skipped entirely — raw-only survival, never
8262/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8263fn push_opencode_assistant(
8264    msg_value: &Value,
8265    parts: &[Value],
8266    out: &mut Vec<ChatMessage>,
8267    meta: &mut SessionMeta,
8268) {
8269    let mut text = String::new();
8270    let mut calls: Vec<ToolCall> = Vec::new();
8271    let mut thinking = String::new();
8272    let mut reasoning_seen = false;
8273    let mut thinking_sig: Option<String> = None;
8274    // (call_id, tool_name, the tool part itself) — deferred so the
8275    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8276    // every other loader's message ordering (call, then result).
8277    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8278
8279    for p in parts {
8280        match p.get("type").and_then(Value::as_str) {
8281            Some("text") => {
8282                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8283                    continue;
8284                }
8285                if let Some(t) = p.get("text").and_then(Value::as_str) {
8286                    push_str_field(&mut text, t);
8287                }
8288            }
8289            Some("reasoning") => {
8290                reasoning_seen = true;
8291                if let Some(t) = p.get("text").and_then(Value::as_str) {
8292                    push_str_field(&mut thinking, t);
8293                }
8294                if let Some(sig) = p
8295                    .get("metadata")
8296                    .and_then(|m| m.get("anthropic"))
8297                    .and_then(|a| a.get("signature"))
8298                    .and_then(Value::as_str)
8299                {
8300                    thinking_sig = Some(sig.to_string());
8301                }
8302            }
8303            Some("tool") => {
8304                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8305                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8306                let status = p
8307                    .get("state")
8308                    .and_then(|s| s.get("status"))
8309                    .and_then(Value::as_str);
8310                let known_status = matches!(
8311                    status,
8312                    Some("pending") | Some("running") | Some("completed") | Some("error")
8313                );
8314                if call_id.is_empty() || !known_status {
8315                    // Unknown/unrecognized status, or a malformed part with
8316                    // no callID — raw-only survival, never synthesized.
8317                    continue;
8318                }
8319                let input = p
8320                    .get("state")
8321                    .and_then(|s| s.get("input"))
8322                    .cloned()
8323                    .unwrap_or_else(|| Value::Object(Default::default()));
8324                calls.push(function_call(call_id, tool_name, input.to_string()));
8325                if matches!(status, Some("completed") | Some("error")) {
8326                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8327                }
8328            }
8329            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8330            // — no clean home on an Assistant turn (§2.3).
8331            _ => {}
8332        }
8333    }
8334
8335    let before = out.len();
8336    push_assistant(out, text, calls);
8337    // A native OpenCode assistant record is transcript state even when it
8338    // has no parts. Real stores contain these after an interrupted/empty
8339    // model turn; dropping the record here loses its id, timestamp, model,
8340    // token/cost metadata, and shifts the conversation on every export.
8341    // Keep one empty canonical assistant message so all target writers can
8342    // preserve the turn. This also covers reasoning-only records (whose
8343    // reasoning payload is attached as metadata just below).
8344    if out.len() == before {
8345        let mut empty = ChatMessage {
8346            role: Role::Assistant,
8347            content: None,
8348            content_parts: None,
8349            tool_calls: None,
8350            tool_call_id: None,
8351            name: None,
8352            metadata: Default::default(),
8353        };
8354        if !reasoning_seen {
8355            empty
8356                .metadata
8357                .insert("empty_assistant_record".to_string(), "true".to_string());
8358        }
8359        out.push(empty);
8360    }
8361    if out.len() > before {
8362        let msg = out.last_mut().expect("just pushed");
8363        if reasoning_seen {
8364            msg.metadata.insert("thinking".to_string(), thinking);
8365        }
8366        if let Some(sig) = thinking_sig {
8367            msg.metadata.insert("thinking_signature".to_string(), sig);
8368        }
8369        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8370            msg.metadata
8371                .insert("oc_message_id".to_string(), id.to_string());
8372        }
8373        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8374            msg.metadata.insert("agent".to_string(), agent.to_string());
8375            if meta.agent_id.is_none() {
8376                meta.agent_id = Some(agent.to_string());
8377            }
8378        }
8379        let provider = msg_value.get("providerID").and_then(Value::as_str);
8380        let model_id = msg_value.get("modelID").and_then(Value::as_str);
8381        if let (Some(p), Some(i)) = (provider, model_id) {
8382            let full = format!("{p}/{i}");
8383            msg.metadata.insert("model".to_string(), full.clone());
8384            if meta.model.is_none() {
8385                meta.model = Some(full);
8386            }
8387        }
8388        if let Some(cwd) = msg_value
8389            .get("path")
8390            .and_then(|p| p.get("cwd"))
8391            .and_then(Value::as_str)
8392        {
8393            if meta.cwd.is_none() {
8394                meta.cwd = Some(PathBuf::from(cwd));
8395            }
8396        }
8397        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8398            msg.metadata
8399                .insert("is_summary".to_string(), "true".to_string());
8400        }
8401        for (key, field) in [
8402            ("finish", "finish"),
8403            ("variant", "variant"),
8404            ("mode", "mode"),
8405        ] {
8406            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8407                msg.metadata.insert(key.to_string(), s.to_string());
8408            }
8409        }
8410        for (key, field) in [
8411            ("cost", "cost"),
8412            ("tokens", "tokens"),
8413            ("error", "error"),
8414            ("structured", "structured"),
8415        ] {
8416            if let Some(v) = msg_value.get(field) {
8417                if !v.is_null() {
8418                    msg.metadata.insert(key.to_string(), v.to_string());
8419                }
8420            }
8421        }
8422        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8423        // the spawned child session id — keyed by callID so multiple `task`
8424        // calls in one message never collide.
8425        // `resolve_opencode_parent_tool_use_ids` reads these back once a
8426        // whole session set is loaded.
8427        for p in parts {
8428            if p.get("type").and_then(Value::as_str) == Some("tool")
8429                && p.get("tool").and_then(Value::as_str) == Some("task")
8430            {
8431                if let (Some(call_id), Some(child)) = (
8432                    p.get("callID").and_then(Value::as_str),
8433                    p.get("metadata")
8434                        .and_then(|m| m.get("sessionId"))
8435                        .and_then(Value::as_str),
8436                ) {
8437                    msg.metadata.insert(
8438                        format!("oc_task_child_session_id__{call_id}"),
8439                        child.to_string(),
8440                    );
8441                }
8442            }
8443        }
8444        set_opencode_msg_timestamp(msg, msg_value);
8445        restore_grok_message_extension(msg_value, msg);
8446    }
8447
8448    // Second pass: the paired Tool-role message for each completed/error
8449    // tool part, split by callID (§2.1 — "the SAME part carries call and
8450    // result").
8451    for (call_id, tool_name, part) in tool_results {
8452        let status = part
8453            .get("state")
8454            .and_then(|s| s.get("status"))
8455            .and_then(Value::as_str);
8456        let compacted_at = part
8457            .get("state")
8458            .and_then(|s| s.get("time"))
8459            .and_then(|t| t.get("compacted"))
8460            .and_then(Value::as_i64);
8461        let real_output = part
8462            .get("state")
8463            .and_then(|s| s.get("output"))
8464            .and_then(Value::as_str)
8465            .unwrap_or("")
8466            .to_string();
8467        let (content, is_error) = match status {
8468            Some("completed") => {
8469                if compacted_at.is_some() {
8470                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8471                } else {
8472                    (real_output.clone(), false)
8473                }
8474            }
8475            Some("error") => {
8476                let err = part
8477                    .get("state")
8478                    .and_then(|s| s.get("error"))
8479                    .and_then(Value::as_str)
8480                    .unwrap_or("")
8481                    .to_string();
8482                (err, true)
8483            }
8484            _ => (String::new(), false),
8485        };
8486        let mut tmsg = ChatMessage {
8487            role: Role::Tool,
8488            content: Some(content),
8489            content_parts: None,
8490            tool_calls: None,
8491            tool_call_id: Some(call_id),
8492            name: Some(tool_name),
8493            metadata: Default::default(),
8494        };
8495        if let Some(original_position) = part
8496            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8497            .and_then(Value::as_u64)
8498        {
8499            tmsg.metadata.insert(
8500                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8501                original_position.to_string(),
8502            );
8503        }
8504        if is_error {
8505            crate::mark_tool_error(&mut tmsg);
8506        }
8507        restore_tool_outcome_extension(&part, &mut tmsg);
8508        if let Some(ts) = compacted_at {
8509            // S1: the real output is preserved — reversible, never erased.
8510            tmsg.metadata
8511                .insert("oc_tool_output_compacted".to_string(), real_output);
8512            tmsg.metadata
8513                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8514        }
8515        if status == Some("completed") {
8516            if let Some(atts) = part
8517                .get("state")
8518                .and_then(|s| s.get("attachments"))
8519                .and_then(Value::as_array)
8520            {
8521                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8522                if !images.is_empty() {
8523                    // D-mix consistency fix (Fable-recommended, same
8524                    // pattern as `push_claude_user`'s tool_result arm above):
8525                    // a completed opencode tool part with BOTH `state.output`
8526                    // text and `state.attachments` images is the same
8527                    // non-self-contained hybrid shape — `content_parts` here
8528                    // used to hold images only, so opencode -> pi silently
8529                    // dropped the output text (`pi_content_value` reads
8530                    // `content_parts` exclusively for `Role::Tool`). Prepend
8531                    // the text as part 0 so `content_parts` is
8532                    // self-contained; `tmsg.content` keeps the text too,
8533                    // unchanged, for writers that read it from there and
8534                    // only scan `content_parts` for `image_url` entries.
8535                    let mut parts = Vec::new();
8536                    if let Some(t) = &tmsg.content {
8537                        if !t.is_empty() {
8538                            parts.push(serde_json::json!({"type": "text", "text": t}));
8539                        }
8540                    }
8541                    parts.extend(images);
8542                    tmsg.content_parts = Some(parts);
8543                }
8544            }
8545        }
8546        if let Some(id) = part.get("id").and_then(Value::as_str) {
8547            tmsg.metadata
8548                .insert("oc_part_id".to_string(), id.to_string());
8549        }
8550        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8551        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8552        // cite `state.time.compacted`, but the SAME object also carries
8553        // `start`/`end` on every completed/error call) is this Tool
8554        // message's real source timestamp; prefer `end` (completion, closer
8555        // to when the RESULT — this message's content — was produced) and
8556        // fall back to `start` when only that is present.
8557        let tool_ts = part
8558            .get("state")
8559            .and_then(|s| s.get("time"))
8560            .and_then(|t| t.get("end").or_else(|| t.get("start")))
8561            .and_then(Value::as_i64);
8562        if let Some(ms) = tool_ts {
8563            tmsg.metadata
8564                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8565        }
8566        // OpenCode folds a canonical tool result into the assistant's tool
8567        // part. Restore the portable envelope from that part after native
8568        // fields have been captured so A -> OpenCode -> A retains fields
8569        // OpenCode does not model independently (for example Goose's
8570        // message-level metadata and an intentionally absent tool name).
8571        restore_grok_message_extension(&part, &mut tmsg);
8572        out.push(tmsg);
8573    }
8574}
8575
8576// ---- shared helpers -------------------------------------------------------
8577
8578fn push_text(buf: &mut String, v: Option<&Value>) {
8579    if let Some(Value::String(s)) = v {
8580        if !buf.is_empty() {
8581            buf.push('\n');
8582        }
8583        buf.push_str(s);
8584    }
8585}
8586
8587/// Extract a Claude `tool_result` block's content, preserving non-text items
8588/// instead of silently dropping them:
8589///
8590/// - text blocks are concatenated;
8591/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8592///   PNG / screenshot tool output" shape): `image` blocks are captured into
8593///   the returned `content_parts`-shaped `Vec<Value>` via
8594///   [`claude_image_block_to_part`] — the SAME base64/url conversion the
8595///   top-level `image` content-block path (`push_claude_user`) already uses
8596///   — instead of being flattened to the bare `[image]` marker text that used
8597///   to make the data unrecoverable from every writer. An unconvertible
8598///   source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8599///   etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8600///   vanishing, exactly like the top-level path;
8601/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8602///
8603/// and if the block yields no text/images at all, fall back to the record's
8604/// `toolUseResult` field (string used directly, structured value serialized),
8605/// which is where Claude Code stores the actual result in many cases.
8606///
8607/// Returns `(text, images)`; callers that only need the old text-only
8608/// behavior can ignore the second element — every caller MUST fold non-empty
8609/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8610/// function has no `ChatMessage` to attach to).
8611fn extract_tool_result_content(
8612    content: Option<&Value>,
8613    tool_use_result: Option<&Value>,
8614) -> (String, Vec<Value>) {
8615    let mut parts: Vec<String> = Vec::new();
8616    let mut images: Vec<Value> = Vec::new();
8617    match content {
8618        Some(Value::String(s)) => {
8619            if !s.is_empty() {
8620                parts.push(s.clone());
8621            }
8622        }
8623        Some(Value::Array(items)) => {
8624            for item in items {
8625                match item.get("type").and_then(Value::as_str) {
8626                    Some("text") => {
8627                        if let Some(t) = item.get("text").and_then(Value::as_str) {
8628                            parts.push(t.to_string());
8629                        }
8630                    }
8631                    Some("image") => match claude_image_block_to_part(item) {
8632                        Some(part) => images.push(part),
8633                        None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8634                    },
8635                    Some("tool_reference") => {
8636                        let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8637                        parts.push(format!("[tool_reference: {name}]"));
8638                    }
8639                    _ => {
8640                        if let Some(s) = item.as_str() {
8641                            parts.push(s.to_string());
8642                        }
8643                    }
8644                }
8645            }
8646        }
8647        Some(other) => parts.push(other.to_string()),
8648        None => {}
8649    }
8650
8651    let joined = parts.join("\n");
8652    if !joined.trim().is_empty() || !images.is_empty() {
8653        return (joined, images);
8654    }
8655    // Empty tool_result content — recover from toolUseResult.
8656    match tool_use_result {
8657        Some(Value::String(s)) => (s.clone(), images),
8658        Some(v) => (v.to_string(), images),
8659        None => (joined, images),
8660    }
8661}
8662
8663/// Pull readable text out of a content value that may be a plain string or an
8664/// array of `{ "text": "..." }`-bearing blocks (any block type).
8665fn extract_text_content(v: Option<&Value>) -> String {
8666    match v {
8667        Some(Value::String(s)) => s.clone(),
8668        Some(Value::Array(items)) => {
8669            let mut parts = Vec::new();
8670            for item in items {
8671                if let Some(t) = item.get("text").and_then(Value::as_str) {
8672                    parts.push(t.to_string());
8673                } else if let Some(s) = item.as_str() {
8674                    parts.push(s.to_string());
8675                }
8676            }
8677            parts.join("\n")
8678        }
8679        Some(other) => other.to_string(),
8680        None => String::new(),
8681    }
8682}
8683
8684/// Extract Codex `input_image` content blocks from a `message` response_item's
8685/// `content` value into `content_parts` `image_url` entries — the inverse of
8686/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8687/// block whose `image_url` is a non-empty string is recognized; anything else
8688/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8689/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8690/// the pi/opencode/Claude loaders' image-shape discipline.
8691fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8692    let Some(Value::Array(items)) = content else {
8693        return Vec::new();
8694    };
8695    items
8696        .iter()
8697        .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8698        .filter_map(|item| {
8699            let url = item.get("image_url").and_then(Value::as_str)?;
8700            if url.is_empty() {
8701                return None;
8702            }
8703            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8704        })
8705        .collect()
8706}
8707
8708fn value_to_arg_string(v: &Value) -> String {
8709    match v {
8710        Value::String(s) => s.clone(),
8711        other => other.to_string(),
8712    }
8713}
8714
8715fn push_gemini_user_parts(
8716    messages: &mut Vec<ChatMessage>,
8717    content_parts: Vec<Value>,
8718    timestamp: Option<&str>,
8719    source: &Value,
8720) {
8721    if content_parts.is_empty() {
8722        return;
8723    }
8724    let mut message = ChatMessage {
8725        role: Role::User,
8726        content: None,
8727        content_parts: Some(content_parts),
8728        tool_calls: None,
8729        tool_call_id: None,
8730        name: None,
8731        metadata: Default::default(),
8732    };
8733    if let Some(timestamp) = timestamp {
8734        message
8735            .metadata
8736            .insert("timestamp".into(), timestamp.into());
8737    }
8738    restore_gemini_message_extension(source, &mut message);
8739    messages.push(message);
8740}
8741
8742fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8743    ToolCall {
8744        id: id.to_string(),
8745        kind: "function".to_string(),
8746        function: FunctionCall {
8747            name: name.to_string(),
8748            arguments,
8749        },
8750    }
8751}
8752
8753fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8754    ChatMessage {
8755        role: Role::Tool,
8756        content: Some(content),
8757        content_parts: None,
8758        tool_calls: None,
8759        tool_call_id: Some(tool_call_id.to_string()),
8760        name: None,
8761        metadata: Default::default(),
8762    }
8763}
8764
8765/// Emit a single assistant message combining accumulated text and tool calls.
8766/// A turn with neither (e.g. thinking-only) produces nothing.
8767fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8768    let has_text = !text.trim().is_empty();
8769    if !has_text && calls.is_empty() {
8770        return;
8771    }
8772    out.push(ChatMessage {
8773        role: Role::Assistant,
8774        content: has_text.then_some(text),
8775        content_parts: None,
8776        tool_calls: (!calls.is_empty()).then_some(calls),
8777        tool_call_id: None,
8778        name: None,
8779        metadata: Default::default(),
8780    });
8781}
8782
8783// ---- writers --------------------------------------------------------------
8784
8785/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8786/// fallback (`docs/interop` build brief): every writer now emits a message's
8787/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8788/// field every loader populates) when one is present. `SYNTH_TS` fires only
8789/// for a message with no source timestamp at all — a turn synthesized/
8790/// appended after import (the live agent loop, a splice's appended tail,
8791/// ...), which was never loaded from a real per-message timestamp to begin
8792/// with. Both tools tolerate identical timestamps; callers that need real
8793/// ones for a synthesized turn can post-process.
8794const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8795
8796/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8797/// `time.created`/`time.updated` fields.
8798const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8799
8800/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8801/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8802/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8803/// parse, not just a presence check) so an absent, empty, or malformed
8804/// source value all degrade to the same documented fallback rather than
8805/// propagating garbage verbatim. Used by every writer that emits an
8806/// ISO-8601 timestamp field
8807/// (Claude Code, Codex, pi's entry-level `timestamp`).
8808fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8809    match msg.metadata.get("timestamp") {
8810        Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8811        _ => SYNTH_TS,
8812    }
8813}
8814
8815/// OpenCode reloads an export document by sorting messages on
8816/// `time.created`, so a timestamp-less appended continuation cannot reuse
8817/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8818/// newer. Advance a deterministic cursor for synthesized clocks while still
8819/// preserving every real source timestamp verbatim.
8820fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8821    if let Some(real) = msg
8822        .metadata
8823        .get("timestamp")
8824        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8825    {
8826        // A NativeTurn timestamp is durable provenance minted by supercode,
8827        // not an OpenCode source clock that must be replayed verbatim.
8828        // Multiple turns may be recorded in the same millisecond, while
8829        // OpenCode sorts solely by `time.created`; allocate such turns after
8830        // the existing cursor so their persisted order cannot collapse. This
8831        // also preserves the fail-closed i64::MAX exhaustion behavior.
8832        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8833            *cursor = cursor.checked_add(1).ok_or_else(|| {
8834                crate::Error::Other(
8835                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8836                        .to_string(),
8837                )
8838            })?;
8839            return Ok(*cursor);
8840        }
8841        *cursor = (*cursor).max(real);
8842        return Ok(real);
8843    }
8844    let next = cursor.checked_add(1).ok_or_else(|| {
8845        crate::Error::Other(
8846            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8847        )
8848    })?;
8849    *cursor = next.max(SYNTH_TS_MS);
8850    Ok(*cursor)
8851}
8852
8853/// Largest integer nested under any OpenCode `time` object. Imported
8854/// prefixes carry more clocks than `message.time.created` (assistant
8855/// completion, tool start/end, session updated); a synthesized continuation
8856/// must follow all of them, not merely sort after message creation times.
8857fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8858    fn max_number(value: &Value) -> Option<i64> {
8859        match value {
8860            Value::Number(n) => n.as_i64(),
8861            Value::Array(values) => values.iter().filter_map(max_number).max(),
8862            Value::Object(fields) => fields.values().filter_map(max_number).max(),
8863            _ => None,
8864        }
8865    }
8866
8867    match value {
8868        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8869        Value::Object(fields) => fields
8870            .iter()
8871            .filter_map(|(key, value)| {
8872                if key == "time" {
8873                    max_number(value)
8874                } else {
8875                    opencode_max_timestamp(value)
8876                }
8877            })
8878            .max(),
8879        _ => None,
8880    }
8881}
8882
8883/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8884/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8885/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8886/// reads. The two carry genuinely different values in real pi corpora (a
8887/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8888/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8889/// nested `message.timestamp` field, so a pi -> pi native round-trip
8890/// preserves the source message-level clock value-exact instead of deriving
8891/// it from the (distinct) entry-level timestamp. Falls back to
8892/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8893/// reading (non-pi-sourced, or a synthesized/appended turn).
8894fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8895    msg.metadata
8896        .get("pi_msg_timestamp")
8897        .and_then(|s| s.parse::<i64>().ok())
8898        .unwrap_or(SYNTH_TS_MS)
8899}
8900
8901/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8902fn synth_uuid(n: usize) -> String {
8903    format!("00000000-0000-4000-8000-{n:012x}")
8904}
8905
8906/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8907/// class N2 closed for the Codex spliced path's group ids, see
8908/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8909/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8910/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8911/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8912/// ahead of the tail this counter mints. Without this, re-splicing a
8913/// previously-exported-then-reimported session (export -> reimport -> append
8914/// -> export again) restarts `counter` at 1 with no memory of the prior
8915/// export's tail uuids now sitting in the prefix, so the second tail
8916/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8917/// — a uuid collision across prefix and tail that can mis-link any
8918/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8919/// climbing monotonically even across skips. `used_ids` is also updated for
8920/// each minted or metadata-backed identity, so collisions are prevented both
8921/// against the replayed prefix and within the appended tail.
8922fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8923    loop {
8924        let candidate = synth_uuid(*counter);
8925        *counter += 1;
8926        if used_ids.insert(candidate.clone()) {
8927            return candidate;
8928        }
8929    }
8930}
8931
8932/// Reuse a message's durable native/source UUID when available, falling back
8933/// to the deterministic synthesized sequence only for hand-built or legacy
8934/// messages that never carried identity metadata.
8935fn claude_message_uuid(
8936    msg: &ChatMessage,
8937    counter: &mut usize,
8938    used_ids: &mut HashSet<String>,
8939) -> String {
8940    for key in ["claude_uuid", "supercode_native_uuid"] {
8941        if let Some(candidate) = msg.metadata.get(key) {
8942            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8943                return candidate.clone();
8944            }
8945        }
8946    }
8947    next_claude_uuid(counter, used_ids)
8948}
8949
8950/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8951/// `raw_prefix` — the verbatim RAW lines
8952/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8953/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8954/// the GROUND TRUTH of what physically lands in the exported `out` string
8955/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8956/// the Codex side): each line is parsed as a Claude Code JSONL record and
8957/// its own top-level `uuid` field is read back out of the bytes directly, no
8958/// re-derivation from `self.messages` needed. A line that fails to parse, or
8959/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8960/// record), contributes nothing.
8961fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8962    let mut ids = HashSet::new();
8963    for line in raw_prefix {
8964        if let Ok(v) = serde_json::from_str::<Value>(line) {
8965            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8966                ids.insert(uuid.to_string());
8967            }
8968        }
8969    }
8970    ids
8971}
8972
8973fn push_jsonl(out: &mut String, value: &Value) {
8974    out.push_str(&value.to_string());
8975    out.push('\n');
8976}
8977
8978/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8979/// `new_id` when the line parses as a JSON object carrying that key — used
8980/// by A12's Claude Code splice, where the session id lives at the top level
8981/// of (almost) every record under `key = "sessionId"`. A line that fails to
8982/// parse, or parses but lacks `key`, is copied through byte-for-byte
8983/// (nothing to patch, so nothing is reserialized).
8984fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8985    if let Some(new_id) = new_id {
8986        if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8987            if v.get(key).is_some() {
8988                v[key] = Value::String(new_id.to_string());
8989                out.push_str(&v.to_string());
8990                out.push('\n');
8991                return;
8992            }
8993        }
8994    }
8995    out.push_str(line);
8996    out.push('\n');
8997}
8998
8999impl Session {
9000    fn cwd_string(&self) -> String {
9001        self.meta
9002            .cwd
9003            .as_ref()
9004            .map(|p| p.to_string_lossy().into_owned())
9005            .unwrap_or_else(|| ".".to_string())
9006    }
9007
9008    /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
9009    /// leading `raw` lines / `messages` came from the imported log, as
9010    /// opposed to being appended after import.
9011    ///
9012    /// `imported_message_count` (see its doc comment) pins the message-side
9013    /// boundary directly. The raw-side boundary isn't separately tracked —
9014    /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
9015    /// `raw` line per appended message, so the two lists grow by the same
9016    /// `appended_count` from the same starting point, and
9017    /// `raw.len() - appended_count` recovers it without a second counter.
9018    fn spliced_prefix_lens(&self) -> (usize, usize) {
9019        let message_prefix_len = self
9020            .imported_message_count
9021            .unwrap_or(self.messages.len())
9022            .min(self.messages.len());
9023        let appended_count = self.messages.len() - message_prefix_len;
9024        let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
9025        (raw_prefix_len, message_prefix_len)
9026    }
9027
9028    /// Synthesize a Claude Code transcript.
9029    ///
9030    /// Claude Code transcripts have no slot for the *session-level system
9031    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
9032    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
9033    /// `ChatMessage`s (Claude's own `type: "system"` records with a
9034    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
9035    /// `away_summary` — see `push_claude_system`, the exact inverse of what
9036    /// this writer now does) DO have a first-class slot: the real `type:
9037    /// "system"` record itself. This function used to unconditionally drop
9038    /// every `System` message, silently losing e.g. a real
9039    /// `<local-command-stdout>` record on any format -> Claude Code hop
9040    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
9041    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
9042    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
9043    /// now re-materializes it instead.
9044    fn to_claude_code_jsonl(&self) -> String {
9045        let session_id = self
9046            .meta
9047            .session_id
9048            .clone()
9049            .unwrap_or_else(|| synth_uuid(0));
9050        let cwd = self.cwd_string();
9051        let mut out = String::new();
9052        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
9053        // re-emitted byte-for-byte, ahead of the conversation it applies to —
9054        // this is what makes the record survive the SEMANTIC Claude Code
9055        // writer (the raw-passthrough diagonal in `crates/cli` already
9056        // preserves it by construction; this covers the library `to_jsonl`
9057        // path too, e.g. a `--session-id` override that forces the semantic
9058        // writer).
9059        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9060            out.push_str(raw);
9061            out.push('\n');
9062        }
9063        // Full synthesis: `out` at this point has no raw prefix ahead of it
9064        // (unlike the A12 splice below), so there are no uuids yet in play
9065        // to seed against — see `next_claude_uuid`'s doc comment.
9066        self.write_claude_code_records(
9067            &mut out,
9068            &self.messages,
9069            &session_id,
9070            &cwd,
9071            None,
9072            1,
9073            &HashSet::new(),
9074        );
9075        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9076            if out.is_empty() {
9077                push_jsonl(
9078                    &mut out,
9079                    &serde_json::json!({
9080                        "type": "file-history-snapshot",
9081                        "messageId": synth_uuid(1),
9082                        "snapshot": {},
9083                        "sessionId": session_id,
9084                        "cwd": cwd,
9085                        "timestamp": SYNTH_TS,
9086                    }),
9087                );
9088            }
9089            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9090        }
9091        out
9092    }
9093
9094    /// Synthesize Claude Code records for `messages` (a full session or an
9095    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
9096    /// the latter), starting the `parentUuid` chain at `parent` and the
9097    /// `synth_uuid` counter at `counter`. Factored out of
9098    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
9099    ///
9100    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
9101    /// every uuid that will ALREADY be present in `out` before this call
9102    /// ever runs — see that function's doc comment for why the A12 splice
9103    /// path needs this and full synthesis doesn't.
9104    // R1: this was already at clippy's `too_many_arguments` threshold (7,
9105    // including `&self`) before the fix; the added `seed_used_ids` param
9106    // pushes it to 8. Every argument here is independently meaningful (two
9107    // record-shape inputs, two id/parent-chain threading values, and now
9108    // the collision seed) — bundling them into a params struct is a larger
9109    // refactor of this already-widely-called private helper than the R1 fix
9110    // warrants, so this is allowed rather than restructured.
9111    #[allow(clippy::too_many_arguments)]
9112    fn write_claude_code_records(
9113        &self,
9114        out: &mut String,
9115        messages: &[ChatMessage],
9116        session_id: &str,
9117        cwd: &str,
9118        mut parent: Option<String>,
9119        mut counter: usize,
9120        seed_used_ids: &HashSet<String>,
9121    ) {
9122        let mut used_ids = seed_used_ids.clone();
9123        for msg in messages {
9124            if is_replay_excluded(msg) {
9125                continue;
9126            }
9127            let blocks: Vec<Value> = match msg.role {
9128                // PARITY-6 dev/02: re-materialize a content-bearing System
9129                // `ChatMessage` as a real Claude Code `type: "system"`
9130                // record — the exact inverse of `push_claude_system`, which
9131                // is what produced it in the first place for a message
9132                // loaded FROM a real Claude Code transcript. `subtype`
9133                // prefers the original `systemSubtype` metadata
9134                // (`push_claude_system`'s `.with_meta`, round-tripped
9135                // through the Codex hop via `write_codex_records`'s
9136                // `claude_system_subtype` metadata channel and restored by
9137                // `push_codex_item`); when that channel didn't carry it
9138                // (e.g. a genuinely native, non-Claude-origin developer
9139                // message), fall back to `local_command` — the observed
9140                // common case, and still one of `push_claude_system`'s own
9141                // `keep` subtypes, so the record survives a *subsequent*
9142                // reload rather than being silently re-dropped. This never
9143                // fabricates content: the real text is always carried
9144                // verbatim, only the subtype label is a best-effort guess
9145                // when the true one wasn't recoverable.
9146                Role::System => {
9147                    let content = msg.content.clone().unwrap_or_default();
9148                    if content.trim().is_empty() {
9149                        continue;
9150                    }
9151                    let subtype = msg
9152                        .metadata
9153                        .get("systemSubtype")
9154                        .cloned()
9155                        .unwrap_or_else(|| "local_command".to_string());
9156                    // R1/B3 union: this mint must ALSO route through
9157                    // `next_claude_uuid` + `seed_used_ids` like the other
9158                    // three arms below — otherwise this System arm (added by
9159                    // B3 after R1 landed) mints a raw `synth_uuid` that can
9160                    // collide with a uuid already sitting in the A12 splice's
9161                    // raw prefix (see `next_claude_uuid`'s doc comment).
9162                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9163                    let mut line = serde_json::json!({
9164                        "parentUuid": parent,
9165                        "type": "system",
9166                        "subtype": subtype,
9167                        "content": content,
9168                        "uuid": uuid,
9169                        "sessionId": session_id,
9170                        "cwd": cwd,
9171                        "timestamp": msg_timestamp_or_synth(msg),
9172                    });
9173                    set_grok_message_extension(&mut line, self.meta.source, msg);
9174                    push_jsonl(out, &line);
9175                    parent = Some(uuid);
9176                    continue;
9177                }
9178                Role::User => {
9179                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9180                    let mut line = serde_json::json!({
9181                        "parentUuid": parent,
9182                        "type": "user",
9183                        "message": {
9184                            "role": "user",
9185                            "content": claude_user_content_value(msg),
9186                        },
9187                        "uuid": uuid,
9188                        "sessionId": session_id,
9189                        "cwd": cwd,
9190                        "timestamp": msg_timestamp_or_synth(msg),
9191                    });
9192                    set_grok_message_extension(&mut line, self.meta.source, msg);
9193                    push_jsonl(out, &line);
9194                    parent = Some(uuid);
9195                    continue;
9196                }
9197                Role::Tool => {
9198                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9199                    let mut line = serde_json::json!({
9200                        "parentUuid": parent,
9201                        "type": "user",
9202                        "message": {
9203                            "role": "user",
9204                            "content": [{
9205                                "type": "tool_result",
9206                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9207                                "content": claude_tool_result_content_value(msg),
9208                            }],
9209                        },
9210                        "uuid": uuid,
9211                        "sessionId": session_id,
9212                        "cwd": cwd,
9213                        "timestamp": msg_timestamp_or_synth(msg),
9214                    });
9215                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9216                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9217                    }
9218                    set_grok_message_extension(&mut line, self.meta.source, msg);
9219                    push_jsonl(out, &line);
9220                    parent = Some(uuid);
9221                    continue;
9222                }
9223                Role::Assistant => {
9224                    let mut blocks = Vec::new();
9225                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
9226                    // dev/01): thinking/redacted_thinking must be re-emitted
9227                    // BEFORE text/tool_use, unconditionally whenever
9228                    // retained metadata is present — not only when `blocks`
9229                    // is otherwise empty. The previous `if blocks.is_empty()`
9230                    // gate (now below, applied unconditionally instead)
9231                    // meant a turn that thinks AND THEN answers/calls a tool
9232                    // in the SAME turn — pi's own default emission shape,
9233                    // and the overwhelmingly common real-world case for any
9234                    // reasoning model, not the rare reasoning-only edge case
9235                    // this gate's comment described — silently dropped its
9236                    // entire `thinking` block on Pi -> Claude Code export. A
9237                    // genuine multi-turn pi session driven through pi's own
9238                    // real Agent loop (faux provider, see
9239                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
9240                    // thinking+text turns lost the thinking block entirely.
9241                    // D8: prefer the exact per-block list when present —
9242                    // every `thinking`/`redacted_thinking` block re-emitted
9243                    // SEPARATELY with its own signature/data, exactly as
9244                    // captured (`push_claude_assistant`), instead of the
9245                    // legacy singular fields' lossy collapse (which drops
9246                    // every signature but the last one's on a multi-block
9247                    // message). Falls back to the legacy fields only for a
9248                    // `Session` that never populated `thinking_blocks` (e.g.
9249                    // hand-constructed in another loader/test, or loaded
9250                    // from a non-Claude-Code source like Pi).
9251                    match msg
9252                        .metadata
9253                        .get("thinking_blocks")
9254                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9255                        .and_then(|v| v.as_array().cloned())
9256                    {
9257                        Some(saved_blocks) => blocks.extend(saved_blocks),
9258                        None => {
9259                            if let Some(t) = msg.metadata.get("thinking") {
9260                                let mut block =
9261                                    serde_json::json!({"type": "thinking", "thinking": t});
9262                                if let Some(sig) = msg.metadata.get("thinking_signature") {
9263                                    block["signature"] = Value::String(sig.clone());
9264                                }
9265                                blocks.push(block);
9266                            }
9267                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
9268                                blocks.push(
9269                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
9270                                );
9271                            }
9272                        }
9273                    }
9274                    if let Some(t) = &msg.content {
9275                        if !t.is_empty() {
9276                            blocks.push(serde_json::json!({"type": "text", "text": t}));
9277                        }
9278                    }
9279                    // PARITY-11: an assistant-emitted image (`content_parts`,
9280                    // e.g. a generated image — `push_claude_assistant`'s
9281                    // load-side counterpart) has no slot in `msg.content`;
9282                    // without this, `blocks` stayed empty for an image-only
9283                    // turn and the whole message vanished on Claude Code
9284                    // semantic export, same failure mode the IX-6 Codex
9285                    // writer fix already closed on that side.
9286                    if let Some(parts) = &msg.content_parts {
9287                        for p in parts {
9288                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
9289                                if let Some(url) = p
9290                                    .get("image_url")
9291                                    .and_then(|u| u.get("url"))
9292                                    .and_then(Value::as_str)
9293                                {
9294                                    blocks.push(match parse_data_uri(url) {
9295                                        Some((mime, data)) => serde_json::json!({
9296                                            "type": "image",
9297                                            "source": {"type": "base64", "media_type": mime, "data": data},
9298                                        }),
9299                                        None => serde_json::json!({
9300                                            "type": "image",
9301                                            "source": {"type": "url", "url": url},
9302                                        }),
9303                                    });
9304                                }
9305                            }
9306                        }
9307                    }
9308                    for tc in msg.tool_calls() {
9309                        let input = tc
9310                            .function
9311                            .parsed_arguments()
9312                            .unwrap_or_else(|_| Value::Object(Default::default()));
9313                        blocks.push(serde_json::json!({
9314                            "type": "tool_use",
9315                            "id": tc.id,
9316                            "name": tc.function.name,
9317                            "input": input,
9318                        }));
9319                    }
9320                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9321                    // (no text, no tool_use, no image) still doesn't vanish
9322                    // — the thinking/redacted_thinking prepend above already
9323                    // ran unconditionally, so `blocks` is non-empty here
9324                    // whenever any of those were present.
9325                    blocks
9326                }
9327            };
9328
9329            // An empty assistant content array is a valid native interrupted
9330            // turn and must remain a record. Every non-assistant arm above
9331            // already `continue`s after writing its own shape, so an empty
9332            // `blocks` value here belongs specifically to that assistant.
9333            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9334            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9335            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9336                message["model"] = Value::String(model.clone());
9337            }
9338            let mut line = serde_json::json!({
9339                "parentUuid": parent,
9340                "type": "assistant",
9341                "message": message,
9342                "uuid": uuid,
9343                "sessionId": session_id,
9344                "cwd": cwd,
9345                "timestamp": msg_timestamp_or_synth(msg),
9346            });
9347            set_grok_message_extension(&mut line, self.meta.source, msg);
9348            push_jsonl(out, &line);
9349            parent = Some(uuid);
9350        }
9351    }
9352
9353    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9354    /// (patching `sessionId` on each line when `session_id` is `Some`), then
9355    /// synthesize records only for the appended tail, via
9356    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9357    /// last original `uuid` found anywhere in the raw prefix (not just its
9358    /// final line: a trailing loader-skipped record, e.g.
9359    /// `file-history-snapshot`, may carry no `uuid` of its own).
9360    fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9361        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9362        let sid = session_id
9363            .map(str::to_string)
9364            .or_else(|| self.meta.session_id.clone())
9365            .unwrap_or_else(|| synth_uuid(0));
9366        let cwd = self.cwd_string();
9367
9368        let mut out = String::new();
9369        let mut parent: Option<String> = None;
9370        for line in &self.raw[..raw_prefix_len] {
9371            push_spliced_line(&mut out, line, session_id, "sessionId");
9372            if let Ok(v) = serde_json::from_str::<Value>(line) {
9373                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9374                    parent = Some(uuid.to_string());
9375                }
9376            }
9377        }
9378
9379        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9380        // the tail's collision guard with every uuid the just-replayed RAW
9381        // prefix already carries, so `write_claude_code_records` never
9382        // fabricates a `synth_uuid` for the appended tail that collides with
9383        // one already sitting in the prefix (see `next_claude_uuid`'s and
9384        // `collect_claude_uuids_from_raw`'s doc comments).
9385        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9386        self.write_claude_code_records(
9387            &mut out,
9388            &self.messages[message_prefix_len..],
9389            &sid,
9390            &cwd,
9391            parent,
9392            1,
9393            &seed_used_ids,
9394        );
9395        out
9396    }
9397
9398    /// Synthesize a Codex rollout.
9399    fn to_codex_jsonl(&self) -> String {
9400        let mut out = String::new();
9401
9402        if self.meta.codex_headers.is_empty() {
9403            self.write_synthesized_codex_header(&mut out);
9404        } else {
9405            // Replay the exact header records the original tool wrote — Codex's
9406            // reader validates the header shape strictly — overriding only the
9407            // session id when the caller changed it.
9408            for header in &self.meta.codex_headers {
9409                let mut header = header.clone();
9410                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9411                    if let Some(id) = &self.meta.session_id {
9412                        if let Some(payload) = header.get_mut("payload") {
9413                            payload["id"] = Value::String(id.clone());
9414                        }
9415                    }
9416                }
9417                push_jsonl(&mut out, &header);
9418            }
9419        }
9420
9421        // Full synthesis: `out` at this point is only the header, so there
9422        // are no group ids yet in play to seed against (see
9423        // `write_codex_records`'s doc comment).
9424        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9425        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9426            inject_codex_provenance(&mut out, extension);
9427        }
9428        out
9429    }
9430
9431    /// Synthesize Codex `response_item` records for `messages` (a full
9432    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9433    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9434    /// the record shape is defined once; `tool_search_call_ids` pairing is
9435    /// scoped to this call's `messages`, matching the header-replay
9436    /// contract that only appended records need synthesizing.
9437    ///
9438    /// `seed_used_ids` primes the N2 collision guard below with every group
9439    /// id that will ALREADY be present in `out` before this call ever runs —
9440    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9441    /// header) passes an empty set, since every group id in that case is
9442    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9443    /// splice) passes the ids already used by the verbatim RAW prefix it
9444    /// replayed into `out` just before calling this for the appended tail —
9445    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9446    /// start blind to the prefix and can fabricate/reuse a group id that
9447    /// COLLIDES with one still "open" at the end of the prefix, letting
9448    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9449    /// an unrelated appended message into a historical one — the same
9450    /// bug-class N2 closed for full synthesis, reopened here because the
9451    /// spliced tail's tracking set used to always start empty regardless of
9452    /// what the replayed prefix already contained.
9453    fn write_codex_records(
9454        &self,
9455        out: &mut String,
9456        messages: &[ChatMessage],
9457        seed_used_ids: &std::collections::HashSet<String>,
9458    ) {
9459        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9460        // the matching tool result below can be emitted as the paired
9461        // `tool_search_output` record rather than a generic
9462        // `function_call_output` — the exact inverse of the importer's
9463        // `tool_search_call`/`tool_search_output` normalization
9464        // (`push_codex_item`, above).
9465        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9466        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9467        // records (e.g. a text-only narration turn immediately followed by a
9468        // bare tool-call turn, no user turn between — a real, common Claude
9469        // Code shape) each become their own Codex `message`/`function_call`
9470        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9471        // opportunistically RE-MERGES an assistant `message` immediately
9472        // followed by a `function_call` back into ONE `ChatMessage`, to match
9473        // how a genuinely single Claude turn (text+tool_use in the SAME
9474        // record) round-trips — but with no distinguishing signal, it can't
9475        // tell that case apart from two originally-separate records that
9476        // just happen to be adjacent, so it wrongly recombines them too,
9477        // silently shrinking the message count on every Claude -> Codex ->
9478        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9479        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9480        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9481        // itself emits. `push_codex_item`'s merge already treats a turn_id
9482        // mismatch as "different turn, do not merge" (the pre-existing
9483        // belt-and-suspenders check); real native Codex data almost never
9484        // carries this field (per that check's own comment), so this is a
9485        // no-op there and only sharpens fidelity for OUR OWN synthesized
9486        // export.
9487        let mut next_group_id: u64 = 0;
9488        // N2 (Fable-5 review, turn_id-collision hardening): every group id
9489        // this export has already assigned — whether REUSED from a real
9490        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9491        // `ChatMessage` never emits one that's already in use. Two concrete
9492        // mis-merge scenarios motivate this:
9493        //
9494        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9495        //     own text+tool_use); reload makes A carry REAL turn_id
9496        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9497        //     its own) is then appended. Re-export: A reuses its real
9498        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
9499        //     from `next_group_id == 0` again (nothing bumped it when A's id
9500        //     was reused rather than fabricated) — also `sc-grp-0`.
9501        //     Collision. If A's call has no output (interrupted session),
9502        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9503        //     adjacent with nothing to break the run and merges all three
9504        //     into ONE message (2 -> 1).
9505        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9506        //     truncation/clear event strips `__codex_open_turn` (closing the
9507        //     turn without changing the id), then `function_call(turn-7)`
9508        //     loads as a SECOND, separate `ChatMessage` that still carries
9509        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
9510        //     restamps it). Full-synthesis export naively reuses `turn-7`
9511        //     verbatim for BOTH messages (they're two different loop
9512        //     iterations, each independently reusing its own `real_turn_id`)
9513        //     and emits them adjacent — reimport's merge check can't tell
9514        //     this apart from a single message's own multi-call turn and
9515        //     recombines them (2 -> 1).
9516        //
9517        // Fix: the fabricated-id counter is advanced (skipped) past any id
9518        // already in `used_group_ids`, AND a real id that's already been
9519        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9520        // — never letting two DIFFERENT `ChatMessage`s in this export share
9521        // one group id, since `push_codex_item`'s merge check treats a
9522        // shared id as "same turn, merge". A single `ChatMessage`'s own
9523        // message record + its own tool call records still share ONE group
9524        // id (computed once per loop iteration below, before insertion), so
9525        // the D1 tool_search merge and ordinary same-turn multi-call
9526        // grouping are unaffected — this only stops REUSE across iterations.
9527        //
9528        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9529        // spliced-export tail is likewise blind-proof against the prefix it
9530        // doesn't itself write.
9531        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9532
9533        for msg in messages {
9534            if is_replay_excluded(msg) {
9535                continue;
9536            }
9537            // D3 (Fable-5 review): a message loaded FROM real native Codex
9538            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9539            // (`push_codex_item`'s "message" arm stamps it whenever the
9540            // source record itself has one). The group-id logic below used
9541            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9542            // silently overwriting/discarding that real id on any
9543            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9544            // when present; only fabricate a synthetic id as a fallback for
9545            // our own merge-disambiguation need (PARITY-6/7) when the
9546            // message has no real one of its own.
9547            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9548            match msg.role {
9549                Role::System => {
9550                    // PARITY-6 dev/02: carry the original Claude
9551                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9552                    // through as `metadata.claude_system_subtype`, so
9553                    // `push_codex_item`'s reverse load can restore it and
9554                    // `write_claude_code_records`'s `Role::System` arm can
9555                    // re-materialize the EXACT original subtype rather than
9556                    // guessing on a Codex -> Claude hop.
9557                    let subtype_meta = msg
9558                        .metadata
9559                        .get("systemSubtype")
9560                        .map(|s| ("claude_system_subtype", s.as_str()));
9561                    self.push_codex_message(
9562                        out,
9563                        "developer",
9564                        "input_text",
9565                        msg,
9566                        real_turn_id,
9567                        subtype_meta,
9568                    )
9569                }
9570                Role::User => {
9571                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9572                }
9573                Role::Assistant => {
9574                    // Emit the message record whenever there is text OR
9575                    // content_parts (IX-6 follow-up): an image-only assistant
9576                    // message has `content: None, content_parts:
9577                    // Some([image])` (the loader's `codex_extract_images` is
9578                    // role-general, so this shape can occur on the assistant
9579                    // side too) — gating on `msg.content` alone silently
9580                    // dropped the whole message, image included. A
9581                    // text-only message (content_parts: None) keeps taking
9582                    // the historical byte-identical path via
9583                    // `codex_message_content_blocks`'s `None` arm. A real
9584                    // empty native assistant record carries the
9585                    // loader's explicit marker and must also be emitted.
9586                    // Reasoning-only cross-provider turns deliberately lack
9587                    // that marker and keep the documented Codex residue.
9588                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9589                    let has_message_record = has_text
9590                        || msg.content_parts.is_some()
9591                        || msg.metadata.contains_key("empty_assistant_record");
9592                    // Only assign a synthetic group id when there's actual
9593                    // merge ambiguity to resolve (a message AND its own tool
9594                    // calls, or 2+ of this message's own tool calls) — a
9595                    // pure-text message with no tool calls, or a lone tool
9596                    // call with nothing else from the same `ChatMessage`,
9597                    // has nothing to disambiguate, so it keeps the exact
9598                    // historical byte shape (no `metadata` key at all).
9599                    let group_id: Option<String> = if let Some(real) = real_turn_id {
9600                        if used_group_ids.contains(real) {
9601                            // N2: this real turn_id was already used by an
9602                            // earlier (now-closed) `ChatMessage` in this same
9603                            // export — reusing it verbatim would let the
9604                            // reimport merge check recombine two originally
9605                            // separate messages (see the doc comment above).
9606                            let mut n = 1u64;
9607                            let mut candidate = format!("{real}~dup{n}");
9608                            while used_group_ids.contains(&candidate) {
9609                                n += 1;
9610                                candidate = format!("{real}~dup{n}");
9611                            }
9612                            Some(candidate)
9613                        } else {
9614                            Some(real.to_string())
9615                        }
9616                    } else if !msg.tool_calls().is_empty() {
9617                        // N2: skip past any id already used (e.g. a REAL
9618                        // turn_id that happens to look like `sc-grp-N`, or an
9619                        // id an earlier reused-real case landed on).
9620                        let mut candidate = format!("sc-grp-{next_group_id}");
9621                        next_group_id += 1;
9622                        while used_group_ids.contains(&candidate) {
9623                            candidate = format!("sc-grp-{next_group_id}");
9624                            next_group_id += 1;
9625                        }
9626                        Some(candidate)
9627                    } else {
9628                        None
9629                    };
9630                    if let Some(g) = &group_id {
9631                        used_group_ids.insert(g.clone());
9632                    }
9633                    if has_message_record {
9634                        self.push_codex_message(
9635                            out,
9636                            "assistant",
9637                            "output_text",
9638                            msg,
9639                            group_id.as_deref(),
9640                            None,
9641                        );
9642                    }
9643                    for tc in msg.tool_calls() {
9644                        let custom_tool_call = msg
9645                            .metadata
9646                            .get("codex_custom_tool_call_ids")
9647                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9648                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9649                        if custom_tool_call {
9650                            let input = tc
9651                                .function
9652                                .parsed_arguments()
9653                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9654                            let mut payload = with_turn_id(
9655                                serde_json::json!({
9656                                    "type": "custom_tool_call",
9657                                    "name": tc.function.name,
9658                                    "input": input,
9659                                    "call_id": tc.id,
9660                                }),
9661                                group_id.as_deref(),
9662                            );
9663                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9664                            push_jsonl(
9665                                out,
9666                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9667                            );
9668                        } else if tc.function.name == "tool_search" {
9669                            tool_search_call_ids.insert(tc.id.clone());
9670                            let mut payload = with_turn_id(
9671                                serde_json::json!({
9672                                    "type": "tool_search_call",
9673                                    "arguments": tc.function.arguments,
9674                                    "call_id": tc.id,
9675                                }),
9676                                group_id.as_deref(),
9677                            );
9678                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9679                            push_jsonl(
9680                                out,
9681                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9682                            );
9683                        } else {
9684                            let mut payload = with_turn_id(
9685                                serde_json::json!({
9686                                    "type": "function_call",
9687                                    "name": tc.function.name,
9688                                    "arguments": tc.function.arguments,
9689                                    "call_id": tc.id,
9690                                }),
9691                                group_id.as_deref(),
9692                            );
9693                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9694                            push_jsonl(
9695                                out,
9696                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9697                            );
9698                        }
9699                    }
9700                    // PARITY-11: a genuinely reasoning-only turn (Claude
9701                    // `thinking`/`redacted_thinking` with no text, tool_use,
9702                    // or image — `push_claude_assistant`'s load-side fix for
9703                    // the ~21% of real assistant records that are exactly
9704                    // this shape) has no message record and no tool calls,
9705                    // so nothing above writes anything for it. This is
9706                    // DELIBERATE, not a residual gap: Codex's `reasoning`
9707                    // response_item is understood on import (see the
9708                    // `response_item`/`"reasoning"` arm above), but its
9709                    // real-native semantics is "the reasoning immediately
9710                    // BEFORE the next turn" — the reader attaches it to
9711                    // whatever response_item comes next, unconditionally.
9712                    // For a genuinely standalone Claude reasoning-only turn
9713                    // (no related turn follows in Codex's export at all),
9714                    // emitting one here would get silently misattributed as
9715                    // belonging to some later, unrelated turn instead —
9716                    // strictly worse than the current honest, accounted-for
9717                    // absence (thinking/redacted_thinking is provider-
9718                    // private and "not replayed across providers" by
9719                    // original design; the audit correctly classifies it
9720                    // `Coverage::Dropped`, not `Unmodeled`). See the
9721                    // PARITY-6/7 corpus test's `is_replayable` filter for
9722                    // why this doesn't count as a message-count regression.
9723                }
9724                Role::Tool
9725                    if msg
9726                        .tool_call_id
9727                        .as_deref()
9728                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9729                {
9730                    let content = msg.content.clone().unwrap_or_default();
9731                    let tools =
9732                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9733                    let mut payload = serde_json::json!({
9734                        "type": "tool_search_output",
9735                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9736                        "tools": tools,
9737                    });
9738                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9739                    push_jsonl(
9740                        out,
9741                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9742                    );
9743                }
9744                Role::Tool => {
9745                    let mut payload = serde_json::json!({
9746                        "type": "function_call_output",
9747                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9748                        "output": codex_tool_output_text(msg),
9749                    });
9750                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9751                    push_jsonl(
9752                        out,
9753                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9754                    );
9755                }
9756            }
9757        }
9758    }
9759
9760    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9761    /// line, not just the `session_meta`/`turn_context` headers
9762    /// [`Self::to_codex_jsonl`] replays — overriding only
9763    /// `session_meta.payload.id` when `session_id` is `Some` (every other
9764    /// line, including `response_item`s the stock synthesis would otherwise
9765    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9766    /// `response_item` records only for the appended tail, via
9767    /// [`Self::write_codex_records`].
9768    fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9769        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9770
9771        let mut out = String::new();
9772        for line in &self.raw[..raw_prefix_len] {
9773            match session_id {
9774                Some(id) => {
9775                    let patched = serde_json::from_str::<Value>(line)
9776                        .ok()
9777                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9778                        .map(|mut v| {
9779                            if let Some(payload) = v.get_mut("payload") {
9780                                payload["id"] = Value::String(id.to_string());
9781                            }
9782                            v.to_string()
9783                        });
9784                    out.push_str(patched.as_deref().unwrap_or(line));
9785                }
9786                None => out.push_str(line),
9787            }
9788            out.push('\n');
9789        }
9790
9791        // N2 (spliced-path hardening): seed the tail's collision guard with
9792        // every group id the just-replayed RAW prefix already carries, so
9793        // `write_codex_records` never fabricates/reuses an id for the
9794        // appended tail that collides with one still open at the end of the
9795        // prefix (see that fn's doc comment, and
9796        // `collect_codex_group_ids_from_raw`'s).
9797        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9798        // Belt-and-suspenders: also union in the prefix `messages`' own
9799        // recorded `turn_id` metadata. In the ordinary case this is already
9800        // a subset of what the raw-line scan above found (the loader stamps
9801        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9802        // field the scan reads) — but scanning `messages` too costs nothing
9803        // and means this stays correct even if some future loader path ever
9804        // derives a message's `turn_id` by some means other than a literal
9805        // `payload.metadata.turn_id` copy.
9806        for msg in &self.messages[..message_prefix_len] {
9807            if let Some(tid) = msg.metadata.get("turn_id") {
9808                seed_used_ids.insert(tid.clone());
9809            }
9810        }
9811        self.write_codex_records(
9812            &mut out,
9813            &self.messages[message_prefix_len..],
9814            &seed_used_ids,
9815        );
9816        out
9817    }
9818
9819    /// Build a Codex header from scratch (used when converting from another
9820    /// format, where no original Codex header exists to replay). Emits the
9821    /// fields Codex requires on `session_meta`.
9822    fn write_synthesized_codex_header(&self, out: &mut String) {
9823        let mut meta_payload = serde_json::json!({
9824            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9825            "timestamp": SYNTH_TS,
9826            "cwd": self.cwd_string(),
9827            "originator": "supercode",
9828            "cli_version": env!("CARGO_PKG_VERSION"),
9829            "source": "exec",
9830            "thread_source": "user",
9831            "model_provider": "openai",
9832        });
9833        if let Some(sp) = &self.meta.system_prompt {
9834            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9835        }
9836        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9837        // `capture_claude_meta`) through the Codex hop under a clearly
9838        // namespaced custom field — real Codex tooling ignores unknown
9839        // `session_meta.payload` keys, and `capture_codex_session_meta`
9840        // reads this same key back on import, so a Claude -> Codex -> Claude
9841        // round trip still reconstructs the original record instead of
9842        // silently losing the lineage note on the cross-format hop.
9843        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9844            meta_payload["claude_fork_context_ref"] =
9845                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9846        }
9847        push_jsonl(
9848            out,
9849            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9850        );
9851        if let Some(model) = &self.meta.model {
9852            push_jsonl(
9853                out,
9854                &serde_json::json!({
9855                    "timestamp": SYNTH_TS,
9856                    "type": "turn_context",
9857                    "payload": {"model": model, "cwd": self.cwd_string()},
9858                }),
9859            );
9860        }
9861    }
9862
9863    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9864    /// [`Self::write_codex_records`] — `Some` when the source message
9865    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9866    /// (assistant only) a synthetic disambiguation id when it owns tool
9867    /// calls needing merge disambiguation and has no real id of its own;
9868    /// `None` reproduces the exact historical shape (no `metadata` key at
9869    /// all).
9870    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9871    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9872    /// `Role::System` case in [`Self::write_codex_records`] to carry
9873    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9874    /// system record's subtype survives the Claude -> Codex -> Claude round
9875    /// trip instead of only its text; `None` for every other caller,
9876    /// preserving the exact historical shape).
9877    fn push_codex_message(
9878        &self,
9879        out: &mut String,
9880        role: &str,
9881        text_type: &str,
9882        msg: &ChatMessage,
9883        turn_id: Option<&str>,
9884        extra_metadata: Option<(&str, &str)>,
9885    ) {
9886        let mut payload = with_turn_id(
9887            serde_json::json!({
9888                "type": "message",
9889                "role": role,
9890                "content": codex_message_content_blocks(text_type, msg),
9891            }),
9892            turn_id,
9893        );
9894        if let Some((k, v)) = extra_metadata {
9895            if payload.get("metadata").is_none() {
9896                payload["metadata"] = serde_json::json!({});
9897            }
9898            payload["metadata"][k] = serde_json::json!(v);
9899        }
9900        set_grok_message_extension(&mut payload, self.meta.source, msg);
9901        push_jsonl(
9902            out,
9903            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9904        );
9905    }
9906
9907    /// Synthesize a fresh pi v3 session from the canonical `messages`
9908    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9909    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9910    /// through `raw` + `to_native_jsonl(_v2)` instead).
9911    fn to_pi_jsonl(&self) -> String {
9912        let session_id = self
9913            .meta
9914            .session_id
9915            .clone()
9916            .unwrap_or_else(|| synth_uuid(0));
9917        let cwd = self.cwd_string();
9918        let mut out = String::new();
9919        push_pi_header(
9920            &mut out,
9921            &session_id,
9922            &cwd,
9923            self.meta
9924                .lineage
9925                .get("parent_session_path")
9926                .map(String::as_str),
9927            self.meta.lineage.get("created_at").map(String::as_str),
9928            // D7: carry a captured Claude `fork-context-ref` (see
9929            // `capture_claude_meta`) through the Pi hop too — mirrors the
9930            // Codex hop's `claude_fork_context_ref` passthrough
9931            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9932            // round trip doesn't silently lose fork lineage just because Pi
9933            // has no native slot for it.
9934            self.meta
9935                .lineage
9936                .get("claude_fork_context_ref_raw")
9937                .map(String::as_str),
9938        );
9939        let mut used_ids: HashSet<String> = HashSet::new();
9940        let mut counter: u64 = 0;
9941        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9942        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9943            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9944        }
9945        out
9946    }
9947
9948    /// Synthesize pi `message` entries for `messages` (a full session, or —
9949    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9950    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9951    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9952    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9953    fn write_pi_entries(
9954        &self,
9955        out: &mut String,
9956        messages: &[ChatMessage],
9957        mut parent: Option<String>,
9958        used_ids: &mut HashSet<String>,
9959        counter: &mut u64,
9960    ) {
9961        // Claude Code and Codex do not repeat the tool name on their native
9962        // tool-result records. Recover that redundant Pi field from the
9963        // paired assistant call when a cross-format round trip therefore
9964        // returns a canonical Tool message with `name == None`.
9965        let mut paired_tool_names = HashMap::<String, String>::new();
9966        for msg in messages {
9967            if is_replay_excluded(msg) {
9968                continue;
9969            }
9970            for call in msg.tool_calls() {
9971                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9972            }
9973            let id = pi_fresh_id(used_ids, counter);
9974            let mut entry = match msg.role {
9975                // B4: pi has no session-level system/developer PROMPT slot
9976                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9977                // content-bearing `Role::System` message loaded from a real
9978                // Claude Code `type: "system"` record (`push_claude_system`'s
9979                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9980                // `away_summary`) is NOT a system prompt — it's a real,
9981                // non-regenerable transcript event. Pi's own `role:"custom"`
9982                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9983                // as a user message") is the closest existing, non-fabricated
9984                // slot pi's own parser already understands, so this
9985                // re-materializes the record there instead of silently
9986                // dropping it — the exact allowance push_claude_system's own
9987                // doc comment describes in reverse. `customType` is a
9988                // supercode-namespaced marker (`push_pi_custom_common`
9989                // recognizes it on reload and restores `Role::System` +
9990                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9991                // produced in the first place); a real pi customType never
9992                // collides with this name. `details.claude_system_subtype`
9993                // carries the original subtype losslessly through the pi leg
9994                // (mirrors `write_codex_records`'s `claude_system_subtype`
9995                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9996                // is never fabricated — only emitted when non-empty.
9997                Role::System => {
9998                    let content = msg.content.clone().unwrap_or_default();
9999                    if content.trim().is_empty() {
10000                        continue;
10001                    }
10002                    let subtype = msg
10003                        .metadata
10004                        .get("systemSubtype")
10005                        .cloned()
10006                        .unwrap_or_else(|| "local_command".to_string());
10007                    serde_json::json!({
10008                        "type": "message",
10009                        "id": id,
10010                        "parentId": parent,
10011                        "timestamp": msg_timestamp_or_synth(msg),
10012                        "message": {
10013                            "role": "custom",
10014                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
10015                            "content": content,
10016                            "display": true,
10017                            "details": {"claude_system_subtype": subtype},
10018                            "timestamp": msg_pi_native_timestamp_ms(msg),
10019                        },
10020                    })
10021                }
10022                Role::User => serde_json::json!({
10023                    "type": "message",
10024                    "id": id,
10025                    "parentId": parent,
10026                    "timestamp": msg_timestamp_or_synth(msg),
10027                    "message": {
10028                        "role": "user",
10029                        "content": pi_content_value(msg),
10030                        "timestamp": msg_pi_native_timestamp_ms(msg),
10031                    },
10032                }),
10033                Role::Assistant => {
10034                    let api = msg
10035                        .metadata
10036                        .get("pi_api")
10037                        .cloned()
10038                        .unwrap_or_else(|| "anthropic-messages".to_string());
10039                    let provider = msg
10040                        .metadata
10041                        .get("pi_provider")
10042                        .cloned()
10043                        .unwrap_or_else(|| "anthropic".to_string());
10044                    let model = self
10045                        .meta
10046                        .model
10047                        .clone()
10048                        .unwrap_or_else(|| "unknown".to_string());
10049                    let usage = msg
10050                        .metadata
10051                        .get("pi_usage")
10052                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
10053                        .unwrap_or_else(default_pi_usage);
10054                    let stop_reason = msg
10055                        .metadata
10056                        .get("pi_stop_reason")
10057                        .cloned()
10058                        .unwrap_or_else(|| "stop".to_string());
10059                    serde_json::json!({
10060                        "type": "message",
10061                        "id": id,
10062                        "parentId": parent,
10063                        "timestamp": msg_timestamp_or_synth(msg),
10064                        "message": {
10065                            "role": "assistant",
10066                            "content": pi_assistant_content_value(msg),
10067                            "api": api,
10068                            "provider": provider,
10069                            "model": model,
10070                            "usage": usage,
10071                            "stopReason": stop_reason,
10072                            "timestamp": msg_pi_native_timestamp_ms(msg),
10073                        },
10074                    })
10075                }
10076                Role::Tool => serde_json::json!({
10077                    "type": "message",
10078                    "id": id,
10079                    "parentId": parent,
10080                    "timestamp": msg_timestamp_or_synth(msg),
10081                    "message": {
10082                        "role": "toolResult",
10083                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
10084                        "toolName": msg.name.as_deref().or_else(|| {
10085                            msg.tool_call_id
10086                                .as_deref()
10087                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
10088                        }).unwrap_or_default(),
10089                        "content": pi_content_value(msg),
10090                        "isError": is_tool_error_flag(msg),
10091                        "timestamp": msg_pi_native_timestamp_ms(msg),
10092                    },
10093                }),
10094            };
10095            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
10096                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
10097            }
10098            set_grok_message_extension(&mut entry, self.meta.source, msg);
10099            push_jsonl(out, &entry);
10100            parent = Some(id);
10101            if msg.role == Role::Tool {
10102                if let Some(call_id) = msg.tool_call_id.as_deref() {
10103                    paired_tool_names.remove(call_id);
10104                }
10105            }
10106        }
10107    }
10108
10109    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
10110    /// **verbatim** — the header line always has its `version` normalized to
10111    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
10112    /// byte-identity, so the writer never re-emits one; this intentionally
10113    /// breaks byte-identity for pre-v3 originals only, the accepted
10114    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
10115    /// other raw line — every entry — is untouched (pi repeats the session
10116    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
10117    /// entries only for the appended tail via [`Self::write_pi_entries`],
10118    /// chaining from the last entry `id` found in the raw prefix.
10119    fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10120        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10121        if raw_prefix_len == 0 {
10122            return Ok(self.to_pi_jsonl());
10123        }
10124
10125        let mut out = String::new();
10126        let mut used_ids: HashSet<String> = HashSet::new();
10127        let mut leaf: Option<String> = None;
10128        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10129            if i == 0 {
10130                if let Ok(v) = serde_json::from_str::<Value>(line) {
10131                    if v.get("type").and_then(Value::as_str) == Some("session") {
10132                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10133                        // Only reparse+reserialize the header when something
10134                        // actually needs to change — this crate doesn't
10135                        // enable serde_json's `preserve_order`, so a no-op
10136                        // round-trip through `Value` would reorder keys
10137                        // alphabetically and silently break the "prefix
10138                        // bytes unchanged" splice guarantee for the (common)
10139                        // already-v3, no-override case.
10140                        if needs_v3 || session_id.is_some() {
10141                            let mut v = v;
10142                            v["version"] = serde_json::json!(3);
10143                            if let Some(new_id) = session_id {
10144                                v["id"] = Value::String(new_id.to_string());
10145                            }
10146                            out.push_str(&v.to_string());
10147                            out.push('\n');
10148                            continue;
10149                        }
10150                    }
10151                }
10152            }
10153            out.push_str(line);
10154            out.push('\n');
10155            if let Ok(v) = serde_json::from_str::<Value>(line) {
10156                if let Some(id) = v.get("id").and_then(Value::as_str) {
10157                    used_ids.insert(id.to_string());
10158                    leaf = Some(id.to_string());
10159                }
10160            }
10161        }
10162
10163        let mut counter: u64 = 0;
10164        self.write_pi_entries(
10165            &mut out,
10166            &self.messages[message_prefix_len..],
10167            leaf,
10168            &mut used_ids,
10169            &mut counter,
10170        );
10171        Ok(out)
10172    }
10173
10174    // ---- Grok writers -----------------------------------------------
10175
10176    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10177    fn to_grok_jsonl(&self) -> String {
10178        let mut out = String::new();
10179        if let Some(prompt) = self
10180            .meta
10181            .system_prompt
10182            .as_deref()
10183            .filter(|prompt| !prompt.is_empty())
10184        {
10185            push_jsonl(
10186                &mut out,
10187                &serde_json::json!({
10188                    "type": "system",
10189                    "content": prompt,
10190                }),
10191            );
10192        }
10193        self.write_grok_records(&mut out, &self.messages);
10194        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10195            if out.is_empty() {
10196                push_jsonl(
10197                    &mut out,
10198                    &serde_json::json!({"type": "system", "content": ""}),
10199                );
10200            }
10201            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10202        }
10203        out
10204    }
10205
10206    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10207        for message in messages {
10208            if is_replay_excluded(message) {
10209                continue;
10210            }
10211            let mut value = match message.role {
10212                Role::System => serde_json::json!({
10213                    "type": "user",
10214                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10215                    "synthetic_reason": "supercode_system_event",
10216                }),
10217                Role::User => {
10218                    let mut value = serde_json::json!({
10219                        "type": "user",
10220                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10221                    });
10222                    if let Some(object) = value.as_object_mut() {
10223                        for (metadata, field) in [
10224                            ("grok_prompt_index", "prompt_index"),
10225                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10226                            ("grok_synthetic_reason", "synthetic_reason"),
10227                        ] {
10228                            if let Some(raw) = message.metadata.get(metadata) {
10229                                object.insert(
10230                                    field.to_string(),
10231                                    serde_json::from_str(raw)
10232                                        .unwrap_or_else(|_| Value::String(raw.clone())),
10233                                );
10234                            }
10235                        }
10236                    }
10237                    value
10238                }
10239                Role::Assistant => {
10240                    let calls = message
10241                        .tool_calls()
10242                        .iter()
10243                        .map(|call| {
10244                            serde_json::json!({
10245                                "id": call.id,
10246                                "name": call.function.name,
10247                                "arguments": call.function.arguments,
10248                            })
10249                        })
10250                        .collect::<Vec<_>>();
10251                    let mut value = serde_json::json!({
10252                        "type": "assistant",
10253                        "content": message.content.clone().unwrap_or_default(),
10254                        "tool_calls": calls,
10255                        "model_id": message.metadata.get("grok_model_id")
10256                            .or(self.meta.model.as_ref())
10257                            .cloned()
10258                            .unwrap_or_else(|| "unknown".to_string()),
10259                    });
10260                    if let Some(object) = value.as_object_mut() {
10261                        for (metadata, field) in [
10262                            ("grok_model_fingerprint", "model_fingerprint"),
10263                            ("grok_reasoning_effort", "reasoning_effort"),
10264                        ] {
10265                            if let Some(raw) = message.metadata.get(metadata) {
10266                                object.insert(field.to_string(), Value::String(raw.clone()));
10267                            }
10268                        }
10269                    }
10270                    value
10271                }
10272                Role::Tool => serde_json::json!({
10273                    "type": "tool_result",
10274                    "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10275                    "content": message.content.clone().unwrap_or_default(),
10276                }),
10277            };
10278            set_grok_target_message_extension(&mut value, message);
10279            push_jsonl(out, &value);
10280        }
10281    }
10282
10283    /// Replay a Grok imported prefix verbatim, then append newly-created
10284    /// canonical turns. Grok stores the session id in the directory name,
10285    /// not in transcript records, so there is no in-file id to rewrite.
10286    fn to_grok_jsonl_spliced(&self) -> String {
10287        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10288        if raw_prefix_len == 0 {
10289            return self.to_grok_jsonl();
10290        }
10291        let mut out = String::new();
10292        for line in &self.raw[..raw_prefix_len] {
10293            out.push_str(line);
10294            out.push('\n');
10295        }
10296        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10297        out
10298    }
10299
10300    // ---- Gemini writers ---------------------------------------------
10301
10302    fn to_gemini_jsonl(&self) -> String {
10303        let mut out = String::new();
10304        self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10305        self.write_gemini_records(&mut out, &self.messages);
10306        push_jsonl(
10307            &mut out,
10308            &serde_json::json!({
10309                "$set": {"lastUpdated": SYNTH_TS}
10310            }),
10311        );
10312        out
10313    }
10314
10315    fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10316        push_jsonl(
10317            out,
10318            &serde_json::json!({
10319                "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10320                "projectHash": self.meta.lineage.get("gemini_project_hash")
10321                    .cloned().unwrap_or_else(|| "supercode".to_string()),
10322                "startTime": self.meta.lineage.get("created_at")
10323                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10324                "lastUpdated": self.meta.lineage.get("updated_at")
10325                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10326                "kind": self.meta.lineage.get("gemini_session_kind")
10327                    .cloned().unwrap_or_else(|| "main".to_string()),
10328            }),
10329        );
10330    }
10331
10332    fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10333        let mut call_names = HashMap::new();
10334        for (index, message) in messages.iter().enumerate() {
10335            if is_replay_excluded(message) {
10336                continue;
10337            }
10338            let timestamp = message
10339                .metadata
10340                .get("timestamp")
10341                .cloned()
10342                .unwrap_or_else(|| SYNTH_TS.to_string());
10343            match message.role {
10344                Role::System | Role::User => {
10345                    let mut parts = Vec::new();
10346                    let text = message.content.clone().or_else(|| {
10347                        message.content_parts.as_ref().and_then(|parts| {
10348                            let text = parts
10349                                .iter()
10350                                .filter_map(|part| part.get("text").and_then(Value::as_str))
10351                                .collect::<Vec<_>>()
10352                                .join(" ");
10353                            (!text.is_empty()).then_some(text)
10354                        })
10355                    });
10356                    if let Some(text) = text {
10357                        let text = if message.role == Role::System {
10358                            format!("[System] {text}")
10359                        } else {
10360                            text
10361                        };
10362                        parts.push(serde_json::json!({"text": text}));
10363                    }
10364                    if let Some(content_parts) = &message.content_parts {
10365                        for part in content_parts {
10366                            let Some(url) = part
10367                                .get("image_url")
10368                                .and_then(|value| value.get("url"))
10369                                .and_then(Value::as_str)
10370                            else {
10371                                continue;
10372                            };
10373                            let Some(rest) = url.strip_prefix("data:") else {
10374                                continue;
10375                            };
10376                            let Some((media_type, data)) = rest.split_once(";base64,") else {
10377                                continue;
10378                            };
10379                            parts.push(serde_json::json!({
10380                                "inlineData": {"mimeType": media_type, "data": data}
10381                            }));
10382                        }
10383                    }
10384                    if !parts.is_empty() {
10385                        let mut value = serde_json::json!({
10386                            "id": format!("supercode-user-{index}"),
10387                            "timestamp": timestamp,
10388                            "type": "user",
10389                            "content": parts,
10390                        });
10391                        set_gemini_message_extension(&mut value, message);
10392                        push_jsonl(out, &value);
10393                    }
10394                }
10395                Role::Assistant => {
10396                    let mut tool_calls = Vec::new();
10397                    for call in message.tool_calls() {
10398                        call_names.insert(call.id.clone(), call.function.name.clone());
10399                        let args = serde_json::from_str::<Value>(&call.function.arguments)
10400                            .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10401                        tool_calls.push(serde_json::json!({
10402                            "id": call.id,
10403                            "name": call.function.name,
10404                            "args": args,
10405                        }));
10406                    }
10407                    let mut value = serde_json::json!({
10408                        "id": format!("supercode-gemini-{index}"),
10409                        "timestamp": timestamp,
10410                        "type": "gemini",
10411                        "content": message.content.clone().unwrap_or_default(),
10412                        "model": message.metadata.get("gemini_model")
10413                            .or(self.meta.model.as_ref())
10414                            .cloned().unwrap_or_else(|| "unknown".to_string()),
10415                    });
10416                    if !tool_calls.is_empty() {
10417                        value["toolCalls"] = Value::Array(tool_calls);
10418                    }
10419                    if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10420                        value["thoughts"] = serde_json::from_str(thoughts)
10421                            .unwrap_or_else(|_| Value::String(thoughts.clone()));
10422                    }
10423                    set_gemini_message_extension(&mut value, message);
10424                    push_jsonl(out, &value);
10425                }
10426                Role::Tool => {
10427                    let id = message.tool_call_id.clone().unwrap_or_default();
10428                    let name = message
10429                        .name
10430                        .clone()
10431                        .or_else(|| call_names.get(&id).cloned())
10432                        .unwrap_or_else(|| "tool".to_string());
10433                    let output = message.content.clone().unwrap_or_else(|| {
10434                        message
10435                            .content_parts
10436                            .as_ref()
10437                            .map(|parts| Value::Array(parts.clone()))
10438                            .map(|value| value.to_string())
10439                            .unwrap_or_default()
10440                    });
10441                    let mut value = serde_json::json!({
10442                        "id": format!("supercode-tool-{index}"),
10443                        "timestamp": timestamp,
10444                        "type": "user",
10445                        "content": [{
10446                            "functionResponse": {
10447                                "id": id,
10448                                "name": name,
10449                                "response": {"output": output}
10450                            }
10451                        }],
10452                    });
10453                    set_gemini_message_extension(&mut value, message);
10454                    push_jsonl(out, &value);
10455                }
10456            }
10457        }
10458    }
10459
10460    fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10461        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10462        if raw_prefix_len == 0 {
10463            let mut out = String::new();
10464            self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10465            self.write_gemini_records(&mut out, &self.messages);
10466            return out;
10467        }
10468        let mut out = String::new();
10469        for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10470            if index == 0 && session_id.is_some() {
10471                if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10472                    if value.get("type").is_none() && value.get("sessionId").is_some() {
10473                        value["sessionId"] =
10474                            Value::String(session_id.unwrap_or_default().to_string());
10475                        push_jsonl(&mut out, &value);
10476                        continue;
10477                    }
10478                }
10479            }
10480            out.push_str(line);
10481            out.push('\n');
10482        }
10483        self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10484        out
10485    }
10486
10487    // ---- Goose writers ----------------------------------------------
10488
10489    fn to_goose_json(&self) -> String {
10490        if self.meta.source == SessionSource::Goose
10491            && !self.raw.is_empty()
10492            && self.imported_message_count == Some(self.messages.len())
10493        {
10494            return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10495        }
10496        self.synthesized_goose_document(None, &self.messages)
10497    }
10498
10499    fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10500        let message_prefix_len = self
10501            .imported_message_count
10502            .unwrap_or(self.messages.len())
10503            .min(self.messages.len());
10504        if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10505            if session_id.is_none() && message_prefix_len == self.messages.len() {
10506                return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10507            }
10508            let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10509            if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10510                if let Some(session_id) = session_id {
10511                    document["id"] = Value::String(session_id.to_string());
10512                }
10513                let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10514                if let Some(conversation) = document
10515                    .get_mut("conversation")
10516                    .and_then(Value::as_array_mut)
10517                {
10518                    conversation.extend(appended);
10519                    document["message_count"] = Value::from(conversation.len());
10520                }
10521                return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10522                    self.synthesized_goose_document(session_id, &self.messages)
10523                });
10524            }
10525        }
10526        self.synthesized_goose_document(session_id, &self.messages)
10527    }
10528
10529    fn synthesized_goose_document(
10530        &self,
10531        session_id: Option<&str>,
10532        messages: &[ChatMessage],
10533    ) -> String {
10534        let mut document = self
10535            .meta
10536            .goose_header
10537            .clone()
10538            .or_else(|| {
10539                self.messages.iter().find_map(|message| {
10540                    message
10541                        .metadata
10542                        .get("goose_session_header")
10543                        .and_then(|value| serde_json::from_str(value).ok())
10544                })
10545            })
10546            .unwrap_or_else(|| {
10547                serde_json::json!({
10548                    "id": "supercode-goose-session",
10549                    "working_dir": self.cwd_string(),
10550                    "name": "supercode export",
10551                    "user_set_name": false,
10552                    "session_type": "user",
10553                    "created_at": SYNTH_TS,
10554                    "updated_at": SYNTH_TS,
10555                    "extension_data": {},
10556                    "usage": {},
10557                    "accumulated_usage": {},
10558                    "accumulated_cost": Value::Null,
10559                    "schedule_id": Value::Null,
10560                    "recipe": Value::Null,
10561                    "user_recipe_values": Value::Null,
10562                    "message_count": 0,
10563                    "last_message_at": Value::Null,
10564                    "provider_name": Value::Null,
10565                    "model_config": Value::Null,
10566                    "goose_mode": "auto",
10567                    "archived_at": Value::Null,
10568                    "project_id": Value::Null,
10569                    "parent_session_id": Value::Null,
10570                    "last_message_snippet": Value::Null,
10571                })
10572            });
10573        document["id"] = Value::String(
10574            session_id
10575                .map(str::to_string)
10576                .or_else(|| self.meta.session_id.clone())
10577                .unwrap_or_else(|| "supercode-goose-session".to_string()),
10578        );
10579        document["working_dir"] = Value::String(self.cwd_string());
10580        let conversation = self.goose_conversation(messages);
10581        document["message_count"] = Value::from(conversation.len());
10582        document["conversation"] = Value::Array(conversation);
10583        serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10584    }
10585
10586    fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10587        let mut out = Vec::new();
10588        let mut last_native_index: Option<String> = None;
10589        let mut tool_names = HashMap::<String, String>::new();
10590        for (index, message) in messages.iter().enumerate() {
10591            if is_replay_excluded(message) {
10592                continue;
10593            }
10594            if let Some(native_index) = message.metadata.get("goose_native_index") {
10595                if last_native_index.as_ref() == Some(native_index) {
10596                    continue;
10597                }
10598                last_native_index = Some(native_index.clone());
10599                if let Some(native) = message
10600                    .metadata
10601                    .get("goose_native_message")
10602                    .and_then(|value| serde_json::from_str::<Value>(value).ok())
10603                {
10604                    out.push(native);
10605                    continue;
10606                }
10607            } else {
10608                last_native_index = None;
10609            }
10610
10611            for call in message.tool_calls() {
10612                tool_names.insert(call.id.clone(), call.function.name.clone());
10613            }
10614            let created = message
10615                .metadata
10616                .get("goose_created")
10617                .and_then(|value| value.parse::<i64>().ok())
10618                .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10619            let role = match message.role {
10620                Role::Assistant => "assistant",
10621                _ => "user",
10622            };
10623            let mut content = Vec::new();
10624            // A Goose tool response carries its output inside
10625            // `toolResult.value.content`; duplicating it as a sibling text
10626            // block makes the loader normalize one Tool message twice.
10627            if message.role != Role::Tool {
10628                if let Some(text) = &message.content {
10629                    let text = if message.role == Role::System {
10630                        format!("[System] {text}")
10631                    } else {
10632                        text.clone()
10633                    };
10634                    content.push(serde_json::json!({"type": "text", "text": text}));
10635                }
10636                if let Some(parts) = &message.content_parts {
10637                    for part in parts {
10638                        if let Some(text) = part.get("text").and_then(Value::as_str) {
10639                            if message.content.is_none() {
10640                                content.push(serde_json::json!({"type": "text", "text": text}));
10641                            }
10642                        }
10643                        let Some(url) = part
10644                            .get("image_url")
10645                            .and_then(|image| image.get("url"))
10646                            .and_then(Value::as_str)
10647                        else {
10648                            continue;
10649                        };
10650                        let Some(data) = url.strip_prefix("data:") else {
10651                            continue;
10652                        };
10653                        let Some((media_type, data)) = data.split_once(";base64,") else {
10654                            continue;
10655                        };
10656                        content.push(serde_json::json!({
10657                            "type": "image",
10658                            "data": data,
10659                            "mimeType": media_type,
10660                        }));
10661                    }
10662                }
10663            }
10664            for call in message.tool_calls() {
10665                let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10666                    .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10667                content.push(serde_json::json!({
10668                    "type": "toolRequest",
10669                    "id": call.id,
10670                    "toolCall": {
10671                        "status": "success",
10672                        "value": {"name": call.function.name, "arguments": arguments}
10673                    }
10674                }));
10675            }
10676            if message.role == Role::Tool {
10677                let id = message.tool_call_id.clone().unwrap_or_default();
10678                let output = message.content.clone().unwrap_or_else(|| {
10679                    message
10680                        .content_parts
10681                        .as_ref()
10682                        .map(|parts| Value::Array(parts.clone()).to_string())
10683                        .unwrap_or_default()
10684                });
10685                let tool_result = if crate::is_tool_error(message) {
10686                    serde_json::json!({"status": "error", "error": output})
10687                } else {
10688                    serde_json::json!({
10689                        "status": "success",
10690                        "value": {
10691                            "content": [{"type": "text", "text": output}],
10692                            "isError": false
10693                        }
10694                    })
10695                };
10696                content.push(serde_json::json!({
10697                    "type": "toolResponse",
10698                    "id": id,
10699                    "toolResult": tool_result,
10700                    "metadata": {
10701                        "toolName": message.name.as_ref()
10702                            .or_else(|| tool_names.get(&id))
10703                    }
10704                }));
10705            }
10706            if content.is_empty() {
10707                continue;
10708            }
10709            let metadata = message
10710                .metadata
10711                .get("goose_metadata")
10712                .and_then(|value| serde_json::from_str::<Value>(value).ok())
10713                .unwrap_or_else(|| {
10714                    serde_json::json!({
10715                        "userVisible": true,
10716                        "agentVisible": true
10717                    })
10718                });
10719            let mut native = serde_json::json!({
10720                "id": message.metadata.get("goose_message_id")
10721                    .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10722                "role": role,
10723                "created": created,
10724                "content": content,
10725                "metadata": metadata,
10726            });
10727            // Goose tolerates unknown top-level fields on a conversation
10728            // message. Always carry the canonical envelope when Goose is
10729            // the TARGET so metadata absent from Goose's stock schema can
10730            // make a later Goose -> source round trip without residue.
10731            set_grok_target_message_extension(&mut native, message);
10732            out.push(native);
10733        }
10734        out
10735    }
10736
10737    // ---- OpenCode writers ---------------------------------------------
10738
10739    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10740    /// `(message value, part values)` list) directly from `self.raw`'s
10741    /// envelope lines — the same classification
10742    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10743    /// rather than canonical `ChatMessage`s. Used by
10744    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10745    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10746    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10747    /// path for excess keys/timestamps/side-records `opencode import`
10748    /// cannot restore).
10749    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10750        let mut session_info: Option<Value> = None;
10751        let mut msg_order: Vec<String> = Vec::new();
10752        let mut msg_values: HashMap<String, Value> = HashMap::new();
10753        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10754        for line in &self.raw {
10755            let Ok(env) = serde_json::from_str::<Value>(line) else {
10756                continue;
10757            };
10758            let Some(key) = env.get("key").and_then(Value::as_array) else {
10759                continue;
10760            };
10761            let value = env.get("value").cloned().unwrap_or(Value::Null);
10762            match key.first().and_then(Value::as_str) {
10763                Some("session") => session_info = Some(value),
10764                Some("message") => {
10765                    if let Some(id) = value.get("id").and_then(Value::as_str) {
10766                        if !msg_values.contains_key(id) {
10767                            msg_order.push(id.to_string());
10768                        }
10769                        msg_values.insert(id.to_string(), value);
10770                    }
10771                }
10772                Some("part") => {
10773                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10774                        msg_parts.entry(mid.to_string()).or_default().push(value);
10775                    }
10776                }
10777                _ => {}
10778            }
10779        }
10780        let mut ordered: Vec<(String, i64)> = msg_order
10781            .iter()
10782            .map(|id| {
10783                let tc = msg_values
10784                    .get(id)
10785                    .and_then(|v| v.get("time"))
10786                    .and_then(|t| t.get("created"))
10787                    .and_then(Value::as_i64)
10788                    .unwrap_or(0);
10789                (id.clone(), tc)
10790            })
10791            .collect();
10792        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10793        let mut out = Vec::new();
10794        for (id, _) in ordered {
10795            let mut parts = msg_parts.remove(&id).unwrap_or_default();
10796            parts.sort_by(|a, b| {
10797                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10798                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10799                ai.cmp(bi)
10800            });
10801            if let Some(v) = msg_values.remove(&id) {
10802                out.push((v, parts));
10803            }
10804        }
10805        (session_info, out)
10806    }
10807
10808    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10809    /// `raw` prefix exists to replay (a fresh/cross-format-converted
10810    /// session). T3 tier: only what `SessionMeta` carries survives.
10811    fn synthesized_opencode_info(&self) -> Value {
10812        let id = self
10813            .meta
10814            .session_id
10815            .clone()
10816            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10817        let mut info = serde_json::json!({
10818            "id": id,
10819            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10820            // OpenCode 1.2.15's import path writes this into a NOT NULL
10821            // SQLite column. Preserve a real source slug when available and
10822            // mint a stable, human-readable fallback for foreign sessions.
10823            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10824            "directory": self.cwd_string(),
10825            "title": "supercode export",
10826            "version": env!("CARGO_PKG_VERSION"),
10827            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10828        });
10829        if let Some(agent) = &self.meta.agent_id {
10830            info["agent"] = Value::String(agent.clone());
10831        }
10832        if let Some(model) = &self.meta.model {
10833            if let Some((provider, mid)) = model.split_once('/') {
10834                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10835            }
10836        }
10837        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10838            info["parentID"] = Value::String(parent.clone());
10839        }
10840        // D7: carry a captured Claude `fork-context-ref` through the
10841        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10842        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10843        // the `session` header) hops already do — namespaced so real
10844        // OpenCode tooling ignores it, and `capture_opencode_session_info`
10845        // reads this same key back on import so a Claude -> OpenCode ->
10846        // Claude round trip doesn't silently lose fork lineage either.
10847        //
10848        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10849        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10850        // this `claude_fork_context_ref` key on `SessionInfo` survives
10851        // supercode's OWN round-trip (write here, read back by
10852        // `capture_opencode_session_info` above) but NOT a real upstream
10853        // `opencode import` ingestion — that path decodes with
10854        // `Schema.decodeUnknownSync`, which strips any key its schema
10855        // doesn't declare. The direct-file/DB fallback (bypassing
10856        // `opencode import` entirely) is the per-spec fidelity path for
10857        // this lineage to actually reach real OpenCode.
10858        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10859            info["claude_fork_context_ref"] =
10860                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10861        }
10862        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10863            info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10864        }
10865        info
10866    }
10867
10868    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10869    /// synthesized continuation message therefore has to advance the
10870    /// session clock along with its own `time.created` value.
10871    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10872        if !info.get("time").is_some_and(Value::is_object) {
10873            info["time"] = serde_json::json!({});
10874        }
10875        info["time"]["updated"] = serde_json::json!(timestamp);
10876    }
10877
10878    /// Synthesize opencode `{info, parts}` message objects for `messages`
10879    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10880    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10881    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10882    /// back into its call's assistant `tool` part (match by
10883    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10884    /// whole slice)
10885    /// — the exact inverse of the loader's call/result split. This is a
10886    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10887    /// immediately following each assistant: two-or-more consecutive
10888    /// assistant-with-tool-call messages before their results (streamed /
10889    /// parallel tool calls) otherwise strand the earlier call's real result
10890    /// behind a later assistant message, silently downgrading it to
10891    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10892    /// messages exactly like every other writer.
10893    fn append_synthesized_opencode_messages(
10894        &self,
10895        out: &mut Vec<Value>,
10896        messages: &[ChatMessage],
10897        session_id: &str,
10898        counter: &mut u64,
10899        timestamp_cursor: &mut i64,
10900    ) -> Result<()> {
10901        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10902        // over the ENTIRE slice being processed, rather than by scanning
10903        // only the contiguous run of `Role::Tool` messages immediately
10904        // following a given assistant message. Two-or-more consecutive
10905        // assistant-with-tool-call messages before their results (streamed
10906        // / parallel tool calls — extremely common in real Claude Code and
10907        // Codex sessions) break the contiguous-run assumption: the first
10908        // assistant's own result(s) land AFTER a second assistant message,
10909        // not immediately after the first, so a contiguous scan starting
10910        // right after the first assistant finds nothing and silently drops
10911        // its real tool output into the `None => "pending"` branch below.
10912        // A single `id -> result` map is still insufficient: long real
10913        // sessions can reuse provider call ids. Last-write-wins then attaches
10914        // the final output to every earlier occurrence. Collect calls and
10915        // results independently and zip their occurrences in transcript
10916        // order, giving every concrete call position its own result.
10917        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10918        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10919        for (message_index, message) in messages.iter().enumerate() {
10920            if message.role == Role::Assistant {
10921                for (tool_index, call) in message.tool_calls().iter().enumerate() {
10922                    calls_by_id
10923                        .entry(call.id.as_str())
10924                        .or_default()
10925                        .push((message_index, tool_index));
10926                }
10927            } else if message.role == Role::Tool {
10928                if let Some(id) = &message.tool_call_id {
10929                    results_by_id
10930                        .entry(id.as_str())
10931                        .or_default()
10932                        .push((message_index, message));
10933                }
10934            }
10935        }
10936        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10937        for (id, calls) in calls_by_id {
10938            let Some(results) = results_by_id.get(id) else {
10939                continue;
10940            };
10941            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10942                paired_results.insert(call_position, result);
10943            }
10944        }
10945        let mut i = 0;
10946        while i < messages.len() {
10947            let msg = &messages[i];
10948            if is_replay_excluded(msg) {
10949                i += 1;
10950                continue;
10951            }
10952            match msg.role {
10953                // B4: opencode V1 has no session-level system-PROMPT slot
10954                // either — `User.system` is a per-turn system-PROMPT
10955                // OVERRIDE (§2.1), a different thing from a content-bearing
10956                // `Role::System` message loaded from a real Claude `type:
10957                // "system"` record (`push_claude_system`'s keep-listed
10958                // subtypes). Stuffing real transcript content into
10959                // `User.system` would be a genuine misuse — it overrides the
10960                // replayed system prompt, not just annotates a turn — so
10961                // this instead reuses opencode's own `text` part `synthetic`
10962                // flag (§3.1: "injected by opencode, not typed by user"),
10963                // which is EXACTLY the right existing, non-fabricated
10964                // semantic for "system-originated content presented as a
10965                // user turn": a dedicated `User` message with one
10966                // `synthetic: true` text part, tagged with a
10967                // supercode-namespaced part-`metadata` key so
10968                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10969                // recognize it on reload and restore `Role::System` +
10970                // `metadata["systemSubtype"]` rather than treating it as a
10971                // real user turn. Content is never fabricated — only
10972                // emitted when non-empty.
10973                Role::System => {
10974                    let content = msg.content.clone().unwrap_or_default();
10975                    if content.trim().is_empty() {
10976                        i += 1;
10977                        continue;
10978                    }
10979                    let subtype = msg
10980                        .metadata
10981                        .get("systemSubtype")
10982                        .cloned()
10983                        .unwrap_or_else(|| "local_command".to_string());
10984                    let msg_id = opencode_fresh_id("msg", counter);
10985                    let part_id = opencode_fresh_id("prt", counter);
10986                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10987                    let mut info = serde_json::json!({
10988                        "id": msg_id,
10989                        "sessionID": session_id,
10990                        "role": "user",
10991                        "time": {"created": timestamp},
10992                    });
10993                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10994                    let parts = vec![serde_json::json!({
10995                        "id": part_id,
10996                        "sessionID": session_id,
10997                        "messageID": msg_id,
10998                        "type": "text",
10999                        "text": content,
11000                        "synthetic": true,
11001                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
11002                    })];
11003                    out.push(serde_json::json!({"info": info, "parts": parts}));
11004                    i += 1;
11005                }
11006                Role::User => {
11007                    let msg_id = opencode_fresh_id("msg", counter);
11008                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
11009                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
11010                    let mut info = serde_json::json!({
11011                        "id": msg_id,
11012                        "sessionID": session_id,
11013                        "role": "user",
11014                        "time": {"created": timestamp},
11015                    });
11016                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11017                    opencode_restore_agent_model_fields(
11018                        &mut info, msg, /* is_assistant */ false,
11019                    );
11020                    set_grok_message_extension(&mut info, self.meta.source, msg);
11021                    out.push(serde_json::json!({
11022                        "info": info,
11023                        "parts": parts,
11024                    }));
11025                    i += 1;
11026                }
11027                Role::Assistant => {
11028                    let msg_id = opencode_fresh_id("msg", counter);
11029                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
11030                    let mut parts = Vec::new();
11031                    if let Some(thinking) = msg.metadata.get("thinking") {
11032                        let mut part = serde_json::json!({
11033                            "id": opencode_fresh_id("prt", counter),
11034                            "sessionID": session_id,
11035                            "messageID": msg_id,
11036                            "type": "reasoning",
11037                            "text": thinking,
11038                            // Required by OpenCode V1's native reasoning
11039                            // schema. A synthesized part has no distinct
11040                            // stream start/end, so the source message clock
11041                            // is the honest zero-duration span.
11042                            "time": {"start": timestamp, "end": timestamp},
11043                        });
11044                        if let Some(signature) = msg.metadata.get("thinking_signature") {
11045                            part["metadata"] = serde_json::json!({
11046                                "anthropic": {"signature": signature},
11047                            });
11048                        }
11049                        parts.push(part);
11050                    }
11051                    if let Some(t) = &msg.content {
11052                        if !t.is_empty() {
11053                            parts.push(serde_json::json!({
11054                                "id": opencode_fresh_id("prt", counter),
11055                                "sessionID": session_id,
11056                                "messageID": msg_id,
11057                                "type": "text",
11058                                "text": t,
11059                            }));
11060                        }
11061                    }
11062                    // Fold each tool call's result back into ONE `tool`
11063                    // part, matched by tool_call_id via the GLOBAL
11064                    // `all_results` map built above (not a contiguous scan)
11065                    // — a result may be many messages away when other
11066                    // assistant turns with their own pending calls
11067                    // intervene before it appears.
11068                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
11069                        let input = tc
11070                            .function
11071                            .parsed_arguments()
11072                            .unwrap_or_else(|_| Value::Object(Default::default()));
11073                        let paired_result = paired_results.get(&(i, tool_index)).copied();
11074                        let state = match paired_result {
11075                            Some((_, result)) if crate::is_tool_error(result) => {
11076                                let result_timestamp =
11077                                    opencode_message_timestamp(result, timestamp_cursor)?;
11078                                serde_json::json!({
11079                                    "status": "error",
11080                                    "input": input,
11081                                    "error": result.content.clone().unwrap_or_default(),
11082                                    "time": {"end": result_timestamp},
11083                                })
11084                            }
11085                            Some((_, result)) => {
11086                                let result_timestamp =
11087                                    opencode_message_timestamp(result, timestamp_cursor)?;
11088                                let mut s = serde_json::json!({
11089                                    "status": "completed",
11090                                    "input": input,
11091                                    "output": result.content.clone().unwrap_or_default(),
11092                                    "title": tc.function.name,
11093                                    "time": {"end": result_timestamp},
11094                                });
11095                                // PARITY-11 (nested images): the LOADER already
11096                                // reads a completed tool part's
11097                                // `state.attachments` back into `content_parts`
11098                                // (`opencode_file_image_part`, above) — this is
11099                                // the missing WRITE-side inverse. Without it, a
11100                                // Claude `tool_result`'s nested image (now
11101                                // captured into `content_parts` by
11102                                // `extract_tool_result_content`) reached
11103                                // `content_parts` on the canonical `ChatMessage`
11104                                // but was silently dropped again on re-export to
11105                                // OpenCode, because nothing ever read it back
11106                                // out. `mime`/`url` shape matches exactly what
11107                                // `opencode_file_image_part` expects on reload.
11108                                if let Some(cps) = &result.content_parts {
11109                                    let atts: Vec<Value> = cps
11110                                        .iter()
11111                                        .filter(|p| {
11112                                            p.get("type").and_then(Value::as_str)
11113                                                == Some("image_url")
11114                                        })
11115                                        .filter_map(|p| {
11116                                            let url = p
11117                                                .get("image_url")
11118                                                .and_then(|u| u.get("url"))
11119                                                .and_then(Value::as_str)?;
11120                                            let mime = url
11121                                                .strip_prefix("data:")
11122                                                .and_then(|r| r.split_once(','))
11123                                                .map(|(m, _)| m.trim_end_matches(";base64"))
11124                                                .unwrap_or("application/octet-stream");
11125                                            Some(serde_json::json!({
11126                                                "mime": mime,
11127                                                "url": url,
11128                                            }))
11129                                        })
11130                                        .collect();
11131                                    if !atts.is_empty() {
11132                                        s["attachments"] = Value::Array(atts);
11133                                    }
11134                                }
11135                                s
11136                            }
11137                            None => serde_json::json!({"status": "pending", "input": input}),
11138                        };
11139                        let mut part = serde_json::json!({
11140                            "id": opencode_fresh_id("prt", counter),
11141                            "sessionID": session_id,
11142                            "messageID": msg_id,
11143                            "type": "tool",
11144                            "callID": tc.id,
11145                            "tool": tc.function.name,
11146                            "state": state,
11147                        });
11148                        if let Some((result_position, _)) = paired_result {
11149                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11150                                serde_json::json!(result_position);
11151                        }
11152                        if paired_result.is_some_and(|(_, result)| {
11153                            crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11154                        }) {
11155                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11156                        }
11157                        if let Some((_, result)) = paired_result {
11158                            set_grok_message_extension(&mut part, self.meta.source, result);
11159                        }
11160                        parts.push(part);
11161                    }
11162                    let mut info = serde_json::json!({
11163                        "id": msg_id,
11164                        "sessionID": session_id,
11165                        "role": "assistant",
11166                        "time": {"created": timestamp},
11167                    });
11168                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11169                    opencode_restore_agent_model_fields(
11170                        &mut info, msg, /* is_assistant */ true,
11171                    );
11172                    set_grok_message_extension(&mut info, self.meta.source, msg);
11173                    out.push(serde_json::json!({
11174                        "info": info,
11175                        "parts": parts,
11176                    }));
11177                    i += 1;
11178                }
11179                // A Tool message is always folded into its call's assistant
11180                // `tool` part above (via occurrence-aware global pairing, not
11181                // positional adjacency), so it never needs its own entry
11182                // here — just advance past it.
11183                Role::Tool => i += 1,
11184            }
11185        }
11186        Ok(())
11187    }
11188
11189    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11190    /// `messages` (T3 cross-format/full synthesis tier — mirrors
11191    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11192    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11193    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11194    /// (§1.2 — the `opencode export`/`import` interchange shape).
11195    fn to_opencode_jsonl(&self) -> Result<String> {
11196        let mut info = self.synthesized_opencode_info();
11197        let ses_id = info
11198            .get("id")
11199            .and_then(Value::as_str)
11200            .unwrap_or("ses_new")
11201            .to_string();
11202        let mut messages_json: Vec<Value> = Vec::new();
11203        let mut counter: u64 = 0;
11204        let mut timestamp_cursor =
11205            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11206        self.append_synthesized_opencode_messages(
11207            &mut messages_json,
11208            &self.messages,
11209            &ses_id,
11210            &mut counter,
11211            &mut timestamp_cursor,
11212        )?;
11213        if !messages_json.is_empty() {
11214            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11215        }
11216        let doc = serde_json::json!({"info": info, "messages": messages_json});
11217        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11218    }
11219
11220    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11221    /// imported records **value-equal at their position** in the export
11222    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11223    /// via [`Self::opencode_records_from_raw`], never re-derived from the
11224    /// lossy canonical `messages` — then append freshly synthesized
11225    /// `{info, parts}` objects for the tail via
11226    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11227    /// line-oriented formats' splice, `out` here is a single export
11228    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11229    /// assertion accordingly: value-equality at position, not byte
11230    /// equality of a line range).
11231    fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11232        if self.raw.is_empty() {
11233            return self.to_opencode_jsonl();
11234        }
11235        let (session_info, records) = self.opencode_records_from_raw();
11236        let (_, message_prefix_len) = self.spliced_prefix_lens();
11237
11238        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11239        if let Some(id) = session_id {
11240            info["id"] = Value::String(id.to_string());
11241        }
11242        let ses_id_for_new = info
11243            .get("id")
11244            .and_then(Value::as_str)
11245            .unwrap_or("ses_new")
11246            .to_string();
11247
11248        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11249            .chain(records.iter().flat_map(|(msg, parts)| {
11250                std::iter::once(opencode_max_timestamp(msg))
11251                    .chain(parts.iter().map(opencode_max_timestamp))
11252            }))
11253            .flatten()
11254            .max()
11255            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11256
11257        let mut messages_json: Vec<Value> = records
11258            .into_iter()
11259            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11260            .collect();
11261        let imported_len = messages_json.len();
11262
11263        let mut counter: u64 = 0;
11264        self.append_synthesized_opencode_messages(
11265            &mut messages_json,
11266            &self.messages[message_prefix_len..],
11267            &ses_id_for_new,
11268            &mut counter,
11269            &mut timestamp_cursor,
11270        )?;
11271        if messages_json.len() > imported_len {
11272            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11273        }
11274
11275        let doc = serde_json::json!({"info": info, "messages": messages_json});
11276        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11277    }
11278
11279    /// The **required** direct-write fallback (S5): write the imported
11280    /// OpenCode records **verbatim** — excess/unknown keys, part-row
11281    /// timestamps, and `session_diff`/`todo` side-records intact — to a
11282    /// generation-B JSON-file storage tree
11283    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11284    /// `opencode import` cannot provide (S5: import re-decodes through a
11285    /// strict schema and STRIPS excess keys; inserts part rows without
11286    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11287    /// has no ingestion path for `session_diff`/`todo` at all).
11288    ///
11289    /// Writes the JSON-FILE layout rather than a live SQLite write
11290    /// specifically to avoid a new `rusqlite`-class dependency on this
11291    /// build's memory-constrained box (see the build report); `session_diff`
11292    /// itself is still JSON-written by upstream even on SQLite installs
11293    /// (§1.3), so this is a real fidelity path, not a fictional one.
11294    ///
11295    /// Returns the `storage/session/<projectID>/` directory written to.
11296    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11297        let (session_info, mut records) = self.opencode_records_from_raw();
11298        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11299        let ses_id = info
11300            .get("id")
11301            .and_then(Value::as_str)
11302            .unwrap_or("ses_new")
11303            .to_string();
11304        if info.get("id").is_none() {
11305            info["id"] = Value::String(ses_id.clone());
11306        }
11307        let project_id = info
11308            .get("projectID")
11309            .and_then(Value::as_str)
11310            .unwrap_or("global")
11311            .to_string();
11312
11313        // Appended tail (messages produced after import): synthesize fresh
11314        // message/part VALUES via the same T3 synthesis the splice writer
11315        // uses, so continuation turns get files too. Do this BEFORE creating
11316        // any directories: timestamp exhaustion must fail atomically rather
11317        // than leave a partial direct-write tree behind.
11318        let (_, message_prefix_len) = self.spliced_prefix_lens();
11319        let mut counter: u64 = 0;
11320        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11321            .chain(records.iter().flat_map(|(msg, parts)| {
11322                std::iter::once(opencode_max_timestamp(msg))
11323                    .chain(parts.iter().map(opencode_max_timestamp))
11324            }))
11325            .flatten()
11326            .max()
11327            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11328        let mut appended_json: Vec<Value> = Vec::new();
11329        self.append_synthesized_opencode_messages(
11330            &mut appended_json,
11331            &self.messages[message_prefix_len..],
11332            &ses_id,
11333            &mut counter,
11334            &mut timestamp_cursor,
11335        )?;
11336        if !appended_json.is_empty() {
11337            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11338        }
11339        for entry in appended_json {
11340            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11341            let parts = entry
11342                .get("parts")
11343                .and_then(Value::as_array)
11344                .cloned()
11345                .unwrap_or_default();
11346            records.push((msg, parts));
11347        }
11348
11349        let storage = data_root.join("storage");
11350        let session_dir = storage.join("session").join(&project_id);
11351        std::fs::create_dir_all(&session_dir)?;
11352        std::fs::write(
11353            session_dir.join(format!("{ses_id}.json")),
11354            serde_json::to_string_pretty(&info).unwrap_or_default(),
11355        )?;
11356
11357        let message_dir = storage.join("message").join(&ses_id);
11358        let part_dir = storage.join("part");
11359        std::fs::create_dir_all(&message_dir)?;
11360
11361        for (msg, parts) in &records {
11362            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11363                continue;
11364            };
11365            std::fs::write(
11366                message_dir.join(format!("{msg_id}.json")),
11367                serde_json::to_string_pretty(msg).unwrap_or_default(),
11368            )?;
11369            let this_part_dir = part_dir.join(msg_id);
11370            std::fs::create_dir_all(&this_part_dir)?;
11371            for part in parts {
11372                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11373                    continue;
11374                };
11375                std::fs::write(
11376                    this_part_dir.join(format!("{part_id}.json")),
11377                    serde_json::to_string_pretty(part).unwrap_or_default(),
11378                )?;
11379            }
11380        }
11381
11382        // Side-records (S5c): session_diff / todo have NO ingestion path via
11383        // `opencode import` at all — the direct write is their only
11384        // fidelity path.
11385        for header in &self.meta.opencode_headers {
11386            let Some(key) = header.get("key").and_then(Value::as_array) else {
11387                continue;
11388            };
11389            let Some(kind) = key.first().and_then(Value::as_str) else {
11390                continue;
11391            };
11392            let value = header.get("value").cloned().unwrap_or(Value::Null);
11393            if !matches!(kind, "session_diff" | "todo") {
11394                continue;
11395            }
11396            let dir = storage.join(kind);
11397            std::fs::create_dir_all(&dir)?;
11398            std::fs::write(
11399                dir.join(format!("{ses_id}.json")),
11400                serde_json::to_string_pretty(&value).unwrap_or_default(),
11401            )?;
11402        }
11403
11404        Ok(session_dir)
11405    }
11406}
11407
11408fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11409    *counter += 1;
11410    format!("{prefix}_synth{counter:06}")
11411}
11412
11413/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11414/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11415/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11416/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11417/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11418/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11419/// ONLY when its metadata key is present (a synthesized continuation turn, or
11420/// a User message that never carried `agent`, stays clean — no spurious
11421/// null/empty fields).
11422///
11423/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11424///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11425///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11426/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11427///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11428///   inverse must match per-role:
11429///   - User: `push_opencode_user` stores `metadata["model"]` as the
11430///     STRINGIFIED `{providerID, modelID, variant?}` object
11431///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11432///     as that same object under `"model"`.
11433///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11434///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11435///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
11436///     join; a `modelID` containing further `/`s round-trips correctly since
11437///     `split_once` only consumes the first) and re-emitted as the two
11438///     top-level `providerID`/`modelID` fields the loader actually reads.
11439/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11440///   fields exist on opencode's `User` schema) — `is_summary` re-expands
11441///   `"true"` back to the native `summary: true` bool (the loader only ever
11442///   sets the metadata key on `Some(true)`, never on absent/false, so the
11443///   inverse never needs to emit `false`); `finish` is a plain string;
11444///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11445///   `Value` (a number and an object respectively), so they're re-parsed
11446///   from that stringified form and re-emitted as the native JSON value —
11447///   NOT as strings — matching `msg_value.get(field)` shape exactly.
11448fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11449    if let Some(agent) = msg.metadata.get("agent") {
11450        info["agent"] = Value::String(agent.clone());
11451    }
11452    if let Some(model) = msg.metadata.get("model") {
11453        if is_assistant {
11454            if let Some((provider, model_id)) = model.split_once('/') {
11455                info["providerID"] = Value::String(provider.to_string());
11456                info["modelID"] = Value::String(model_id.to_string());
11457            }
11458        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11459            info["model"] = v;
11460        }
11461    }
11462    if !is_assistant {
11463        return;
11464    }
11465    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11466        info["summary"] = Value::Bool(true);
11467    }
11468    if let Some(finish) = msg.metadata.get("finish") {
11469        info["finish"] = Value::String(finish.clone());
11470    }
11471    if let Some(cost) = msg.metadata.get("cost") {
11472        if let Ok(v) = serde_json::from_str::<Value>(cost) {
11473            info["cost"] = v;
11474        }
11475    }
11476    if let Some(tokens) = msg.metadata.get("tokens") {
11477        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11478            info["tokens"] = v;
11479        }
11480    }
11481}
11482
11483fn opencode_user_parts_from_message(
11484    msg: &ChatMessage,
11485    msg_id: &str,
11486    session_id: &str,
11487    counter: &mut u64,
11488) -> Vec<Value> {
11489    let mut parts = Vec::new();
11490    if let Some(cps) = &msg.content_parts {
11491        for p in cps {
11492            match p.get("type").and_then(Value::as_str) {
11493                Some("text") => {
11494                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11495                        parts.push(serde_json::json!({
11496                            "id": opencode_fresh_id("prt", counter),
11497                            "sessionID": session_id,
11498                            "messageID": msg_id,
11499                            "type": "text",
11500                            "text": t,
11501                        }));
11502                    }
11503                }
11504                Some("image_url") => {
11505                    if let Some(url) = p
11506                        .get("image_url")
11507                        .and_then(|u| u.get("url"))
11508                        .and_then(Value::as_str)
11509                    {
11510                        let mime = url
11511                            .strip_prefix("data:")
11512                            .and_then(|r| r.split_once(','))
11513                            .map(|(m, _)| m.trim_end_matches(";base64"))
11514                            .unwrap_or("application/octet-stream");
11515                        parts.push(serde_json::json!({
11516                            "id": opencode_fresh_id("prt", counter),
11517                            "sessionID": session_id,
11518                            "messageID": msg_id,
11519                            "type": "file",
11520                            "mime": mime,
11521                            "url": url,
11522                        }));
11523                    }
11524                }
11525                _ => {}
11526            }
11527        }
11528    } else if let Some(t) = &msg.content {
11529        if !t.is_empty() {
11530            parts.push(serde_json::json!({
11531                "id": opencode_fresh_id("prt", counter),
11532                "sessionID": session_id,
11533                "messageID": msg_id,
11534                "type": "text",
11535                "text": t,
11536            }));
11537        }
11538    }
11539    parts
11540}
11541
11542fn codex_response_item(payload: Value, ts: &str) -> Value {
11543    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11544}
11545
11546/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11547/// see [`Session::write_codex_records`]); a no-op returning `payload`
11548/// untouched when `None`, so the historical byte shape is preserved for
11549/// every record that has no merge ambiguity to disambiguate.
11550fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11551    if let Some(tid) = turn_id {
11552        payload["metadata"] = serde_json::json!({"turn_id": tid});
11553    }
11554    payload
11555}
11556
11557/// Build a Codex `message` response_item's `content` block array from a
11558/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11559/// parse. When `content_parts` is `None` this MUST reproduce the historical
11560/// single-block shape exactly (IX-5's overriding constraint: a text-only
11561/// message's export stays byte-identical) — only a multimodal message gets
11562/// one `{text_type}` block per non-empty text part plus one native Codex
11563/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11564/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11565/// `output_text` blocks already follow the family of) per `image_url` part.
11566fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11567    match &msg.content_parts {
11568        Some(parts) => {
11569            let mut blocks = Vec::new();
11570            for p in parts {
11571                match p.get("type").and_then(Value::as_str) {
11572                    Some("text") => {
11573                        if let Some(t) = p.get("text").and_then(Value::as_str) {
11574                            if !t.is_empty() {
11575                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
11576                            }
11577                        }
11578                    }
11579                    Some("image_url") => {
11580                        if let Some(url) = p
11581                            .get("image_url")
11582                            .and_then(|u| u.get("url"))
11583                            .and_then(Value::as_str)
11584                        {
11585                            blocks.push(serde_json::json!({
11586                                "type": "input_image",
11587                                "image_url": url,
11588                            }));
11589                        }
11590                    }
11591                    _ => {}
11592                }
11593            }
11594            Value::Array(blocks)
11595        }
11596        None => {
11597            let text = msg.content.clone().unwrap_or_default();
11598            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11599        }
11600    }
11601}
11602
11603/// PARITY-11 (nested images, honest-residue side): a Codex
11604/// `function_call_output` response_item's `output` field is a BARE STRING
11605/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11606/// no structured content array, so [`codex_message_content_blocks`]'s
11607/// `input_image` slot genuinely does not apply here). A nested image captured
11608/// off a Claude `tool_result` (`extract_tool_result_content`,
11609/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11610/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11611/// from a real, intentional annotation and impossible to tell apart from
11612/// "the data survived") or dropping it with zero trace, fold in an honest,
11613/// countable disclosure of exactly how many images were dropped and why —
11614/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11615/// on the WRITE side instead of the read side. `content_parts` being `None`
11616/// (every pre-existing call site, and any tool result with no nested image)
11617/// reproduces the historical `msg.content` text byte-for-byte.
11618fn codex_tool_output_text(msg: &ChatMessage) -> String {
11619    let mut text = msg.content.clone().unwrap_or_default();
11620    if let Some(parts) = &msg.content_parts {
11621        let n = parts
11622            .iter()
11623            .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11624            .count();
11625        if n > 0 {
11626            if !text.is_empty() {
11627                text.push('\n');
11628            }
11629            text.push_str(&format!(
11630                "[image: {n} nested image(s) dropped — codex tool output has no \
11631                 structured content slot to carry them]"
11632            ));
11633        }
11634    }
11635    text
11636}
11637
11638// ---- Pi writer helpers -----------------------------------------------------
11639
11640/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11641/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11642/// file in place on first resume (`pi-fields.md` sm:848-850).
11643fn push_pi_header(
11644    out: &mut String,
11645    id: &str,
11646    cwd: &str,
11647    parent_session: Option<&str>,
11648    created_at: Option<&str>,
11649    claude_fork_context_ref: Option<&str>,
11650) {
11651    let mut header = serde_json::json!({
11652        "type": "session",
11653        "version": 3,
11654        "id": id,
11655        "timestamp": created_at.unwrap_or(SYNTH_TS),
11656        "cwd": cwd,
11657    });
11658    if let Some(ps) = parent_session {
11659        header["parentSession"] = Value::String(ps.to_string());
11660    }
11661    // D7: namespaced passthrough field, exactly like the Codex writer's
11662    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11663    // header keys, and `capture_pi_header` reads this same key back on
11664    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11665    // fork-context-ref record instead of silently losing it on this hop.
11666    if let Some(raw) = claude_fork_context_ref {
11667        header["claude_fork_context_ref"] =
11668            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11669    }
11670    push_jsonl(out, &header);
11671}
11672
11673/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11674/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11675/// deterministic here rather than random, which still satisfies "fresh,
11676/// collision-free" without an extra RNG dependency).
11677fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11678    loop {
11679        *counter += 1;
11680        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11681        let id = format!("{:08x}", (h >> 32) as u32);
11682        if used.insert(id.clone()) {
11683            return id;
11684        }
11685    }
11686}
11687
11688/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11689/// inverse of the loader's `data:{mime};base64,{data}` construction.
11690fn parse_data_uri(url: &str) -> Option<(String, String)> {
11691    let rest = url.strip_prefix("data:")?;
11692    let (meta, data) = rest.split_once(',')?;
11693    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11694    Some((mime.to_string(), data.to_string()))
11695}
11696
11697/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11698/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11699/// `toolResult` entries (both use the identical union on the wire).
11700fn pi_content_value(msg: &ChatMessage) -> Value {
11701    if let Some(parts) = &msg.content_parts {
11702        let mut arr = Vec::new();
11703        for p in parts {
11704            match p.get("type").and_then(Value::as_str) {
11705                Some("text") => {
11706                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11707                        arr.push(serde_json::json!({"type": "text", "text": t}));
11708                    }
11709                }
11710                Some("image_url") => {
11711                    if let Some(url) = p
11712                        .get("image_url")
11713                        .and_then(|u| u.get("url"))
11714                        .and_then(Value::as_str)
11715                    {
11716                        if let Some((mime, data)) = parse_data_uri(url) {
11717                            arr.push(
11718                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11719                            );
11720                        }
11721                    }
11722                }
11723                _ => {}
11724            }
11725        }
11726        Value::Array(arr)
11727    } else {
11728        Value::String(msg.content.clone().unwrap_or_default())
11729    }
11730}
11731
11732fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11733    let mut arr = Vec::new();
11734    if let Some(thinking) = msg.metadata.get("thinking") {
11735        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11736        if let Some(sig) = msg.metadata.get("thinking_signature") {
11737            block["thinkingSignature"] = Value::String(sig.clone());
11738        }
11739        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11740            block["redacted"] = Value::Bool(true);
11741        }
11742        arr.push(block);
11743    }
11744    if let Some(text) = &msg.content {
11745        if !text.is_empty() {
11746            let mut block = serde_json::json!({"type": "text", "text": text});
11747            if let Some(sig) = msg.metadata.get("pi_text_signature") {
11748                block["textSignature"] = Value::String(sig.clone());
11749            }
11750            arr.push(block);
11751        }
11752    }
11753    for tc in msg.tool_calls() {
11754        let args = tc
11755            .function
11756            .parsed_arguments()
11757            .unwrap_or_else(|_| Value::Object(Default::default()));
11758        let mut block = serde_json::json!({
11759            "type": "toolCall",
11760            "id": tc.id,
11761            "name": tc.function.name,
11762            "arguments": args,
11763        });
11764        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11765            block["thoughtSignature"] = Value::String(sig.clone());
11766        }
11767        arr.push(block);
11768    }
11769    Value::Array(arr)
11770}
11771
11772fn default_pi_usage() -> Value {
11773    serde_json::json!({
11774        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11775        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11776    })
11777}
11778
11779fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11780    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11781}
11782
11783#[cfg(test)]
11784mod tests {
11785    use super::{
11786        opencode_message_timestamp, parent_tool_use_index, read_display_jsonl,
11787        truncate_messages_with_anchor, Session, SessionFormat,
11788    };
11789    use crate::message::ChatMessage;
11790    use crate::{Fidelity, Role};
11791
11792    #[test]
11793    fn a_new_user_message_slides_the_display_tail_without_becoming_its_only_anchor() {
11794        let mut messages = vec![
11795            ChatMessage::user("original prompt"),
11796            ChatMessage::assistant("one"),
11797            ChatMessage::assistant("two"),
11798            ChatMessage::assistant("three"),
11799            ChatMessage::assistant("four"),
11800            ChatMessage::assistant("five"),
11801            ChatMessage::user("new prompt"),
11802        ];
11803
11804        truncate_messages_with_anchor(&mut messages, 4, Vec::new());
11805
11806        assert_eq!(messages.len(), 4);
11807        assert_eq!(messages[0].content.as_deref(), Some("original prompt"));
11808        assert_eq!(messages[3].content.as_deref(), Some("new prompt"));
11809    }
11810
11811    #[test]
11812    fn a_tool_heavy_current_turn_keeps_the_previous_and_current_user_anchors() {
11813        let mut messages = vec![
11814            ChatMessage::user("previous prompt"),
11815            ChatMessage::assistant("previous answer"),
11816            ChatMessage::user("current prompt"),
11817            ChatMessage::assistant("tool one"),
11818            ChatMessage::assistant("tool two"),
11819            ChatMessage::assistant("tool three"),
11820            ChatMessage::assistant("tool four"),
11821            ChatMessage::assistant("tool five"),
11822        ];
11823
11824        truncate_messages_with_anchor(&mut messages, 4, Vec::new());
11825
11826        assert_eq!(messages.len(), 4);
11827        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11828        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11829        assert_eq!(messages[3].content.as_deref(), Some("tool five"));
11830    }
11831
11832    #[test]
11833    fn a_preceding_user_anchor_is_restored_when_dedup_shrinks_below_the_limit() {
11834        let mut messages = vec![
11835            ChatMessage::user("current prompt"),
11836            ChatMessage::assistant("tool one"),
11837            ChatMessage::assistant("tool two"),
11838        ];
11839
11840        truncate_messages_with_anchor(&mut messages, 4, vec![ChatMessage::user("previous prompt")]);
11841
11842        assert_eq!(messages.len(), 4);
11843        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11844        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11845    }
11846
11847    #[test]
11848    fn a_tool_heavy_turn_restores_two_users_that_both_left_the_retention_buffer() {
11849        let mut messages = vec![
11850            ChatMessage::assistant("tool one"),
11851            ChatMessage::assistant("tool two"),
11852            ChatMessage::assistant("tool three"),
11853            ChatMessage::assistant("tool four"),
11854        ];
11855
11856        truncate_messages_with_anchor(
11857            &mut messages,
11858            4,
11859            vec![
11860                ChatMessage::user("previous prompt"),
11861                ChatMessage::user("current prompt"),
11862            ],
11863        );
11864
11865        assert_eq!(messages.len(), 4);
11866        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11867        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11868        assert_eq!(messages[3].content.as_deref(), Some("tool four"));
11869    }
11870
11871    #[test]
11872    fn a_loaded_boundary_anchor_survives_several_newer_user_turns() {
11873        let mut messages = vec![
11874            ChatMessage::assistant("older tool one"),
11875            ChatMessage::assistant("older tool two"),
11876            ChatMessage::user("recent prompt one"),
11877            ChatMessage::assistant("recent answer one"),
11878            ChatMessage::user("recent prompt two"),
11879            ChatMessage::assistant("recent answer two"),
11880            ChatMessage::user("current prompt"),
11881            ChatMessage::assistant("current tool"),
11882        ];
11883
11884        truncate_messages_with_anchor(
11885            &mut messages,
11886            6,
11887            vec![ChatMessage::user("loaded earlier boundary")],
11888        );
11889
11890        assert_eq!(messages.len(), 6);
11891        assert_eq!(
11892            messages[0].content.as_deref(),
11893            Some("loaded earlier boundary"),
11894            "newer user prompts must not replace the prompt that owns the retained activity",
11895        );
11896        assert_eq!(messages[4].content.as_deref(), Some("current prompt"));
11897        assert_eq!(messages[5].content.as_deref(), Some("current tool"));
11898    }
11899
11900    #[test]
11901    fn a_bounded_byte_window_recovers_preceding_users_even_when_its_tail_has_users() {
11902        let nonce = std::time::SystemTime::now()
11903            .duration_since(std::time::UNIX_EPOCH)
11904            .unwrap()
11905            .as_nanos();
11906        let path = std::env::temp_dir().join(format!(
11907            "supercode-display-boundary-{}-{nonce}.jsonl",
11908            std::process::id()
11909        ));
11910        let user = |text: &str| {
11911            format!(
11912                r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#,
11913            )
11914        };
11915        let lines = [
11916            r#"{"type":"session_meta","payload":{"id":"session-1"}}"#.to_string(),
11917            user("preceding boundary"),
11918            format!(
11919                r#"{{"type":"event_msg","payload":{{"type":"token_count","noise":"{}"}}}}"#,
11920                "x".repeat(5 * 1024 * 1024)
11921            ),
11922            user("newer prompt one"),
11923            user("newer prompt two"),
11924        ];
11925        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11926
11927        let (_, text, omitted_prefix) = read_display_jsonl(&path, 120).unwrap();
11928        std::fs::remove_file(&path).unwrap();
11929
11930        assert!(omitted_prefix);
11931        assert!(text.contains("preceding boundary"));
11932        assert!(text.contains("newer prompt one"));
11933        assert!(text.contains("newer prompt two"));
11934    }
11935
11936    #[test]
11937    fn bounded_codex_file_expands_past_tool_noise_and_loads_real_earlier_history() {
11938        let nonce = std::time::SystemTime::now()
11939            .duration_since(std::time::UNIX_EPOCH)
11940            .unwrap()
11941            .as_nanos();
11942        let path = std::env::temp_dir().join(format!(
11943            "supercode-display-history-{}-{nonce}.jsonl",
11944            std::process::id()
11945        ));
11946        let user = |text: &str| {
11947            format!(
11948                r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#,
11949            )
11950        };
11951        let assistant = |index: usize| {
11952            format!(
11953                r#"{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"answer-{index}"}}]}}}}"#,
11954            )
11955        };
11956        let mut lines = vec![
11957            r#"{"type":"session_meta","payload":{"id":"session-1"}}"#.to_string(),
11958            user("earlier prompt"),
11959            format!(
11960                r#"{{"type":"event_msg","payload":{{"type":"token_count","noise":"{}"}}}}"#,
11961                "x".repeat(5 * 1024 * 1024)
11962            ),
11963            user("latest prompt"),
11964        ];
11965        lines.extend((0..130).map(assistant));
11966        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11967
11968        let initial = Session::load_display_view(&path, Fidelity::Semantic, 120).unwrap();
11969        let expanded = Session::load_display_view(&path, Fidelity::Semantic, 240).unwrap();
11970        std::fs::remove_file(&path).unwrap();
11971
11972        let initial_users = initial
11973            .messages
11974            .iter()
11975            .filter(|message| message.role == Role::User)
11976            .filter_map(|message| message.content.as_deref())
11977            .collect::<Vec<_>>();
11978        assert_eq!(initial.messages.len(), 120);
11979        assert_eq!(initial_users, ["earlier prompt", "latest prompt"]);
11980        assert!(
11981            initial.imported_message_count.unwrap() > initial.messages.len(),
11982            "a bounded initial page must truthfully report earlier history"
11983        );
11984        assert_eq!(expanded.messages.len(), 132);
11985        assert_eq!(expanded.imported_message_count, Some(132));
11986    }
11987
11988    #[test]
11989    fn bounded_codex_display_history_reports_the_unbounded_message_total() {
11990        let jsonl = (0..6)
11991            .map(|index| {
11992                let role = if index % 2 == 0 { "user" } else { "assistant" };
11993                format!(
11994                    r#"{{"type":"response_item","payload":{{"type":"message","role":"{role}","content":[{{"type":"input_text","text":"message-{index}"}}]}}}}"#,
11995                )
11996            })
11997            .collect::<Vec<_>>()
11998            .join("\n");
11999
12000        let session = Session::from_codex_display_str(&jsonl, 2).unwrap();
12001
12002        assert_eq!(session.messages.len(), 2);
12003        assert_eq!(session.imported_message_count, Some(6));
12004    }
12005
12006    #[test]
12007    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
12008        let base = Session::from_native_messages(Vec::new());
12009        let mut native = base.to_native_jsonl_v2(&[]);
12010        native.push_str("{\"supercode_turn\":1}\n");
12011
12012        let parsed = Session::from_native_str(&native).unwrap();
12013        assert_eq!(parsed.parse_error_lines, 1);
12014        assert!(parsed.messages.is_empty());
12015        assert_eq!(
12016            parsed.raw.last().map(String::as_str),
12017            Some("{\"supercode_turn\":1}")
12018        );
12019    }
12020
12021    #[test]
12022    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
12023        let imported = Session::from_claude_code_str(
12024            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
12025        )
12026        .unwrap();
12027        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
12028        native.push_str("{\"supercode_turn\":1}\n");
12029
12030        let parsed = Session::from_native_str(&native).unwrap();
12031        let error = parsed
12032            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
12033            .unwrap_err();
12034        assert!(error.to_string().contains("parse loss"), "{error}");
12035    }
12036
12037    #[test]
12038    fn sidecar_loader_requires_a_supported_native_header() {
12039        for malformed in [
12040            "",
12041            "not-json\n",
12042            "{}\n",
12043            "{\"supercode_native\":2}\n",
12044            "{\"supercode_native\":99,\"source\":\"native\"}\n",
12045        ] {
12046            let error = Session::from_sidecar_str(malformed).unwrap_err();
12047            assert!(error.to_string().contains("sidecar header"), "{error}");
12048        }
12049    }
12050
12051    #[test]
12052    fn gemini_user_parts_preserve_text_media_and_response_order() {
12053        let session = Session::from_gemini_str(
12054            r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
12055{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
12056{"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"}]}
12057"#,
12058        )
12059        .unwrap();
12060
12061        assert_eq!(session.messages.len(), 6);
12062        assert_eq!(
12063            session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
12064            "before"
12065        );
12066        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
12067        assert!(
12068            session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
12069                .as_str()
12070                .unwrap()
12071                .starts_with("data:image/png;base64,")
12072        );
12073        assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
12074        assert_eq!(
12075            session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
12076            "after"
12077        );
12078    }
12079
12080    #[test]
12081    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
12082        let msg = ChatMessage::user("continuation");
12083        let mut cursor = i64::MAX - 1;
12084        assert_eq!(
12085            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
12086            i64::MAX
12087        );
12088        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
12089        assert!(err.to_string().contains("after i64::MAX"));
12090        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
12091    }
12092
12093    /// Pin of the single-pass indexer against the relevant Claude tool-result
12094    /// shape (SUP-21). An id absent from the transcript must map to nothing.
12095    #[test]
12096    fn parent_tool_use_index_matches_known_fixture_linkage() {
12097        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"}}"#;
12098
12099        let ids = vec![
12100            "ad8dc6cf98b49eea6".to_string(),
12101            "no-such-agent-id".to_string(),
12102        ];
12103        let index = parent_tool_use_index(main_text, &ids);
12104
12105        assert_eq!(
12106            index.get("ad8dc6cf98b49eea6").map(String::as_str),
12107            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
12108            "known agent id must resolve to the pinned parent tool_use_id"
12109        );
12110        assert_eq!(
12111            index.get("no-such-agent-id"),
12112            None,
12113            "unknown agent id must yield no entry (best-effort None)"
12114        );
12115    }
12116
12117    #[test]
12118    fn parent_tool_use_index_empty_ids_returns_empty_map() {
12119        let index = parent_tool_use_index("irrelevant text", &[]);
12120        assert!(index.is_empty());
12121    }
12122}